add spec system: sdd/bdd/tdd loops, trace enforcement, envelope+config specs
This commit is contained in:
@@ -0,0 +1,86 @@
|
||||
#!/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())
|
||||
Reference in New Issue
Block a user