#!/usr/bin/env python3 """Semantic diff of npm ``package-lock.json`` files for PR comments. ``git diff`` on a lockfile is unreadable: npm reorders entries, rewrites integrity hashes, and moves packages between nesting levels, so a one-line ``package.json`` bump can produce a thousand-line textual diff. This script ignores the text entirely — it parses the ``packages`` map out of both versions of each lockfile (lockfileVersion 2/3), reduces each to ``{install path: version}``, and set-diffs the two dicts. Reordering and hash churn vanish; what's left is the actual dependency change. Usage (from a checkout that still has the base ref available): python scripts/ci/lockfile_diff.py --base --head \ --output diff.md [--repo-root .] Reads every ``package-lock.json`` tracked at either ref (top-level and nested — the repo has several), diffs each, and writes a Markdown report to ``--output``. Exits 0 always; an empty report file means "no version changes" (the caller uses that to decide whether to post/update the PR comment). The report embeds ``COMMENT_MARKER`` so the workflow can find and update its own previous comment instead of stacking new ones. """ from __future__ import annotations import argparse import json import subprocess import sys # Hidden marker used to locate the bot's previous comment for in-place update. COMMENT_MARKER = "" def parse_lockfile(text: str) -> dict[str, str]: """Reduce lockfile JSON to ``{install path: version}``. Keys are the ``packages`` map's keys (e.g. ``node_modules/react`` or ``node_modules/foo/node_modules/react``), so the same package deduped at two versions shows up as two distinct entries. The root entry (``""``, the workspace itself) is skipped, as are versionless link entries. """ data = json.loads(text) out: dict[str, str] = {} for path, meta in data.get("packages", {}).items(): if not path: continue # root project entry, not a dependency version = meta.get("version") if version: out[path] = version return out def diff_locks(base: dict[str, str], head: dict[str, str]) -> dict[str, list]: """Set-diff two ``{path: version}`` maps. Returns ``added`` / ``removed`` as ``[(path, version)]`` and ``updated`` as ``[(path, base_version, head_version)]``, each sorted by path. """ added = sorted((p, v) for p, v in head.items() if p not in base) removed = sorted((p, v) for p, v in base.items() if p not in head) updated = sorted( (p, base[p], head[p]) for p in base.keys() & head.keys() if base[p] != head[p] ) return {"added": added, "removed": removed, "updated": updated} def _display_name(path: str) -> str: """``node_modules/foo/node_modules/@scope/bar`` → ``@scope/bar (nested under foo)``.""" parts = path.split("node_modules/") name = parts[-1].rstrip("/") if len(parts) > 2: parents = " → ".join(p.rstrip("/") for p in parts[1:-1]) return f"{name} *(nested under {parents})*" return name def render_markdown(diffs: dict[str, dict[str, list]]) -> str: """Render per-lockfile diffs as a Markdown PR comment body. ``diffs`` maps lockfile repo-path → the output of :func:`diff_locks`. Lockfiles with no version changes are omitted. Returns ``""`` when nothing changed anywhere (caller skips commenting entirely). """ sections = [] total = 0 for lockfile, d in sorted(diffs.items()): added, removed, updated = d["added"], d["removed"], d["updated"] n = len(added) + len(removed) + len(updated) if n == 0: continue total += n lines = [f"### `{lockfile}`", ""] lines.append("| Package | Before | After |") lines.append("| --- | --- | --- |") for path, old, new in updated: lines.append(f"| {_display_name(path)} | `{old}` | `{new}` |") for path, version in added: lines.append(f"| ➕ {_display_name(path)} | — | `{version}` |") for path, version in removed: lines.append(f"| ➖ {_display_name(path)} | `{version}` | — |") sections.append("\n".join(lines)) if not sections: return "" header = ( f"{COMMENT_MARKER}\n" f"## ⚠️ `package-lock.json` changes ({total} package" f"{'s' if total != 1 else ''})\n\n" "This PR changes locked npm dependency versions." ) return header + "\n" + "\n\n".join(sections) + "\n" def _git_show(ref: str, path: str, repo_root: str) -> str | None: """Contents of ``path`` at ``ref``, or None if it doesn't exist there.""" proc = subprocess.run( ["git", "show", f"{ref}:{path}"], capture_output=True, text=True, cwd=repo_root, ) return proc.stdout if proc.returncode == 0 else None def _tracked_lockfiles(ref: str, repo_root: str) -> set[str]: proc = subprocess.run( ["git", "ls-tree", "-r", "--name-only", ref], capture_output=True, text=True, cwd=repo_root, check=True, ) return { line for line in proc.stdout.splitlines() if line.split("/")[-1] == "package-lock.json" } def diff_refs(base: str, head: str, repo_root: str = ".") -> dict[str, dict[str, list]]: """Diff every package-lock.json tracked at either ref.""" lockfiles = _tracked_lockfiles(base, repo_root) | _tracked_lockfiles(head, repo_root) diffs = {} for path in sorted(lockfiles): base_text = _git_show(base, path, repo_root) head_text = _git_show(head, path, repo_root) base_map = parse_lockfile(base_text) if base_text else {} head_map = parse_lockfile(head_text) if head_text else {} diffs[path] = diff_locks(base_map, head_map) return diffs def main() -> int: ap = argparse.ArgumentParser(description=__doc__) ap.add_argument("--base", required=True, help="base git ref (merge base)") ap.add_argument("--head", required=True, help="head git ref") ap.add_argument("--output", required=True, help="markdown output path") ap.add_argument("--repo-root", default=".", help="repository root") args = ap.parse_args() diffs = diff_refs(args.base, args.head, args.repo_root) markdown = render_markdown(diffs) with open(args.output, "w", encoding="utf-8") as fh: fh.write(markdown) if markdown: changed = sum(len(v) for d in diffs.values() for v in d.values()) print(f"{changed} package version change(s) — report written to {args.output}") else: print("No package version changes.") return 0 if __name__ == "__main__": sys.exit(main())