Files
llm-wiki/harness/runtime/release_gate.py
T

386 lines
14 KiB
Python

#!/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())