#!/usr/bin/env python3 """trace.py — enforce spec requirement coverage (SPEC-000). Stdlib only. Collects declared requirement IDs from specs/SPEC-*.md headers (code fences stripped), @ID tags from features/*.feature, ID mentions from tests/**/*.py, and rows from manual-verification.md. Fails when a declared requirement lacks its coverage artifact, when a feature tag or manual row references an undeclared ID, or when an ID is declared twice. """ import re import sys from pathlib import Path ROOT = Path(__file__).resolve().parent.parent ID_PATTERN = r"[A-Z]{2,8}-\d{2,3}" HEADER_RE = re.compile(rf"^###\s+({ID_PATTERN})\s+[—–-]+\s+.*\(coverage:\s*(feature|test|manual|withdrawn[^)]*)\)", re.M) FENCE_RE = re.compile(r"^```.*?^```", re.M | re.S) TAG_RE = re.compile(rf"@({ID_PATTERN})\b") MANUAL_ROW_RE = re.compile(rf"^\|\s*({ID_PATTERN})\s*\|", re.M) def collect(): declared = {} errors = [] for spec in sorted((ROOT / "specs").glob("SPEC-*.md")): text = FENCE_RE.sub("", spec.read_text(encoding="utf-8")) for req_id, coverage in HEADER_RE.findall(text): if req_id in declared: errors.append(f"{req_id}: declared twice") declared[req_id] = coverage feature_tags = set() features_dir = ROOT / "features" if features_dir.exists(): for feature in features_dir.glob("**/*.feature"): feature_tags |= set(TAG_RE.findall(feature.read_text(encoding="utf-8"))) test_ids = set() for test_file in (ROOT / "tests").glob("**/*.py"): test_ids |= set(re.findall(ID_PATTERN, test_file.read_text(encoding="utf-8"))) manual_ids = set() manual_file = ROOT / "manual-verification.md" if manual_file.exists(): manual_ids = set(MANUAL_ROW_RE.findall(manual_file.read_text(encoding="utf-8"))) return declared, feature_tags, test_ids, manual_ids, errors def coverage_errors(declared, feature_tags, test_ids, manual_ids): errors = [] for req_id, coverage in sorted(declared.items()): if "withdrawn" in coverage: continue if coverage == "feature" and req_id not in feature_tags: errors.append(f"{req_id}: coverage 'feature' but no @{req_id} tag under features/") elif coverage == "test" and req_id not in test_ids and req_id not in feature_tags: errors.append(f"{req_id}: coverage 'test' but not mentioned under tests/ or features/") elif coverage == "manual" and req_id not in manual_ids: errors.append(f"{req_id}: coverage 'manual' but no row in manual-verification.md") errors += [f"@{tag}: tagged under features/ but not declared in specs/" for tag in sorted(feature_tags - set(declared))] errors += [f"{mid}: manual-verification.md row without spec declaration" for mid in sorted(manual_ids - set(declared))] return errors def main() -> int: declared, feature_tags, test_ids, manual_ids, errors = collect() errors += coverage_errors(declared, feature_tags, test_ids, manual_ids) if errors: print("trace: FAIL") for error in errors: print(f" - {error}") return 1 by_class = {c: sum(1 for v in declared.values() if v == c) for c in ("feature", "test", "manual")} print( f"trace: OK — {len(declared)} requirements ({by_class['feature']} feature / {by_class['test']} test / {by_class['manual']} manual)" ) return 0 if __name__ == "__main__": sys.exit(main())