Repoint every remaining literal `.specify/...` / `docs/input` reference to the real package/domain location and delete all compat symlinks. The harness now runs purely via packages/casan-harness/... with no .specify facade; .specify holds ONLY runtime state (logs/, agentops/alerts.log, level5/central-governance). Refs fixed (Phase 0.5 only caught `$VAR/.specify/` — these were bare/`__file__`/literal): - secrets-scan.sh: scan-target excludes -> packages/casan-harness/... (+ apps/okr/domain/corpus) - loop_common.py: loop-policy.yaml -> harness config (package-relative) - evidence-pack-build.py: judge-gate test + traceability-matrix.py -> harness/sibling - phase10-traceability: REQ -> $CASAN_DOMAIN_ROOT/input - run-casan-pipeline.mjs: model-fallback/drift-detect/rollback-manager -> HARNESS_BASH, golden -> GOLDEN_PLAN (apps/okr/domain), with .specify/logs state kept - casan-step.mjs: requirement fallback restored to docs/input for hermetic sandboxes - descriptive config (tool-registry/harness-package/drift-policy/hallucination/risk-registry/ loop-policy.schema + docstrings) repointed for accuracy - policy-bundle.yaml: 8 policy paths -> packages/casan-harness/...; manifest regenerated + re-signed (POLICY_HASHES_VALID files=8, POLICY_SIGNATURE_VALID) Removed 22 .specify code/config symlinks + docs/input symlink. Full gate via packages path, NO facade: PASS=64 FAIL=0 SKIP=3. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
208 lines
7.6 KiB
Python
Executable File
208 lines
7.6 KiB
Python
Executable File
#!/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())
|