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:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user