diff --git a/.gitignore b/.gitignore index 91ce31a0..bf093351 100644 --- a/.gitignore +++ b/.gitignore @@ -239,4 +239,5 @@ documentation.json .envrc /public -/tests/axe-report.json \ No newline at end of file +/tests/axe-report.json +/tests/deprecated-imports-report.txt diff --git a/Makefile b/Makefile index 47956435..a69c536e 100644 --- a/Makefile +++ b/Makefile @@ -6,6 +6,7 @@ test: node_modules npx elm-verify-examples --run-tests make axe-report make percy-tests + make deprecated-imports-report tests/axe-report.json: public script/run-axe.sh script/axe-puppeteer.js script/run-axe.sh > $@ @@ -18,6 +19,14 @@ axe-report: tests/axe-report.json script/format-axe-report.sh script/axe-report. percy-tests: script/percy-tests.sh +tests/deprecated-imports-report.txt: $(shell find src -type f) script/deprecated-imports.py + script/deprecated-imports.py report > $@ + +.PHONY: deprecated-imports-report +deprecated-imports-report: tests/deprecated-imports-report.txt script/deprecated-imports.py + @cat tests/deprecated-imports-report.txt + @script/deprecated-imports.py check + .PHONY: checks checks: script/check-exposed.py diff --git a/script/deprecated-imports.csv b/script/deprecated-imports.csv new file mode 100644 index 00000000..734eec0f --- /dev/null +++ b/script/deprecated-imports.csv @@ -0,0 +1,30 @@ +filename,name,version +src/Nri/Ui/Page/V3.elm,Nri.Ui.Button,5 +src/Nri/Ui/Page/V3.elm,Nri.Ui.Text,2 +src/Nri/Ui/Page/V2.elm,Nri.Ui.Button,5 +src/Nri/Ui/Page/V2.elm,Nri.Ui.Text,2 +src/Nri/Ui/PremiumCheckbox/V3.elm,Nri.Ui.Checkbox,4 +src/Nri/Ui/PremiumCheckbox/V2.elm,Nri.Ui.Checkbox,3 +src/Nri/Ui/PremiumCheckbox/V1.elm,Nri.Ui.Checkbox,3 +src/Nri/Ui/SlideModal/V2.elm,Nri.Ui.Button,8 +src/Nri/Ui/SlideModal/V2.elm,Nri.Ui.Icon,3 +src/Nri/Ui/SlideModal/V2.elm,Nri.Ui.Text,2 +src/Nri/Ui/SlideModal/V1.elm,Nri.Ui.Button,8 +src/Nri/Ui/SlideModal/V1.elm,Nri.Ui.Icon,3 +src/Nri/Ui/SlideModal/V1.elm,Nri.Ui.Text,2 +src/Nri/Ui/Alert/V3.elm,Nri.Ui.Icon,3 +src/Nri/Ui/Alert/V2.elm,Nri.Ui.Icon,3 +src/Nri/Ui/Alert/V4.elm,Nri.Ui.Icon,3 +src/Nri/Ui/SortableTable/V1.elm,Nri.Ui.Table,4 +src/Nri/Ui/Button/V3.elm,Nri.Ui.Icon,3 +src/Nri/Ui/Button/V5.elm,Nri.Ui.Icon,3 +src/Nri/Ui/Button/V4.elm,Nri.Ui.Icon,3 +src/Nri/Ui/Button/V6.elm,Nri.Ui.Icon,4 +src/Nri/Ui/Button/V7.elm,Nri.Ui.Icon,4 +src/Nri/Ui/SegmentedControl/V6.elm,Nri.Ui.Icon,3 +src/Nri/Ui/Modal/V3.elm,Nri.Ui.Icon,3 +src/Nri/Ui/Modal/V5.elm,Nri.Ui.Modal,5 +src/Nri/Ui/Modal/V4.elm,Nri.Ui.Icon,3 +src/Nri/Ui/Modal/V6.elm,Nri.Ui.Modal,6 +src/Nri/Ui/Modal/V7.elm,Nri.Ui.Modal,7 +src/Nri/Ui/ClickableText/V1.elm,Nri.Ui.Icon,4 diff --git a/script/deprecated-imports.py b/script/deprecated-imports.py new file mode 100755 index 00000000..100786f3 --- /dev/null +++ b/script/deprecated-imports.py @@ -0,0 +1,379 @@ +#!/usr/bin/env python3 +""" +Make sure we don't use deprecated versions of Nri.Ui modules. We do this by only +allowing new imports for the highest version of every module. We keep an +artifact with old imports which haven't been removed yet, and are temporarily +allowed. See the subcommands of this script for managing this artifact! +""" + +import argparse +from collections import defaultdict +import csv +import json +import os +import os.path +import re +import sys +import textwrap + + +class ElmJson: + """parse elm.json for things we will need to determine version usage""" + + def __init__(self, path): + with open(path, "r") as fh: + self.data = json.load(fh) + + self.elm_version = self.data["elm-version"] + self.source_directories = ["src"] + + +class NriUiModules: + """ + look in a given version of noredink-ui to get the versioned modules and the + latest version for each. + """ + + MODULE_RE = "(?PNri\.Ui\.\w+).V(?P\d+)" + + def __init__(self): + with open("elm.json", "r") as fh: + self.module_list = json.load(fh)["exposed-modules"] + + self.versions = defaultdict(list) + for module in self.module_list: + match = re.match(self.MODULE_RE, module) + if match is None: + continue + + parts = match.groupdict() + self.versions[parts["name"]].append(int(parts["version"])) + self.versions[parts["name"]].sort() + + def latest_version(self, name): + if name in self.versions: + return max(self.versions[name]) + + def is_latest_version(self, name, version): + return self.latest_version(name) == version + + +class Import: + """a single import.""" + def __init__(self, filename, name, version): + self.filename = filename + self.name = name + self.version = int(version) + + def to_dict(self): + return {"filename": self.filename, "name": self.name, "version": self.version} + + def __eq__(self, other): + return ( + self.filename == other.filename + and self.name == other.name + and self.version == other.version + ) + + def __hash__(self): + # objects aren't hashable by default but we need to use this one in a + # set. So we just make one big hash out of all the data we have! + return hash(self.filename + self.name + str(self.version)) + + def __str__(self): + return "{} imports {} version {}".format(self.filename, self.name, self.version) + + +class Imports(list): + """ + get all the imports in the project according to specific rules. + + this class is all about loading and storing this data, so we just subclass + `list` to save some hassle on writing iteration etc. + """ + + NRI_UI_IMPORT_RE = "import\s+" + NriUiModules.MODULE_RE + DEPRECATED_IMPORT_RE = "import\s+(?P[\w\.]*deprecated[\w\.]*)" + GENERIC_IMPORT_RE = "import\s+(?P[\w\.]+)" + + @classmethod + def from_source_directories(cls, source_directories, extras): + """ + construct a list of project imports given an elm.json's source directories. + """ + results = cls() + + for source_directory in source_directories: + for (dirpath, _, filenames) in os.walk(source_directory): + for filename in filenames: + if os.path.splitext(filename)[1] != ".elm": + continue + + full_path = os.path.join(dirpath, filename) + with open(full_path, "r") as fh: + lines = [line.strip() for line in fh.readlines()] + + for line in lines: + line = line.strip() + + # add whatever imports we want to track here, continue + # after each. + + match = re.match(cls.NRI_UI_IMPORT_RE, line) + if match is not None: + parts = match.groupdict() + + results.append( + Import( + filename=full_path, + name=parts["name"], + version=parts["version"], + ) + ) + continue + + match = re.match( + cls.DEPRECATED_IMPORT_RE, line, flags=re.IGNORECASE + ) + if match is not None: + parts = match.groupdict() + results.append( + Import( + filename=full_path, + name=parts["import"], + version=Import.DEPRECATED, + ) + ) + continue + + match = re.match(cls.GENERIC_IMPORT_RE, line) + if match is not None: + parts = match.groupdict() + if parts["import"] not in extras: + continue + + results.append( + Import( + filename=full_path, + name=parts["import"], + version=Import.DEPRECATED + ) + ) + continue + + return results + + @classmethod + def from_file(cls, filename): + try: + with open(filename, "r") as fh: + return cls([Import(**entry) for entry in csv.DictReader(fh)]) + except FileNotFoundError: + return cls([]) + + def to_file(self, filename, filter=None): + filter = filter or (lambda _: True) + + out = (entry.to_dict() for entry in self if filter(entry)) + + with open(filename, "w") as fh: + writer = csv.DictWriter(fh, ["filename", "name", "version"]) + writer.writeheader() + writer.writerows(out) + + +class Main: + def __init__(self, args): + self.args = args + self.elm_json = ElmJson("elm.json") + + self.deprecated = set(args.deprecate) + + # cache-y things. These get set away from `None` the first time their + # associated methods are called. This has the same effect as doing + # `@foo ||= bar` in Ruby. + self._current_imports = None + self._previous_imports = None + self._modules = None + + def run(self): + if self.args.command == "update": + return self.update() + elif self.args.command == "check": + return self.check() + elif self.args.command == "report": + return self.report() + else: + print("unrecognized command. Make sure the cases in main match the parser.") + return 1 + + def is_latest_version(self, name, version): + return self.modules().is_latest_version(name, version) and name not in self.deprecated + + def update(self): + self.current_imports().to_file( + self.args.imports_file, + filter=lambda import_: not self.is_latest_version(import_.name, import_.version), + ) + + return 0 + + def check(self): + new_imports = set(self.current_imports()) - set(self.previous_imports()) + new_deprecated_imports = set() + + for entry in new_imports: + if self.is_latest_version(entry.name, entry.version): + continue + + new_deprecated_imports.add(entry) + + status = 0 + + if new_deprecated_imports: + print("==== {} new deprecated imports".format(len(new_deprecated_imports))) + print( + "\n".join( + "{} (latest version: {})".format( + entry, + self.modules().latest_version(entry.name) + or "completely deprecated.", + ) + for entry in new_deprecated_imports + ) + ) + print() + print( + textwrap.fill( + textwrap.dedent( + """ + If you meant to import a deprecated version, please run + `{}` and commit the changes to mark these deprecated + imports as acceptable. Otherwise, please upgrade to the + latest version (listed in each error above.) + """ + ), + width=80, + ).format(self.args.check_message_fix_command) + ) + status = 1 + + newly_removed = set(self.previous_imports()) - set(self.current_imports()) + if newly_removed: + print() + print("==== {} newly removed imports".format(len(newly_removed))) + print("\n".join(str(entry) for entry in newly_removed)) + print() + print( + "good job! Please run `{}` and commit the output to make sure these don't come back".format( + self.args.check_message_fix_command + ) + ) + status = 1 + + return status + + def report(self): + allowed_counts = {True: 0, False: 0} + counts = defaultdict(lambda: {True: 0, False: 0}) + + for entry in self.current_imports(): + allowed = self.is_latest_version(entry.name, entry.version) + + allowed_counts[allowed] += 1 + counts[entry.name][allowed] += 1 + + try: + widest = max(len(name) for name in counts) + except ValueError: # empty sequence + widest = 1 + + lines = [] + for name, alloweds in counts.items(): + total = alloweds[False] + alloweds[True] + percent = alloweds[True] / total + + out = "{} {: 4} of {: 4} ({:> 8.2%}) imports use the latest version".format( + name.ljust(widest), alloweds[True], total, percent + ) + + lines.append((percent, out)) + + lines.sort() + print("\n".join(line for (_, line) in lines)) + print() + + total = allowed_counts[False] + allowed_counts[True] + percent = allowed_counts[True] / (total or 1) + print( + "in total, {} of {} ({:> 8.2%}) imports use the latest version".format( + allowed_counts[True], total, percent + ) + ) + return 0 + + def previous_imports(self): + if self._previous_imports is None: + self._previous_imports = Imports.from_file(self.args.imports_file) + + return self._previous_imports + + def current_imports(self): + if self._current_imports is None: + self._current_imports = Imports.from_source_directories( + [ + os.path.join(os.path.dirname("elm.json"), directory) + for directory in self.elm_json.source_directories + ], + self.deprecated + ) + + return self._current_imports + + def modules(self): + if self._modules is None: + self._modules = NriUiModules() + + return self._modules + + +def parser(): + out = argparse.ArgumentParser( + description=__doc__, formatter_class=argparse.ArgumentDefaultsHelpFormatter + ) + + out.add_argument( + "--check-message-fix-command", + help="update command to show in check error messages", + default=" ".join(sys.argv).replace("check", "update").replace("report", "update"), + ) + out.add_argument( + "--imports-file", + help="where do we store acceptable deprecated imports?", + default=os.path.join( + os.path.dirname(os.path.abspath(__file__)), + os.path.basename(__file__).replace(".py", ".csv"), + ), + ) + out.add_argument( + '--deprecate', + help="explicitly deprecate a module by name", + action="append", + default=[], + ) + + sub = out.add_subparsers() + sub.required = True + sub.dest = "command" + + sub.add_parser("update", help="update the acceptable import file") + sub.add_parser( + "check", help="check that we do not have any new instances of old modules" + ) + sub.add_parser("report", help="print an import report") + + return out + + +if __name__ == "__main__": + sys.exit(Main(parser().parse_args()).run())