T09: close the spec->code link mechanically (M-D1-LNK)

make coverage now reports a second number: how many claimed rules are
also NAMED in the aggregate source. 58/58 tag coverage was weaker
evidence than it read as, and this says how much weaker.

Measured: 49 of 58. Nine rules are claimed by a scenario and appear
nowhere in games/ground/src/lib.rs --
GR-D07 GR-F02 GR-L03 GR-O03 GR-P01 GR-P02 GR-P03 GR-P04 GR-T01.

The gate REPORTS rather than fails, on purpose. Closing the gap by
adding those IDs to comments would satisfy the check without
establishing that any of the nine is implemented -- the overclaim
InnerLoop implementation rule 2 exists to prevent, and one CB-WP-0001
already committed once. Each needs its implementation confirmed before
it is tagged; promoting M-D1-LNK to a failing gate is correct after
that, not before.

Also added: a phantom check that fails when a rule id appears in code
that the spec does not define (currently zero).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
tegwick 2026-07-31 09:27:14 +02:00
parent 4fd6322e17
commit 9e30bbb2b3
3 changed files with 58 additions and 1 deletions

View file

@ -31,6 +31,7 @@ and add capability-specific rows only when these don't cover the claim.
| ID | Dimension | Metric | Unit | Provenance |
|---|---|---|---|---|
| M-D1-COV | D1 | numbered spec rules covered by ≥1 passing scenario | % | adapted:requirements-traceability (per-rule, not per-feature) |
| M-D1-LNK | D1 | claimed rules also **named in the aggregate source** | count | novel — closes the spec→code→scenario chain; instrument: `make coverage` (AM-1b) |
| M-D1-SPL | D1 | spec lines per numbered rule | lines | novel — proxies statement simplicity; gameable, so paired with M-D1-COV |
| M-D2-LOC | D2 | source LOC excluding tests (tokei) | lines | adopted:tokei |
| M-D2-DEP | D2 | transitive dependency count (cargo tree) | crates | adopted:cargo-deny practice |
@ -44,6 +45,32 @@ and add capability-specific rows only when these don't cover the claim.
| M-D4-LEAK | D4 | foreign types in canonical interfaces | count | novel — must be 0; enforced by grep/deny rule, the Clay-Borg hard rule |
| M-D4-SWAP | D4 | capability has null + reference impls passing the same conformance suite | bool | adapted:hexagonal-architecture port testing |
### 1b. The coverage gate's two numbers (M-D1-COV, M-D1-LNK)
M-D1-COV counts tags. It proves no rule is unclaimed and no claimed rule
is invented; it does **not** prove a scenario exercises what it names, and
that limit is printed with the number every time (InnerLoop implementation
rule 4).
**M-D1-LNK** (CB-WP-0003 T09) closes one link of that chain mechanically:
a rule a scenario claims should also appear in the aggregate source, or
the claim rests on a tag and nothing else.
Measured 2026-07-31: **49 of 58** claimed rules are named in
`games/ground/src/lib.rs`. **Unmet**, target 58. The nine unlinked:
```text
GR-D07 GR-F02 GR-L03 GR-O03 GR-P01 GR-P02 GR-P03 GR-P04 GR-T01
```
The gate **reports** this rather than failing, deliberately. Closing the
gap by adding rule IDs to comments would satisfy the check without
establishing that each rule is implemented — which is the overclaim
InnerLoop implementation rule 2 exists to prevent, and which CB-WP-0001
committed once already. Each of the nine needs its implementation
confirmed before it is tagged. Promoting M-D1-LNK to a failing gate is
correct **after** that, not before.
### 1a. Token cost accounting (M-D2-CST)
> **Superseded 2026-07-31 by [CostAccounting.md](CostAccounting.md)**, which

View file

@ -26,12 +26,19 @@ import sys
RULE_RE = r"\*\*(GR-[A-Z]+\d+)"
COVERS_RE = r"covers: \[(.*?)\]"
AGGREGATE = "games/ground/src/lib.rs"
ID_RE = r"GR-[A-Z]+\d+"
def parse_rules(spec_text):
return sorted(set(re.findall(RULE_RE, spec_text)))
def parse_code_ids(text):
"""Rule IDs named anywhere in the aggregate source (T09)."""
return set(re.findall(ID_RE, text))
def parse_covers(text):
match = re.search(COVERS_RE, text, re.S)
if not match:
@ -58,6 +65,12 @@ def self_test():
check("covers matcher works", parse_covers("covers: [GR-R06, GR-A12]")
== {"GR-R06", "GR-A12"})
check("missing covers yields empty set", parse_covers("no covers key") == set())
# T09: the spec -> code link must be detectable.
check("code-id matcher finds ids in source",
parse_code_ids("// GR-R06: lead first\nfn f(){} // GR-A12")
== {"GR-R06", "GR-A12"})
check("code-id matcher finds none in unmarked source",
parse_code_ids("fn f() { let x = 1; }") == set())
print("rule-coverage self-test (positive control)")
ok = True
@ -94,10 +107,27 @@ def main():
missing = [r for r in rules if r not in covered]
invented = sorted(covered - known)
# T09: the spec -> code -> scenario chain, made mechanical. A rule a
# scenario claims should also be named in the aggregate, or the claim
# rests on nothing but a tag.
code_ids = parse_code_ids(open(AGGREGATE).read())
unlinked = sorted((known & covered) - code_ids)
phantom = sorted(code_ids - known)
pct = 100 * len(hit) // len(rules)
linked = len((known & covered) & code_ids)
print(f"AM-1 rule coverage: {len(hit)}/{len(rules)} ({pct}%) "
f"over {len(paths)} scenarios")
print(f"AM-1b spec->code link: {linked}/{len(hit)} claimed rules also "
f"named in {AGGREGATE}")
print(" NOTE: counts tags; does not prove a scenario exercises what it names")
if unlinked:
print(" unlinked (claimed by a scenario, absent from the aggregate):")
print(" ", " ".join(unlinked))
if phantom:
print(" ERROR — rule id in code that the spec does not define:",
" ".join(phantom), file=sys.stderr)
return 1
if missing:
print(" uncovered:", " ".join(missing))
if invented:

View file

@ -304,7 +304,7 @@ file must list them, not that the build breaks.
```task
id: CB-WP-0003-T09
status: todo
status: done
priority: low
state_hub_task_id: "69ccc9ec-dc35-481c-8065-cef040f07f50"
```