e93507a09c
Lockfile supply-chain audit / lockfile supply-chain audit (push) Has been cancelled
Windows Studio GGUF CI / GPU prebuilt resolves without Visual Studio (push) Has been cancelled
Windows Studio GGUF CI / setup.ps1 unit tests (VS 2026 / CMake guard) (push) Has been cancelled
Windows Studio GGUF CI / real-VS detection (VS 2022) (push) Has been cancelled
Windows Studio GGUF CI / real-VS detection (VS 2026) (push) Has been cancelled
Windows Studio GGUF CI / VC++ runtime detect + install round-trip (windows-2025-vs2026) (push) Has been cancelled
Windows Studio GGUF CI / VC++ runtime detect + install round-trip (windows-latest) (push) Has been cancelled
Windows Studio Update CI / Studio Updating Tests (push) Has been cancelled
Wheel CI / Wheel build + content sanity + import smoke (push) Has been cancelled
Lint CI / Source lint (Python + shell + YAML + JSON + safety nets) (push) Has been cancelled
MLX CI on Mac M1 / dispatch (push) Has been cancelled
Security audit / advisory audit (pip + npm + cargo) (push) Has been cancelled
Security audit / pip scan-packages :: extras (push) Has been cancelled
Security audit / pip scan-packages :: studio (push) Has been cancelled
Security audit / pip scan-packages :: hf-stack (push) Has been cancelled
Security audit / npm scan-packages (Studio frontend tarballs) (push) Has been cancelled
Security audit / workflow-trigger lint (pull_request_target / cache-poisoning) (push) Has been cancelled
Security audit / pytest tests/security (push) Has been cancelled
Security audit / npm provenance + new install-script diff (push) Has been cancelled
Studio API CI / Studio API & Auth Tests (push) Has been cancelled
Backend CI / (Python 3.10) (push) Has been cancelled
Backend CI / (Python 3.11) (push) Has been cancelled
Backend CI / (Python 3.12) (push) Has been cancelled
Backend CI / (Python 3.13) (push) Has been cancelled
Backend CI / Repo tests (CPU) (push) Has been cancelled
Frontend CI / Frontend build + bundle sanity (push) Has been cancelled
Studio GGUF CI / OpenAI, Anthropic API tests (push) Has been cancelled
Studio GGUF CI / Tool calling Tests (push) Has been cancelled
Studio GGUF CI / JSON, images (push) Has been cancelled
Mac Studio GGUF CI / OpenAI, Anthropic API tests (push) Has been cancelled
Mac Studio GGUF CI / Tool calling Tests (push) Has been cancelled
Mac Studio GGUF CI / JSON, images (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-14) (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-15) (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-26) (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-15-intel) (push) Has been cancelled
Mac Studio API CI / Studio API & Auth Tests (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-26-intel) (push) Has been cancelled
Mac Studio UI CI / Chat UI Tests (push) Has been cancelled
Studio Tauri CI / Tauri Linux debug build (no codesign) (push) Has been cancelled
Mac Studio Update CI / Studio Updating Tests (push) Has been cancelled
Studio UI CI / Chat UI Tests (push) Has been cancelled
Windows Studio API CI / Studio API & Auth Tests (push) Has been cancelled
Windows Studio UI CI / Chat UI Tests (push) Has been cancelled
Studio Update CI / Studio Updating Tests (push) Has been cancelled
Core / Core (HF=default + TRL=default) (push) Has been cancelled
Core / Core (HF=4.57.6 + TRL<1) (push) Has been cancelled
Core / Core (HF=latest + TRL=latest) (push) Has been cancelled
Core / llama.cpp build + smoke (push) Has been cancelled
Windows Studio GGUF CI / OpenAI, Anthropic API tests (push) Has been cancelled
Windows Studio GGUF CI / Tool calling Tests (push) Has been cancelled
Windows Studio GGUF CI / JSON, images (push) Has been cancelled
Windows Studio GGUF CI / Studio install + inference without Visual Studio (push) Has been cancelled
Studio export capability / capability (macos-latest) (push) Has been cancelled
Studio export capability / capability (ubuntu-latest) (push) Has been cancelled
Studio export capability / capability (windows-latest) (push) Has been cancelled
Cross-platform parity / parity (macos-latest) (push) Has been cancelled
Cross-platform parity / parity (windows-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
Studio load-orchestrator CI / test (push) Has been cancelled
1043 lines
37 KiB
Python
1043 lines
37 KiB
Python
#!/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"(?<!@)\bimport\b[^;'\"]*?\bfrom\s+['\"]{esc}{sub}['\"]",
|
|
content,
|
|
flags_dotall,
|
|
):
|
|
return "static_import"
|
|
# Side-effect import `import "pkg"` (no `from`); lookbehind rules out @import.
|
|
if is_script and re.search(rf"(?<!@)\bimport\s+['\"]{esc}{sub}['\"]", content):
|
|
return "side_effect_import"
|
|
# Dynamic import: `import("pkg")` and `await import("pkg")`.
|
|
if is_script and re.search(rf"\bimport\(\s*['\"]{esc}{sub}['\"]\s*\)", content):
|
|
return "dynamic_import"
|
|
# require / require.resolve
|
|
if is_script and re.search(rf"\brequire(?:\.resolve)?\(\s*['\"]{esc}{sub}['\"]\s*\)", content):
|
|
return "require"
|
|
# Re-exports: `export * from`, `export { x } from`, `export type { Foo } from`.
|
|
if is_script and re.search(
|
|
rf"\bexport\b[^;'\"]*?\bfrom\s+['\"]{esc}{sub}['\"]",
|
|
content,
|
|
flags_dotall,
|
|
):
|
|
return "re_export"
|
|
# HTML script / link. Match pkg as a complete path segment so
|
|
# `/node_modules/foo-extra/...` is not treated as usage of `foo`.
|
|
html_pkg = rf"{esc}(?:/[^'\"#?]*)?(?=['\"#?])"
|
|
if is_html and re.search(rf"<script[^>]*src\s*=\s*['\"][^'\"]*/{html_pkg}", content):
|
|
return "html_script"
|
|
if is_html and re.search(rf"<link[^>]*href\s*=\s*['\"][^'\"]*/{html_pkg}", content):
|
|
return "html_link"
|
|
# TypeScript triple-slash
|
|
if is_ts and re.search(rf"///\s*<reference\s+types\s*=\s*['\"]{esc}{sub}['\"]", content):
|
|
return "tsc_triple_slash"
|
|
# new URL("pkg/...", import.meta.url)
|
|
if is_script and re.search(rf"\bnew\s+URL\(\s*['\"]{esc}{sub}['\"]", content):
|
|
return "new_url"
|
|
# CSS url(...), quoted and unquoted, bounded so `pkg-extra` doesn't match.
|
|
if is_style and re.search(
|
|
rf"\burl\(\s*['\"]?(?:[^)'\"\s]+/)?{esc}(?:/[^)'\"`]*)?['\"]?\s*\)",
|
|
content,
|
|
):
|
|
return "css_url"
|
|
# Template literal containing the package as the leading specifier
|
|
if is_script and re.search(rf"`{esc}{sub}`", content):
|
|
return "template_literal"
|
|
# JSDoc / TS @import comment: `@import("pkg")`
|
|
if is_script and re.search(rf"@import\(\s*['\"]{esc}{sub}['\"]\s*\)", content):
|
|
return "jsdoc_import"
|
|
# Bare quoted-string fallback (config plugin lists, vite aliases,
|
|
# tsconfig paths, biome plugin arrays, shadcn registries).
|
|
if not JS_LIKE_EXT.search(file):
|
|
return None
|
|
# pkg must be followed by `'`, `"`, or `/` so `foo` doesn't match `foobar`.
|
|
if re.search(rf"['\"]{esc}(?:['\"]|/)", content):
|
|
return "string_literal"
|
|
return None
|
|
|
|
|
|
def lockfile_root_sync(head_pkg: dict, head_lock: dict) -> list[str]:
|
|
"""Warn if package-lock.json's <root> 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 <root> 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 <root> (run npm install): {sorted(only_in_pkg)}"
|
|
)
|
|
return warnings
|
|
|
|
|
|
def types_orphan_warnings(head_pkg: dict) -> list[str]:
|
|
"""Flag @types/<X> deps where <X> 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 <file>` 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/<name>;
|
|
# 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())
|