From b166e796add1ffccd01a427edddbcdbb240c5e0f Mon Sep 17 00:00:00 2001 From: Ty Balascio Date: Sat, 19 Sep 2026 13:17:12 -0700 Subject: [PATCH] tools(docs): require citations in citing docs Two checks were added to catch drift between the point of record and the docs that cite it. - Check 11: a capability or contract doc must declare its IDs in a Requirements impact table, and may cite only those. - Check 12: a section that makes a behavior claim must cite an ID in that section. External-shape sections are exempt. CAPTURE.md and CREATE.md gained the impact-table rows the first check requires. check_coverage is unchanged. --- docs/capabilities/CAPTURE.md | 1 + docs/capabilities/CREATE.md | 1 + tools/check_requirements.py | 75 ++++++++++++++++++++++++++++++++++++ 3 files changed, 77 insertions(+) diff --git a/docs/capabilities/CAPTURE.md b/docs/capabilities/CAPTURE.md index dc16dd8..309e223 100644 --- a/docs/capabilities/CAPTURE.md +++ b/docs/capabilities/CAPTURE.md @@ -17,6 +17,7 @@ Milestone 1 uses one reference photo; Milestone 2 adds the batch. Milestone 1 en | CAPTURE-70, CAPTURE-75, CAPTURE-80, CAPTURE-85, CAPTURE-90 | inputs and measure math | | CAPTURE-95, CAPTURE-100, CAPTURE-105 | inventory PNG | | CAPTURE-141, CAPTURE-145, CAPTURE-150, CAPTURE-155, CAPTURE-160 | preview, knobs, failure, rounding | +| CAPTURE-25, CAPTURE-115 | calibration photo is the measurement reference | | APP-10 | device draft and resume | | QUALITY-40, QUALITY-45, QUALITY-55, QUALITY-60, QUALITY-65, QUALITY-70, QUALITY-75, QUALITY-80, QUALITY-85, QUALITY-100 | deterministic tests and vectors | diff --git a/docs/capabilities/CREATE.md b/docs/capabilities/CREATE.md index 335bd59..1c6f5f5 100644 --- a/docs/capabilities/CREATE.md +++ b/docs/capabilities/CREATE.md @@ -18,6 +18,7 @@ lives in `docs/contracts/` (`API.md`, `WOO-API.md`, `DATA-MODEL.md`, `PROMPTS.md | METADATA-70, METADATA-75, METADATA-80, METADATA-85, METADATA-90, METADATA-95 | deterministic copy, inference-off path, pattern selection, fact block | | METADATA-100, METADATA-105, METADATA-106, METADATA-107, METADATA-110, METADATA-115 | feat mapping, prompt storage, revision interface, Generate text control, failure triage | | PUBLISH-5, PUBLISH-10, PUBLISH-15, PUBLISH-20 | create sequence, draft rule, duplicate, stale | +| CAPTURE-15, CAPTURE-165, OPS-30, OPS-35, QUALITY-115, METADATA-107 | out of scope | | PUBLISH-25, PUBLISH-30, PUBLISH-31, PUBLISH-35, PUBLISH-36, PUBLISH-37, PUBLISH-38 | success, failure, partial failure, triage | | PUBLISH-40, PUBLISH-45, PUBLISH-50, PUBLISH-55, PUBLISH-60 | images, lineup, meaning of published, payload, quantity | | SETTINGS-5, SETTINGS-10, SETTINGS-15, SETTINGS-20, SETTINGS-25, SETTINGS-30, SETTINGS-35 | store URL, auth, crypto, inference config, rates | diff --git a/tools/check_requirements.py b/tools/check_requirements.py index 703c4b8..a3dd133 100644 --- a/tools/check_requirements.py +++ b/tools/check_requirements.py @@ -36,6 +36,19 @@ REQUIREMENT_ROW = re.compile( ) MILESTONE_IDS = re.compile(r"^-\s*IDs:\s*(.*)$") ID_TOKEN = re.compile(r"^[A-Z]+-\d+$") +# An ID cited in prose, e.g. `METADATA-55` or (REVIEW-15). +CITED_ID = re.compile(r"\b([A-Z]+-\d+)\b") +# Behavior-claim language; a section using it must cite an ID. +NORMATIVE = re.compile(r"\b(must|always|never|only runs|requires|is required)\b", re.IGNORECASE) +# Sections that document external facts or shapes, not our behavior. +EXTERNAL_SECTION = re.compile( + r"(shape|versioning|error|canonical|attributes` element)", re.IGNORECASE +) +# Docs that cite requirement IDs and must carry a "Requirements impact" table. +CITING_ROLES = {"capability", "contract"} +# Docs whose whole job is citing IDs across the set; no subset table applies. +CITATION_EXEMPT = {"docs/contracts/TEST-SPEC.md"} +HEADING = re.compile(r"^(#{2,4})\s+(.*)$") SCOPES = {"shipped", "M3", "later"} PLACEHOLDER = {"", "—", "-", "*(none yet)*"} @@ -154,6 +167,68 @@ def main() -> int: elif token not in ids: errors.append(f"MILESTONES.md:{number} unknown ID {token}") + # 11. a citing doc may reference only IDs it declares in its Requirements impact table + for rel, role in sorted(registered.items()): + if role not in CITING_ROLES or rel in CITATION_EXEMPT: + continue + path = ROOT / rel + if not path.exists(): + continue + text = path.read_text(encoding="utf-8") + declared: set[str] = set() + section = "" + for line in text.splitlines(): + if line.startswith("#"): + section = line + continue + if "requirements impact" in section.lower(): + declared.update(CITED_ID.findall(line)) + if not declared: + errors.append(f"{rel} is role '{role}' but declares no Requirements impact IDs") + continue + for number, line in enumerate(text.splitlines(), 1): + for cited in CITED_ID.findall(line): + if cited in ids and cited not in declared: + errors.append( + f"{rel}:{number} cites {cited} outside its Requirements impact table" + ) + + # 12. a normative section in a citing doc must cite an ID + for rel, role in sorted(registered.items()): + if role not in CITING_ROLES or rel in CITATION_EXEMPT: + continue + path = ROOT / rel + if not path.exists(): + continue + section = "" + cites: set[str] = set() + line_no = 0 + blocks: list[tuple[str, int, set[str]]] = [] + + def flush() -> None: + if section and NORMATIVE.search(block_text) and not cites: + blocks.append((section, line_no, set())) + + block_text = "" + for number, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1): + if line.startswith("#"): + flush() + section = line + cites = set() + block_text = "" + line_no = number + continue + block_text += line + "\n" + cites.update(CITED_ID.findall(line)) + flush() + + for name, at, _ in blocks: + if EXTERNAL_SECTION.search(name): + continue + errors.append( + f"{rel}:{at} section '{name.strip('# ')}' makes a behavior claim but cites no requirement ID" + ) + if errors: print(f"FAIL check_requirements: {len(errors)} problem(s)") for error in errors: