#!/usr/bin/env python3 """AM-4: third-party dependency weight, measured as source under audit. Crate count is a poor cross-ecosystem proxy — Rust splits crates far more finely than npm, so "33 crates vs 120 npm packages" flatters us in one direction and a low crate-count target punishes us in the other. What the count stands in for is how much third-party source a reviewer would have to audit. This measures that directly, in two configurations: shipped-runtime cargo build --no-default-features (what a game ships) dev-toolchain cargo build (adds scenario YAML) Positive control (InnerLoop v1.0 §Step 5): every crate in the dependency graph must be located on disk and produce a non-zero line count. A crate that cannot be found is reported and the run exits non-zero rather than silently under-reporting the total — under-reporting is the exact direction this metric could be gamed. Usage: python3 tools/dep-weight.py [--json] [--self-test] """ import glob import json import os import subprocess import sys from repo import cargo_bin, enter_root # AM-4a asks what a GAME SHIPS, so it measures one package's non-dev # graph. AM-4b asks what a CONTRIBUTOR ACQUIRES, so it measures the whole # workspace including dev edges (CB-WP-0019 T01). They had the same scope # until 2026-08-03, which left AM-4b blind to 28 crates and 408,237 lines # — more source than its own target. PACKAGE = "games-ground" WORKSPACE = "--workspace" # ADR-0008 D2. `--edges normal` includes proc-macro crates, which run in # the compiler and never reach a shipped binary — 89,048 lines, 36.2% of # what this tool used to call "what a game ships", `syn` alone 66,916. The # shipped-runtime configuration now excludes them. # # AM-4b's proc-macro share is now MEASURED: 109,585 lines, 15.1% of its # real graph (CB-WP-0019 T02). It is deliberately **not** excluded, and # that is the opposite of AM-4a's treatment for a stated reason: # # AM-4a excludes proc-macros because they run in the compiler and never # reach a shipped binary — counting them in "what a game ships" was # simply false. # # AM-4b counts them, because ADR-0007 D3's acquisition rule counts what # the build causes to be FETCHED, and a proc-macro is fetched, compiled # and unaudited on a contributor's machine exactly like any other # dependency. "It does not ship" is no answer to "we downloaded it". # # When the two rules disagree, the question each budget asks decides: # AM-4a asks what ships, AM-4b asks what is acquired. # # ADR-0008 D2 said this share was unmeasured. That is no longer true. PROC_MACRO_EXCLUDED = ["--edges", "normal,no-proc-macro"] CONFIGS = { "shipped-runtime": ["-p", PACKAGE, "--no-default-features"] + PROC_MACRO_EXCLUDED, "dev-toolchain": [WORKSPACE, "--edges", "normal,dev"], } # AM-4a / AM-4b targets from specs/GameKernel.md §4. Breaching one fails # the build: a gate that only reports is a suggestion. # ADR-0008 D3: the target moves down with the instrument. Leaving it at # 250,000 against a corrected 157,202 would hand this project 89,048 lines # of headroom it did not earn, in the same change that revealed the error. # 161,000 keeps ~2.4% of room where 250,000 kept ~1.5% — the small # rounding up is the only thing this decision gives back, because a target # with 1.5% of room fails on a dependency's patch release. # CB-WP-0019 T01: the target moves to fit the measurement, never the # reverse. AM-4b now measures 725,258 where it used to read 317,021 — not # because anything was added, but because it started looking at what it # always claimed to bound. A 350,000 target against a 725,258 reading # would be a budget that is simply breached, which teaches nothing. # # 745,000 keeps ~2.7% of room, on ADR-0008 D3's reasoning that a target # with ~1.5% fails on a dependency's patch release. It is NOT generosity: # it is the same margin AM-4a got, applied to a number that grew because # the instrument was fixed. TARGETS = { "shipped-runtime": 161_000, "dev-toolchain": 745_000, } def crates(extra_args): """Third-party crates in the normal (non-dev) dependency graph.""" # T01: locate cargo rather than demanding the caller export PATH. The # error below is kept as a positive control — it should now be # unreachable on a machine with rustup installed, and a control that # never fires is still cheaper than a regression. cargo = cargo_bin() if not cargo: print( "ERROR: cargo not found on PATH or in ~/.cargo/bin — is rustup installed?", file=sys.stderr, ) sys.exit(1) out = subprocess.run( # The package/workspace selector now comes from the config, so the # two budgets can ask different questions. Before CB-WP-0019 both # were pinned to one package, which is what made AM-4b blind. [cargo, "tree", "--prefix", "none"] + (extra_args if "--edges" in extra_args else ["--edges", "normal"] + extra_args), capture_output=True, text=True, check=True, ).stdout found = {} for line in out.splitlines(): parts = line.split() if len(parts) < 2 or not parts[1].startswith("v"): continue name, version = parts[0], parts[1].lstrip("v") # Path dependencies are our own code, not third-party. if "(/" in line: continue found[name] = version return found def source_lines(name, version): """Lines of Rust in the vendored source for one crate.""" roots = glob.glob(os.path.expanduser("~/.cargo/registry/src/*/")) for root in roots: # Version may carry a build suffix (e.g. 0.9.34+deprecated). for d in glob.glob(f"{root}{name}-{version}*/") + glob.glob(f"{root}{name}-*/"): total = 0 for dirpath, _, files in os.walk(d): for f in files: if f.endswith(".rs"): try: with open(os.path.join(dirpath, f), "rb") as fh: total += fh.read().count(b"\n") except OSError: pass if total: return total return 0 def _dev_only_dependency_is_counted(): """AM-4b must actually see dev edges — the defect it was blind to. `quick-js` is a dev-dependency of `cb-render-html` and is the crate that exposed the scope defect: it landed in CB-WP-0014, AM-4b did not move, and the ADR that added it withdrew its own cost argument as a result. If this budget stops seeing it, the blindness is back. """ dev = crates(CONFIGS["dev-toolchain"]) shipped = crates(CONFIGS["shipped-runtime"]) return "quick-js" in dev, "quick-js" in shipped def self_test(): """Each assertion pins a failure this tool must detect. The controls that matter here are: an unlocatable crate must not be silently counted as zero lines (that under-reports, the direction this metric could be gamed), and a target breach must fail rather than merely print. """ results = [] def _check(name, ok, detail=""): results.append((name, ok, detail)) # CB-WP-0019 T01: the two budgets must ask DIFFERENT questions, and # `quick-js` is the case that proves it. It is a dev-dependency of # cb-render-html; it landed in CB-WP-0014, AM-4b did not move, and # ADR-0009 withdrew its own cost argument as a result. If AM-4b stops # seeing it the blindness is back; if AM-4a starts seeing it, the # shipped budget has been widened by accident. in_dev, in_shipped = _dev_only_dependency_is_counted() _check("AM-4b sees a dev-only dependency", in_dev, "quick-js is dev-only and is what exposed the scope defect") _check("AM-4a does NOT see a dev-only dependency", not in_shipped, "the shipped budget must stay about what ships") def check(name, ok, detail=""): results.append((name, ok, detail)) # A crate that does not exist must measure zero, so the caller's # `lines == 0` guard fires rather than silently shrinking the total. check("unlocatable crate measures zero (so the guard fires)", source_lines("definitely-not-a-real-crate-xyz", "9.9.9") == 0) # A crate we do depend on must measure non-zero, or the guard above # would fire on everything and the tool would never report at all. real = source_lines("serde", "1") check("a real vendored crate measures non-zero", real > 0, f"{real:,} lines") # Targets must be present and numeric — a missing target would make # the breach check vacuous. # T01: this tool is useless without cargo, and used to demand the caller # put it on PATH. Assert it resolves unaided. check("cargo resolves without caller PATH setup", bool(cargo_bin()), cargo_bin() or "NOT FOUND") # ADR-0008 D2. The exclusion must remove exactly the proc-macro crates # and nothing else — a flag that quietly dropped a runtime dependency # would shrink the number in the direction this metric can be gamed. with_pm = crates(["--no-default-features"]) without_pm = crates(["--no-default-features"] + PROC_MACRO_EXCLUDED) dropped = set(with_pm) - set(without_pm) check("the proc-macro exclusion drops exactly the expected crates", dropped == {"syn", "quote", "proc-macro2", "unicode-ident", "serde_derive"}, f"dropped {sorted(dropped)}") check("the exclusion only ever removes crates, never adds", set(without_pm) <= set(with_pm), f"{len(with_pm)} -> {len(without_pm)}") # And it must actually remove something: an exclusion that excluded # nothing would leave the old figure while claiming the new meaning. check("the exclusion is not a no-op", len(dropped) > 0, f"{len(dropped)} crate(s) dropped") check("targets defined for every configuration", set(TARGETS) == set(CONFIGS) and all( isinstance(v, int) and v > 0 for v in TARGETS.values()), f"{TARGETS}") print("dep-weight self-test (positive control)") ok = True for name, passed, detail in results: print(f" [{'ok ' if passed else 'FAIL'}] {name}" + (f" — {detail}" if detail else "")) ok &= passed return 0 if ok else 1 def main(): # T01: `cargo tree` and the own-source walk are both repo-relative. enter_root() if "--self-test" in sys.argv: return self_test() report = {} missing = [] for label, args in CONFIGS.items(): found = crates(args) per_crate = {} for name, version in sorted(found.items()): lines = source_lines(name, version) if lines == 0: missing.append(f"{name} {version} ({label})") per_crate[name] = lines report[label] = { "crates": len(found), "third_party_loc": sum(per_crate.values()), "per_crate": per_crate, } own = 0 for base in ("crates", "games", "tools"): for dirpath, _, files in os.walk(base): if "target" in dirpath.split(os.sep): continue for f in files: if f.endswith(".rs"): with open(os.path.join(dirpath, f), "rb") as fh: own += fh.read().count(b"\n") report["own_loc"] = own if "--json" in sys.argv: print(json.dumps(report, indent=2)) else: print("AM-4 dependency weight") print(f" own source {own:>9,} lines") for label in CONFIGS: r = report[label] limit = TARGETS[label] mark = "ok " if r["third_party_loc"] <= limit else "FAIL" print( f" {label:<18}{r['crates']:>3} crates " f"{r['third_party_loc']:>9,} lines third-party " f"[{mark} target {limit:,}]" ) # AM-4c: own source per 100k third-party lines. A DIAGNOSTIC, not a # target — see specs/GameKernel.md §5 and CB-WP-0006 T04. The ratio # has no monotone better direction, so it cannot carry a threshold. for label in CONFIGS: tp = report[label]["third_party_loc"] if tp: print(f" AM-4c {label:<18}{own / (tp / 100_000):>9,.0f} own " f"lines per 100k third-party (diagnostic, not targeted)") delta = ( report["dev-toolchain"]["third_party_loc"] - report["shipped-runtime"]["third_party_loc"] ) print(f" scenario tooling costs {delta:>9,} lines (dev only)") if missing: # Positive control: a crate we could not measure would silently # shrink the total, so refuse to report rather than under-report. print("\nERROR — source not found for:", ", ".join(missing), file=sys.stderr) return 1 breached = [ (label, report[label]["third_party_loc"], limit) for label, limit in TARGETS.items() if report[label]["third_party_loc"] > limit ] for label, actual, limit in breached: print( f"\nFAIL AM-4 — {label}: {actual:,} lines exceeds target {limit:,}", file=sys.stderr, ) return 1 if breached else 0 if __name__ == "__main__": sys.exit(main())