Files
Ty 84d7681c6f docs(phase2): coverage gate, migration TODOs, session handoff
- add tools/check_coverage.py and .audit/coverage ledger (schema + settings pilot)
- add coverage gap worklist and source-doc migration TODOs
- add session handoff; redirect stale resume points in AGENTS.md, docs/README.md, reconciliation record
- add rows: CAPTURE-41, CAPTURE-141, METADATA-66, PUBLISH-31, SETTINGS-51
2026-09-18 12:54:08 -07:00

158 lines
5.5 KiB
Python

#!/usr/bin/env python3
"""Enforce the coverage ledger described in .audit/coverage/README.md.
Run from the repo root:
python tools/check_coverage.py # forward checks; report gaps
python tools/check_coverage.py --strict # also require full reverse coverage
python tools/check_coverage.py --ledger-dir <path>
Forward checks always run. `--strict` adds the reverse rule and fails on any `gap`.
Exit 0 = clean for the selected mode. Exit 1 = problems.
"""
from __future__ import annotations
import argparse
import re
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
REQUIREMENTS = ROOT / "docs" / "REQUIREMENTS.md"
DEFAULT_LEDGER = ROOT / ".audit" / "coverage"
REQUIREMENT_ROW = re.compile(
r"^\|\s*([A-Z]+)-(\d+)\s*\|\s*(.+?)\s*\|\s*(.+?)\s*\|\s*(.+?)\s*\|\s*$"
)
SEPARATOR = re.compile(r"^-+$")
DISPOSITIONS = {"ported", "duplicate", "new", "superseded", "deferred", "dropped", "gap"}
NEEDS_ID = {"ported", "new"}
FORBIDS_ID = {"dropped", "gap"}
PLACEHOLDER = {"", "\u2014", "-"}
def defined_ids() -> set[str]:
ids: set[str] = set()
if not REQUIREMENTS.exists():
return ids
for line in REQUIREMENTS.read_text(encoding="utf-8").splitlines():
match = REQUIREMENT_ROW.match(line)
if match:
ids.add(f"{match.group(1)}-{match.group(2)}")
return ids
def cells_of(line: str) -> list[str] | None:
"""Return the six cells of a ledger data row, or None for non-rows."""
stripped = line.strip()
if not stripped.startswith("|"):
return None
cells = [cell.strip() for cell in stripped.split("|")][1:-1]
if not cells:
return None
if cells[0].lower() == "source":
return None
if all(SEPARATOR.match(cell) for cell in cells if cell):
return None
return cells
def parse_ids(field: str) -> list[str]:
parts = [part.strip() for part in field.split(",")]
return [part for part in parts if part not in PLACEHOLDER]
def main() -> int:
parser = argparse.ArgumentParser(description="Check the coverage ledger.")
parser.add_argument("--strict", action="store_true", help="require full reverse coverage")
parser.add_argument("--ledger-dir", type=Path, default=DEFAULT_LEDGER)
args = parser.parse_args()
ids = defined_ids()
errors: list[str] = []
gaps: list[str] = []
claimed: set[str] = set()
seen_atoms: dict[tuple[str, str], str] = {}
counts: dict[str, int] = {}
ledger_dir: Path = args.ledger_dir
if not ledger_dir.exists():
print(f"FAIL check_coverage: ledger directory missing: {ledger_dir}")
return 1
files = [path for path in sorted(ledger_dir.glob("*.md")) if path.name != "README.md"]
if not files:
print(f"FAIL check_coverage: no ledger files in {ledger_dir}")
return 1
for path in files:
for number, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1):
cells = cells_of(line)
if cells is None:
continue
loc = f"{path.name}:{number}"
if len(cells) != 6:
errors.append(f"{loc} expected 6 columns, got {len(cells)}")
continue
source, atom, requirement, disposition, poer, rationale = cells
if not source or not atom or not requirement:
errors.append(f"{loc} empty Source, Atom, or Requirement")
continue
if disposition not in DISPOSITIONS:
errors.append(f"{loc} unknown disposition '{disposition}'")
continue
counts[disposition] = counts.get(disposition, 0) + 1
key = (source, atom)
if key in seen_atoms:
errors.append(f"{loc} duplicate atom {source} {atom} (first at {seen_atoms[key]})")
else:
seen_atoms[key] = loc
cited = parse_ids(poer)
if disposition in NEEDS_ID and not cited:
errors.append(f"{loc} {source} {atom}: {disposition} needs a PoR ID")
if disposition in FORBIDS_ID and cited:
errors.append(f"{loc} {source} {atom}: {disposition} must not cite a PoR ID")
if disposition != "ported" and rationale in PLACEHOLDER:
errors.append(f"{loc} {source} {atom}: {disposition} needs a rationale")
for rid in cited:
if rid not in ids:
errors.append(f"{loc} {source} {atom}: unknown PoR ID {rid}")
else:
claimed.add(rid)
if disposition == "gap":
gaps.append(f"{source} {atom}: {rationale}")
if args.strict:
for rid in sorted(ids):
if rid not in claimed:
errors.append(f"unclaimed requirement ID (no source atom and not marked new): {rid}")
for gap in gaps:
errors.append(f"open gap: {gap}")
total_rows = sum(counts.values())
summary = ", ".join(f"{key}={counts[key]}" for key in sorted(counts))
claimed_pct = (len(claimed) / len(ids) * 100) if ids else 0.0
if errors:
print(f"FAIL check_coverage: {len(errors)} problem(s)")
for error in errors:
print(f" - {error}")
return 1
print(
f"PASS check_coverage: {total_rows} atoms ({summary}); "
f"claimed {len(claimed)}/{len(ids)} IDs ({claimed_pct:.0f}%); "
f"{len(gaps)} gap(s)"
)
for gap in gaps:
print(f" gap: {gap}")
return 0
if __name__ == "__main__":
sys.exit(main())