By Contexory

Dependency Upgrade Auditor

Works out what a dependency upgrade actually breaks for you, by extracting the specific APIs your code imports from a package and reading the release notes against that list rather than in general. Use when the user is upgrading a package, evaluating whether a version bump is safe, or asks why something broke after a dependency changed.

1 supporting file

Dependency upgrade auditor

A major version's release notes describe everything that changed for everybody. Almost none of it applies to you. The work is intersecting their breaking-change list with the handful of APIs you actually call, and that intersection is usually small enough to check line by line.

Reading a changelog top to bottom produces a summary of someone else's release. Reading it against your own import list produces a decision.

Procedure

1. Extract what you actually use

<skill-dir> is the directory this SKILL.md was loaded from — the skill installs outside your project, so its script is named by full path, never relatively.

python3 <skill-dir>/scripts/dep_usage.py react-router     # one package
python3 <skill-dir>/scripts/dep_usage.py --all            # every direct dependency, summarised

The script reports the declared version range, every file importing the package, and the specific named imports, called members and subpath imports in use. That last list is the one that matters — it is what you check the changelog against.

2. Get the real changelog, not a summary of it

Prefer, in order: the package's CHANGELOG.md in its published tarball or repository, the GitHub release notes for each intervening version, then a migration guide. Read every version between the installed one and the target, not just the target — breaking changes land in the majors you are skipping over.

If you cannot reach the notes, say so and stop. Guessing at a changelog produces confident, invented migration steps, which is worse than no answer.

3. Intersect, and be specific about the misses

For each breaking change in the notes, decide: does it touch an API in the step-1 list? Most will not. Report the ones that do with the call site, and state how many you dismissed — the count is what tells the reader you read the whole list rather than stopping at the first hit.

4. Check the quiet categories

The intersection catches renamed and removed APIs. Four kinds of break do not show up as an API you import, and each needs a deliberate look:

  • peer dependency ranges that no longer overlap with what you have installed
  • runtime floor raised — a new minimum Node, Python or compiler version
  • default behaviour changes under an unchanged signature, which the intersection

    cannot see because the name did not move

  • transitive breakage, where the package you are bumping pulls a new major of

    something else

5. Rank the work

Output an ordered list: what must change before the upgrade compiles, what must change before it behaves correctly, and what is merely deprecated and can wait. Those are three different urgencies and collapsing them into one list is how upgrades stall.

Output

  1. Versions — from, to, and how many majors are being crossed
  2. Surface in use — the APIs this repo imports, and how many call sites
  3. Breaking changes that hit you — each with the call site and the fix
  4. Dismissed — how many breaking changes did not apply, so the reader knows the list

    was read to the end

  5. Quiet risks — peers, runtime floor, behaviour changes, transitives
  6. Order of work — compile-blocking, behaviour-blocking, deferrable

What this skill deliberately does not do

  • It does not invent a changelog. If the release notes cannot be fetched, it says so

    and stops rather than reconstructing plausible migration steps from the version number.

  • It does not run the upgrade. No installs, no lockfile edits, no package.json

    changes — the audit informs a decision that is the author's to make.

  • It does not report every breaking change in the release. The ones that miss your

    code are noise, and the count is enough.

  • It does not assume semver was honoured. A minor can break you; the intersection is

    run regardless of the bump's advertised size.

  • It does not audit transitive dependencies it was not asked about, beyond naming the

    ones this bump drags along.

When this is the wrong tool

  • Something is already failing and you have a trace. Start from the trace — it names a

    frame, which is more than any changelog will. Come back here if it lands in vendored code.

  • The changed surface is your own code, not a dependency's. That is a question about

    your own exports and who calls them.

  • You want the upgrade written up for a release. Announcing a change and deciding

    whether to take it are different jobs.

Supporting files

1 file

scripts/dep_usage.pyPython10 KB
#!/usr/bin/env python3
"""Extract the surface of a dependency that a repository actually uses.

An upgrade decision needs the intersection of the package's breaking changes and
your import list. This produces the second half of that intersection: declared
range, importing files, and the specific names in use.

    python3 dep_usage.py <package>     # one package, in detail
    python3 dep_usage.py --all         # every direct dependency, ranked by use

Handles JS/TS (`import`/`require`) and Python (`import`/`from`). Standard library
only; nothing is installed, fetched or modified.
"""

from __future__ import annotations

import json
import os
import re
import subprocess
import sys
from collections import defaultdict

SOURCE_RE = re.compile(r"\.(ts|tsx|js|jsx|mjs|cjs|py)$")
SKIP_RE = re.compile(r"(^|/)(node_modules|dist|build|\.next|coverage|vendor|__pycache__)(/|$)")

MANIFEST_FIELDS = ("dependencies", "devDependencies", "peerDependencies", "optionalDependencies")


def git_root() -> str:
    out = subprocess.run(
        ["git", "rev-parse", "--show-toplevel"], capture_output=True, text=True
    )
    return out.stdout.strip() or os.getcwd()


def manifests(root: str) -> list[str]:
    """Every package.json in the tree, so a monorepo reports per-workspace ranges."""
    found = []
    for dirpath, dirnames, filenames in os.walk(root):
        dirnames[:] = [d for d in dirnames if not d.startswith(".") and not SKIP_RE.search(f"/{d}/")]
        if "package.json" in filenames:
            found.append(os.path.join(dirpath, "package.json"))
    return found


def declared(root: str, package: str) -> list[tuple[str, str, str]]:
    """(manifest, field, range) for each declaration of `package`."""
    rows = []
    for path in manifests(root):
        try:
            with open(path, encoding="utf-8") as handle:
                data = json.load(handle)
        except (OSError, json.JSONDecodeError):
            continue
        for field in MANIFEST_FIELDS:
            spec = (data.get(field) or {}).get(package)
            if spec:
                rows.append((os.path.relpath(path, root), field, spec))
    return rows


def sources(root: str) -> list[str]:
    out = []
    for dirpath, dirnames, filenames in os.walk(root):
        dirnames[:] = [d for d in dirnames if not d.startswith(".") and not SKIP_RE.search(f"/{d}/")]
        for name in filenames:
            rel = os.path.relpath(os.path.join(dirpath, name), root).replace("\\", "/")
            if SOURCE_RE.search(rel) and not SKIP_RE.search(f"/{rel}"):
                out.append(rel)
    return out


def usage(root: str, package: str) -> tuple[dict[str, list[int]], set[str], set[str]]:
    """(files → line numbers, named imports, subpath imports)."""
    esc = re.escape(package)
    patterns = [
        # import { a, b } from "pkg" / import x from "pkg/sub"
        re.compile(rf"""import\s+(?P<names>[^'"]+?)\s+from\s+['"](?P<path>{esc}(?:/[^'"]*)?)['"]"""),
        # const { a } = require("pkg")
        re.compile(rf"""(?P<names>(?:const|let|var)\s+[^=]+?)=\s*require\(['"](?P<path>{esc}(?:/[^'"]*)?)['"]\)"""),
        # bare side-effect import
        re.compile(rf"""import\s+['"](?P<path>{esc}(?:/[^'"]*)?)['"]"""),
        # python: from pkg.sub import a, b
        re.compile(rf"""from\s+(?P<path>{esc}(?:\.[\w.]+)?)\s+import\s+(?P<names>.+)"""),
        re.compile(rf"""import\s+(?P<path>{esc}(?:\.[\w.]+)?)\s*$"""),
    ]

    files: dict[str, list[int]] = defaultdict(list)
    names: set[str] = set()
    subpaths: set[str] = set()

    for rel in sources(root):
        try:
            with open(os.path.join(root, rel), encoding="utf-8", errors="replace") as handle:
                lines = handle.readlines()
        except OSError:
            continue
        for i, line in enumerate(lines, 1):
            for pattern in patterns:
                m = pattern.search(line)
                if not m:
                    continue
                files[rel].append(i)
                path = m.groupdict().get("path") or ""
                if path and path not in (package,):
                    subpaths.add(path)
                raw = m.groupdict().get("names") or ""
                for token in re.split(r"[{},*\s]+|as\s+", raw):
                    token = token.strip().strip(";")
                    if token and token not in ("const", "let", "var", "import", "from", "type", "default"):
                        names.add(token)
                break
    return files, names, subpaths


# Module specifiers, split into two passes rather than one alternation.
#
# They cannot share a regex. Alternation is resolved leftmost-first, not
# best-first, so on `import yaml from "js-yaml"` the unquoted Python branch
# matched at column 0 and captured the *binding* `yaml` — the quoted branch was
# never reached. The survey then reported `js-yaml` as imported by zero files and
# printed "usually a build-time or transitive-only dependency", which is the
# worst possible answer from a tool whose job is upgrade safety.
#
# Quoted specifiers are unambiguous, so they are tried first and win outright.
QUOTED_SPECIFIER_RE = re.compile(
    r"""(?:from|import)\s*\(?\s*['"]([^'"]+)['"]"""
    r"""|require\(\s*['"]([^'"]+)['"]\s*\)"""
)
# Only then the unquoted Python form, and only when the line carries no quote at
# all — `import x from "pkg"` is JavaScript however much its prefix looks like
# Python.
PY_IMPORT_RE = re.compile(r"""^\s*(?:from|import)\s+([A-Za-z_][\w.]*)(?![^\n'"]*['"])""")
IMPORT_NAMES_RE = re.compile(r"""import\s+(?:type\s+)?\{([^}]*)\}|import\s+(?:type\s+)?(\w+)\s+from""")


def package_of(specifier: str) -> str | None:
    """Map a module specifier to the package that owns it.

    `@scope/pkg/sub` → `@scope/pkg`; `pkg/sub` → `pkg`; `pkg.sub` (Python) →
    `pkg`. A relative specifier belongs to no package and returns None.
    """
    if not specifier or specifier.startswith(("." , "/")) or specifier.startswith("node:"):
        return None
    if "." in specifier and "/" not in specifier and not specifier.startswith("@"):
        return specifier.split(".", 1)[0]
    parts = specifier.split("/")
    if specifier.startswith("@"):
        return "/".join(parts[:2]) if len(parts) >= 2 else specifier
    return parts[0]


def scan_all(root: str) -> tuple[dict[str, set[str]], dict[str, set[str]]]:
    """Single pass: package → importing files, and package → named imports."""
    files: dict[str, set[str]] = defaultdict(set)
    names: dict[str, set[str]] = defaultdict(set)

    for rel in sources(root):
        try:
            with open(os.path.join(root, rel), encoding="utf-8", errors="replace") as handle:
                content = handle.read()
        except OSError:
            continue
        for line in content.splitlines():
            m = QUOTED_SPECIFIER_RE.search(line) or PY_IMPORT_RE.search(line)
            if not m:
                continue
            specifier = next((g for g in m.groups() if g), "")
            package = package_of(specifier)
            if not package:
                continue
            files[package].add(rel)
            n = IMPORT_NAMES_RE.search(line)
            if n:
                raw = n.group(1) or n.group(2) or ""
                for token in re.split(r"[,\s]+|as\s+", raw):
                    token = token.strip()
                    if token and token not in ("type", "default"):
                        names[package].add(token)
    return files, names


def report_one(root: str, package: str) -> int:
    decls = declared(root, package)
    files, names, subpaths = usage(root, package)

    print(f"package: {package}\n")
    if decls:
        print("declared:")
        for manifest, field, spec in decls:
            print(f"  {spec:<16} {field:<18} {manifest}")
    else:
        print("declared: not found in any package.json (transitive, or not a JS package)")

    total = sum(len(v) for v in files.values())
    print(f"\nimported in {len(files)} file(s), {total} import site(s)")
    for rel in sorted(files)[:30]:
        print(f"  {rel}:{','.join(str(n) for n in files[rel][:6])}")
    if len(files) > 30:
        print(f"  … and {len(files) - 30} more files")

    if subpaths:
        print("\nsubpath imports (each is its own compatibility surface):")
        for path in sorted(subpaths):
            print(f"  {path}")

    print(f"\nnamed imports in use ({len(names)}):")
    print("  " + (", ".join(sorted(names)) if names else "(none — side-effect or default import only)"))

    print(
        "\nThis is the list to check the changelog against. A breaking change that "
        "touches none of these names does not apply to you — count it and move on.\n"
        "Names alone cannot catch a changed default under an unchanged signature; "
        "check behaviour changes separately."
    )
    return 0


def report_all(root: str) -> int:
    seen: dict[str, str] = {}
    for path in manifests(root):
        try:
            with open(path, encoding="utf-8") as handle:
                data = json.load(handle)
        except (OSError, json.JSONDecodeError):
            continue
        for field in ("dependencies", "devDependencies"):
            for name, spec in (data.get(field) or {}).items():
                seen.setdefault(name, spec)

    if not seen:
        print("No package.json dependencies found.")
        return 0

    print(f"{len(seen)} direct dependencies\n")
    # One pass over the tree, attributing every import to a package — not one
    # pass per package. The naive form was O(files × dependencies) and took 94
    # seconds on a 112-dependency monorepo, which is long enough that nobody
    # runs the survey twice.
    per_files, per_names = scan_all(root)
    rows = [
        (name, seen[name], len(per_files.get(name, ())), len(per_names.get(name, ())))
        for name in seen
    ]
    rows.sort(key=lambda r: -r[2])

    print(f"{'files':>6}  {'names':>6}  {'range':<16} package")
    for name, spec, n_files, n_names in rows:
        print(f"{n_files:>6}  {n_names:>6}  {spec:<16} {name}")
    print("\nZero files usually means a build-time or transitive-only dependency.")
    return 0


def main() -> int:
    if len(sys.argv) != 2:
        print(f"usage: {sys.argv[0]} <package>|--all", file=sys.stderr)
        return 64
    root = git_root()
    return report_all(root) if sys.argv[1] == "--all" else report_one(root, sys.argv[1])


if __name__ == "__main__":
    sys.exit(main())