#!/usr/bin/env python3 """CASAN Plan-10 traceability matrix generator/gate. Parses FR-* requirements from apps/okr/domain/input/okr-requirement.md and checks each requirement has at least one existing code file and one existing test file. """ import argparse import json import os import re import sys from datetime import datetime, timezone FR_RE = re.compile(r"\|\s*(FR-\d+)\s*\|\s*([^|]+?)\s*\|") def project_root() -> str: # Plan-01: harness code lives in packages/casan-harness/; a fixed __file__ parent # depth lands on the package, not the app. Walk UP for the `.specify` state marker # so this resolves the app root whether invoked via packages/... or the .specify facade. d = os.path.abspath(os.path.dirname(__file__)) p = d while p != os.path.dirname(p): if os.path.isdir(os.path.join(p, ".specify")): return p p = os.path.dirname(p) return os.path.abspath(os.path.join(d, "..", "..", "..")) def parse_requirements(path: str): seen = {} with open(path, encoding="utf-8") as f: for line in f: match = FR_RE.search(line) if not match: continue fr_id, name = match.groups() if fr_id not in seen: seen[fr_id] = {"id": fr_id, "name": " ".join(name.split())} return [seen[k] for k in sorted(seen)] def normalize_entry(value): """Accept either a plain path string or an object with symbol/line refs. Backward compatible: a bare string behaves exactly as file-level tracing. Object form: {"file": "path", "symbols": ["name", ...], "lines": [n, ...]}. """ if isinstance(value, str): return value, [], [] if isinstance(value, dict): return value.get("file", ""), list(value.get("symbols", [])), list(value.get("lines", [])) return "", [], [] def symbol_present(text: str, symbol: str) -> bool: """Symbol-level check: the symbol appears as a definition or reference. Covers common TS/JS/Python forms: `class X`, `function x`, `x(`, `const x`, `x =`, `x:` (method/property). Deliberately permissive but anchored on word boundaries so a substring alone does not count. """ esc = re.escape(symbol) patterns = [ rf"\b(?:function|class|interface|type|enum|const|let|var|def)\s+{esc}\b", rf"\b{esc}\s*[=:(]", ] return any(re.search(p, text) for p in patterns) def resolve_files(root: str, values): """Resolve file existence plus optional symbol/line coverage. Returns present files, missing files, per-file symbol results, and the list of unsatisfied symbol/line references (which make the requirement FAIL). """ present, missing = [], [] symbol_results = [] missing_symbols = [] missing_lines = [] for value in values or []: rel, symbols, lines = normalize_entry(value) if not rel: continue abs_path = os.path.join(root, rel) if not os.path.isfile(abs_path): missing.append(rel) for sym in symbols: missing_symbols.append(f"{rel}#{sym}") for ln in lines: missing_lines.append(f"{rel}:L{ln}") continue present.append(rel) if not symbols and not lines: continue with open(abs_path, encoding="utf-8", errors="replace") as fh: text = fh.read() total_lines = text.count("\n") + 1 for sym in symbols: found = symbol_present(text, sym) symbol_results.append({"file": rel, "symbol": sym, "found": found}) if not found: missing_symbols.append(f"{rel}#{sym}") for ln in lines: if not isinstance(ln, int) or ln < 1 or ln > total_lines: missing_lines.append(f"{rel}:L{ln}") return present, missing, symbol_results, missing_symbols, missing_lines def main() -> int: root = project_root() ap = argparse.ArgumentParser() ap.add_argument("--requirements", default=os.path.join(root, "apps/okr/domain/input/okr-requirement.md")) ap.add_argument("--map", default=os.path.join(root, "apps/okr/domain/traceability-map.json")) ap.add_argument("--out", default=os.path.join(root, "docs/output/casan/traceability-matrix.json")) ap.add_argument("--gate", action="store_true") args = ap.parse_args() reqs = parse_requirements(args.requirements) with open(args.map, encoding="utf-8") as f: mapping = json.load(f) rows = [] failures = [] total_symbols = 0 total_symbols_found = 0 for req in reqs: entry = mapping.get(req["id"], {}) code, missing_code, code_syms, code_missing_syms, code_missing_lines = resolve_files( root, entry.get("code", []) ) tests, missing_tests, test_syms, test_missing_syms, test_missing_lines = resolve_files( root, entry.get("tests", []) ) symbol_results = code_syms + test_syms missing_symbols = code_missing_syms + test_missing_syms missing_lines = code_missing_lines + test_missing_lines total_symbols += len(symbol_results) total_symbols_found += sum(1 for s in symbol_results if s["found"]) ok = ( code and tests and not missing_code and not missing_tests and not missing_symbols and not missing_lines ) status = "PASS" if ok else "FAIL" row = { "id": req["id"], "name": req["name"], "status": status, "code": code, "tests": tests, "missing_code": missing_code, "missing_tests": missing_tests, "symbol_refs": symbol_results, "missing_symbols": missing_symbols, "missing_lines": missing_lines, } rows.append(row) if status != "PASS": failures.append(row) orphan_mappings = sorted(set(mapping) - {r["id"] for r in reqs}) out = { "generated_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), "requirements_source": os.path.relpath(args.requirements, root), "mapping_source": os.path.relpath(args.map, root), "summary": { "requirements": len(reqs), "passed": sum(1 for r in rows if r["status"] == "PASS"), "failed": len(failures), "symbol_refs": total_symbols, "symbols_found": total_symbols_found, "symbols_missing": total_symbols - total_symbols_found, "orphan_mappings": orphan_mappings, }, "matrix": rows, } os.makedirs(os.path.dirname(args.out), exist_ok=True) with open(args.out, "w", encoding="utf-8") as f: json.dump(out, f, indent=2, ensure_ascii=False) f.write("\n") if failures: for row in failures: print( f"TRACEABILITY_FAIL {row['id']} code={len(row['code'])} tests={len(row['tests'])} " f"missing_code={len(row['missing_code'])} missing_tests={len(row['missing_tests'])} " f"missing_symbols={len(row['missing_symbols'])} missing_lines={len(row['missing_lines'])}", file=sys.stderr, ) if orphan_mappings: print(f"TRACEABILITY_WARN orphan_mappings={','.join(orphan_mappings)}", file=sys.stderr) print( f"TRACEABILITY_MATRIX requirements={len(reqs)} pass={out['summary']['passed']} " f"fail={len(failures)} symbols={total_symbols_found}/{total_symbols} out={args.out}" ) if args.gate and failures: return 1 return 0 if __name__ == "__main__": raise SystemExit(main())