#!/usr/bin/env python3 # SPDX-License-Identifier: AGPL-3.0-only # Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. """Guard against breaking npm dependency removals in studio/frontend. Diffs the current package.json against a git base, finds every package that was removed, and confirms each is no longer referenced anywhere in the repo. If a removed package is still imported and is not transitively resolvable through the new lockfile, exits non-zero with file:line citations. Usage: python scripts/check_frontend_dep_removal.py python scripts/check_frontend_dep_removal.py --base origin/main python scripts/check_frontend_dep_removal.py --base HEAD~1 python scripts/check_frontend_dep_removal.py --base-pkg PATH --head-lock PATH Exit codes: 0 every removed dep is safe (no source refs or still resolvable) 1 at least one removed dep is referenced and not resolvable 2 invocation error (bad args, missing file, git error) """ from __future__ import annotations import argparse import json import os import re import subprocess import sys from dataclasses import dataclass from pathlib import Path REPO_ROOT = Path(__file__).resolve().parent.parent FRONTEND_PKG = "studio/frontend/package.json" FRONTEND_LOCK = "studio/frontend/package-lock.json" DEP_FIELDS = ( "dependencies", "devDependencies", "peerDependencies", "optionalDependencies", ) # Files where seeing a package name does NOT count as usage. EXPECTED_NOISE_FILES = { "studio/frontend/package.json", "studio/frontend/package-lock.json", "studio/backend/core/data_recipe/oxc-validator/package.json", "studio/backend/core/data_recipe/oxc-validator/package-lock.json", } # File types where a quoted string can be a module specifier. JS_LIKE_EXT = re.compile(r"\.(ts|tsx|js|jsx|mjs|cjs|html|htm|css|scss|sass|json|jsonc)$") # Files where JS import patterns could be a real module reference (.mdx is # real ESM; .md code fences are not). SCRIPT_LIKE_EXT = re.compile(r"\.(ts|tsx|js|jsx|mjs|cjs|mdx)$") STYLE_EXT = re.compile(r"\.(css|scss|sass)$") HTML_EXT = re.compile(r"\.(html|htm)$") TS_LIKE_EXT = re.compile(r"\.(ts|tsx|mts|cts|mdx)$") # Files where a removed package's CLI binary could be invoked. COMMAND_LIKE_EXT = re.compile(r"(\.(ya?ml|sh|ps1|bat)$|(^|/)Dockerfile[^/]*$)") GREP_INCLUDES = [ "--include=*.ts", "--include=*.tsx", "--include=*.js", "--include=*.jsx", "--include=*.mjs", "--include=*.cjs", "--include=*.html", "--include=*.htm", "--include=*.css", "--include=*.scss", "--include=*.sass", "--include=*.json", "--include=*.jsonc", "--include=*.md", "--include=*.mdx", "--include=*.py", "--include=*.rs", "--include=*.toml", "--include=*.yml", "--include=*.yaml", "--include=*.sh", "--include=*.ps1", "--include=*.bat", "--include=Dockerfile*", ] GREP_EXCLUDES = [ "--exclude-dir=node_modules", "--exclude-dir=dist", "--exclude-dir=.git", "--exclude-dir=__pycache__", "--exclude-dir=target", "--exclude-dir=.next", "--exclude-dir=build", "--exclude-dir=.venv", "--exclude-dir=venv", ] # A pip-installed playwright ref is the PyPI package, not npm. PIP_PLAYWRIGHT = re.compile( r"(pip\s+install\s+['\"]?playwright" r"|python\s+-m\s+playwright" r"|from\s+playwright" r"|^\s*import\s+playwright)" ) @dataclass class Hit: file: str line: int kind: str snippet: str def run(cmd: list[str], cwd: Path | None = None) -> str: """Run a command, return stdout. On non-zero exit, return ''.""" res = subprocess.run( cmd, cwd = cwd or REPO_ROOT, stdout = subprocess.PIPE, stderr = subprocess.PIPE, text = True, ) return res.stdout if res.returncode == 0 else "" def read_pkg_at(base: str, path: str) -> dict: """Read JSON at `base:path` via git show. Empty dict if missing.""" out = run(["git", "show", f"{base}:{path}"]) if not out.strip(): return {} return json.loads(out) def read_pkg_file(path: Path) -> dict: if not path.exists(): return {} return json.loads(path.read_text(encoding = "utf-8")) def all_decl_names(pkg: dict) -> set[str]: names: set[str] = set() for field in DEP_FIELDS: names.update((pkg.get(field) or {}).keys()) return names def _resolve_install_path(parent_path: str, name: str, pkgs: dict) -> str | None: """Walk up the nested node_modules chain from `parent_path` to find where `name` resolves, mirroring Node module resolution.""" parts = parent_path.split("/node_modules/") for i in range(len(parts), 0, -1): prefix = "/node_modules/".join(parts[:i]) trial = (prefix + "/node_modules/" if prefix else "node_modules/") + name if trial in pkgs: return trial if f"node_modules/{name}" in pkgs: return f"node_modules/{name}" return None def _deps_of(meta: dict) -> dict: """Deps npm actually installs. Optional peers are skipped: they can't keep a removed top-level dep reachable on their own.""" out = {} for field in ("dependencies", "optionalDependencies"): out.update(meta.get(field) or {}) peer_meta = meta.get("peerDependenciesMeta") or {} for name, spec in (meta.get("peerDependencies") or {}).items(): if (peer_meta.get(name) or {}).get("optional"): continue out[name] = spec return out def reachable_from_head(head_pkg: dict, lock: dict) -> set[str]: """BFS the lockfile dep graph from `head_pkg`'s top-level deps. Returns the surviving install paths, excluding stale (orphaned) lockfile entries.""" pkgs = lock.get("packages", {}) if not pkgs: return set() roots = all_decl_names(head_pkg) seen: set[str] = set() frontier: list[str] = [] for name in roots: p = _resolve_install_path("", name, pkgs) if p: frontier.append(p) while frontier: path = frontier.pop() if path in seen: continue seen.add(path) meta = pkgs.get(path, {}) for dep_name in _deps_of(meta): p = _resolve_install_path(path, dep_name, pkgs) if p and p not in seen: frontier.append(p) return seen def classify(pkg: str, file: str, content: str) -> str | None: """Return why `content` references `pkg`, or None. `content` may span multiple lines (multi-line imports/exports use re.DOTALL). Bare-spec regexes word-boundary the package name so `foobar` doesn't match `foo`. File-type gating restricts JS patterns to .ts/.tsx/.js/.jsx/.mjs/ .cjs/.mdx, CSS to .css/.scss/.sass, HTML to .html/.htm, so a snippet inside a Python fixture or Markdown code block isn't mistaken for real npm usage. """ if file in EXPECTED_NOISE_FILES: return None esc = re.escape(pkg) # Subpath gate: pkg must be followed by quote, `/`, or end-of-string. sub = r"(?:/[^'\"`]*)?" flags_dotall = re.DOTALL | re.MULTILINE is_script = bool(SCRIPT_LIKE_EXT.search(file)) is_style = bool(STYLE_EXT.search(file)) is_html = bool(HTML_EXT.search(file)) is_ts = bool(TS_LIKE_EXT.search(file)) # Gate out Python fixtures, Markdown code blocks, shell snippets, etc. is_json = file.endswith(".json") or file.endswith(".jsonc") if not (is_script or is_style or is_html or is_json): return None # CSS @import first so it doesn't collide with side-effect-import below. if is_style and re.search(rf"@import\s+['\"]{esc}{sub}['\"]", content): return "css_import" # Static imports, including multi-line `import { ... } from "pkg"`. if is_script and re.search( rf"(?]*src\s*=\s*['\"][^'\"]*/{html_pkg}", content): return "html_script" if is_html and re.search(rf"]*href\s*=\s*['\"][^'\"]*/{html_pkg}", content): return "html_link" # TypeScript triple-slash if is_ts and re.search(rf"///\s* list[str]: """Warn if package-lock.json's dep map disagrees with package.json (i.e. npm install was not re-run).""" warnings = [] if not head_lock: return warnings root = head_lock.get("packages", {}).get("", {}) lock_decl = { **(root.get("dependencies") or {}), **(root.get("devDependencies") or {}), **(root.get("peerDependencies") or {}), **(root.get("optionalDependencies") or {}), } pkg_decl = {} for f in DEP_FIELDS: pkg_decl.update(head_pkg.get(f) or {}) only_in_lock = set(lock_decl) - set(pkg_decl) only_in_pkg = set(pkg_decl) - set(lock_decl) if only_in_lock: warnings.append( f"lockfile lists deps not in package.json (lockfile stale): {sorted(only_in_lock)}" ) if only_in_pkg: warnings.append( f"package.json declares deps not in lockfile (run npm install): {sorted(only_in_pkg)}" ) return warnings def types_orphan_warnings(head_pkg: dict) -> list[str]: """Flag @types/ deps where is no longer declared in package.json, which leaves dangling type packages.""" decl = set() for f in DEP_FIELDS: decl.update((head_pkg.get(f) or {}).keys()) warnings = [] for name in decl: if not name.startswith("@types/"): continue # @types/scope__pkg provides types for @scope/pkg. target = name[len("@types/") :] if "__" in target: scope, sub = target.split("__", 1) target = f"@{scope}/{sub}" if target == "node": continue # Node.js types are always implicit if target not in decl: warnings.append( f"@types/{target.replace('@', '').replace('/', '__')} present but '{target}' is not declared" ) return warnings _PKG_JSON_SKIP_KEYS = { "dependencies", "devDependencies", "peerDependencies", "optionalDependencies", "bundleDependencies", "bundledDependencies", } # Top-level fields whose contents are never package references. _PKG_JSON_OPAQUE_KEYS = { "browserslist", # browser queries "keywords", # free-form strings "engines", # node/npm version constraints "engineStrict", # bool "packageManager", # `pnpm@9.0.0` -- the package manager binary "volta", # version pins for node/npm/yarn "files", # paths included in publish "directories", # paths "publishConfig", # registry / access config "config", # generic npm config values "main", "module", "browser", "types", "typings", "type", "exports", "imports", "bin", "man", # author-side fields (not consumer refs) "scripts", # handled separately via scripts_bin_refs() "repository", "bugs", "homepage", "funding", "author", "contributors", "maintainers", "license", "licenses", "name", "version", "description", "private", "sideEffects", "workspaces", # paths/globs, NOT pkg names } def package_json_extra_refs(pkg: dict, target: str) -> list[str]: """Walk package.json (except dep declaration blocks) and return citations for string values or dict keys equal to `target` (or `target/subpath`). Catches refs that public dep-checkers commonly miss: overrides/resolutions/ pnpm.overrides keys, pnpm.patchedDependencies, peerDependenciesMeta, prettier, eslintConfig.extends, stylelint, babel, jest, commitlint, etc. """ target_sub = target + "/" cites: list[str] = [] def matches(s: object) -> bool: return isinstance(s, str) and (s == target or s.startswith(target_sub)) def walk(obj: object, path: str) -> None: if isinstance(obj, dict): for k, v in obj.items(): if path == "" and k in _PKG_JSON_SKIP_KEYS: continue if path == "" and k in _PKG_JSON_OPAQUE_KEYS: continue # Inside overrides/resolutions/etc., the KEY is a package ref. if matches(k): cites.append(f"{path}.{k}" if path else k) walk(v, f"{path}.{k}" if path else k) elif isinstance(obj, list): for i, v in enumerate(obj): walk(v, f"{path}[{i}]") elif isinstance(obj, str): if matches(obj): cites.append(f"{path}: {obj}") walk(pkg, "") return cites def build_bin_to_pkg(head_lock: dict) -> dict[str, str]: """Map a binary name (e.g. 'vite', 'eslint') to its providing package, from each lockfile entry's `bin` field.""" out: dict[str, str] = {} if not head_lock: return out for path, meta in head_lock.get("packages", {}).items(): if not path: continue name = path.split("node_modules/")[-1] bins = meta.get("bin") if isinstance(bins, dict): for binname in bins: out.setdefault(binname, name) elif isinstance(bins, str): out.setdefault(name.split("/")[-1], name) return out _SCRIPT_TOKENIZE = re.compile(r"\s*(?:&&|\|\||;|\|(?!\|))\s*") # Wrappers that delegate to a real CLI in the same shell word list; we skip # past them and their flags to find the wrapped bin. Script-name wrappers # (concurrently, npm-run-all, turbo, nx) are excluded: they reference script # names, so the real bin lives in the target script's chunk we already tokenize. _SCRIPT_WRAPPERS = {"cross-env", "dotenv", "dotenvx", "env-cmd"} _ENV_PREFIX_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*=") def _next_real_bin(words: list[str], idx: int) -> str | None: """Walk `words` from `idx`, peeling env-prefix tokens, the package-manager runner (npx, pnpm exec, etc.), and known wrapper bins. Return the next real CLI binary, or None. Bounded by the chunk's word count.""" seen_wrappers: set[str] = set() while idx < len(words): # 1. env-prefix run `FOO=bar BAZ="a b" cmd ...` (shlex pre-collapsed). while idx < len(words) and _ENV_PREFIX_RE.match(words[idx]): idx += 1 if idx >= len(words): return None first = words[idx] # 2. Package-manager runner (npx/pnpm exec/yarn dlx/bunx): strip and # continue so the wrapped command re-enters the unwrap loop. if first in {"npx", "pnpx", "bunx"} and idx + 1 < len(words): idx += 1 continue if first in {"pnpm", "yarn"} and idx + 2 < len(words) and words[idx + 1] in {"exec", "dlx"}: idx += 2 continue # 3. Wrapper bin (cross-env, dotenv): skip its flags and env prefixes. bin_token = first.removeprefix("./node_modules/.bin/").removeprefix("node_modules/.bin/") if bin_token in _SCRIPT_WRAPPERS and bin_token not in seen_wrappers: seen_wrappers.add(bin_token) idx += 1 # dotenv/dotenvx use `-e ` flags and an optional `--`. while idx < len(words): tok = words[idx] if tok.startswith("-") and tok != "--": idx += 1 # `-e .env`: also skip the flag's argument. if ( idx < len(words) and not words[idx].startswith("-") and not _ENV_PREFIX_RE.match(words[idx]) ): idx += 1 continue if tok == "--": idx += 1 break break continue return bin_token return None def scripts_bin_refs(head_pkg: dict, bin_to_pkg: dict[str, str]) -> dict[str, list[str]]: """Return `{package_name: ['scripts.X: cmd', ...]}` for every package referenced via its bin name in package.json scripts. Each script is split on shell separators; `_next_real_bin()` unwraps env prefixes, package-manager runners, and wrapper bins so `cross-env CI=1 biome check` credits `biome`. Uses shlex.split so quoted env values survive. """ import shlex scripts = head_pkg.get("scripts", {}) or {} refs: dict[str, list[str]] = {} for script_name, raw_cmd in scripts.items(): if not isinstance(raw_cmd, str): continue for chunk in _SCRIPT_TOKENIZE.split(raw_cmd): chunk = chunk.strip() if not chunk: continue try: words = shlex.split(chunk, posix = True) except ValueError: # Unbalanced quotes: fall back to plain split. words = chunk.split() if not words: continue bin_name = _next_real_bin(words, 0) if bin_name is None: continue pkg = bin_to_pkg.get(bin_name) if pkg: refs.setdefault(pkg, []).append(f"scripts.{script_name}: {raw_cmd}") return refs def tsconfig_compiler_types_refs() -> set[str]: """Return package names in tsconfig*.json compilerOptions.types arrays. These are implicitly loaded by tsc and count as real uses.""" out: set[str] = set() base = REPO_ROOT / "studio/frontend" for name in ("tsconfig.json", "tsconfig.app.json", "tsconfig.node.json"): path = base / name if not path.exists(): continue try: text = path.read_text() # tsconfig allows comments; strip simple line comments. text = re.sub(r"//[^\n]*", "", text) data = json.loads(text) except (OSError, json.JSONDecodeError): continue types = (data.get("compilerOptions", {}) or {}).get("types", []) or [] for t in types: if not isinstance(t, str): continue # `vite/client` resolves to the `vite` package. pkg = t.split("/", 1)[0] if not t.startswith("@") else "/".join(t.split("/", 2)[:2]) out.add(pkg) return out def enumerate_dep_usage(head_pkg: dict, head_lock: dict) -> dict[str, list]: """For every declared dep, classify usage into a dict of package-name lists: used, unused, type_pkg_kept (@types/X with X declared), type_pkg_orphan (@types/X with X gone). `unused` is a CANDIDATE list; verify before deletion. """ decl = all_decl_names(head_pkg) bin_to_pkg = build_bin_to_pkg(head_lock) if head_lock else {} script_refs = scripts_bin_refs(head_pkg, bin_to_pkg) tsc_types = tsconfig_compiler_types_refs() results: dict[str, list] = { "used": [], "unused": [], "type_pkg_kept": [], "type_pkg_orphan": [], } for name in sorted(decl): if name.startswith("@types/"): target = name[len("@types/") :] if "__" in target: scope, sub = target.split("__", 1) target = f"@{scope}/{sub}" if target == "node": results["type_pkg_kept"].append(name) elif target in decl: results["type_pkg_kept"].append(name) else: results["type_pkg_orphan"].append(name) continue # Real-source-usage check hits = find_usage(name) used = bool(hits) # CLI usage in shell/workflow/Dockerfile. Skipped for @types/* (no CLI # binary; the bare-name bin candidate would false-match the runtime). if not used and not name.startswith("@types/") and find_command_usage(name): used = True # Bin scripts if not used and name in script_refs: used = True # package.json non-dep field references if not used and package_json_extra_refs(head_pkg, name): used = True # tsconfig compilerOptions.types implicit usage if not used and name in tsc_types: used = True if used: results["used"].append(name) else: results["unused"].append(name) return results def find_imports_without_decl(head_pkg: dict) -> list[tuple[str, int, str]]: """Reverse check: find bare-specifier imports in studio/frontend/src with no matching package.json dep (import added but dep declaration forgotten). Covers import/require/dynamic-import shapes. Returns (file, line, spec). """ decl = set() for f in DEP_FIELDS: decl.update((head_pkg.get(f) or {}).keys()) # Exclude relative paths and the `@/` alias by requiring the specifier's # first char to be neither `.` nor `/`. Capture group is the specifier. pattern = ( r"(?:\bfrom\s+|" r"\bimport\s+(?:\(\s*)?|" r"\brequire(?:\.resolve)?\(\s*)" r"['\"]([^'\"./][^'\"]*)['\"]" ) args = [ "grep", "-rnE", pattern, "--include=*.ts", "--include=*.tsx", "--include=*.js", "--include=*.jsx", "studio/frontend/src", ] out = run(args) missing = [] for line in out.splitlines(): m = re.match(r"^(?:\./)?([^:]+):(\d+):(.*)$", line) if not m: continue file, ln, content = m.group(1), int(m.group(2)), m.group(3) for spec_match in re.finditer(pattern, content): spec = spec_match.group(1) # Resolve to package name (strip subpath). if spec.startswith("@"): parts = spec.split("/", 2) pkg_name = "/".join(parts[:2]) if len(parts) >= 2 else spec else: pkg_name = spec.split("/", 1)[0] if pkg_name in decl: continue # Internal aliases like '@/foo' or builtin names. if pkg_name == "@": continue if pkg_name in { "node:fs", "node:path", "fs", "path", "url", "stream", "crypto", "buffer", "util", "events", "child_process", }: continue missing.append((file, ln, spec)) return missing def grep_repo(pat: str) -> list[tuple[str, int, str]]: args = ["grep", "-rnE", pat] + GREP_INCLUDES + GREP_EXCLUDES + ["."] out = run(args) rows = [] for line in out.splitlines(): m = re.match(r"^(\./)?([^:]+):(\d+):(.*)$", line) if m: rows.append((m.group(2), int(m.group(3)), m.group(4))) return rows _file_lines_cache: dict[str, list[str]] = {} def _read_file(path: str) -> list[str]: if path not in _file_lines_cache: try: _file_lines_cache[path] = Path(path).read_text(errors = "replace").splitlines() except (OSError, UnicodeDecodeError): _file_lines_cache[path] = [] return _file_lines_cache[path] def find_usage(pkg: str) -> list[Hit]: """Return real usages of `pkg` (pip-playwright filtered separately). For each grep hit, also feed a multi-line window into classify() so multi-line imports get picked up. """ rows = grep_repo(re.escape(pkg)) hits = [] seen_keys: set[tuple[str, str]] = set() for file, lineno, content in rows: if pkg == "playwright" and PIP_PLAYWRIGHT.search(content): continue # Try the single-line classify first. kind = classify(pkg, file, content) if not kind: # Multi-line window (25 lines each side) so Prettier's # one-import-per-line formatting still pairs `import` with `from`. lines = _read_file(file) lo = max(0, lineno - 26) hi = min(len(lines), lineno + 25) window = "\n".join(lines[lo:hi]) kind = classify(pkg, file, window) if kind: key = (file, kind) if key in seen_keys: continue seen_keys.add(key) hits.append(Hit(file, lineno, kind, content[:160])) return hits def _candidate_bin_names(pkg: str) -> set[str]: """Bin names a removed package's CLI could be invoked under. Most npm CLIs use the package name; scoped ones expose an unscoped bin (@biomejs/biome -> biome).""" return {pkg, pkg.rsplit("/", 1)[-1]} def find_command_usage(pkg: str) -> list[Hit]: """Find package CLI invocations in shell/workflow/Dockerfile surfaces (npx, bunx, pnpm exec, yarn dlx, or bare `pkg --flag`). Bounded to COMMAND_LIKE_EXT so `npx foo` in a TS fixture isn't mistaken for real use. """ bins = sorted(_candidate_bin_names(pkg), key = len, reverse = True) esc_bins = "|".join(re.escape(b) for b in bins) # grep ERE pattern. Built without f-strings to avoid clashing with the # POSIX `[[:space:]]` literals. grep_pat = ( r"(^|[[:space:]:;&|(\[])" r"(npx[[:space:]]+|pnpm[[:space:]]+exec[[:space:]]+" r"|yarn[[:space:]]+(dlx[[:space:]]+)?|bunx[[:space:]]+)?" r"(" + esc_bins + r")" r"([[:space:])};|\]]|$)" ) py_pat = re.compile( r"(^|[\s:;&|(\[])" r"(?:npx\s+|pnpm\s+exec\s+|yarn\s+(?:dlx\s+)?|bunx\s+)?" r"(" + esc_bins + r")" r"([\s)};|\]]|$)" ) hits: list[Hit] = [] seen: set[tuple[str, int]] = set() for file, lineno, content in grep_repo(grep_pat): if not COMMAND_LIKE_EXT.search(file): continue if pkg == "playwright" and PIP_PLAYWRIGHT.search(content): continue if not py_pat.search(content): continue key = (file, lineno) if key in seen: continue seen.add(key) hits.append(Hit(file, lineno, "command_bin", content[:160])) return hits def types_target_name(pkg: str) -> str | None: """Strip `@types/` and decode scope-encoding to the runtime package name (`@types/foo__bar` -> `@foo/bar`). None for non-@types packages.""" if not pkg.startswith("@types/"): return None target = pkg[len("@types/") :] if "__" in target: scope, sub = target.split("__", 1) return f"@{scope}/{sub}" return target def find_types_runtime_usage(pkg: str, tsc_types: set[str]) -> list[Hit]: """For a removed `@types/X`, find usages of `X` itself (triple-slash reference, tsconfig types, runtime import). If any exist, @types/X stays.""" target = types_target_name(pkg) if target is None: return [] hits = find_usage(target) if target in tsc_types: hits.append( Hit( "studio/frontend/tsconfig*.json", 0, "tsconfig_types", f'compilerOptions.types includes "{target}"', ) ) return hits def main() -> int: p = argparse.ArgumentParser(description = __doc__, formatter_class = argparse.RawTextHelpFormatter) p.add_argument( "--base", default = "origin/main", help = "git ref to diff against (default: origin/main). " "Examples: HEAD~1, main, a-tag, a-sha.", ) p.add_argument("--base-pkg", help = "optional override: read base package.json from this path") p.add_argument( "--base-lock", help = "optional override: read base package-lock.json from this path. " "Used to recover the bin -> package mapping for removed packages so " "scripts.foo still flags as a usage even after the PR drops node_modules/foo.", ) p.add_argument( "--head-pkg", default = str(REPO_ROOT / FRONTEND_PKG), help = "head package.json path (default: working tree)", ) p.add_argument( "--head-lock", default = str(REPO_ROOT / FRONTEND_LOCK), help = "head lockfile path (default: working tree). " "Reachability analysis runs against this lockfile.", ) p.add_argument("--verbose", action = "store_true") p.add_argument( "--strict", action = "store_true", help = "Also fail on hygiene warnings (lockfile sync, " "@types orphans, imports without declared dep, unused deps).", ) p.add_argument( "--enumerate-dead", action = "store_true", help = "Print every declared dep that appears unused anywhere " "in the repo. Informational; does not fail unless --strict.", ) args = p.parse_args() if args.base_pkg: base_pkg = read_pkg_file(Path(args.base_pkg)) else: base_pkg = read_pkg_at(args.base, FRONTEND_PKG) head_pkg = read_pkg_file(Path(args.head_pkg)) if not base_pkg: print( f"ERROR: could not read base package.json at {args.base}:{FRONTEND_PKG}", file = sys.stderr, ) return 2 if not head_pkg: print( f"ERROR: could not read head package.json at {args.head_pkg}", file = sys.stderr, ) return 2 head_lock_path = Path(args.head_lock) if not head_lock_path.exists(): print( f"ERROR: head lockfile not found at {head_lock_path}", file = sys.stderr, ) return 2 head_lock = read_pkg_file(head_lock_path) # Base lockfile is best-effort: only used to recover the bin -> package # mapping for packages the PR removes, so a scripts.biome cite still fires # when @biomejs/biome is dropped from the head lockfile. if args.base_lock: base_lock_path = Path(args.base_lock) base_lock = read_pkg_file(base_lock_path) if base_lock_path.exists() else {} else: base_lock = read_pkg_at(args.base, FRONTEND_LOCK) base_names = all_decl_names(base_pkg) head_names = all_decl_names(head_pkg) removed = sorted(base_names - head_names) # Hygiene checks compute up front so they run on both the removal-present # and removal-empty paths (so --strict fails on hygiene-only issues). sync_warns = lockfile_root_sync(head_pkg, head_lock) types_warns = types_orphan_warnings(head_pkg) missing_imports = find_imports_without_decl(head_pkg) enum = enumerate_dep_usage(head_pkg, head_lock) if args.enumerate_dead else None def _print_hygiene() -> None: if sync_warns: print("Lockfile sync warnings:") for w in sync_warns: print(f" - {w}") print() if types_warns: print("@types orphan warnings:") for w in types_warns: print(f" - {w}") print() if missing_imports: print(f"Imports without a matching package.json dep ({len(missing_imports)}):") for file, ln, spec in missing_imports[:20]: print(f" - {file}:{ln} imports '{spec}'") print() if enum is not None: print("Dead-dep enumeration:") if enum["unused"]: print(f" unused ({len(enum['unused'])}):") for n in enum["unused"]: print(f" - {n}") else: print(" unused: none") if enum["type_pkg_orphan"]: print(f" type_pkg_orphan ({len(enum['type_pkg_orphan'])}):") for n in enum["type_pkg_orphan"]: print(f" - {n}") if args.verbose: print(f" used: {len(enum['used'])}") print(f" type_pkg_kept: {len(enum['type_pkg_kept'])}") print() hygiene_strict_fail = args.strict and ( sync_warns or types_warns or missing_imports or (enum is not None and (enum["unused"] or enum["type_pkg_orphan"])) ) if not removed: print("[OK] no dependencies removed from studio/frontend/package.json") if args.enumerate_dead or sync_warns or types_warns or missing_imports: print() _print_hygiene() if hygiene_strict_fail: print("FAIL (--strict): one or more hygiene warnings present") return 1 return 0 print(f"Checking {len(removed)} removed package(s) from studio/frontend/package.json") print(f"Base: {args.base} Head: working tree") print() reachable_paths = reachable_from_head(head_pkg, head_lock) if head_lock else set() # bin -> package map from the head lockfile, layering base-lockfile entries # for removed packages so scripts.biome still flags when @biomejs/biome is # dropped (head lockfile no longer maps it). bin_to_pkg = build_bin_to_pkg(head_lock) if head_lock else {} base_bin_to_pkg = build_bin_to_pkg(base_lock) if base_lock else {} removed_set = set(removed) for bin_name, pkg_name in base_bin_to_pkg.items(): if pkg_name in removed_set: bin_to_pkg.setdefault(bin_name, pkg_name) script_refs = scripts_bin_refs(head_pkg, bin_to_pkg) tsc_types = tsconfig_compiler_types_refs() def reachable_install_paths(name: str) -> tuple[str | None, list[str]]: """Return (top_level_path, nested_paths). top_level is what bare `import "name"` resolves to; nested copies are only visible inside their parent package.""" top = f"node_modules/{name}" top_path = top if top in reachable_paths else None nested = sorted( p for p in reachable_paths if p != top and p.endswith(f"/node_modules/{name}") ) return top_path, nested failures: list[tuple[str, list[Hit]]] = [] for name in removed: hits = find_usage(name) # CLI invocations in shell scripts / workflows / Dockerfiles. hits.extend(find_command_usage(name)) # @types/X is "used" if X is referenced as a type or runtime import. hits.extend(find_types_runtime_usage(name, tsc_types)) for cite in script_refs.get(name, []): hits.append(Hit("studio/frontend/package.json", 0, "script_bin", cite)) for cite in package_json_extra_refs(head_pkg, name): hits.append(Hit("studio/frontend/package.json", 0, "pkg_json_field", cite)) top, nested = reachable_install_paths(name) importable_top_level = top is not None # Bare specifier `name` resolves ONLY to top-level node_modules/; # nested copies are invisible to src/ files. if hits and not importable_top_level: status = "FAIL" elif hits and importable_top_level: status = "OK-via-transitive" else: status = "OK" print(f" [{status}] {name}") if top: print(f" reachable (top-level): {top}") if nested: print( f" reachable (nested, NOT importable from src/): {nested[0]}" + (f" (+{len(nested)-1} more)" if len(nested) > 1 else "") ) if hits: for h in hits[:5]: print(f" [{h.kind}] {h.file}:{h.line} {h.snippet}") if status == "FAIL": failures.append((name, hits)) if args.verbose and not hits and not (top or nested): print(" no references, not reachable -- clean removal") print() _print_hygiene() if failures: print(f"FAIL: {len(failures)} removed package(s) still referenced and not resolvable") for name, _ in failures: print(f" - {name}") return 1 if hygiene_strict_fail: print("FAIL (--strict): one or more hygiene warnings present") return 1 print("PASS: all removed packages are safe to drop") return 0 if __name__ == "__main__": raise SystemExit(main())