@hunter-harness/workflow-harness 0.2.11 → 0.2.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/harness/bundles/general/claude-code/.harness-build.json +1 -1
- package/harness/bundles/general/claude-code/contracts/ci-metrics.schema.json +36 -0
- package/harness/bundles/general/claude-code/contracts/workflow-policy.json +30 -0
- package/harness/bundles/general/claude-code/harness-archive/SKILL.md +1 -1
- package/harness/bundles/general/claude-code/harness-codebase-map/SKILL.md +1 -1
- package/harness/bundles/general/claude-code/harness-knowledge-ingest/SKILL.md +1 -1
- package/harness/bundles/general/claude-code/harness-knowledge-query/SKILL.md +1 -1
- package/harness/bundles/general/claude-code/harness-plan/SKILL.md +4 -4
- package/harness/bundles/general/claude-code/harness-plan/checklist.md +6 -0
- package/harness/bundles/general/claude-code/harness-plan/reference.md +7 -2
- package/harness/bundles/general/claude-code/harness-review/SKILL.md +1 -1
- package/harness/bundles/general/claude-code/harness-run/SKILL.md +1 -1
- package/harness/bundles/general/claude-code/harness-run/checklist.md +3 -2
- package/harness/bundles/general/claude-code/harness-run/reference.md +6 -4
- package/harness/bundles/general/claude-code/harness-submit/SKILL.md +1 -1
- package/harness/bundles/general/claude-code/harness-sync/SKILL.md +1 -1
- package/harness/bundles/general/claude-code/harness-test/SKILL.md +1 -1
- package/harness/bundles/general/claude-code/scripts/harness_archive.py +282 -120
- package/harness/bundles/general/claude-code/scripts/harness_events.py +400 -24
- package/harness/bundles/general/claude-code/scripts/harness_gate.py +585 -21
- package/harness/bundles/general/claude-code/scripts/harness_integration.py +403 -18
- package/harness/bundles/general/claude-code/scripts/harness_ledger.py +225 -29
- package/harness/bundles/general/claude-code/scripts/harness_phase.py +926 -0
- package/harness/bundles/general/claude-code/scripts/harness_plan_finalize.py +361 -0
- package/harness/bundles/general/claude-code/scripts/harness_profile.py +50 -0
- package/harness/bundles/general/claude-code/scripts/harness_runtime.py +248 -0
- package/harness/bundles/general/claude-code/scripts/harness_state.py +15 -2
- package/harness/bundles/general/claude-code/scripts/harness_test_guard.py +188 -0
- package/harness/bundles/general/claude-code/scripts/harness_workflow_policy.py +19 -0
- package/harness/bundles/general/codebuddy/.harness-build.json +1 -1
- package/harness/bundles/general/codebuddy/contracts/ci-metrics.schema.json +36 -0
- package/harness/bundles/general/codebuddy/contracts/workflow-policy.json +30 -0
- package/harness/bundles/general/codebuddy/harness-archive/SKILL.md +1 -1
- package/harness/bundles/general/codebuddy/harness-codebase-map/SKILL.md +1 -1
- package/harness/bundles/general/codebuddy/harness-knowledge-ingest/SKILL.md +1 -1
- package/harness/bundles/general/codebuddy/harness-knowledge-query/SKILL.md +1 -1
- package/harness/bundles/general/codebuddy/harness-plan/SKILL.md +4 -4
- package/harness/bundles/general/codebuddy/harness-plan/checklist.md +6 -0
- package/harness/bundles/general/codebuddy/harness-plan/reference.md +7 -2
- package/harness/bundles/general/codebuddy/harness-review/SKILL.md +1 -1
- package/harness/bundles/general/codebuddy/harness-run/SKILL.md +1 -1
- package/harness/bundles/general/codebuddy/harness-run/checklist.md +3 -2
- package/harness/bundles/general/codebuddy/harness-run/reference.md +6 -4
- package/harness/bundles/general/codebuddy/harness-submit/SKILL.md +1 -1
- package/harness/bundles/general/codebuddy/harness-sync/SKILL.md +1 -1
- package/harness/bundles/general/codebuddy/harness-test/SKILL.md +1 -1
- package/harness/bundles/general/codebuddy/scripts/harness_archive.py +282 -120
- package/harness/bundles/general/codebuddy/scripts/harness_events.py +400 -24
- package/harness/bundles/general/codebuddy/scripts/harness_gate.py +585 -21
- package/harness/bundles/general/codebuddy/scripts/harness_integration.py +403 -18
- package/harness/bundles/general/codebuddy/scripts/harness_ledger.py +225 -29
- package/harness/bundles/general/codebuddy/scripts/harness_phase.py +926 -0
- package/harness/bundles/general/codebuddy/scripts/harness_plan_finalize.py +361 -0
- package/harness/bundles/general/codebuddy/scripts/harness_profile.py +50 -0
- package/harness/bundles/general/codebuddy/scripts/harness_runtime.py +248 -0
- package/harness/bundles/general/codebuddy/scripts/harness_state.py +15 -2
- package/harness/bundles/general/codebuddy/scripts/harness_test_guard.py +188 -0
- package/harness/bundles/general/codebuddy/scripts/harness_workflow_policy.py +19 -0
- package/harness/bundles/general/codex/.harness-build.json +1 -1
- package/harness/bundles/general/codex/contracts/ci-metrics.schema.json +36 -0
- package/harness/bundles/general/codex/contracts/workflow-policy.json +30 -0
- package/harness/bundles/general/codex/harness-archive/SKILL.md +1 -1
- package/harness/bundles/general/codex/harness-codebase-map/SKILL.md +1 -1
- package/harness/bundles/general/codex/harness-knowledge-ingest/SKILL.md +1 -1
- package/harness/bundles/general/codex/harness-knowledge-query/SKILL.md +1 -1
- package/harness/bundles/general/codex/harness-plan/SKILL.md +4 -4
- package/harness/bundles/general/codex/harness-plan/checklist.md +6 -0
- package/harness/bundles/general/codex/harness-plan/reference.md +7 -2
- package/harness/bundles/general/codex/harness-review/SKILL.md +1 -1
- package/harness/bundles/general/codex/harness-run/SKILL.md +1 -1
- package/harness/bundles/general/codex/harness-run/checklist.md +3 -2
- package/harness/bundles/general/codex/harness-run/reference.md +6 -4
- package/harness/bundles/general/codex/harness-submit/SKILL.md +1 -1
- package/harness/bundles/general/codex/harness-sync/SKILL.md +1 -1
- package/harness/bundles/general/codex/harness-test/SKILL.md +1 -1
- package/harness/bundles/general/codex/scripts/harness_archive.py +282 -120
- package/harness/bundles/general/codex/scripts/harness_events.py +400 -24
- package/harness/bundles/general/codex/scripts/harness_gate.py +585 -21
- package/harness/bundles/general/codex/scripts/harness_integration.py +403 -18
- package/harness/bundles/general/codex/scripts/harness_ledger.py +225 -29
- package/harness/bundles/general/codex/scripts/harness_phase.py +926 -0
- package/harness/bundles/general/codex/scripts/harness_plan_finalize.py +361 -0
- package/harness/bundles/general/codex/scripts/harness_profile.py +50 -0
- package/harness/bundles/general/codex/scripts/harness_runtime.py +248 -0
- package/harness/bundles/general/codex/scripts/harness_state.py +15 -2
- package/harness/bundles/general/codex/scripts/harness_test_guard.py +188 -0
- package/harness/bundles/general/codex/scripts/harness_workflow_policy.py +19 -0
- package/harness/bundles/general/cursor/.harness-build.json +1 -1
- package/harness/bundles/general/cursor/contracts/ci-metrics.schema.json +36 -0
- package/harness/bundles/general/cursor/contracts/workflow-policy.json +30 -0
- package/harness/bundles/general/cursor/harness-archive/SKILL.md +1 -1
- package/harness/bundles/general/cursor/harness-codebase-map/SKILL.md +1 -1
- package/harness/bundles/general/cursor/harness-knowledge-ingest/SKILL.md +1 -1
- package/harness/bundles/general/cursor/harness-knowledge-query/SKILL.md +1 -1
- package/harness/bundles/general/cursor/harness-plan/SKILL.md +4 -4
- package/harness/bundles/general/cursor/harness-plan/checklist.md +6 -0
- package/harness/bundles/general/cursor/harness-plan/reference.md +7 -2
- package/harness/bundles/general/cursor/harness-review/SKILL.md +1 -1
- package/harness/bundles/general/cursor/harness-run/SKILL.md +1 -1
- package/harness/bundles/general/cursor/harness-run/checklist.md +3 -2
- package/harness/bundles/general/cursor/harness-run/reference.md +6 -4
- package/harness/bundles/general/cursor/harness-submit/SKILL.md +1 -1
- package/harness/bundles/general/cursor/harness-sync/SKILL.md +1 -1
- package/harness/bundles/general/cursor/harness-test/SKILL.md +1 -1
- package/harness/bundles/general/cursor/scripts/harness_archive.py +282 -120
- package/harness/bundles/general/cursor/scripts/harness_events.py +400 -24
- package/harness/bundles/general/cursor/scripts/harness_gate.py +585 -21
- package/harness/bundles/general/cursor/scripts/harness_integration.py +403 -18
- package/harness/bundles/general/cursor/scripts/harness_ledger.py +225 -29
- package/harness/bundles/general/cursor/scripts/harness_phase.py +926 -0
- package/harness/bundles/general/cursor/scripts/harness_plan_finalize.py +361 -0
- package/harness/bundles/general/cursor/scripts/harness_profile.py +50 -0
- package/harness/bundles/general/cursor/scripts/harness_runtime.py +248 -0
- package/harness/bundles/general/cursor/scripts/harness_state.py +15 -2
- package/harness/bundles/general/cursor/scripts/harness_test_guard.py +188 -0
- package/harness/bundles/general/cursor/scripts/harness_workflow_policy.py +19 -0
- package/harness/bundles/java/claude-code/.harness-build.json +1 -1
- package/harness/bundles/java/claude-code/contracts/ci-metrics.schema.json +36 -0
- package/harness/bundles/java/claude-code/contracts/workflow-policy.json +30 -0
- package/harness/bundles/java/claude-code/harness-apidoc/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/harness-archive/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/harness-codebase-map/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/harness-knowledge-ingest/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/harness-knowledge-query/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/harness-package/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/harness-plan/SKILL.md +4 -4
- package/harness/bundles/java/claude-code/harness-plan/checklist.md +6 -0
- package/harness/bundles/java/claude-code/harness-plan/reference.md +7 -2
- package/harness/bundles/java/claude-code/harness-review/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/harness-run/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/harness-submit/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/harness-sync/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/harness-test/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/scripts/harness_archive.py +282 -120
- package/harness/bundles/java/claude-code/scripts/harness_events.py +400 -24
- package/harness/bundles/java/claude-code/scripts/harness_gate.py +585 -21
- package/harness/bundles/java/claude-code/scripts/harness_integration.py +403 -18
- package/harness/bundles/java/claude-code/scripts/harness_ledger.py +225 -29
- package/harness/bundles/java/claude-code/scripts/harness_phase.py +926 -0
- package/harness/bundles/java/claude-code/scripts/harness_plan_finalize.py +361 -0
- package/harness/bundles/java/claude-code/scripts/harness_profile.py +50 -0
- package/harness/bundles/java/claude-code/scripts/harness_runtime.py +248 -0
- package/harness/bundles/java/claude-code/scripts/harness_state.py +15 -2
- package/harness/bundles/java/claude-code/scripts/harness_test_guard.py +188 -0
- package/harness/bundles/java/claude-code/scripts/harness_workflow_policy.py +19 -0
- package/harness/bundles/java/codebuddy/.harness-build.json +1 -1
- package/harness/bundles/java/codebuddy/contracts/ci-metrics.schema.json +36 -0
- package/harness/bundles/java/codebuddy/contracts/workflow-policy.json +30 -0
- package/harness/bundles/java/codebuddy/harness-apidoc/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/harness-archive/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/harness-codebase-map/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/harness-knowledge-ingest/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/harness-knowledge-query/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/harness-package/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/harness-plan/SKILL.md +4 -4
- package/harness/bundles/java/codebuddy/harness-plan/checklist.md +6 -0
- package/harness/bundles/java/codebuddy/harness-plan/reference.md +7 -2
- package/harness/bundles/java/codebuddy/harness-review/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/harness-run/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/harness-submit/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/harness-sync/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/harness-test/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/scripts/harness_archive.py +282 -120
- package/harness/bundles/java/codebuddy/scripts/harness_events.py +400 -24
- package/harness/bundles/java/codebuddy/scripts/harness_gate.py +585 -21
- package/harness/bundles/java/codebuddy/scripts/harness_integration.py +403 -18
- package/harness/bundles/java/codebuddy/scripts/harness_ledger.py +225 -29
- package/harness/bundles/java/codebuddy/scripts/harness_phase.py +926 -0
- package/harness/bundles/java/codebuddy/scripts/harness_plan_finalize.py +361 -0
- package/harness/bundles/java/codebuddy/scripts/harness_profile.py +50 -0
- package/harness/bundles/java/codebuddy/scripts/harness_runtime.py +248 -0
- package/harness/bundles/java/codebuddy/scripts/harness_state.py +15 -2
- package/harness/bundles/java/codebuddy/scripts/harness_test_guard.py +188 -0
- package/harness/bundles/java/codebuddy/scripts/harness_workflow_policy.py +19 -0
- package/harness/bundles/java/codex/.harness-build.json +1 -1
- package/harness/bundles/java/codex/contracts/ci-metrics.schema.json +36 -0
- package/harness/bundles/java/codex/contracts/workflow-policy.json +30 -0
- package/harness/bundles/java/codex/harness-apidoc/SKILL.md +1 -1
- package/harness/bundles/java/codex/harness-archive/SKILL.md +1 -1
- package/harness/bundles/java/codex/harness-codebase-map/SKILL.md +1 -1
- package/harness/bundles/java/codex/harness-knowledge-ingest/SKILL.md +1 -1
- package/harness/bundles/java/codex/harness-knowledge-query/SKILL.md +1 -1
- package/harness/bundles/java/codex/harness-package/SKILL.md +1 -1
- package/harness/bundles/java/codex/harness-plan/SKILL.md +4 -4
- package/harness/bundles/java/codex/harness-plan/checklist.md +6 -0
- package/harness/bundles/java/codex/harness-plan/reference.md +7 -2
- package/harness/bundles/java/codex/harness-review/SKILL.md +1 -1
- package/harness/bundles/java/codex/harness-run/SKILL.md +1 -1
- package/harness/bundles/java/codex/harness-submit/SKILL.md +1 -1
- package/harness/bundles/java/codex/harness-sync/SKILL.md +1 -1
- package/harness/bundles/java/codex/harness-test/SKILL.md +1 -1
- package/harness/bundles/java/codex/scripts/harness_archive.py +282 -120
- package/harness/bundles/java/codex/scripts/harness_events.py +400 -24
- package/harness/bundles/java/codex/scripts/harness_gate.py +585 -21
- package/harness/bundles/java/codex/scripts/harness_integration.py +403 -18
- package/harness/bundles/java/codex/scripts/harness_ledger.py +225 -29
- package/harness/bundles/java/codex/scripts/harness_phase.py +926 -0
- package/harness/bundles/java/codex/scripts/harness_plan_finalize.py +361 -0
- package/harness/bundles/java/codex/scripts/harness_profile.py +50 -0
- package/harness/bundles/java/codex/scripts/harness_runtime.py +248 -0
- package/harness/bundles/java/codex/scripts/harness_state.py +15 -2
- package/harness/bundles/java/codex/scripts/harness_test_guard.py +188 -0
- package/harness/bundles/java/codex/scripts/harness_workflow_policy.py +19 -0
- package/harness/bundles/java/cursor/.harness-build.json +1 -1
- package/harness/bundles/java/cursor/contracts/ci-metrics.schema.json +36 -0
- package/harness/bundles/java/cursor/contracts/workflow-policy.json +30 -0
- package/harness/bundles/java/cursor/harness-apidoc/SKILL.md +1 -1
- package/harness/bundles/java/cursor/harness-archive/SKILL.md +1 -1
- package/harness/bundles/java/cursor/harness-codebase-map/SKILL.md +1 -1
- package/harness/bundles/java/cursor/harness-knowledge-ingest/SKILL.md +1 -1
- package/harness/bundles/java/cursor/harness-knowledge-query/SKILL.md +1 -1
- package/harness/bundles/java/cursor/harness-package/SKILL.md +1 -1
- package/harness/bundles/java/cursor/harness-plan/SKILL.md +4 -4
- package/harness/bundles/java/cursor/harness-plan/checklist.md +6 -0
- package/harness/bundles/java/cursor/harness-plan/reference.md +7 -2
- package/harness/bundles/java/cursor/harness-review/SKILL.md +1 -1
- package/harness/bundles/java/cursor/harness-run/SKILL.md +1 -1
- package/harness/bundles/java/cursor/harness-submit/SKILL.md +1 -1
- package/harness/bundles/java/cursor/harness-sync/SKILL.md +1 -1
- package/harness/bundles/java/cursor/harness-test/SKILL.md +1 -1
- package/harness/bundles/java/cursor/scripts/harness_archive.py +282 -120
- package/harness/bundles/java/cursor/scripts/harness_events.py +400 -24
- package/harness/bundles/java/cursor/scripts/harness_gate.py +585 -21
- package/harness/bundles/java/cursor/scripts/harness_integration.py +403 -18
- package/harness/bundles/java/cursor/scripts/harness_ledger.py +225 -29
- package/harness/bundles/java/cursor/scripts/harness_phase.py +926 -0
- package/harness/bundles/java/cursor/scripts/harness_plan_finalize.py +361 -0
- package/harness/bundles/java/cursor/scripts/harness_profile.py +50 -0
- package/harness/bundles/java/cursor/scripts/harness_runtime.py +248 -0
- package/harness/bundles/java/cursor/scripts/harness_state.py +15 -2
- package/harness/bundles/java/cursor/scripts/harness_test_guard.py +188 -0
- package/harness/bundles/java/cursor/scripts/harness_workflow_policy.py +19 -0
- package/harness/manifests/general/claude-code.json +42 -26
- package/harness/manifests/general/codebuddy.json +42 -26
- package/harness/manifests/general/codex.json +42 -26
- package/harness/manifests/general/cursor.json +42 -26
- package/harness/manifests/java/claude-code.json +42 -26
- package/harness/manifests/java/codebuddy.json +42 -26
- package/harness/manifests/java/codex.json +42 -26
- package/harness/manifests/java/cursor.json +42 -26
- package/hunter-workflow-family.json +2 -2
- package/package.json +1 -1
|
@@ -0,0 +1,926 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Phase reconciliation, trace projection, and normalized CI metrics.
|
|
3
|
+
|
|
4
|
+
The reconciler is deliberately an evidence orchestrator: it decides REUSE,
|
|
5
|
+
RUN, or BLOCK for the required gate DAG. It never executes user test commands.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
import datetime as dt
|
|
12
|
+
import hashlib
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
import re
|
|
16
|
+
import subprocess
|
|
17
|
+
import sys
|
|
18
|
+
import uuid
|
|
19
|
+
import xml.etree.ElementTree as ET
|
|
20
|
+
from collections import defaultdict
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
SCRIPTS_DIR = Path(__file__).resolve().parent
|
|
26
|
+
if str(SCRIPTS_DIR) not in sys.path:
|
|
27
|
+
sys.path.insert(0, str(SCRIPTS_DIR))
|
|
28
|
+
|
|
29
|
+
import harness_events as he # noqa: E402
|
|
30
|
+
import harness_ledger as hl # noqa: E402
|
|
31
|
+
import harness_paths as hpaths # noqa: E402
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
SCHEMA_VERSION = 1
|
|
35
|
+
CI_METRICS_SCHEMA_VERSION = 1
|
|
36
|
+
PHASE_ORDER = ("plan", "run", "test", "review", "package", "apidoc", "submit", "archive")
|
|
37
|
+
VALIDATION_PHASES = {
|
|
38
|
+
"compile": "run",
|
|
39
|
+
"unitTest": "run",
|
|
40
|
+
"unitTestFull": "test",
|
|
41
|
+
"apiTest": "test",
|
|
42
|
+
"dbCompatibility": "test",
|
|
43
|
+
"package": "package",
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _read_json(path: Path) -> dict[str, Any]:
|
|
48
|
+
value = json.loads(path.read_text(encoding="utf-8-sig"))
|
|
49
|
+
if not isinstance(value, dict):
|
|
50
|
+
raise ValueError(f"JSON object required: {path}")
|
|
51
|
+
return value
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _write_json(path: Path, value: Any) -> None:
|
|
55
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
56
|
+
temp = path.with_name(f".{path.name}.{os.getpid()}.{uuid.uuid4().hex}.tmp")
|
|
57
|
+
try:
|
|
58
|
+
temp.write_text(
|
|
59
|
+
json.dumps(value, ensure_ascii=False, indent=2) + "\n",
|
|
60
|
+
encoding="utf-8",
|
|
61
|
+
newline="\n",
|
|
62
|
+
)
|
|
63
|
+
temp.replace(path)
|
|
64
|
+
except BaseException:
|
|
65
|
+
temp.unlink(missing_ok=True)
|
|
66
|
+
raise
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _stable_hex(*parts: Any, length: int) -> str:
|
|
70
|
+
raw = "\x1f".join(str(part) for part in parts).encode("utf-8")
|
|
71
|
+
return hashlib.sha256(raw).hexdigest()[:length]
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def target_required_dag(policy: dict[str, Any], target_phase: str) -> dict[str, Any]:
|
|
75
|
+
"""Return only the required predecessors needed to close ``target_phase``.
|
|
76
|
+
|
|
77
|
+
Validations owned by the target phase are prerequisites to closing it;
|
|
78
|
+
stage nodes for the target itself are deliberately excluded because their
|
|
79
|
+
``phase.end`` is the result of close, not evidence required before close.
|
|
80
|
+
"""
|
|
81
|
+
if target_phase not in PHASE_ORDER:
|
|
82
|
+
raise ValueError(f"unsupported reconcile target phase: {target_phase}")
|
|
83
|
+
dag = policy.get("requiredGateDag")
|
|
84
|
+
if not isinstance(dag, dict) or not isinstance(dag.get("nodes"), list):
|
|
85
|
+
raise ValueError("gate policy missing requiredGateDag; run harness_gate.py classify")
|
|
86
|
+
target_rank = PHASE_ORDER.index(target_phase)
|
|
87
|
+
selected: list[dict[str, Any]] = []
|
|
88
|
+
for raw in dag["nodes"]:
|
|
89
|
+
if not isinstance(raw, dict):
|
|
90
|
+
raise ValueError("requiredGateDag node must be an object")
|
|
91
|
+
node = dict(raw)
|
|
92
|
+
node_id = str(node.get("id") or "")
|
|
93
|
+
node_kind, _, node_name = node_id.partition(":")
|
|
94
|
+
inferred_phase = (
|
|
95
|
+
VALIDATION_PHASES.get(node_name)
|
|
96
|
+
if node_kind == "validation"
|
|
97
|
+
else node_name
|
|
98
|
+
)
|
|
99
|
+
node_phase = str(node.get("phase") or inferred_phase or "")
|
|
100
|
+
if node_phase not in PHASE_ORDER:
|
|
101
|
+
raise ValueError(f"requiredGateDag node has unsupported phase: {node_id}")
|
|
102
|
+
node_rank = PHASE_ORDER.index(node_phase)
|
|
103
|
+
is_validation = node.get("kind") == "validation" or node_id.startswith(
|
|
104
|
+
"validation:"
|
|
105
|
+
)
|
|
106
|
+
if node_rank < target_rank or (is_validation and node_rank == target_rank):
|
|
107
|
+
selected.append(node)
|
|
108
|
+
selected_ids = {str(node.get("id")) for node in selected}
|
|
109
|
+
for node in selected:
|
|
110
|
+
excluded_dependencies = [
|
|
111
|
+
dependency
|
|
112
|
+
for dependency in (node.get("dependsOn") or [])
|
|
113
|
+
if dependency not in selected_ids
|
|
114
|
+
]
|
|
115
|
+
if excluded_dependencies:
|
|
116
|
+
raise ValueError(
|
|
117
|
+
f"target DAG for {target_phase} excludes dependencies of "
|
|
118
|
+
f"{node.get('id')}: {', '.join(excluded_dependencies)}"
|
|
119
|
+
)
|
|
120
|
+
return {
|
|
121
|
+
"schemaVersion": dag.get("schemaVersion", 1),
|
|
122
|
+
"nodes": selected,
|
|
123
|
+
"edges": [
|
|
124
|
+
edge
|
|
125
|
+
for edge in (dag.get("edges") or [])
|
|
126
|
+
if isinstance(edge, dict)
|
|
127
|
+
and edge.get("from") in selected_ids
|
|
128
|
+
and edge.get("to") in selected_ids
|
|
129
|
+
],
|
|
130
|
+
"targetPhase": target_phase,
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def select_phase_capsule(
|
|
135
|
+
change_dir: Path,
|
|
136
|
+
project_root: Path,
|
|
137
|
+
*,
|
|
138
|
+
phase: str,
|
|
139
|
+
run_id: str | None,
|
|
140
|
+
) -> dict[str, Any]:
|
|
141
|
+
"""Select a phase capsule by its exact phase/run identity, never by mtime."""
|
|
142
|
+
if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_-]*", phase):
|
|
143
|
+
return {
|
|
144
|
+
"ok": False,
|
|
145
|
+
"code": "PHASE_INVALID",
|
|
146
|
+
"message": f"invalid phase selector: {phase}",
|
|
147
|
+
"remediation": "pass the phase name emitted by harness_gate.py begin",
|
|
148
|
+
}
|
|
149
|
+
state_dir = hpaths.resolve_state_dir_for_contract(change_dir, project_root)
|
|
150
|
+
capsule_dir = state_dir / "runtime" / "phase-context"
|
|
151
|
+
candidates = (
|
|
152
|
+
[
|
|
153
|
+
path
|
|
154
|
+
for path in capsule_dir.iterdir()
|
|
155
|
+
if path.is_file()
|
|
156
|
+
and path.name.startswith(f"{phase}-")
|
|
157
|
+
and path.suffix == ".json"
|
|
158
|
+
]
|
|
159
|
+
if capsule_dir.is_dir()
|
|
160
|
+
else []
|
|
161
|
+
)
|
|
162
|
+
if not run_id:
|
|
163
|
+
if not candidates:
|
|
164
|
+
return {
|
|
165
|
+
"ok": True,
|
|
166
|
+
"code": "PHASE_CAPSULE_ABSENT",
|
|
167
|
+
"capsule": None,
|
|
168
|
+
"path": None,
|
|
169
|
+
}
|
|
170
|
+
return {
|
|
171
|
+
"ok": False,
|
|
172
|
+
"code": "PHASE_RUN_ID_REQUIRED",
|
|
173
|
+
"message": f"{len(candidates)} capsule(s) exist for phase {phase}",
|
|
174
|
+
"remediation": f"rerun reconcile with --phase {phase} --run-id <run-id>",
|
|
175
|
+
}
|
|
176
|
+
key = hashlib.sha256(f"{phase}\0{run_id}".encode("utf-8")).hexdigest()[:20]
|
|
177
|
+
path = capsule_dir / f"{phase}-{key}.json"
|
|
178
|
+
if not path.is_file():
|
|
179
|
+
return {
|
|
180
|
+
"ok": False,
|
|
181
|
+
"code": "PHASE_CAPSULE_NOT_FOUND",
|
|
182
|
+
"message": f"no capsule for phase {phase} and run-id {run_id}",
|
|
183
|
+
"expectedPath": str(path),
|
|
184
|
+
"remediation": f"begin/resume the phase or verify --phase {phase} --run-id {run_id}",
|
|
185
|
+
}
|
|
186
|
+
try:
|
|
187
|
+
capsule = _read_json(path)
|
|
188
|
+
except (OSError, ValueError, json.JSONDecodeError) as exc:
|
|
189
|
+
return {
|
|
190
|
+
"ok": False,
|
|
191
|
+
"code": "PHASE_CAPSULE_INVALID",
|
|
192
|
+
"message": str(exc),
|
|
193
|
+
"path": str(path),
|
|
194
|
+
"remediation": f"repair or resume the capsule for --phase {phase} --run-id {run_id}",
|
|
195
|
+
}
|
|
196
|
+
if capsule.get("phase") != phase or capsule.get("runId") != run_id:
|
|
197
|
+
return {
|
|
198
|
+
"ok": False,
|
|
199
|
+
"code": "PHASE_CAPSULE_IDENTITY_MISMATCH",
|
|
200
|
+
"message": "capsule content does not match the requested phase/run-id",
|
|
201
|
+
"path": str(path),
|
|
202
|
+
"remediation": f"resume the exact phase with --phase {phase} --run-id {run_id}",
|
|
203
|
+
}
|
|
204
|
+
return {
|
|
205
|
+
"ok": True,
|
|
206
|
+
"code": "PHASE_CAPSULE_SELECTED",
|
|
207
|
+
"capsule": capsule,
|
|
208
|
+
"path": str(path),
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def reconcile_dag(
|
|
213
|
+
dag: dict[str, Any],
|
|
214
|
+
evidence: dict[str, dict[str, Any]],
|
|
215
|
+
*,
|
|
216
|
+
identity: dict[str, Any],
|
|
217
|
+
) -> dict[str, Any]:
|
|
218
|
+
"""Reduce a required DAG to deterministic REUSE/RUN/BLOCK decisions."""
|
|
219
|
+
raw_nodes = dag.get("nodes")
|
|
220
|
+
if not isinstance(raw_nodes, list):
|
|
221
|
+
raise ValueError("requiredGateDag.nodes must be an array")
|
|
222
|
+
if not raw_nodes:
|
|
223
|
+
raise ValueError("requiredGateDag.nodes must not be empty")
|
|
224
|
+
nodes_by_id: dict[str, dict[str, Any]] = {}
|
|
225
|
+
for raw in raw_nodes:
|
|
226
|
+
if not isinstance(raw, dict) or not str(raw.get("id") or "").strip():
|
|
227
|
+
raise ValueError("requiredGateDag node requires a non-empty id")
|
|
228
|
+
node_id = str(raw["id"])
|
|
229
|
+
if node_id in nodes_by_id:
|
|
230
|
+
raise ValueError(f"duplicate DAG node: {node_id}")
|
|
231
|
+
depends = raw.get("dependsOn") or []
|
|
232
|
+
if not isinstance(depends, list) or any(not isinstance(item, str) for item in depends):
|
|
233
|
+
raise ValueError(f"invalid dependsOn for {node_id}")
|
|
234
|
+
nodes_by_id[node_id] = dict(raw)
|
|
235
|
+
for node_id, node in nodes_by_id.items():
|
|
236
|
+
missing = [dep for dep in node.get("dependsOn") or [] if dep not in nodes_by_id]
|
|
237
|
+
if missing:
|
|
238
|
+
raise ValueError(f"unknown dependency for {node_id}: {', '.join(missing)}")
|
|
239
|
+
|
|
240
|
+
if not identity.get("ok"):
|
|
241
|
+
blocker = {
|
|
242
|
+
"code": str(identity.get("code") or "IDENTITY_MISMATCH"),
|
|
243
|
+
"message": str(identity.get("message") or "identity validation failed"),
|
|
244
|
+
}
|
|
245
|
+
return {
|
|
246
|
+
"nodes": [
|
|
247
|
+
{
|
|
248
|
+
**node,
|
|
249
|
+
"decision": "BLOCK",
|
|
250
|
+
"reason": blocker["code"],
|
|
251
|
+
"evidence": evidence.get(node_id) or {},
|
|
252
|
+
}
|
|
253
|
+
for node_id, node in nodes_by_id.items()
|
|
254
|
+
],
|
|
255
|
+
"blockers": [blocker],
|
|
256
|
+
"canClose": False,
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
pending = set(nodes_by_id)
|
|
260
|
+
decided: dict[str, dict[str, Any]] = {}
|
|
261
|
+
while pending:
|
|
262
|
+
progressed = False
|
|
263
|
+
for node_id in list(pending):
|
|
264
|
+
node = nodes_by_id[node_id]
|
|
265
|
+
dependencies = list(node.get("dependsOn") or [])
|
|
266
|
+
if any(dep not in decided for dep in dependencies):
|
|
267
|
+
continue
|
|
268
|
+
facts = evidence.get(node_id) or {}
|
|
269
|
+
upstream = [decided[dep] for dep in dependencies]
|
|
270
|
+
if any(item["decision"] == "BLOCK" for item in upstream):
|
|
271
|
+
decision, reason = "BLOCK", "upstream-blocked"
|
|
272
|
+
elif any(item["decision"] != "REUSE" for item in upstream):
|
|
273
|
+
decision, reason = "RUN", "upstream-invalidated"
|
|
274
|
+
elif bool(facts.get("reusable")):
|
|
275
|
+
decision, reason = "REUSE", str(facts.get("reason") or "evidence-current")
|
|
276
|
+
elif str(facts.get("decision") or "").upper() == "BLOCK":
|
|
277
|
+
decision, reason = "BLOCK", str(facts.get("reason") or "evidence-blocked")
|
|
278
|
+
else:
|
|
279
|
+
decision, reason = "RUN", str(facts.get("reason") or "evidence-missing")
|
|
280
|
+
decided[node_id] = {
|
|
281
|
+
**node,
|
|
282
|
+
"decision": decision,
|
|
283
|
+
"reason": reason,
|
|
284
|
+
"evidence": facts,
|
|
285
|
+
}
|
|
286
|
+
pending.remove(node_id)
|
|
287
|
+
progressed = True
|
|
288
|
+
if not progressed:
|
|
289
|
+
raise ValueError("requiredGateDag contains a cycle")
|
|
290
|
+
|
|
291
|
+
ordered = [decided[str(raw["id"])] for raw in raw_nodes]
|
|
292
|
+
blockers = [
|
|
293
|
+
{"code": "NODE_BLOCKED", "nodeId": node["id"], "message": node["reason"]}
|
|
294
|
+
for node in ordered
|
|
295
|
+
if node["decision"] == "BLOCK"
|
|
296
|
+
]
|
|
297
|
+
return {
|
|
298
|
+
"nodes": ordered,
|
|
299
|
+
"blockers": blockers,
|
|
300
|
+
"canClose": bool(ordered) and all(node["decision"] == "REUSE" for node in ordered),
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def _event_groups(events: list[dict[str, Any]]) -> list[tuple[str, int, list[dict[str, Any]]]]:
|
|
305
|
+
order: list[tuple[str, int]] = []
|
|
306
|
+
groups: dict[tuple[str, int], list[dict[str, Any]]] = defaultdict(list)
|
|
307
|
+
for event in events:
|
|
308
|
+
key = (str(event.get("phase") or "unknown"), int(event.get("attempt") or 1))
|
|
309
|
+
if key not in groups:
|
|
310
|
+
order.append(key)
|
|
311
|
+
groups[key].append(event)
|
|
312
|
+
return [(phase, attempt, groups[(phase, attempt)]) for phase, attempt in order]
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def timing_dimensions(events: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
316
|
+
"""Keep runner, orchestration-active, wall-clock, and user wait distinct."""
|
|
317
|
+
output: list[dict[str, Any]] = []
|
|
318
|
+
for phase, attempt, grouped in _event_groups(events):
|
|
319
|
+
timing = he.canonical_phase_timing(grouped)
|
|
320
|
+
runner_values = [
|
|
321
|
+
event.get("duration_ms", event.get("durationMs"))
|
|
322
|
+
for event in grouped
|
|
323
|
+
if event.get("type") == "command"
|
|
324
|
+
and isinstance(event.get("duration_ms", event.get("durationMs")), int)
|
|
325
|
+
]
|
|
326
|
+
wait_values = [
|
|
327
|
+
event.get("user_wait_ms", event.get("userWaitMs"))
|
|
328
|
+
for event in grouped
|
|
329
|
+
if isinstance(event.get("user_wait_ms", event.get("userWaitMs")), int)
|
|
330
|
+
]
|
|
331
|
+
output.append(
|
|
332
|
+
{
|
|
333
|
+
"phase": phase,
|
|
334
|
+
"attempt": attempt,
|
|
335
|
+
"runnerMs": sum(runner_values) if runner_values else None,
|
|
336
|
+
"orchestrationActiveMs": timing.get("activeExecutionMs"),
|
|
337
|
+
"wallClockMs": timing.get("wallClockSpanMs"),
|
|
338
|
+
"userWaitMs": sum(wait_values) if wait_values else None,
|
|
339
|
+
"lateEventCount": timing.get("lateEventCount"),
|
|
340
|
+
}
|
|
341
|
+
)
|
|
342
|
+
return output
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def build_trace(events: list[dict[str, Any]], *, change_id: str) -> dict[str, Any]:
|
|
346
|
+
"""Project legacy or trace-aware events into stable phase/attempt/tool spans."""
|
|
347
|
+
event_ids = [str(event.get("id") or index) for index, event in enumerate(events)]
|
|
348
|
+
explicit_trace = next(
|
|
349
|
+
(
|
|
350
|
+
str(event.get("traceId") or event.get("trace_id"))
|
|
351
|
+
for event in events
|
|
352
|
+
if event.get("traceId") or event.get("trace_id")
|
|
353
|
+
),
|
|
354
|
+
"",
|
|
355
|
+
)
|
|
356
|
+
trace_id = explicit_trace if len(explicit_trace) == 32 else _stable_hex(change_id, *event_ids, length=32)
|
|
357
|
+
root_span = _stable_hex(trace_id, "root", length=16)
|
|
358
|
+
spans: list[dict[str, Any]] = [
|
|
359
|
+
{
|
|
360
|
+
"traceId": trace_id,
|
|
361
|
+
"spanId": root_span,
|
|
362
|
+
"parentSpanId": None,
|
|
363
|
+
"kind": "change",
|
|
364
|
+
"changeId": change_id,
|
|
365
|
+
}
|
|
366
|
+
]
|
|
367
|
+
for phase, attempt, grouped in _event_groups(events):
|
|
368
|
+
phase_span = _stable_hex(trace_id, phase, attempt, length=16)
|
|
369
|
+
spans.append(
|
|
370
|
+
{
|
|
371
|
+
"traceId": trace_id,
|
|
372
|
+
"spanId": phase_span,
|
|
373
|
+
"parentSpanId": root_span,
|
|
374
|
+
"kind": "phase-attempt",
|
|
375
|
+
"phase": phase,
|
|
376
|
+
"attempt": attempt,
|
|
377
|
+
}
|
|
378
|
+
)
|
|
379
|
+
tools: list[str] = []
|
|
380
|
+
for event in grouped:
|
|
381
|
+
tool = str(event.get("executor_tool") or event.get("executorTool") or "").strip()
|
|
382
|
+
if tool and tool not in tools:
|
|
383
|
+
tools.append(tool)
|
|
384
|
+
for tool in tools:
|
|
385
|
+
spans.append(
|
|
386
|
+
{
|
|
387
|
+
"traceId": trace_id,
|
|
388
|
+
"spanId": _stable_hex(trace_id, phase, attempt, tool, length=16),
|
|
389
|
+
"parentSpanId": phase_span,
|
|
390
|
+
"kind": "tool",
|
|
391
|
+
"phase": phase,
|
|
392
|
+
"attempt": attempt,
|
|
393
|
+
"tool": tool,
|
|
394
|
+
}
|
|
395
|
+
)
|
|
396
|
+
return {"schemaVersion": 1, "traceId": trace_id, "spans": spans}
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def _number(value: Any, *, integer: bool = True) -> int | float | None:
|
|
400
|
+
if value is None or isinstance(value, bool):
|
|
401
|
+
return None
|
|
402
|
+
try:
|
|
403
|
+
number = float(value)
|
|
404
|
+
except (TypeError, ValueError):
|
|
405
|
+
return None
|
|
406
|
+
return int(number) if integer else number
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def _junit_metrics(text: str) -> dict[str, Any]:
|
|
410
|
+
root = ET.fromstring(text)
|
|
411
|
+
suites = [root] if root.tag.endswith("testsuite") else list(root.findall(".//testsuite"))
|
|
412
|
+
aggregate = root if root.tag.endswith(("testsuite", "testsuites")) else None
|
|
413
|
+
attrs = aggregate.attrib if aggregate is not None else {}
|
|
414
|
+
def attribute_total(name: str, fallback: str | None = None) -> int | None:
|
|
415
|
+
direct = _number(attrs.get(name, attrs.get(fallback) if fallback else None))
|
|
416
|
+
if direct is not None:
|
|
417
|
+
return int(direct)
|
|
418
|
+
values = [
|
|
419
|
+
_number(item.attrib.get(name, item.attrib.get(fallback) if fallback else None))
|
|
420
|
+
for item in suites
|
|
421
|
+
]
|
|
422
|
+
return (
|
|
423
|
+
sum(int(value) for value in values if value is not None)
|
|
424
|
+
if any(value is not None for value in values)
|
|
425
|
+
else None
|
|
426
|
+
)
|
|
427
|
+
|
|
428
|
+
total = attribute_total("tests")
|
|
429
|
+
failures = attribute_total("failures")
|
|
430
|
+
errors = attribute_total("errors")
|
|
431
|
+
skipped = attribute_total("skipped", "disabled")
|
|
432
|
+
duration_seconds = _number(attrs.get("time"), integer=False)
|
|
433
|
+
if duration_seconds is None:
|
|
434
|
+
durations = [_number(item.attrib.get("time"), integer=False) for item in suites]
|
|
435
|
+
duration_seconds = (
|
|
436
|
+
sum(value for value in durations if value is not None)
|
|
437
|
+
if any(value is not None for value in durations)
|
|
438
|
+
else None
|
|
439
|
+
)
|
|
440
|
+
passed = None
|
|
441
|
+
if all(value is not None for value in (total, failures, errors, skipped)):
|
|
442
|
+
passed = max(int(total) - int(failures) - int(errors) - int(skipped), 0)
|
|
443
|
+
return {
|
|
444
|
+
"suites": len(suites) if suites else None,
|
|
445
|
+
"passed": passed,
|
|
446
|
+
"failed": failures,
|
|
447
|
+
"skipped": skipped,
|
|
448
|
+
"setupErrors": errors,
|
|
449
|
+
"durationMs": int(duration_seconds * 1000) if duration_seconds is not None else None,
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
def normalize_ci_metrics(
|
|
454
|
+
raw: str | dict[str, Any],
|
|
455
|
+
*,
|
|
456
|
+
runner: str,
|
|
457
|
+
head_sha: str | None,
|
|
458
|
+
source: str | None = None,
|
|
459
|
+
) -> dict[str, Any]:
|
|
460
|
+
"""Normalize JUnit, Vitest, or Playwright machine output without inventing zeros."""
|
|
461
|
+
normalized_runner = runner.lower()
|
|
462
|
+
if normalized_runner == "junit":
|
|
463
|
+
if not isinstance(raw, str):
|
|
464
|
+
raise ValueError("JUnit input must be XML text")
|
|
465
|
+
metrics = _junit_metrics(raw)
|
|
466
|
+
else:
|
|
467
|
+
if isinstance(raw, str):
|
|
468
|
+
value = json.loads(raw)
|
|
469
|
+
else:
|
|
470
|
+
value = raw
|
|
471
|
+
if not isinstance(value, dict):
|
|
472
|
+
raise ValueError("runner metrics must be a JSON object")
|
|
473
|
+
if normalized_runner == "vitest":
|
|
474
|
+
runtimes = [
|
|
475
|
+
_number((item.get("perfStats") or {}).get("runtime"))
|
|
476
|
+
for item in (value.get("testResults") or [])
|
|
477
|
+
if isinstance(item, dict)
|
|
478
|
+
]
|
|
479
|
+
metrics = {
|
|
480
|
+
"suites": _number(value.get("numTotalTestSuites")),
|
|
481
|
+
"passed": _number(value.get("numPassedTests")),
|
|
482
|
+
"failed": _number(value.get("numFailedTests")),
|
|
483
|
+
"skipped": _number(value.get("numPendingTests")),
|
|
484
|
+
"setupErrors": _number(value.get("numRuntimeErrorTestSuites")),
|
|
485
|
+
"durationMs": sum(item for item in runtimes if item is not None)
|
|
486
|
+
if any(item is not None for item in runtimes)
|
|
487
|
+
else None,
|
|
488
|
+
}
|
|
489
|
+
elif normalized_runner == "playwright":
|
|
490
|
+
stats = value.get("stats") if isinstance(value.get("stats"), dict) else {}
|
|
491
|
+
suites = value.get("suites")
|
|
492
|
+
error_counts = []
|
|
493
|
+
for raw_errors in (value.get("errors"), stats.get("errors")):
|
|
494
|
+
count = len(raw_errors) if isinstance(raw_errors, list) else _number(raw_errors)
|
|
495
|
+
if count is not None:
|
|
496
|
+
error_counts.append(count)
|
|
497
|
+
setup_errors = max(error_counts) if error_counts else None
|
|
498
|
+
metrics = {
|
|
499
|
+
"suites": len(suites) if isinstance(suites, list) else None,
|
|
500
|
+
"passed": _number(stats.get("expected")),
|
|
501
|
+
"failed": _number(stats.get("unexpected")),
|
|
502
|
+
"skipped": _number(stats.get("skipped")),
|
|
503
|
+
"setupErrors": _number(setup_errors),
|
|
504
|
+
"durationMs": _number(stats.get("duration")),
|
|
505
|
+
}
|
|
506
|
+
else:
|
|
507
|
+
raise ValueError(f"unsupported runner: {runner}")
|
|
508
|
+
result = {
|
|
509
|
+
"schemaVersion": CI_METRICS_SCHEMA_VERSION,
|
|
510
|
+
"runner": normalized_runner,
|
|
511
|
+
"source": source,
|
|
512
|
+
**metrics,
|
|
513
|
+
"headSha": head_sha,
|
|
514
|
+
}
|
|
515
|
+
for field in ("suites", "passed", "failed", "skipped", "setupErrors", "durationMs"):
|
|
516
|
+
value = result.get(field)
|
|
517
|
+
if value is not None and (not isinstance(value, int) or value < 0):
|
|
518
|
+
raise ValueError(f"invalid CI metric {field}: expected nonnegative integer or null")
|
|
519
|
+
if head_sha is not None and not re.fullmatch(r"[0-9a-f]{40}(?:[0-9a-f]{24})?", head_sha):
|
|
520
|
+
raise ValueError("headSha must be a lowercase 40- or 64-character hex commit id")
|
|
521
|
+
return result
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
def _git_head(project_root: Path) -> str | None:
|
|
525
|
+
try:
|
|
526
|
+
proc = subprocess.run(
|
|
527
|
+
["git", "rev-parse", "--verify", "HEAD"],
|
|
528
|
+
cwd=str(project_root),
|
|
529
|
+
capture_output=True,
|
|
530
|
+
text=True,
|
|
531
|
+
encoding="utf-8",
|
|
532
|
+
errors="replace",
|
|
533
|
+
check=False,
|
|
534
|
+
)
|
|
535
|
+
except OSError:
|
|
536
|
+
return None
|
|
537
|
+
return proc.stdout.strip() if proc.returncode == 0 and proc.stdout.strip() else None
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
def assess_identity(
|
|
541
|
+
change_dir: Path,
|
|
542
|
+
project_root: Path,
|
|
543
|
+
ledger: dict[str, Any] | None,
|
|
544
|
+
*,
|
|
545
|
+
target_phase: str | None = None,
|
|
546
|
+
run_id: str | None = None,
|
|
547
|
+
) -> dict[str, Any]:
|
|
548
|
+
current_head = _git_head(project_root)
|
|
549
|
+
capsule: dict[str, Any] | None = None
|
|
550
|
+
capsule_selection: dict[str, Any] | None = None
|
|
551
|
+
if target_phase is not None:
|
|
552
|
+
capsule_selection = select_phase_capsule(
|
|
553
|
+
change_dir,
|
|
554
|
+
project_root,
|
|
555
|
+
phase=target_phase,
|
|
556
|
+
run_id=run_id,
|
|
557
|
+
)
|
|
558
|
+
if not capsule_selection.get("ok"):
|
|
559
|
+
return {
|
|
560
|
+
"ok": False,
|
|
561
|
+
"code": capsule_selection.get("code"),
|
|
562
|
+
"message": capsule_selection.get("message"),
|
|
563
|
+
"remediation": capsule_selection.get("remediation"),
|
|
564
|
+
"capsuleSelection": {
|
|
565
|
+
key: value
|
|
566
|
+
for key, value in capsule_selection.items()
|
|
567
|
+
if key != "capsule"
|
|
568
|
+
},
|
|
569
|
+
}
|
|
570
|
+
selected = capsule_selection.get("capsule")
|
|
571
|
+
capsule = selected if isinstance(selected, dict) else None
|
|
572
|
+
if capsule is not None:
|
|
573
|
+
stored_head = capsule.get("currentHead")
|
|
574
|
+
if stored_head and current_head and stored_head != current_head:
|
|
575
|
+
return {
|
|
576
|
+
"ok": False,
|
|
577
|
+
"code": "HEAD_MISMATCH",
|
|
578
|
+
"message": f"phase capsule head {stored_head} != current {current_head}",
|
|
579
|
+
"storedHead": stored_head,
|
|
580
|
+
"currentHead": current_head,
|
|
581
|
+
"capsuleSelection": capsule_selection,
|
|
582
|
+
}
|
|
583
|
+
if not ledger or ledger.get("schemaVersion") != 3:
|
|
584
|
+
return {
|
|
585
|
+
"ok": True,
|
|
586
|
+
"code": "IDENTITY_NOT_REUSABLE",
|
|
587
|
+
"message": "no ledger v3 identity; validations must RUN unless their own evidence is current",
|
|
588
|
+
"currentHead": current_head,
|
|
589
|
+
"capsuleSelection": capsule_selection,
|
|
590
|
+
}
|
|
591
|
+
missing = hl.validate_ledger_identity(ledger)
|
|
592
|
+
if missing:
|
|
593
|
+
return {
|
|
594
|
+
"ok": False,
|
|
595
|
+
"code": "LEDGER_IDENTITY_INVALID",
|
|
596
|
+
"message": "missing: " + ", ".join(missing),
|
|
597
|
+
}
|
|
598
|
+
if capsule is not None and capsule.get("baseCommit") not in {
|
|
599
|
+
None,
|
|
600
|
+
ledger.get("baseCommit"),
|
|
601
|
+
}:
|
|
602
|
+
return {
|
|
603
|
+
"ok": False,
|
|
604
|
+
"code": "BASE_MISMATCH",
|
|
605
|
+
"message": "phase capsule and ledger base commits differ",
|
|
606
|
+
"capsuleBase": capsule.get("baseCommit"),
|
|
607
|
+
"ledgerBase": ledger.get("baseCommit"),
|
|
608
|
+
}
|
|
609
|
+
try:
|
|
610
|
+
contract = hpaths.load_change_contract(change_dir)
|
|
611
|
+
current_repository = hpaths.repository_identity(project_root)
|
|
612
|
+
current_ownership = hl.ownership_hash(contract)
|
|
613
|
+
current_diff = hl.compute_ownership_diff(
|
|
614
|
+
project_root,
|
|
615
|
+
base=str(ledger["baseCommit"]),
|
|
616
|
+
change_dir=change_dir,
|
|
617
|
+
)["diffHash"]
|
|
618
|
+
except (OSError, ValueError, RuntimeError) as exc:
|
|
619
|
+
return {"ok": False, "code": "IDENTITY_RESOLUTION_FAILED", "message": str(exc)}
|
|
620
|
+
identity_dimensions = {
|
|
621
|
+
"repositoryId": (ledger.get("repositoryId"), current_repository),
|
|
622
|
+
"ownershipHash": (ledger.get("ownershipHash"), current_ownership),
|
|
623
|
+
"currentHead": (ledger.get("currentHead"), current_head),
|
|
624
|
+
}
|
|
625
|
+
changed = {
|
|
626
|
+
key: {"stored": stored, "current": current}
|
|
627
|
+
for key, (stored, current) in identity_dimensions.items()
|
|
628
|
+
if stored != current
|
|
629
|
+
}
|
|
630
|
+
if changed:
|
|
631
|
+
return {
|
|
632
|
+
"ok": False,
|
|
633
|
+
"code": "LEDGER_IDENTITY_MISMATCH",
|
|
634
|
+
"message": "ledger identity does not match current change",
|
|
635
|
+
"mismatches": changed,
|
|
636
|
+
}
|
|
637
|
+
if ledger.get("diffHash") != current_diff:
|
|
638
|
+
return {
|
|
639
|
+
"ok": True,
|
|
640
|
+
"code": "DIFF_CHANGED",
|
|
641
|
+
"message": "ownership diff changed; reuse is decided per node inputs",
|
|
642
|
+
"storedDiffHash": ledger.get("diffHash"),
|
|
643
|
+
"currentDiffHash": current_diff,
|
|
644
|
+
"currentHead": current_head,
|
|
645
|
+
}
|
|
646
|
+
return {
|
|
647
|
+
"ok": True,
|
|
648
|
+
"code": "IDENTITY_OK",
|
|
649
|
+
"currentHead": current_head,
|
|
650
|
+
"capsuleSelection": capsule_selection,
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
|
|
654
|
+
def _latest_phase_status(events: list[dict[str, Any]], phase: str) -> str | None:
|
|
655
|
+
status = None
|
|
656
|
+
for event in he.apply_event_corrections(events):
|
|
657
|
+
if event.get("type") == "phase.end" and event.get("phase") == phase:
|
|
658
|
+
status = str(event.get("status") or "").upper() or None
|
|
659
|
+
return status
|
|
660
|
+
|
|
661
|
+
|
|
662
|
+
def collect_node_evidence(
|
|
663
|
+
dag: dict[str, Any],
|
|
664
|
+
change_dir: Path,
|
|
665
|
+
events: list[dict[str, Any]],
|
|
666
|
+
ledger: dict[str, Any] | None,
|
|
667
|
+
) -> dict[str, dict[str, Any]]:
|
|
668
|
+
result: dict[str, dict[str, Any]] = {}
|
|
669
|
+
validations = (ledger or {}).get("validations") or {}
|
|
670
|
+
for node in dag.get("nodes") or []:
|
|
671
|
+
node_id = str(node.get("id") or "")
|
|
672
|
+
if node.get("kind") == "validation" or node_id.startswith("validation:"):
|
|
673
|
+
verification = node_id.split(":", 1)[1]
|
|
674
|
+
entry = validations.get(verification) if isinstance(validations, dict) else None
|
|
675
|
+
if not isinstance(entry, dict):
|
|
676
|
+
result[node_id] = {"reusable": False, "reason": "validation-missing"}
|
|
677
|
+
continue
|
|
678
|
+
files = entry.get("inputsFiles") if isinstance(entry.get("inputsFiles"), list) else []
|
|
679
|
+
if not files:
|
|
680
|
+
result[node_id] = {"reusable": False, "reason": "inputs-missing"}
|
|
681
|
+
continue
|
|
682
|
+
reuse = hl.decide_can_reuse(
|
|
683
|
+
change_dir=change_dir,
|
|
684
|
+
verification=verification,
|
|
685
|
+
files=[str(item) for item in files],
|
|
686
|
+
requested_scope=entry.get("scope") if isinstance(entry.get("scope"), str) else None,
|
|
687
|
+
requested_command=str(entry.get("command") or "") or None,
|
|
688
|
+
)
|
|
689
|
+
result[node_id] = {
|
|
690
|
+
"reusable": bool(reuse.get("reuse")),
|
|
691
|
+
"reason": str(reuse.get("code") or reuse.get("reason") or "not-reusable"),
|
|
692
|
+
"reuse": reuse,
|
|
693
|
+
}
|
|
694
|
+
else:
|
|
695
|
+
phase = str(node.get("phase") or node_id.split(":", 1)[-1])
|
|
696
|
+
status = _latest_phase_status(events, phase)
|
|
697
|
+
result[node_id] = {
|
|
698
|
+
"reusable": status in {"OK", "WARN"},
|
|
699
|
+
"reason": f"phase-status:{status or 'missing'}",
|
|
700
|
+
"status": status,
|
|
701
|
+
}
|
|
702
|
+
return result
|
|
703
|
+
|
|
704
|
+
|
|
705
|
+
def reconcile(
|
|
706
|
+
change_dir: Path,
|
|
707
|
+
project_root: Path,
|
|
708
|
+
*,
|
|
709
|
+
target_phase: str | None = None,
|
|
710
|
+
run_id: str | None = None,
|
|
711
|
+
) -> dict[str, Any]:
|
|
712
|
+
change_dir = change_dir.resolve()
|
|
713
|
+
project_root = project_root.resolve()
|
|
714
|
+
policy = _read_json(change_dir / "meta" / "gate-policy.json")
|
|
715
|
+
dag = (
|
|
716
|
+
target_required_dag(policy, target_phase)
|
|
717
|
+
if target_phase is not None
|
|
718
|
+
else policy.get("requiredGateDag")
|
|
719
|
+
)
|
|
720
|
+
if not isinstance(dag, dict):
|
|
721
|
+
raise ValueError("gate policy missing requiredGateDag; run harness_gate.py classify")
|
|
722
|
+
ledger, ledger_path = hl.load_ledger(change_dir)
|
|
723
|
+
events_file = he.events_path(change_dir)
|
|
724
|
+
events = he.load_events(events_file) if events_file.is_file() else []
|
|
725
|
+
identity = assess_identity(
|
|
726
|
+
change_dir,
|
|
727
|
+
project_root,
|
|
728
|
+
ledger,
|
|
729
|
+
target_phase=target_phase,
|
|
730
|
+
run_id=run_id,
|
|
731
|
+
)
|
|
732
|
+
evidence = collect_node_evidence(dag, change_dir, events, ledger)
|
|
733
|
+
if identity.get("code") not in {"IDENTITY_OK", "DIFF_CHANGED"}:
|
|
734
|
+
for facts in evidence.values():
|
|
735
|
+
facts["reusable"] = False
|
|
736
|
+
facts["reason"] = "identity-not-reusable"
|
|
737
|
+
if dag.get("nodes"):
|
|
738
|
+
decision = reconcile_dag(dag, evidence, identity=identity)
|
|
739
|
+
elif identity.get("ok"):
|
|
740
|
+
decision = {"nodes": [], "blockers": [], "canClose": True}
|
|
741
|
+
else:
|
|
742
|
+
decision = {
|
|
743
|
+
"nodes": [],
|
|
744
|
+
"blockers": [
|
|
745
|
+
{
|
|
746
|
+
"code": str(identity.get("code") or "IDENTITY_MISMATCH"),
|
|
747
|
+
"message": str(identity.get("message") or "identity validation failed"),
|
|
748
|
+
}
|
|
749
|
+
],
|
|
750
|
+
"canClose": False,
|
|
751
|
+
}
|
|
752
|
+
guard_path = change_dir / "evidence" / "test-guard-snapshot.json"
|
|
753
|
+
guard = _read_json(guard_path) if guard_path.is_file() else None
|
|
754
|
+
return {
|
|
755
|
+
"ok": not decision["blockers"],
|
|
756
|
+
"action": "reconcile",
|
|
757
|
+
"schemaVersion": SCHEMA_VERSION,
|
|
758
|
+
"generatedAt": dt.datetime.now().astimezone().isoformat(timespec="milliseconds"),
|
|
759
|
+
"changeId": change_dir.name,
|
|
760
|
+
"projectRoot": str(project_root),
|
|
761
|
+
"changeDir": str(change_dir),
|
|
762
|
+
"targetPhase": target_phase,
|
|
763
|
+
"runId": run_id,
|
|
764
|
+
"identity": identity,
|
|
765
|
+
**decision,
|
|
766
|
+
"guard": guard,
|
|
767
|
+
"ledgerPath": str(ledger_path) if ledger_path else None,
|
|
768
|
+
"trace": build_trace(events, change_id=change_dir.name),
|
|
769
|
+
"timing": timing_dimensions(events),
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
|
|
773
|
+
def format_compact(result: dict[str, Any]) -> str:
|
|
774
|
+
lines: list[str] = []
|
|
775
|
+
for node in result.get("nodes") or []:
|
|
776
|
+
decision = str(node.get("decision") or "")
|
|
777
|
+
if decision == "REUSE":
|
|
778
|
+
continue
|
|
779
|
+
lines.append(f"{decision} {node.get('id')}: {node.get('reason')}")
|
|
780
|
+
for blocker in result.get("blockers") or []:
|
|
781
|
+
lines.append(f"BLOCK {blocker.get('code')}: {blocker.get('message')}")
|
|
782
|
+
if not lines:
|
|
783
|
+
nodes = result.get("nodes") or []
|
|
784
|
+
if not result.get("ok") or not nodes:
|
|
785
|
+
lines.append(
|
|
786
|
+
f"BLOCK {result.get('code') or 'EMPTY_RECONCILIATION'}: "
|
|
787
|
+
f"{result.get('error') or 'no required nodes were reconciled'}"
|
|
788
|
+
)
|
|
789
|
+
else:
|
|
790
|
+
lines.append(f"REUSED {len(nodes)} required nodes; safe to close")
|
|
791
|
+
return "\n".join(lines)
|
|
792
|
+
|
|
793
|
+
|
|
794
|
+
def _close_gate(args: argparse.Namespace) -> dict[str, Any]:
|
|
795
|
+
command = [
|
|
796
|
+
sys.executable,
|
|
797
|
+
str(SCRIPTS_DIR / "harness_gate.py"),
|
|
798
|
+
"close",
|
|
799
|
+
"--json",
|
|
800
|
+
"--phase",
|
|
801
|
+
args.phase,
|
|
802
|
+
"--change",
|
|
803
|
+
Path(args.change_dir).name,
|
|
804
|
+
"--project",
|
|
805
|
+
str(Path(args.project).resolve()),
|
|
806
|
+
"--status",
|
|
807
|
+
"OK",
|
|
808
|
+
]
|
|
809
|
+
if args.run_id:
|
|
810
|
+
command.extend(["--run-id", args.run_id])
|
|
811
|
+
proc = subprocess.run(
|
|
812
|
+
command,
|
|
813
|
+
capture_output=True,
|
|
814
|
+
text=True,
|
|
815
|
+
encoding="utf-8",
|
|
816
|
+
errors="replace",
|
|
817
|
+
check=False,
|
|
818
|
+
)
|
|
819
|
+
try:
|
|
820
|
+
payload = json.loads(proc.stdout)
|
|
821
|
+
except json.JSONDecodeError:
|
|
822
|
+
payload = {"ok": False, "code": "GATE_CLOSE_INVALID_OUTPUT", "stderr": proc.stderr}
|
|
823
|
+
payload["exitCode"] = proc.returncode
|
|
824
|
+
return payload
|
|
825
|
+
|
|
826
|
+
|
|
827
|
+
def _cmd_reconcile(args: argparse.Namespace) -> int:
|
|
828
|
+
try:
|
|
829
|
+
result = reconcile(
|
|
830
|
+
Path(args.change_dir),
|
|
831
|
+
Path(args.project),
|
|
832
|
+
target_phase=args.phase,
|
|
833
|
+
run_id=args.run_id,
|
|
834
|
+
)
|
|
835
|
+
if args.close:
|
|
836
|
+
if not result.get("canClose"):
|
|
837
|
+
result["close"] = {
|
|
838
|
+
"ok": False,
|
|
839
|
+
"code": "RECONCILE_NOT_CLOSABLE",
|
|
840
|
+
"message": "required nodes still need RUN or are BLOCKED",
|
|
841
|
+
}
|
|
842
|
+
result["ok"] = False
|
|
843
|
+
result.setdefault("blockers", []).append(
|
|
844
|
+
{
|
|
845
|
+
"code": "RECONCILE_NOT_CLOSABLE",
|
|
846
|
+
"message": "explicit close refused because required nodes are not reusable",
|
|
847
|
+
}
|
|
848
|
+
)
|
|
849
|
+
else:
|
|
850
|
+
result["close"] = _close_gate(args)
|
|
851
|
+
if not result["close"].get("ok"):
|
|
852
|
+
result["ok"] = False
|
|
853
|
+
if args.output:
|
|
854
|
+
_write_json(Path(args.output).resolve(), result)
|
|
855
|
+
except (OSError, ValueError, json.JSONDecodeError) as exc:
|
|
856
|
+
result = {"ok": False, "action": "reconcile", "code": "RECONCILE_FAILED", "error": str(exc)}
|
|
857
|
+
if args.json:
|
|
858
|
+
print(json.dumps(result, ensure_ascii=False, indent=2))
|
|
859
|
+
else:
|
|
860
|
+
print(format_compact(result))
|
|
861
|
+
return 0 if result.get("ok") else 2
|
|
862
|
+
|
|
863
|
+
|
|
864
|
+
def _cmd_metrics(args: argparse.Namespace) -> int:
|
|
865
|
+
input_path = Path(args.input).resolve()
|
|
866
|
+
try:
|
|
867
|
+
text = input_path.read_text(encoding="utf-8-sig")
|
|
868
|
+
runner = args.runner
|
|
869
|
+
if runner == "auto":
|
|
870
|
+
if input_path.suffix.lower() == ".xml":
|
|
871
|
+
runner = "junit"
|
|
872
|
+
raw: str | dict[str, Any] = text
|
|
873
|
+
else:
|
|
874
|
+
raw = json.loads(text)
|
|
875
|
+
runner = (
|
|
876
|
+
"playwright"
|
|
877
|
+
if isinstance(raw, dict) and "stats" in raw and "suites" in raw
|
|
878
|
+
else "vitest"
|
|
879
|
+
)
|
|
880
|
+
else:
|
|
881
|
+
raw = text if runner == "junit" else json.loads(text)
|
|
882
|
+
result = normalize_ci_metrics(
|
|
883
|
+
raw,
|
|
884
|
+
runner=runner,
|
|
885
|
+
head_sha=args.head_sha,
|
|
886
|
+
source=str(input_path),
|
|
887
|
+
)
|
|
888
|
+
if args.output:
|
|
889
|
+
_write_json(Path(args.output).resolve(), result)
|
|
890
|
+
except (OSError, ValueError, ET.ParseError, json.JSONDecodeError) as exc:
|
|
891
|
+
result = {"ok": False, "code": "CI_METRICS_FAILED", "error": str(exc)}
|
|
892
|
+
print(json.dumps(result, ensure_ascii=False, indent=2))
|
|
893
|
+
return 2
|
|
894
|
+
print(json.dumps(result, ensure_ascii=False, indent=2))
|
|
895
|
+
return 0
|
|
896
|
+
|
|
897
|
+
|
|
898
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
899
|
+
parser = argparse.ArgumentParser(prog="harness_phase.py")
|
|
900
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
901
|
+
reconcile_parser = sub.add_parser("reconcile")
|
|
902
|
+
reconcile_parser.add_argument("--change-dir", required=True)
|
|
903
|
+
reconcile_parser.add_argument("--project", required=True)
|
|
904
|
+
reconcile_parser.add_argument("--json", action="store_true")
|
|
905
|
+
reconcile_parser.add_argument("--output")
|
|
906
|
+
reconcile_parser.add_argument("--close", action="store_true")
|
|
907
|
+
reconcile_parser.add_argument("--phase", default="run")
|
|
908
|
+
reconcile_parser.add_argument("--run-id")
|
|
909
|
+
reconcile_parser.set_defaults(func=_cmd_reconcile)
|
|
910
|
+
|
|
911
|
+
metrics_parser = sub.add_parser("metrics")
|
|
912
|
+
metrics_parser.add_argument("--input", required=True)
|
|
913
|
+
metrics_parser.add_argument("--runner", choices=("auto", "junit", "vitest", "playwright"), default="auto")
|
|
914
|
+
metrics_parser.add_argument("--head-sha")
|
|
915
|
+
metrics_parser.add_argument("--output")
|
|
916
|
+
metrics_parser.set_defaults(func=_cmd_metrics)
|
|
917
|
+
return parser
|
|
918
|
+
|
|
919
|
+
|
|
920
|
+
def main(argv: list[str] | None = None) -> int:
|
|
921
|
+
args = build_parser().parse_args(argv)
|
|
922
|
+
return int(args.func(args))
|
|
923
|
+
|
|
924
|
+
|
|
925
|
+
if __name__ == "__main__":
|
|
926
|
+
raise SystemExit(main())
|