173 lines
5.8 KiB
Python
Executable File
173 lines
5.8 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
import argparse
|
|
import json
|
|
from pathlib import Path
|
|
from typing import Dict, List, Tuple
|
|
|
|
|
|
def load_json(path: Path) -> Dict:
|
|
with path.open("r", encoding="utf-8") as f:
|
|
return json.load(f)
|
|
|
|
|
|
def write_json(path: Path, obj: Dict) -> None:
|
|
with path.open("w", encoding="utf-8") as f:
|
|
json.dump(obj, f, indent=2, sort_keys=True)
|
|
f.write("\n")
|
|
|
|
|
|
def load_native_tasks(run_dir: Path, summary: Dict) -> List[Dict]:
|
|
retry = summary.get("native_decomposition_retry") or {}
|
|
retry_applied = bool(retry.get("applied", False))
|
|
retry_path = run_dir / "02aa_generate_taskitems_retry.json"
|
|
base_path = run_dir / "02_generate_taskitems.json"
|
|
tasks: List[Dict] = []
|
|
if retry_applied and retry_path.exists():
|
|
data = load_json(retry_path)
|
|
if isinstance(data, dict):
|
|
tasks = list(data.get("tasks") or [])
|
|
elif base_path.exists():
|
|
data = load_json(base_path)
|
|
if isinstance(data, dict):
|
|
tasks = list(data.get("tasks") or [])
|
|
|
|
# Include explicitly injected extra tasks when present.
|
|
extra = summary.get("extra_tasks") or []
|
|
if isinstance(extra, list) and extra:
|
|
tasks = tasks + extra
|
|
return tasks
|
|
|
|
|
|
def load_spec_text(summary: Dict, repo_root: Path) -> str:
|
|
input_file = str(summary.get("input_file", "")).strip()
|
|
if not input_file:
|
|
return ""
|
|
p = Path(input_file)
|
|
if not p.is_absolute():
|
|
p = repo_root / input_file
|
|
try:
|
|
return p.read_text(encoding="utf-8", errors="ignore")
|
|
except Exception:
|
|
return ""
|
|
|
|
|
|
def activate_profiles(profiles: List[Dict], spec_text: str) -> List[Dict]:
|
|
s = spec_text.lower()
|
|
active = []
|
|
for profile in profiles:
|
|
keys = [str(k).lower() for k in (profile.get("trigger_keywords") or [])]
|
|
if not keys:
|
|
continue
|
|
if any(k in s for k in keys):
|
|
active.append(profile)
|
|
return active
|
|
|
|
|
|
def collect_task_unions(tasks: List[Dict]) -> Tuple[List[str], List[str], List[Dict]]:
|
|
reasons: List[str] = []
|
|
prereq_ops: List[str] = []
|
|
contracts: List[Dict] = []
|
|
for t in tasks:
|
|
rs = t.get("reasons") or []
|
|
po = t.get("prerequisiteOps") or []
|
|
ec = t.get("executionContract") or {}
|
|
for r in rs:
|
|
reasons.append(str(r).lower())
|
|
for op in po:
|
|
prereq_ops.append(str(op))
|
|
if isinstance(ec, dict):
|
|
contracts.append(ec)
|
|
return reasons, prereq_ops, contracts
|
|
|
|
|
|
def check_profile(profile: Dict, tasks: List[Dict]) -> Dict:
|
|
reasons, prereq_ops, contracts = collect_task_unions(tasks)
|
|
missing: List[str] = []
|
|
|
|
min_tasks = int(profile.get("min_native_task_count", 0) or 0)
|
|
if len(tasks) < min_tasks:
|
|
missing.append(f"native_task_count<{min_tasks}")
|
|
|
|
req_reason_keys = [str(x).lower() for x in (profile.get("required_reason_keywords") or [])]
|
|
for key in req_reason_keys:
|
|
if not any(key in r for r in reasons):
|
|
missing.append(f"missing_reason_keyword:{key}")
|
|
|
|
req_ops = [str(x) for x in (profile.get("required_prerequisite_ops") or [])]
|
|
ops_set = set(prereq_ops)
|
|
for op in req_ops:
|
|
if op not in ops_set:
|
|
missing.append(f"missing_prerequisite_op:{op}")
|
|
|
|
req_contract = [str(x) for x in (profile.get("required_execution_contract") or [])]
|
|
for field in req_contract:
|
|
has_field_true = False
|
|
for ec in contracts:
|
|
if isinstance(ec.get(field), bool):
|
|
if ec.get(field) is True:
|
|
has_field_true = True
|
|
break
|
|
elif field in ec:
|
|
has_field_true = True
|
|
break
|
|
if not has_field_true:
|
|
missing.append(f"missing_execution_contract:{field}")
|
|
|
|
return {
|
|
"id": profile.get("id", "unknown"),
|
|
"description": profile.get("description", ""),
|
|
"passed": len(missing) == 0,
|
|
"native_task_count": len(tasks),
|
|
"missing": missing,
|
|
}
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser(description="Check native decomposition coverage against impact profiles.")
|
|
parser.add_argument("--run-dir", required=True, help="Pipeline run directory containing 00_summary.json")
|
|
parser.add_argument("--profiles", default="tools/mcp/profiles/native_decomposition_impact_profiles.json", help="Profile JSON path")
|
|
parser.add_argument("--out", required=True, help="Output report path")
|
|
parser.add_argument("--enforce", action="store_true", help="Exit nonzero on failures")
|
|
args = parser.parse_args()
|
|
|
|
run_dir = Path(args.run_dir)
|
|
summary_path = run_dir / "00_summary.json"
|
|
if not summary_path.exists():
|
|
raise SystemExit(f"missing summary: {summary_path}")
|
|
|
|
summary = load_json(summary_path)
|
|
profiles_doc = load_json(Path(args.profiles))
|
|
profiles = list(profiles_doc.get("profiles") or [])
|
|
repo_root = Path(__file__).resolve().parents[2]
|
|
spec_text = load_spec_text(summary, repo_root)
|
|
|
|
active = activate_profiles(profiles, spec_text)
|
|
tasks = load_native_tasks(run_dir, summary)
|
|
|
|
checks = [check_profile(p, tasks) for p in active]
|
|
failing = [c for c in checks if not c.get("passed", False)]
|
|
|
|
result = {
|
|
"status": "ok" if not failing else "fail",
|
|
"run_dir": str(run_dir),
|
|
"active_profile_count": len(active),
|
|
"failing_profile_count": len(failing),
|
|
"active_profiles": [p.get("id", "unknown") for p in active],
|
|
"checks": checks,
|
|
}
|
|
write_json(Path(args.out), result)
|
|
print(json.dumps({
|
|
"status": result["status"],
|
|
"active_profile_count": result["active_profile_count"],
|
|
"failing_profile_count": result["failing_profile_count"],
|
|
"out": args.out,
|
|
}, sort_keys=True))
|
|
|
|
if args.enforce and failing:
|
|
return 12
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|