#!/usr/bin/env python3 """Cumulative, machine-readable release completion gate for harness v2.""" from __future__ import annotations import argparse from dataclasses import dataclass import json from pathlib import Path import subprocess import sys from typing import Any, Callable, Iterable, Sequence import quality_gate DEFAULT_ROOT = Path(__file__).resolve().parents[2] LEVELS = {"r1": 1, "r2": 2, "r3": 3} RESULT_SCHEMA = "harness-release-gate/v2" @dataclass(frozen=True) class GateCommand: name: str argv: tuple[str, ...] release: int = 1 json_requirements: tuple[tuple[str, object], ...] = () Runner = Callable[..., subprocess.CompletedProcess[str]] def _command_status(code: int, stdout: str) -> str: if code == 0: return "PASS" if code == 1: return "FAIL" # Some older deterministic checkers used exit 2 for both I/O and a # fail-closed contract finding. Preserve the release-gate 0/1/2 contract # by recognizing only an explicit, non-I/O JSON finding as quality FAIL. try: document = json.loads(stdout) except (json.JSONDecodeError, TypeError): return "ERROR" error = document.get("error") if isinstance(document, dict) else None error_code = error.get("code", "") if isinstance(error, dict) else "" if document.get("status") == "FAIL" and error_code and error_code not in { "IO_ERROR", "RELATION_SOURCE_ERROR", "QUALITY_GATE_ERROR", }: return "FAIL" return "ERROR" def _existing_test_modules(root: Path, names: Iterable[str]) -> list[str]: return [name for name in names if (root / (name.replace(".", "/") + ".py")).is_file()] def default_commands(root: Path) -> list[GateCommand]: py = sys.executable commands = [ GateCommand( "typed_contract_gate", (py, "harness/runtime/typed_contract_check.py", "--root", str(root)), ), GateCommand( "projection_gate", (py, "harness/runtime/contract_projection.py", "--root", str(root), "--check"), ), GateCommand( "semantic_surface_gate", (py, "harness/runtime/semantic_surface_extractor.py", "--root", str(root), "--check"), ), GateCommand( "hub_semantic_gate", ( py, "harness/runtime/semantic_certificate.py", "--root", str(root), "--check", "--mode", "hub", ), ), GateCommand( "semantic_certificate_gate", (py, "harness/runtime/semantic_certificate.py", "--root", str(root), "--check"), ), GateCommand( "local_semantic_gate", ( py, "harness/runtime/semantic_certificate.py", "--root", str(root), "--check", "--mode", "local", ), release=2, ), GateCommand( "semantic_regression_gate", (py, "harness/runtime/semantic_regression.py", "--root", str(root), "--check"), release=2, ), GateCommand("source-hygiene-contract", (py, "harness/runtime/source_hygiene.py", "--check", "--root", str(root))), GateCommand("workflow-adapters", (py, "harness/adapters/generate.py", "--check", "--root", str(root))), GateCommand("rule-adapters", (py, "harness/adapters/generate_rules.py", "--check", "--root", str(root))), GateCommand("moc-convergence", (py, "harness/runtime/moc_indexer.py", "--root", str(root), "--check"), release=2), GateCommand("migration-convergence", (py, "harness/runtime/migrate_graph_contracts.py", "--root", str(root), "--check")), GateCommand("graph-contract", (py, ".claude/hooks/wiki_graph_contract_check.py", "--root", str(root), "--all")), GateCommand( "workflow-dispatch-contract", (py, "harness/runtime/workflow_dispatch.py", "--root", str(root), "--check-all"), release=2, ), GateCommand( "workflow-source-connections", (py, "harness/runtime/workflow_connection_check.py", "--root", str(root)), release=2, ), GateCommand( "consistency-contract", (py, ".claude/hooks/wiki_consistency_check.py", "--root", str(root), "--all"), release=2, ), GateCommand( "full-links", (py, ".claude/hooks/wiki_structure_lint.py", "--root", str(root), "--all", "--links-only"), release=2, ), GateCommand( "active-structure", (py, "harness/runtime/active_structure_check.py", "--root", str(root)), release=2, ), ] # 한국어 문체 검사는 별도 하네스 im-not-ai(`/humanize-korean`)로 이관했다. # 이 게이트는 구조·계약·의미 정합만 판정한다. r1_tests = _existing_test_modules( root, ( "harness.tests.test_template_renderer", "harness.tests.test_quality_gate", "harness.tests.test_branch_from_project", "harness.tests.test_migrate_graph_contracts", "harness.tests.test_typed_contract_check", "harness.tests.test_contract_projection", ), ) if r1_tests: commands.append(GateCommand("r1-runtime-tests", (py, "-m", "unittest", *r1_tests))) if (root / ".claude/hooks").is_dir(): commands.append( GateCommand( "hook-tests", (py, "-m", "unittest", "discover", "-s", ".claude/hooks", "-p", "test_*.py"), ) ) for profile, risk in (("capture", "low"), ("design", "medium"), ("audit", "high"), ("publish", "high")): commands.append( GateCommand( f"execution-profile-{profile}", (py, "harness/runtime/execution_profile.py", profile, "--risk", risk), release=2, ) ) r2_tests = _existing_test_modules( root, ( "harness.tests.test_moc_indexer", "harness.tests.test_document_commit", "harness.tests.test_execution_profile", "harness.tests.test_proof_manifest", "harness.tests.test_proof_runner", "harness.tests.test_workflow_dispatch", "harness.tests.test_workflow_connection_check", "harness.tests.test_active_structure_check", ), ) if r2_tests: commands.append(GateCommand("r2-runtime-tests", (py, "-m", "unittest", *r2_tests), release=2)) commands.append( GateCommand( "vault-layout", (py, "harness/runtime/layout_check.py", "--root", str(root)), release=3, json_requirements=( ("mode", "canonical"), ("authority", "vault"), ("write_roots", ("vault",)), ), ) ) r3_tests = _existing_test_modules(root, ("harness.tests.test_layout_check",)) if r3_tests: commands.append(GateCommand("r3-runtime-tests", (py, "-m", "unittest", *r3_tests), release=3)) return commands def hygiene_paths(root: Path) -> list[Path]: patterns = ( "harness/source/skills/*.md", ".agents/skills/*/SKILL.md", ".agents/workflows/*.md", ".claude/commands/*.md", ) return sorted({path for pattern in patterns for path in root.glob(pattern) if path.is_file()}) def run_gate( root: Path, level: str, *, commands: Sequence[GateCommand] | None = None, runner: Runner = subprocess.run, source_paths: Sequence[Path] | None = None, ) -> dict[str, Any]: if level not in LEVELS: raise ValueError(f"unsupported release level: {level}") root = root.resolve(strict=True) selected_level = LEVELS[level] checks: list[dict[str, Any]] = [] try: hygiene_findings = quality_gate.scan_hygiene( source_paths if source_paths is not None else hygiene_paths(root), root=root, ) checks.append( { "name": "source-hygiene", "status": "PASS" if not hygiene_findings else "FAIL", "exit_code": 0 if not hygiene_findings else 1, "findings": hygiene_findings, } ) except (OSError, UnicodeError, ValueError) as exc: checks.append( { "name": "source-hygiene", "status": "ERROR", "exit_code": 2, "errors": [{"code": "HYGIENE_IO_ERROR", "message": str(exc)}], } ) for command in commands if commands is not None else default_commands(root): if command.release > selected_level: continue if ( len(command.argv) >= 2 and command.argv[0] == sys.executable and command.argv[1].endswith(".py") and not (root / command.argv[1]).is_file() ): checks.append( { "name": command.name, "status": "FAIL", "exit_code": 1, "findings": [ { "code": "COMMAND_MISSING", "message": f"required release gate command is missing: {command.argv[1]}", } ], } ) continue try: completed = runner( list(command.argv), cwd=root, check=False, capture_output=True, text=True, timeout=120, ) code = completed.returncode status = _command_status(code, completed.stdout) contract_findings: list[dict[str, str]] = [] child_document: dict[str, Any] | None = None try: parsed_stdout = json.loads(completed.stdout) except (json.JSONDecodeError, TypeError): pass else: if isinstance(parsed_stdout, dict): child_document = parsed_stdout if code == 0 and command.json_requirements: if child_document is None: status = "ERROR" contract_findings.append( {"code": "RESULT_SCHEMA_ERROR", "message": "command returned non-object JSON"} ) else: for key, expected in command.json_requirements: observed = child_document.get(key) comparable = tuple(observed) if isinstance(expected, tuple) and isinstance(observed, list) else observed if comparable != expected: status = "FAIL" contract_findings.append( { "code": "RELEASE_CONTRACT_MISMATCH", "message": f"{key}: expected {expected!r}, observed {observed!r}", } ) result = { "name": command.name, "status": status, "exit_code": code, "stdout": completed.stdout[-4000:], "stderr": completed.stderr[-4000:], } if contract_findings: result["findings"] = contract_findings elif child_document is not None and isinstance(child_document.get("findings"), list): result["findings"] = child_document["findings"] if child_document is not None and isinstance(child_document.get("errors"), list): result["errors"] = child_document["errors"] checks.append(result) except (OSError, subprocess.SubprocessError) as exc: checks.append( { "name": command.name, "status": "ERROR", "exit_code": 2, "errors": [{"code": "COMMAND_ENVIRONMENT_ERROR", "message": str(exc)}], } ) status = "ERROR" if any(item["status"] == "ERROR" for item in checks) else "FAIL" if any( item["status"] == "FAIL" for item in checks ) else "PASS" result = { "schema_version": RESULT_SCHEMA, "status": status, "level": level, "cumulative": True, "gates": checks, "summary": { "total": len(checks), "passed": sum(item["status"] == "PASS" for item in checks), "failed": sum(item["status"] == "FAIL" for item in checks), "errors": sum(item["status"] == "ERROR" for item in checks), }, } # Transitional alias for existing automation; new consumers must use gates. result["checks"] = result["gates"] return result def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--root", type=Path, default=DEFAULT_ROOT) parser.add_argument("--level", choices=sorted(LEVELS), required=True) args = parser.parse_args(argv) try: result = run_gate(args.root, args.level) json.dump(result, sys.stdout, ensure_ascii=False, indent=2, sort_keys=True) sys.stdout.write("\n") return 0 if result["status"] == "PASS" else 1 if result["status"] == "FAIL" else 2 except (OSError, UnicodeError, ValueError) as exc: json.dump( { "schema_version": RESULT_SCHEMA, "status": "ERROR", "errors": [{"code": "RELEASE_GATE_ERROR", "message": str(exc)}], }, sys.stdout, ensure_ascii=False, indent=2, sort_keys=True, ) sys.stdout.write("\n") return 2 if __name__ == "__main__": raise SystemExit(main())