diff options
Diffstat (limited to 'tools/esmify/mach_commands.py')
-rw-r--r-- | tools/esmify/mach_commands.py | 903 |
1 files changed, 903 insertions, 0 deletions
diff --git a/tools/esmify/mach_commands.py b/tools/esmify/mach_commands.py new file mode 100644 index 0000000000..59c0777a9e --- /dev/null +++ b/tools/esmify/mach_commands.py @@ -0,0 +1,903 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +import json +import logging +import os +import pathlib +import re +import subprocess +import sys + +from mach.decorators import Command, CommandArgument + + +def path_sep_to_native(path_str): + """Make separators in the path OS native.""" + return pathlib.os.sep.join(path_str.split("/")) + + +def path_sep_from_native(path): + """Make separators in the path OS native.""" + return "/".join(str(path).split(pathlib.os.sep)) + + +excluded_from_convert_prefix = list( + map( + path_sep_to_native, + [ + # Testcases for actors. + "toolkit/actors/TestProcessActorChild.jsm", + "toolkit/actors/TestProcessActorParent.jsm", + "toolkit/actors/TestWindowChild.jsm", + "toolkit/actors/TestWindowParent.jsm", + "js/xpconnect/tests/unit/", + # Testcase for build system. + "python/mozbuild/mozbuild/test/", + ], + ) +) + + +def is_excluded_from_convert(path): + """Returns true if the JSM file shouldn't be converted to ESM.""" + path_str = str(path) + for prefix in excluded_from_convert_prefix: + if path_str.startswith(prefix): + return True + + return False + + +excluded_from_imports_prefix = list( + map( + path_sep_to_native, + [ + # Vendored or auto-generated files. + "browser/components/pocket/content/panels/js/vendor.bundle.js", + "devtools/client/debugger/dist/parser-worker.js", + "devtools/client/debugger/packages/devtools-source-map/src/tests/fixtures/bundle.js", + "devtools/client/debugger/test/mochitest/examples/react/build/main.js", + "devtools/client/debugger/test/mochitest/examples/sourcemapped/polyfill-bundle.js", + "devtools/client/inspector/markup/test/shadowdom_open_debugger.min.js", + "layout/style/test/property_database.js", + "services/fxaccounts/FxAccountsPairingChannel.js", + "testing/talos/talos/tests/devtools/addon/content/pages/custom/debugger/static/js/main.js", # noqa E501 + "testing/web-platform/", + # Unrelated testcases that has edge case syntax. + "browser/components/sessionstore/test/unit/data/", + "devtools/client/debugger/src/workers/parser/tests/fixtures/", + "devtools/client/debugger/test/mochitest/examples/sourcemapped/fixtures/", + "devtools/client/webconsole/test/browser/test-syntaxerror-worklet.js", + "devtools/server/tests/xpcshell/test_framebindings-03.js", + "devtools/server/tests/xpcshell/test_framebindings-04.js", + "devtools/shared/tests/xpcshell/test_eventemitter_basic.js", + "devtools/shared/tests/xpcshell/test_eventemitter_static.js", + "dom/base/crashtests/module-with-syntax-error.js", + "dom/base/test/file_bug687859-16.js", + "dom/base/test/file_bug687859-16.js", + "dom/base/test/file_js_cache_syntax_error.js", + "dom/base/test/jsmodules/module_badSyntax.js", + "dom/canvas/test/reftest/webgl-utils.js", + "dom/encoding/test/file_utf16_be_bom.js", + "dom/encoding/test/file_utf16_le_bom.js", + "dom/html/test/bug649134/file_bug649134-1.sjs", + "dom/html/test/bug649134/file_bug649134-2.sjs", + "dom/media/webrtc/tests/mochitests/identity/idp-bad.js", + "dom/serviceworkers/test/file_js_cache_syntax_error.js", + "dom/serviceworkers/test/parse_error_worker.js", + "dom/workers/test/importScripts_worker_imported3.js", + "dom/workers/test/invalid.js", + "dom/workers/test/threadErrors_worker1.js", + "dom/xhr/tests/browser_blobFromFile.js", + "image/test/browser/browser_image.js", + "js/xpconnect/tests/chrome/test_bug732665_meta.js", + "js/xpconnect/tests/mochitest/class_static_worker.js", + "js/xpconnect/tests/unit/bug451678_subscript.js", + "js/xpconnect/tests/unit/es6module_parse_error.js", + "js/xpconnect/tests/unit/recursive_importA.jsm", + "js/xpconnect/tests/unit/recursive_importB.jsm", + "js/xpconnect/tests/unit/syntax_error.jsm", + "js/xpconnect/tests/unit/test_defineModuleGetter.js", + "js/xpconnect/tests/unit/test_import.js", + "js/xpconnect/tests/unit/test_import_shim.js", + "js/xpconnect/tests/unit/test_recursive_import.js", + "js/xpconnect/tests/unit/test_unload.js", + "modules/libpref/test/unit/data/testParser.js", + "python/mozbuild/mozbuild/test/", + "remote/shared/messagehandler/test/browser/resources/modules/root/invalid.sys.mjs", + "testing/talos/talos/startup_test/sessionrestore/profile-manywindows/sessionstore.js", + "testing/talos/talos/startup_test/sessionrestore/profile/sessionstore.js", + "toolkit/components/reader/Readerable.jsm", + "toolkit/components/workerloader/tests/moduleF-syntax-error.js", + "tools/lint/test/", + "tools/update-packaging/test/", + # SpiderMonkey internals. + "js/examples/", + "js/src/", + # Files has macro. + "browser/app/profile/firefox.js", + "browser/branding/official/pref/firefox-branding.js", + "browser/components/enterprisepolicies/schemas/schema.jsm", + "browser/locales/en-US/firefox-l10n.js", + "mobile/android/app/geckoview-prefs.js", + "mobile/android/app/mobile.js", + "mobile/android/locales/en-US/mobile-l10n.js", + "modules/libpref/greprefs.js", + "modules/libpref/init/all.js", + "testing/condprofile/condprof/tests/profile/user.js", + "testing/mozbase/mozprofile/tests/files/prefs_with_comments.js", + "toolkit/modules/AppConstants.sys.mjs", + "toolkit/mozapps/update/tests/data/xpcshellConstantsPP.js", + # Uniffi templates + "toolkit/components/uniffi-bindgen-gecko-js/src/templates/js/", + ], + ) +) + +EXCLUSION_FILES = [ + os.path.join("tools", "rewriting", "Generated.txt"), + os.path.join("tools", "rewriting", "ThirdPartyPaths.txt"), +] + + +def load_exclusion_files(): + for path in EXCLUSION_FILES: + with open(path, "r") as f: + for line in f: + p = path_sep_to_native(re.sub("\*$", "", line.strip())) + excluded_from_imports_prefix.append(p) + + +def is_excluded_from_imports(path): + """Returns true if the JS file content shouldn't be handled by + jscodeshift. + + This filter is necessary because jscodeshift cannot handle some + syntax edge cases and results in unexpected rewrite.""" + path_str = str(path) + for prefix in excluded_from_imports_prefix: + if path_str.startswith(prefix): + return True + + return False + + +# Wrapper for hg/git operations +class VCSUtils: + def run(self, cmd): + # Do not pass check=True because the pattern can match no file. + lines = subprocess.run(cmd, stdout=subprocess.PIPE).stdout.decode() + return filter(lambda x: x != "", lines.split("\n")) + + +class HgUtils(VCSUtils): + def is_available(): + return pathlib.Path(".hg").exists() + + def rename(self, before, after): + cmd = ["hg", "rename", before, after] + subprocess.run(cmd, check=True) + + def find_jsms(self, path): + jsms = [] + + cmd = ["hg", "files", f'set:glob:"{path}/**/*.jsm"'] + for line in self.run(cmd): + jsm = pathlib.Path(line) + if is_excluded_from_convert(jsm): + continue + jsms.append(jsm) + + cmd = [ + "hg", + "files", + f"set:grep('EXPORTED_SYMBOLS = \[') and glob:\"{path}/**/*.js\"", + ] + for line in self.run(cmd): + jsm = pathlib.Path(line) + if is_excluded_from_convert(jsm): + continue + jsms.append(jsm) + + return jsms + + def find_all_jss(self, path): + jss = [] + + cmd = [ + "hg", + "files", + f'set:glob:"{path}/**/*.jsm" or glob:"{path}/**/*.js" or ' + + f'glob:"{path}/**/*.mjs" or glob:"{path}/**/*.sjs"', + ] + for line in self.run(cmd): + js = pathlib.Path(line) + if is_excluded_from_imports(js): + continue + jss.append(js) + + return jss + + +class GitUtils(VCSUtils): + def is_available(): + return pathlib.Path(".git").exists() + + def rename(self, before, after): + cmd = ["git", "mv", before, after] + subprocess.run(cmd, check=True) + + def find_jsms(self, path): + jsms = [] + + cmd = ["git", "ls-files", f"{path}/*.jsm"] + for line in self.run(cmd): + jsm = pathlib.Path(line) + if is_excluded_from_convert(jsm): + continue + jsms.append(jsm) + + handled = {} + cmd = ["git", "grep", "EXPORTED_SYMBOLS = \[", f"{path}/*.js"] + for line in self.run(cmd): + m = re.search("^([^:]+):", line) + if not m: + continue + filename = m.group(1) + if filename in handled: + continue + handled[filename] = True + jsm = pathlib.Path(filename) + if is_excluded_from_convert(jsm): + continue + jsms.append(jsm) + + return jsms + + def find_all_jss(self, path): + jss = [] + + cmd = [ + "git", + "ls-files", + f"{path}/*.jsm", + f"{path}/*.js", + f"{path}/*.mjs", + f"{path}/*.sjs", + ] + for line in self.run(cmd): + js = pathlib.Path(line) + if is_excluded_from_imports(js): + continue + jss.append(js) + + return jss + + +class Summary: + def __init__(self): + self.convert_errors = [] + self.import_errors = [] + self.rename_errors = [] + self.no_refs = [] + + +@Command( + "esmify", + category="misc", + description="ESMify JSM files.", +) +@CommandArgument( + "path", + nargs=1, + help="Path to the JSM file to ESMify, or the directory that contains " + "JSM files and/or JS files that imports ESM-ified JSM.", +) +@CommandArgument( + "--convert", + action="store_true", + help="Only perform the step 1 = convert part", +) +@CommandArgument( + "--imports", + action="store_true", + help="Only perform the step 2 = import calls part", +) +@CommandArgument( + "--prefix", + default="", + help="Restrict the target of import in the step 2 to ESM-ified JSM, by the " + "prefix match for the JSM file's path. e.g. 'browser/'.", +) +def esmify(command_context, path=None, convert=False, imports=False, prefix=""): + """ + This command does the following 2 steps: + 1. Convert the JSM file specified by `path` to ESM file, or the JSM files + inside the directory specified by `path` to ESM files, and also + fix references in build files and test definitions + 2. Convert import calls inside file(s) specified by `path` for ESM-ified + files to use new APIs + + Example 1: + # Convert all JSM files inside `browser/components/pagedata` directory, + # and replace all references for ESM-ified files in the entire tree to use + # new APIs + + $ ./mach esmify --convert browser/components/pagedata + $ ./mach esmify --imports . --prefix=browser/components/pagedata + + Example 2: + # Convert all JSM files inside `browser` directory, and replace all + # references for the JSM files inside `browser` directory to use + # new APIs + + $ ./mach esmify browser + """ + + def error(text): + command_context.log(logging.ERROR, "esmify", {}, f"[ERROR] {text}") + + def warn(text): + command_context.log(logging.WARN, "esmify", {}, f"[WARN] {text}") + + def info(text): + command_context.log(logging.INFO, "esmify", {}, f"[INFO] {text}") + + # If no options is specified, perform both. + if not convert and not imports: + convert = True + imports = True + + path = pathlib.Path(path[0]) + + if not verify_path(command_context, path): + return 1 + + if HgUtils.is_available(): + vcs_utils = HgUtils() + elif GitUtils.is_available(): + vcs_utils = GitUtils() + else: + error( + "This script needs to be run inside mozilla-central " + "checkout of either mercurial or git." + ) + return 1 + + load_exclusion_files() + + info("Setting up jscodeshift...") + setup_jscodeshift() + + is_single_file = path.is_file() + + modified_files = [] + summary = Summary() + + if convert: + info("Searching files to convert to ESM...") + if is_single_file: + jsms = [path] + else: + jsms = vcs_utils.find_jsms(path) + + info(f"Found {len(jsms)} file(s) to convert to ESM.") + + info("Converting to ESM...") + jsms = convert_module(jsms, summary) + if jsms is None: + error("Failed to rewrite exports.") + return 1 + + info("Renaming...") + esms = rename_jsms(command_context, vcs_utils, jsms, summary) + + modified_files += esms + + if imports: + info("Searching files to rewrite imports...") + + if is_single_file: + if convert: + # Already converted above + jss = esms + else: + jss = [path] + else: + jss = vcs_utils.find_all_jss(path) + + info(f"Checking {len(jss)} JS file(s). Rewriting any matching imports...") + + result = rewrite_imports(jss, prefix, summary) + if result is None: + return 1 + + info(f"Rewritten {len(result)} file(s).") + + # Only modified files needs eslint fix + modified_files += result + + modified_files = list(set(modified_files)) + + info(f"Applying eslint --fix for {len(modified_files)} file(s)...") + eslint_fix(command_context, modified_files) + + def print_files(f, errors): + for [path, message] in errors: + f(f" * {path}") + if message: + f(f" {message}") + + if len(summary.convert_errors): + error("========") + error("Following files are not converted into ESM due to error:") + print_files(error, summary.convert_errors) + + if len(summary.import_errors): + warn("========") + warn("Following files are not rewritten to import ESMs due to error:") + warn( + "(NOTE: Errors related to 'private names' are mostly due to " + " preprocessor macros in the file):" + ) + print_files(warn, summary.import_errors) + + if len(summary.rename_errors): + error("========") + error("Following files are not renamed due to error:") + print_files(error, summary.rename_errors) + + if len(summary.no_refs): + warn("========") + warn("Following files are not found in any build files.") + warn("Please update references to those files manually:") + print_files(warn, summary.rename_errors) + + return 0 + + +def verify_path(command_context, path): + """Check if the path passed to the command is valid relative path.""" + + def error(text): + command_context.log(logging.ERROR, "esmify", {}, f"[ERROR] {text}") + + if not path.exists(): + error(f"{path} does not exist.") + return False + + if path.is_absolute(): + error("Path must be a relative path from mozilla-central checkout.") + return False + + return True + + +def find_file(path, target): + """Find `target` file in ancestor of path.""" + target_path = path.parent / target + if not target_path.exists(): + if path.parent == path: + return None + + return find_file(path.parent, target) + + return target_path + + +def try_rename_in(command_context, path, target, jsm_name, esm_name, jsm_path): + """Replace the occurrences of `jsm_name` with `esm_name` in `target` + file.""" + + def info(text): + command_context.log(logging.INFO, "esmify", {}, f"[INFO] {text}") + + if type(target) is str: + # Target is specified by filename, that may exist somewhere in + # the jsm's directory or ancestor directories. + target_path = find_file(path, target) + if not target_path: + return False + + # JSM should be specified with relative path in the file. + # + # Single moz.build or jar.mn can contain multiple files with same name. + # Search for relative path. + jsm_relative_path = jsm_path.relative_to(target_path.parent) + jsm_path_str = path_sep_from_native(str(jsm_relative_path)) + else: + # Target is specified by full path. + target_path = target + + # JSM should be specified with full path in the file. + jsm_path_str = path_sep_from_native(str(jsm_path)) + + jsm_path_re = re.compile(r"\b" + jsm_path_str.replace(".", r"\.") + r"\b") + jsm_name_re = re.compile(r"\b" + jsm_name.replace(".", r"\.") + r"\b") + + modified = False + content = "" + with open(target_path, "r") as f: + for line in f: + if jsm_path_re.search(line): + modified = True + line = jsm_name_re.sub(esm_name, line) + + content += line + + if modified: + info(f" {str(target_path)}") + info(f" {jsm_name} => {esm_name}") + with open(target_path, "w", newline="\n") as f: + f.write(content) + + return True + + +def try_rename_uri_in(command_context, target, jsm_name, esm_name, jsm_uri, esm_uri): + """Replace the occurrences of `jsm_uri` with `esm_uri` in `target` file.""" + + def info(text): + command_context.log(logging.INFO, "esmify", {}, f"[INFO] {text}") + + modified = False + content = "" + with open(target, "r") as f: + for line in f: + if jsm_uri in line: + modified = True + line = line.replace(jsm_uri, esm_uri) + + content += line + + if modified: + info(f" {str(target)}") + info(f" {jsm_name} => {esm_name}") + with open(target, "w", newline="\n") as f: + f.write(content) + + return True + + +def try_rename_components_conf(command_context, path, jsm_name, esm_name): + """Replace the occurrences of `jsm_name` with `esm_name` in components.conf + file.""" + + def info(text): + command_context.log(logging.INFO, "esmify", {}, f"[INFO] {text}") + + target_path = find_file(path, "components.conf") + if not target_path: + return False + + # Unlike try_rename_in, components.conf contains the URL instead of + # relative path, and also there are no known files with same name. + # Simply replace the filename. + + with open(target_path, "r") as f: + content = f.read() + + prop_re = re.compile( + "[\"']jsm[\"']:(.*)" + r"\b" + jsm_name.replace(".", r"\.") + r"\b" + ) + + if not prop_re.search(content): + return False + + info(f" {str(target_path)}") + info(f" {jsm_name} => {esm_name}") + + content = prop_re.sub(r"'esModule':\1" + esm_name, content) + with open(target_path, "w", newline="\n") as f: + f.write(content) + + return True + + +def esmify_name(name): + return re.sub(r"\.(jsm|js|jsm\.js)$", ".sys.mjs", name) + + +def esmify_path(jsm_path): + jsm_name = jsm_path.name + esm_name = re.sub(r"\.(jsm|js|jsm\.js)$", ".sys.mjs", jsm_name) + esm_path = jsm_path.parent / esm_name + return esm_path + + +path_to_uri_map = None + + +def load_path_to_uri_map(): + global path_to_uri_map + + if path_to_uri_map: + return + + if "ESMIFY_MAP_JSON" in os.environ: + json_map = pathlib.Path(os.environ["ESMIFY_MAP_JSON"]) + else: + json_map = pathlib.Path(__file__).parent / "map.json" + + with open(json_map, "r") as f: + uri_to_path_map = json.loads(f.read()) + + path_to_uri_map = dict() + + for uri, paths in uri_to_path_map.items(): + if type(paths) is str: + paths = [paths] + + for path in paths: + path_to_uri_map[path] = uri + + +def find_jsm_uri(jsm_path): + load_path_to_uri_map() + + path = path_sep_from_native(jsm_path) + + if path in path_to_uri_map: + return path_to_uri_map[path] + + return None + + +def rename_single_file(command_context, vcs_utils, jsm_path, summary): + """Rename `jsm_path` to .sys.mjs, and fix references to the file in build + and test definitions.""" + + def info(text): + command_context.log(logging.INFO, "esmify", {}, f"[INFO] {text}") + + esm_path = esmify_path(jsm_path) + + jsm_name = jsm_path.name + esm_name = esm_path.name + + target_files = [ + ".eslintignore", + "moz.build", + "jar.mn", + "browser.ini", + "browser-common.ini", + "chrome.ini", + "mochitest.ini", + "xpcshell.ini", + "xpcshell-child-process.ini", + "xpcshell-common.ini", + "xpcshell-parent-process.ini", + pathlib.Path("tools", "lint", "eslint.yml"), + pathlib.Path("tools", "lint", "rejected-words.yml"), + ] + + info(f"{jsm_path} => {esm_path}") + + renamed = False + for target in target_files: + if try_rename_in( + command_context, jsm_path, target, jsm_name, esm_name, jsm_path + ): + renamed = True + + if try_rename_components_conf(command_context, jsm_path, jsm_name, esm_name): + renamed = True + + uri_target_files = [ + pathlib.Path( + "browser", "base", "content", "test", "performance", "browser_startup.js" + ), + pathlib.Path( + "browser", + "base", + "content", + "test", + "performance", + "browser_startup_content.js", + ), + pathlib.Path( + "browser", + "base", + "content", + "test", + "performance", + "browser_startup_content_subframe.js", + ), + pathlib.Path( + "toolkit", + "components", + "backgroundtasks", + "tests", + "browser", + "browser_xpcom_graph_wait.js", + ), + ] + + jsm_uri = find_jsm_uri(jsm_path) + if jsm_uri: + esm_uri = re.sub(r"\.(jsm|js|jsm\.js)$", ".sys.mjs", jsm_uri) + + for target in uri_target_files: + if try_rename_uri_in( + command_context, target, jsm_uri, esm_uri, jsm_name, esm_name + ): + renamed = True + + if not renamed: + summary.no_refs.append([jsm_path, None]) + + if not esm_path.exists(): + vcs_utils.rename(jsm_path, esm_path) + else: + summary.rename_errors.append([jsm_path, f"{esm_path} already exists"]) + + return esm_path + + +def rename_jsms(command_context, vcs_utils, jsms, summary): + esms = [] + for jsm in jsms: + esm = rename_single_file(command_context, vcs_utils, jsm, summary) + esms.append(esm) + + return esms + + +npm_prefix = pathlib.Path("tools") / "esmify" +path_from_npm_prefix = pathlib.Path("..") / ".." + + +def setup_jscodeshift(): + """Install jscodeshift.""" + cmd = [ + sys.executable, + "./mach", + "npm", + "install", + "jscodeshift", + "--save-dev", + "--prefix", + str(npm_prefix), + ] + subprocess.run(cmd, check=True) + + +def run_npm_command(args, env, stdin): + cmd = [ + sys.executable, + "./mach", + "npm", + "run", + ] + args + p = subprocess.Popen(cmd, env=env, stdin=subprocess.PIPE, stdout=subprocess.PIPE) + p.stdin.write(stdin) + p.stdin.close() + + ok_files = [] + errors = [] + while True: + line = p.stdout.readline() + if not line: + break + line = line.rstrip().decode() + + if line.startswith(" NOC "): + continue + + print(line) + + m = re.search(r"^ (OKK|ERR) ([^ ]+)(?: (.+))?", line) + if not m: + continue + + result = m.group(1) + # NOTE: path is written from `tools/esmify`. + path = pathlib.Path(m.group(2)).relative_to(path_from_npm_prefix) + error = m.group(3) + + if result == "OKK": + ok_files.append(path) + + if result == "ERR": + errors.append([path, error]) + + if p.wait() != 0: + return [None, None] + + return ok_files, errors + + +def convert_module(jsms, summary): + """Replace EXPORTED_SYMBOLS with export declarations, and replace + ChromeUtils.importESModule with static import as much as possible, + and return the list of successfully rewritten files.""" + + if len(jsms) == 0: + return [] + + env = os.environ.copy() + + stdin = "\n".join(map(str, paths_from_npm_prefix(jsms))).encode() + + ok_files, errors = run_npm_command( + [ + "convert_module", + "--prefix", + str(npm_prefix), + ], + env=env, + stdin=stdin, + ) + + if ok_files is None and errors is None: + return None + + summary.convert_errors.extend(errors) + + return ok_files + + +def rewrite_imports(jss, prefix, summary): + """Replace import calls for JSM with import calls for ESM or static import + for ESM.""" + + if len(jss) == 0: + return [] + + env = os.environ.copy() + env["ESMIFY_TARGET_PREFIX"] = prefix + + stdin = "\n".join(map(str, paths_from_npm_prefix(jss))).encode() + + ok_files, errors = run_npm_command( + [ + "rewrite_imports", + "--prefix", + str(npm_prefix), + ], + env=env, + stdin=stdin, + ) + + if ok_files is None and errors is None: + return None + + summary.import_errors.extend(errors) + + return ok_files + + +def paths_from_npm_prefix(paths): + """Convert relative path from mozilla-central to relative path from + tools/esmify.""" + return list(map(lambda path: path_from_npm_prefix / path, paths)) + + +def eslint_fix(command_context, files): + """Auto format files.""" + + def info(text): + command_context.log(logging.INFO, "esmify", {}, f"[INFO] {text}") + + if len(files) == 0: + return + + remaining = files[0:] + + # There can be too many files for single command line, perform by chunk. + max_files = 16 + while len(remaining) > max_files: + info(f"{len(remaining)} files remaining") + + chunk = remaining[0:max_files] + remaining = remaining[max_files:] + + cmd = [sys.executable, "./mach", "eslint", "--fix"] + chunk + subprocess.run(cmd, check=True) + + info(f"{len(remaining)} files remaining") + chunk = remaining + cmd = [sys.executable, "./mach", "eslint", "--fix"] + chunk + subprocess.run(cmd, check=True) |