Files
discord_bot/tools/trace.py
T

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())