feat(plan-01): Phase 1 — relocate harness code to packages/casan-harness (symlink facade)

Physically move the pure-code subtrees out of .specify into the package, leaving
compat symlinks at the old .specify/<dir> paths so every existing reference (internal
CASAN_HARNESS_ROOT + external CI/docker/mjs) keeps resolving. Runtime state stays put.

Moved (git mv): scripts/ tests/ security/ templates/ config/ governance/ memory/
  .specify/<dir>  ->  packages/casan-harness/<dir>   (+ .specify/<dir> symlink)
Stays in .specify (state/governance/domain, handled later): logs/ agentops/ level5/
  init-options.json traceability-map.json

Python `.resolve()` self-location followed the compat symlink into packages and lost
the app root; generate-casan-demo-context.py, generate-agentops-dashboard.py and
dashboard-server.py now walk UP for the `.specify` state marker instead of a fixed
parent depth (fixes "missing trace files" in run-casan4).

Full gate: PASS=64 FAIL=0 SKIP=3 (CASAN_CI_STEP_TIMEOUT_SEC=1200 — track-a ~450s runs
close to the 600s default and can tip over under load; this is timing variance, not a
regression — it passed cleanly with headroom). Runtime log/audit artifacts kept unstaged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
thanhnv
2026-07-08 00:06:00 +09:00
co-authored by Claude Opus 4.8
parent 2c765c9a45
commit 664bd1f00c
229 changed files with 268 additions and 3 deletions
@@ -0,0 +1,198 @@
#!/usr/bin/env python3
"""CASAN Plan-10 traceability matrix generator/gate.
Parses FR-* requirements from docs/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:
return os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", ".."))
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, "docs/input/okr-requirement.md"))
ap.add_argument("--map", default=os.path.join(root, ".specify/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())