87 lines
3.4 KiB
Python
87 lines
3.4 KiB
Python
#!/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())
|