386 lines
14 KiB
Python
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())
|