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.
This commit is contained in:
2026-09-19 13:17:12 -07:00
parent 2d12cbcfbd
commit b166e796ad
3 changed files with 77 additions and 0 deletions
+75
View File
@@ -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: