Some checks failed
ci / check (push) Failing after 4s
The two AM-4 budgets had the SAME scope -- one package, no dev edges -- while claiming to bound different things. AM-4b now measures the workspace with dev edges: 57 crates / 725,258 lines where it read 29 / 317,021, having been blind to 28 crates and 408,237 lines, more source than its own target. Target 745,000, ~2.7% of room -- the same margin ADR-0008 D3 gave AM-4a, applied to a number that grew because the instrument was repaired, not because anything was added. The target moved to fit the measurement. T02: proc-macros are COUNTED here and excluded from AM-4a, on purpose. AM-4a asks what ships and a proc-macro never ships. AM-4b asks what is acquired, and ADR-0007 D3's acquisition rule counts what the build fetches -- 'it does not ship' is no answer to 'we downloaded it'. When the rules disagree, the question each budget asks decides. Measured share 109,585 lines / 15.1% against AM-4a's 36.2%, so ADR-0008 D2's refusal to borrow the ratio was right by more than a factor of two. Caught by this project's own earlier work twice: the mutation find-string went stale and --self-test reported it BUILD-FREE (the check CB-WP-0015 added after AM-4a's rotted for two passes), then the DFD gate caught facts.toml carrying the old numbers. CB-EV-0001 and ADR-0004 carried live fact: tags on historical readings. A dated record asserting a CURRENT value is a category error, so those occurrences are marked as-measured instead of retro-edited, and ADR-0004 gains a supersession note. make all exits 0. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
316 lines
13 KiB
Python
Executable file
316 lines
13 KiB
Python
Executable file
#!/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())
|