hunter-harness 0.1.0
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/LICENSE +21 -0
- package/README.md +17 -0
- package/dist/bin.js +3356 -0
- package/package.json +34 -0
- package/resources/bootstrap-ir/manifest.json +19 -0
- package/resources/bootstrap-ir/skills/harness-apidoc.yaml +24 -0
- package/resources/bootstrap-ir/skills/harness-archive.yaml +24 -0
- package/resources/bootstrap-ir/skills/harness-codebase-map.yaml +24 -0
- package/resources/bootstrap-ir/skills/harness-knowledge-ingest.yaml +24 -0
- package/resources/bootstrap-ir/skills/harness-package.yaml +24 -0
- package/resources/bootstrap-ir/skills/harness-plan.yaml +24 -0
- package/resources/bootstrap-ir/skills/harness-review.yaml +24 -0
- package/resources/bootstrap-ir/skills/harness-run.yaml +24 -0
- package/resources/bootstrap-ir/skills/harness-skill-optimizer.yaml +28 -0
- package/resources/bootstrap-ir/skills/harness-submit.yaml +24 -0
- package/resources/bootstrap-ir/skills/harness-sync.yaml +24 -0
- package/resources/bootstrap-ir/skills/harness-test.yaml +24 -0
- package/resources/bootstrap-ir/templates/claude-code-skill.md +12 -0
- package/resources/harness/general/.harness-build.json +5 -0
- package/resources/harness/general/CONTEXT.md +65 -0
- package/resources/harness/general/README.md +478 -0
- package/resources/harness/general/agents/harness-evaluator.md +96 -0
- package/resources/harness/general/agents/harness-explorer.md +72 -0
- package/resources/harness/general/agents/harness-reviewer.md +69 -0
- package/resources/harness/general/harness-archive/SKILL.md +215 -0
- package/resources/harness/general/harness-archive/checklist.md +52 -0
- package/resources/harness/general/harness-archive/reference.md +103 -0
- package/resources/harness/general/harness-archive/scripts/gen-manifest.ps1 +31 -0
- package/resources/harness/general/harness-archive/templates/render-summary.mjs +171 -0
- package/resources/harness/general/harness-archive/templates/summary-data-template.json +128 -0
- package/resources/harness/general/harness-codebase-map/SKILL.md +112 -0
- package/resources/harness/general/harness-codebase-map/checklist.md +126 -0
- package/resources/harness/general/harness-codebase-map/reference.md +364 -0
- package/resources/harness/general/harness-codebase-map/templates/ARCHITECTURE.md +57 -0
- package/resources/harness/general/harness-codebase-map/templates/CONCERNS.md +49 -0
- package/resources/harness/general/harness-codebase-map/templates/CONVENTIONS.md +57 -0
- package/resources/harness/general/harness-codebase-map/templates/INTEGRATIONS.md +52 -0
- package/resources/harness/general/harness-codebase-map/templates/STACK.md +55 -0
- package/resources/harness/general/harness-codebase-map/templates/STRUCTURE.md +69 -0
- package/resources/harness/general/harness-codebase-map/templates/TESTING.md +64 -0
- package/resources/harness/general/harness-codebase-map/templates/map-manifest.schema.json +73 -0
- package/resources/harness/general/harness-codebase-map/templates/map-summary.md +64 -0
- package/resources/harness/general/harness-knowledge-ingest/SKILL.md +246 -0
- package/resources/harness/general/harness-knowledge-ingest/design.md +842 -0
- package/resources/harness/general/harness-knowledge-ingest/evaluations/harness_knowledge_evaluation.xml +42 -0
- package/resources/harness/general/harness-knowledge-ingest/mcp-config.example.json +10 -0
- package/resources/harness/general/harness-knowledge-ingest/reference.md +309 -0
- package/resources/harness/general/harness-knowledge-ingest/scripts/harness_knowledge.py +4082 -0
- package/resources/harness/general/harness-knowledge-ingest/scripts/harness_knowledge_mcp.py +273 -0
- package/resources/harness/general/harness-knowledge-ingest/tests/fixtures/mcp-eval-project/.harness/archive/2026-01-10-ledger-reconciliation/reports/final/summary-data.json +43 -0
- package/resources/harness/general/harness-knowledge-ingest/tests/fixtures/mcp-eval-project/.harness/archive/2026-02-14-ledger-snapshot-followup/reports/final/summary-data.json +38 -0
- package/resources/harness/general/harness-knowledge-ingest/tests/fixtures/mcp-eval-project/.harness/archive/2026-03-05-webhook-contract/reports/final/summary-data.json +36 -0
- package/resources/harness/general/harness-knowledge-ingest/tests/fixtures/mcp-eval-project/.harness/project.yaml +1 -0
- package/resources/harness/general/harness-knowledge-ingest/tests/test_harness_knowledge.py +1792 -0
- package/resources/harness/general/harness-knowledge-query/SKILL.md +164 -0
- package/resources/harness/general/harness-plan/SKILL.md +127 -0
- package/resources/harness/general/harness-plan/checklist.md +243 -0
- package/resources/harness/general/harness-plan/protocols.md +124 -0
- package/resources/harness/general/harness-plan/reference.md +376 -0
- package/resources/harness/general/harness-review/SKILL.md +156 -0
- package/resources/harness/general/harness-review/checklist.md +124 -0
- package/resources/harness/general/harness-review/protocols.md +68 -0
- package/resources/harness/general/harness-review/reference.md +86 -0
- package/resources/harness/general/harness-run/SKILL.md +132 -0
- package/resources/harness/general/harness-run/checklist.md +259 -0
- package/resources/harness/general/harness-run/protocols.md +78 -0
- package/resources/harness/general/harness-run/reference.md +834 -0
- package/resources/harness/general/harness-submit/SKILL.md +159 -0
- package/resources/harness/general/harness-submit/checklist.md +407 -0
- package/resources/harness/general/harness-submit/reference.md +152 -0
- package/resources/harness/general/harness-sync/SKILL.md +82 -0
- package/resources/harness/general/harness-sync/reference.md +153 -0
- package/resources/harness/general/harness-test/SKILL.md +180 -0
- package/resources/harness/general/harness-test/checklist.md +319 -0
- package/resources/harness/general/harness-test/pitfalls.md +260 -0
- package/resources/harness/general/harness-test/reference.md +791 -0
- package/resources/harness/general/protocols/archive-report-protocol.md +175 -0
- package/resources/harness/general/protocols/evidence-based-reporting-protocol.md +134 -0
- package/resources/harness/general/protocols/ledger-protocol.md +270 -0
- package/resources/harness/general/protocols/powershell-protocol.md +181 -0
- package/resources/harness/general/protocols/report-pipeline-protocol.md +150 -0
- package/resources/harness/general/protocols/sensitive-info-protocol.md +92 -0
- package/resources/harness/general/protocols/state-layout-protocol.md +131 -0
- package/resources/harness/general/protocols/submit-protocol.md +78 -0
- package/resources/harness/general/scripts/harness_archive.py +2319 -0
- package/resources/harness/general/scripts/harness_deploy.py +613 -0
- package/resources/harness/general/scripts/harness_events.py +680 -0
- package/resources/harness/general/scripts/harness_ledger.py +671 -0
- package/resources/harness/general/scripts/harness_preflight.py +780 -0
- package/resources/harness/general/scripts/harness_service.py +1331 -0
- package/resources/harness/java/.harness-build.json +5 -0
- package/resources/harness/java/CONTEXT.md +65 -0
- package/resources/harness/java/README.md +478 -0
- package/resources/harness/java/agents/harness-evaluator.md +96 -0
- package/resources/harness/java/agents/harness-explorer.md +72 -0
- package/resources/harness/java/agents/harness-reviewer.md +69 -0
- package/resources/harness/java/harness-apidoc/SKILL.md +86 -0
- package/resources/harness/java/harness-apidoc/checklist.md +142 -0
- package/resources/harness/java/harness-apidoc/reference.md +233 -0
- package/resources/harness/java/harness-archive/SKILL.md +215 -0
- package/resources/harness/java/harness-archive/checklist.md +52 -0
- package/resources/harness/java/harness-archive/reference.md +103 -0
- package/resources/harness/java/harness-archive/scripts/gen-manifest.ps1 +31 -0
- package/resources/harness/java/harness-archive/templates/render-summary.mjs +171 -0
- package/resources/harness/java/harness-archive/templates/summary-data-template.json +128 -0
- package/resources/harness/java/harness-codebase-map/SKILL.md +112 -0
- package/resources/harness/java/harness-codebase-map/checklist.md +126 -0
- package/resources/harness/java/harness-codebase-map/reference.md +364 -0
- package/resources/harness/java/harness-codebase-map/templates/ARCHITECTURE.md +57 -0
- package/resources/harness/java/harness-codebase-map/templates/CONCERNS.md +49 -0
- package/resources/harness/java/harness-codebase-map/templates/CONVENTIONS.md +57 -0
- package/resources/harness/java/harness-codebase-map/templates/INTEGRATIONS.md +52 -0
- package/resources/harness/java/harness-codebase-map/templates/STACK.md +55 -0
- package/resources/harness/java/harness-codebase-map/templates/STRUCTURE.md +69 -0
- package/resources/harness/java/harness-codebase-map/templates/TESTING.md +64 -0
- package/resources/harness/java/harness-codebase-map/templates/map-manifest.schema.json +73 -0
- package/resources/harness/java/harness-codebase-map/templates/map-summary.md +64 -0
- package/resources/harness/java/harness-knowledge-ingest/SKILL.md +246 -0
- package/resources/harness/java/harness-knowledge-ingest/design.md +842 -0
- package/resources/harness/java/harness-knowledge-ingest/evaluations/harness_knowledge_evaluation.xml +42 -0
- package/resources/harness/java/harness-knowledge-ingest/mcp-config.example.json +10 -0
- package/resources/harness/java/harness-knowledge-ingest/reference.md +309 -0
- package/resources/harness/java/harness-knowledge-ingest/scripts/harness_knowledge.py +4082 -0
- package/resources/harness/java/harness-knowledge-ingest/scripts/harness_knowledge_mcp.py +273 -0
- package/resources/harness/java/harness-knowledge-ingest/tests/fixtures/mcp-eval-project/.harness/archive/2026-01-10-ledger-reconciliation/reports/final/summary-data.json +43 -0
- package/resources/harness/java/harness-knowledge-ingest/tests/fixtures/mcp-eval-project/.harness/archive/2026-02-14-ledger-snapshot-followup/reports/final/summary-data.json +38 -0
- package/resources/harness/java/harness-knowledge-ingest/tests/fixtures/mcp-eval-project/.harness/archive/2026-03-05-webhook-contract/reports/final/summary-data.json +36 -0
- package/resources/harness/java/harness-knowledge-ingest/tests/fixtures/mcp-eval-project/.harness/project.yaml +1 -0
- package/resources/harness/java/harness-knowledge-ingest/tests/test_harness_knowledge.py +1792 -0
- package/resources/harness/java/harness-knowledge-query/SKILL.md +164 -0
- package/resources/harness/java/harness-package/SKILL.md +87 -0
- package/resources/harness/java/harness-package/checklist.md +322 -0
- package/resources/harness/java/harness-package/reference.md +210 -0
- package/resources/harness/java/harness-plan/SKILL.md +127 -0
- package/resources/harness/java/harness-plan/checklist.md +243 -0
- package/resources/harness/java/harness-plan/protocols.md +124 -0
- package/resources/harness/java/harness-plan/reference.md +376 -0
- package/resources/harness/java/harness-review/SKILL.md +156 -0
- package/resources/harness/java/harness-review/checklist.md +124 -0
- package/resources/harness/java/harness-review/protocols.md +68 -0
- package/resources/harness/java/harness-review/reference.md +86 -0
- package/resources/harness/java/harness-run/SKILL.md +148 -0
- package/resources/harness/java/harness-run/checklist.md +250 -0
- package/resources/harness/java/harness-run/protocols.md +78 -0
- package/resources/harness/java/harness-run/reference.md +814 -0
- package/resources/harness/java/harness-submit/SKILL.md +166 -0
- package/resources/harness/java/harness-submit/checklist.md +407 -0
- package/resources/harness/java/harness-submit/reference.md +152 -0
- package/resources/harness/java/harness-sync/SKILL.md +82 -0
- package/resources/harness/java/harness-sync/reference.md +153 -0
- package/resources/harness/java/harness-test/SKILL.md +192 -0
- package/resources/harness/java/harness-test/checklist.md +317 -0
- package/resources/harness/java/harness-test/pitfalls-java.md +16 -0
- package/resources/harness/java/harness-test/pitfalls.md +260 -0
- package/resources/harness/java/harness-test/reference.md +788 -0
- package/resources/harness/java/protocols/archive-report-protocol.md +175 -0
- package/resources/harness/java/protocols/evidence-based-reporting-protocol.md +134 -0
- package/resources/harness/java/protocols/ledger-protocol.md +270 -0
- package/resources/harness/java/protocols/powershell-protocol.md +181 -0
- package/resources/harness/java/protocols/report-pipeline-protocol.md +150 -0
- package/resources/harness/java/protocols/sensitive-info-protocol.md +92 -0
- package/resources/harness/java/protocols/state-layout-protocol.md +131 -0
- package/resources/harness/java/protocols/submit-protocol.md +78 -0
- package/resources/harness/java/scripts/harness_archive.py +2319 -0
- package/resources/harness/java/scripts/harness_deploy.py +613 -0
- package/resources/harness/java/scripts/harness_events.py +680 -0
- package/resources/harness/java/scripts/harness_ledger.py +671 -0
- package/resources/harness/java/scripts/harness_preflight.py +780 -0
- package/resources/harness/java/scripts/harness_service.py +1331 -0
- package/resources/harness/manifests/general.json +296 -0
- package/resources/harness/manifests/java.json +324 -0
- package/resources/manifest.json +19 -0
- package/resources/skills/harness-apidoc/SKILL.md +50 -0
- package/resources/skills/harness-archive/SKILL.md +48 -0
- package/resources/skills/harness-codebase-map/SKILL.md +53 -0
- package/resources/skills/harness-knowledge-ingest/SKILL.md +48 -0
- package/resources/skills/harness-package/SKILL.md +48 -0
- package/resources/skills/harness-plan/SKILL.md +51 -0
- package/resources/skills/harness-review/SKILL.md +50 -0
- package/resources/skills/harness-run/SKILL.md +48 -0
- package/resources/skills/harness-skill-optimizer/SKILL.md +54 -0
- package/resources/skills/harness-submit/SKILL.md +47 -0
- package/resources/skills/harness-sync/SKILL.md +48 -0
- package/resources/skills/harness-test/SKILL.md +50 -0
|
@@ -0,0 +1,2319 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Harness archive finalize / status / replay (D3).
|
|
3
|
+
|
|
4
|
+
Subcommands:
|
|
5
|
+
status — pre-archive gate checks (read-only)
|
|
6
|
+
finalize — single-process archive: manifest → move → collect → render →
|
|
7
|
+
validate → after-manifest → delete-or-keep → knowledge/service
|
|
8
|
+
replay — read-only re-collect + validate for historical archives
|
|
9
|
+
|
|
10
|
+
Python 3.10+, stdlib only. UTF-8 without BOM. Windows path safe.
|
|
11
|
+
Depends on P0-1 harness_events.py; optionally P0-3 harness_knowledge.py.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import argparse
|
|
17
|
+
import datetime as dt
|
|
18
|
+
import hashlib
|
|
19
|
+
import json
|
|
20
|
+
import os
|
|
21
|
+
import re
|
|
22
|
+
import shutil
|
|
23
|
+
import subprocess
|
|
24
|
+
import sys
|
|
25
|
+
import tempfile
|
|
26
|
+
import uuid
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
from typing import Any
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
if hasattr(sys.stdout, "reconfigure"):
|
|
32
|
+
sys.stdout.reconfigure(encoding="utf-8")
|
|
33
|
+
if hasattr(sys.stderr, "reconfigure"):
|
|
34
|
+
sys.stderr.reconfigure(encoding="utf-8")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
SCRIPTS_DIR = Path(__file__).resolve().parent
|
|
38
|
+
SKILLS_ROOT = SCRIPTS_DIR.parent
|
|
39
|
+
RENDER_SCRIPT = SKILLS_ROOT / "harness-archive" / "templates" / "render-summary.mjs"
|
|
40
|
+
SUMMARY_TEMPLATE = (
|
|
41
|
+
SKILLS_ROOT / "harness-archive" / "templates" / "summary-data-template.json"
|
|
42
|
+
)
|
|
43
|
+
KNOWLEDGE_SCRIPT = (
|
|
44
|
+
SKILLS_ROOT / "harness-knowledge-ingest" / "scripts" / "harness_knowledge.py"
|
|
45
|
+
)
|
|
46
|
+
SERVICE_SCRIPT = SCRIPTS_DIR / "harness_service.py"
|
|
47
|
+
|
|
48
|
+
# Manifest compare must ignore self-mutating log files appended during finalize.
|
|
49
|
+
MANIFEST_COMPARE_EXCLUDE = frozenset(
|
|
50
|
+
{
|
|
51
|
+
"logs/execution-log.md",
|
|
52
|
+
"execution-log.md",
|
|
53
|
+
"events.ndjson",
|
|
54
|
+
}
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
SCHEMA_VERSION = "2.2"
|
|
58
|
+
NOT_AVAILABLE = "not_available"
|
|
59
|
+
|
|
60
|
+
# Ensure sibling harness_events is importable.
|
|
61
|
+
if str(SCRIPTS_DIR) not in sys.path:
|
|
62
|
+
sys.path.insert(0, str(SCRIPTS_DIR))
|
|
63
|
+
|
|
64
|
+
import harness_events as he # noqa: E402
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
# ---------------------------------------------------------------------------
|
|
68
|
+
# I/O helpers
|
|
69
|
+
# ---------------------------------------------------------------------------
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def now_iso() -> str:
|
|
73
|
+
return dt.datetime.now().astimezone().isoformat(timespec="milliseconds")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def today_date() -> str:
|
|
77
|
+
return dt.date.today().isoformat()
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def emit_json(payload: dict[str, Any]) -> None:
|
|
81
|
+
sys.stdout.write(json.dumps(payload, ensure_ascii=False, indent=2) + "\n")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def emit_error(message: str, *, as_json: bool, extra: dict[str, Any] | None = None) -> int:
|
|
85
|
+
payload: dict[str, Any] = {"ok": False, "error": message}
|
|
86
|
+
if extra:
|
|
87
|
+
payload.update(extra)
|
|
88
|
+
if as_json:
|
|
89
|
+
sys.stderr.write(json.dumps(payload, ensure_ascii=False) + "\n")
|
|
90
|
+
else:
|
|
91
|
+
sys.stderr.write(f"error: {message}\n")
|
|
92
|
+
return 1
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def resolve_path(raw: str) -> Path:
|
|
96
|
+
return Path(raw).expanduser().resolve()
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def read_json(path: Path) -> Any:
|
|
100
|
+
return json.loads(path.read_text(encoding="utf-8-sig"))
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def write_json(path: Path, data: Any) -> None:
|
|
104
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
105
|
+
path.write_text(
|
|
106
|
+
json.dumps(data, ensure_ascii=False, indent=2) + "\n",
|
|
107
|
+
encoding="utf-8",
|
|
108
|
+
newline="\n",
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def sha256_file(path: Path) -> str:
|
|
113
|
+
h = hashlib.sha256()
|
|
114
|
+
with path.open("rb") as f:
|
|
115
|
+
for chunk in iter(lambda: f.read(1024 * 1024), b""):
|
|
116
|
+
h.update(chunk)
|
|
117
|
+
return h.hexdigest()
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def find_project_root(change_or_archive_dir: Path) -> Path:
|
|
121
|
+
"""Resolve project root from .harness/changes|archive/<name>."""
|
|
122
|
+
p = change_or_archive_dir.resolve()
|
|
123
|
+
if p.parent.name in {"changes", "archive"} and p.parent.parent.name == ".harness":
|
|
124
|
+
return p.parent.parent.parent
|
|
125
|
+
# Fallback: walk up looking for .harness
|
|
126
|
+
for parent in p.parents:
|
|
127
|
+
if (parent / ".harness").is_dir():
|
|
128
|
+
return parent
|
|
129
|
+
return p.parent
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def infer_change_name(dir_path: Path) -> str:
|
|
133
|
+
name = dir_path.name
|
|
134
|
+
m = re.match(r"^(\d{4}-\d{2}-\d{2})-(.+)$", name)
|
|
135
|
+
if m:
|
|
136
|
+
return m.group(2)
|
|
137
|
+
return name
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def load_template() -> dict[str, Any]:
|
|
141
|
+
if SUMMARY_TEMPLATE.is_file():
|
|
142
|
+
data = read_json(SUMMARY_TEMPLATE)
|
|
143
|
+
if isinstance(data, dict):
|
|
144
|
+
return data
|
|
145
|
+
return {"schemaVersion": SCHEMA_VERSION}
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
# ---------------------------------------------------------------------------
|
|
149
|
+
# Events append (reuse P0-1)
|
|
150
|
+
# ---------------------------------------------------------------------------
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def append_event(
|
|
154
|
+
change_dir: Path,
|
|
155
|
+
*,
|
|
156
|
+
phase: str,
|
|
157
|
+
type_: str,
|
|
158
|
+
**fields: Any,
|
|
159
|
+
) -> dict[str, Any]:
|
|
160
|
+
"""Append one event via harness_events primitives and re-render execution-log."""
|
|
161
|
+
path = he.events_path(change_dir)
|
|
162
|
+
existing = he.load_events(path) if path.exists() else []
|
|
163
|
+
event: dict[str, Any] = {
|
|
164
|
+
"schema_version": he.SCHEMA_VERSION,
|
|
165
|
+
"id": he.new_event_id(existing),
|
|
166
|
+
"timestamp": now_iso(),
|
|
167
|
+
"phase": phase,
|
|
168
|
+
"type": type_,
|
|
169
|
+
"note": "",
|
|
170
|
+
}
|
|
171
|
+
for key, value in fields.items():
|
|
172
|
+
if value is not None:
|
|
173
|
+
event[key] = value
|
|
174
|
+
line = json.dumps(event, ensure_ascii=False, separators=(",", ":"))
|
|
175
|
+
he.atomic_append_line(path, line)
|
|
176
|
+
all_events = existing + [he.normalize_event(event)]
|
|
177
|
+
he.write_execution_log(change_dir, he.render_execution_log(all_events))
|
|
178
|
+
return event
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
# ---------------------------------------------------------------------------
|
|
182
|
+
# Manifest (Python port of gen-manifest.ps1)
|
|
183
|
+
# ---------------------------------------------------------------------------
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def generate_manifest(root: Path, output_path: Path) -> dict[str, Any]:
|
|
187
|
+
"""Build path/size/sha256 manifest; exclude the output file itself."""
|
|
188
|
+
root = root.resolve()
|
|
189
|
+
exclude: Path | None = None
|
|
190
|
+
if output_path.exists():
|
|
191
|
+
exclude = output_path.resolve()
|
|
192
|
+
|
|
193
|
+
files: list[dict[str, Any]] = []
|
|
194
|
+
total_bytes = 0
|
|
195
|
+
for path in sorted(root.rglob("*")):
|
|
196
|
+
if not path.is_file():
|
|
197
|
+
continue
|
|
198
|
+
if exclude is not None and path.resolve() == exclude:
|
|
199
|
+
continue
|
|
200
|
+
rel = path.relative_to(root).as_posix()
|
|
201
|
+
size = path.stat().st_size
|
|
202
|
+
total_bytes += size
|
|
203
|
+
files.append(
|
|
204
|
+
{
|
|
205
|
+
"path": rel,
|
|
206
|
+
"sizeBytes": size,
|
|
207
|
+
"sha256": sha256_file(path),
|
|
208
|
+
}
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
result = {
|
|
212
|
+
"root": str(root),
|
|
213
|
+
"generatedAt": dt.datetime.now().isoformat(timespec="seconds"),
|
|
214
|
+
"fileCount": len(files),
|
|
215
|
+
"totalBytes": total_bytes,
|
|
216
|
+
"files": files,
|
|
217
|
+
}
|
|
218
|
+
write_json(output_path, result)
|
|
219
|
+
return result
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _manifest_path_excluded(rel: str) -> bool:
|
|
223
|
+
norm = rel.replace("\\", "/")
|
|
224
|
+
if norm in MANIFEST_COMPARE_EXCLUDE:
|
|
225
|
+
return True
|
|
226
|
+
if norm == "events.ndjson" or norm.endswith("/events.ndjson"):
|
|
227
|
+
return True
|
|
228
|
+
if norm.endswith("execution-log.md"):
|
|
229
|
+
return True
|
|
230
|
+
return False
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _manifest_index(manifest: dict[str, Any]) -> dict[str, dict[str, Any]]:
|
|
234
|
+
out: dict[str, dict[str, Any]] = {}
|
|
235
|
+
for item in manifest.get("files") or []:
|
|
236
|
+
if not isinstance(item, dict):
|
|
237
|
+
continue
|
|
238
|
+
rel = str(item.get("path") or "").replace("\\", "/")
|
|
239
|
+
if not rel or _manifest_path_excluded(rel):
|
|
240
|
+
continue
|
|
241
|
+
out[rel] = item
|
|
242
|
+
return out
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def compare_manifests(
|
|
246
|
+
before: dict[str, Any],
|
|
247
|
+
after: dict[str, Any],
|
|
248
|
+
) -> dict[str, Any]:
|
|
249
|
+
"""Compare before/after; exclude execution-log / events self-appends.
|
|
250
|
+
|
|
251
|
+
Files only in after are treated as generated (OK).
|
|
252
|
+
Files in before missing or hash-mismatched in after are errors.
|
|
253
|
+
"""
|
|
254
|
+
b_idx = _manifest_index(before)
|
|
255
|
+
a_idx = _manifest_index(after)
|
|
256
|
+
|
|
257
|
+
missing: list[str] = []
|
|
258
|
+
mismatched: list[dict[str, str]] = []
|
|
259
|
+
for rel, b_item in b_idx.items():
|
|
260
|
+
a_item = a_idx.get(rel)
|
|
261
|
+
if a_item is None:
|
|
262
|
+
missing.append(rel)
|
|
263
|
+
continue
|
|
264
|
+
if str(a_item.get("sha256")) != str(b_item.get("sha256")):
|
|
265
|
+
mismatched.append(
|
|
266
|
+
{
|
|
267
|
+
"path": rel,
|
|
268
|
+
"before": str(b_item.get("sha256")),
|
|
269
|
+
"after": str(a_item.get("sha256")),
|
|
270
|
+
}
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
generated = sorted(set(a_idx) - set(b_idx))
|
|
274
|
+
moved_ok = len(b_idx) - len(missing) - len(mismatched)
|
|
275
|
+
ok = not missing and not mismatched
|
|
276
|
+
return {
|
|
277
|
+
"ok": ok,
|
|
278
|
+
"movedFiles": moved_ok,
|
|
279
|
+
"generatedFiles": len(generated),
|
|
280
|
+
"totalArchiveFiles": int(after.get("fileCount") or len(a_idx)),
|
|
281
|
+
"missing": missing,
|
|
282
|
+
"mismatched": mismatched,
|
|
283
|
+
"generated": generated,
|
|
284
|
+
"checksumStatus": "OK" if ok else "FAIL",
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
# ---------------------------------------------------------------------------
|
|
289
|
+
# Evidence loaders
|
|
290
|
+
# ---------------------------------------------------------------------------
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def load_ledger(change_dir: Path) -> dict[str, Any] | None:
|
|
294
|
+
for rel in (
|
|
295
|
+
"evidence/verification-ledger.json",
|
|
296
|
+
"verification-ledger.json",
|
|
297
|
+
):
|
|
298
|
+
path = change_dir / rel
|
|
299
|
+
if path.is_file():
|
|
300
|
+
try:
|
|
301
|
+
data = read_json(path)
|
|
302
|
+
return data if isinstance(data, dict) else None
|
|
303
|
+
except (OSError, json.JSONDecodeError):
|
|
304
|
+
return None
|
|
305
|
+
return None
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def load_execution_log(change_dir: Path) -> str:
|
|
309
|
+
for rel in ("logs/execution-log.md", "execution-log.md"):
|
|
310
|
+
path = change_dir / rel
|
|
311
|
+
if path.is_file():
|
|
312
|
+
try:
|
|
313
|
+
return path.read_text(encoding="utf-8-sig")
|
|
314
|
+
except OSError:
|
|
315
|
+
return ""
|
|
316
|
+
return ""
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def load_existing_summary(change_dir: Path) -> dict[str, Any] | None:
|
|
320
|
+
for rel in (
|
|
321
|
+
"reports/final/summary-data.json",
|
|
322
|
+
"summary-data.json",
|
|
323
|
+
):
|
|
324
|
+
path = change_dir / rel
|
|
325
|
+
if path.is_file():
|
|
326
|
+
try:
|
|
327
|
+
data = read_json(path)
|
|
328
|
+
return data if isinstance(data, dict) else None
|
|
329
|
+
except (OSError, json.JSONDecodeError):
|
|
330
|
+
return None
|
|
331
|
+
return None
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def find_test_reports(change_dir: Path) -> list[Path]:
|
|
335
|
+
patterns = [
|
|
336
|
+
"tests/test-report-*.md",
|
|
337
|
+
"reports/test/test-report-*.md",
|
|
338
|
+
"reports/test/*.md",
|
|
339
|
+
]
|
|
340
|
+
found: list[Path] = []
|
|
341
|
+
for pattern in patterns:
|
|
342
|
+
found.extend(sorted(change_dir.glob(pattern)))
|
|
343
|
+
return found
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def find_review_reports(change_dir: Path) -> list[Path]:
|
|
347
|
+
patterns = [
|
|
348
|
+
"reports/review/review-report-*.md",
|
|
349
|
+
"reviews/review-report-*.md",
|
|
350
|
+
"reports/review/fixback-*.md",
|
|
351
|
+
]
|
|
352
|
+
found: list[Path] = []
|
|
353
|
+
for pattern in patterns:
|
|
354
|
+
found.extend(sorted(change_dir.glob(pattern)))
|
|
355
|
+
return found
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def git_run(project: Path, *args: str) -> tuple[int, str, str]:
|
|
359
|
+
try:
|
|
360
|
+
proc = subprocess.run(
|
|
361
|
+
["git", "-C", str(project), *args],
|
|
362
|
+
capture_output=True,
|
|
363
|
+
text=True,
|
|
364
|
+
encoding="utf-8",
|
|
365
|
+
errors="replace",
|
|
366
|
+
timeout=30,
|
|
367
|
+
check=False,
|
|
368
|
+
)
|
|
369
|
+
return proc.returncode, proc.stdout.strip(), proc.stderr.strip()
|
|
370
|
+
except (OSError, subprocess.TimeoutExpired) as exc:
|
|
371
|
+
return 1, "", str(exc)
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def extract_final_pushed_hash(execution_log: str) -> str | None:
|
|
375
|
+
"""Parse 'final pushed hash' from execution-log (submit phase)."""
|
|
376
|
+
patterns = [
|
|
377
|
+
r"final pushed hash[:\s`]+([0-9a-f]{7,40})",
|
|
378
|
+
r"finalPushedHash[:\s\"']+([0-9a-f]{7,40})",
|
|
379
|
+
r"pushed hash[:\s`]+([0-9a-f]{7,40})",
|
|
380
|
+
]
|
|
381
|
+
for pat in patterns:
|
|
382
|
+
m = re.search(pat, execution_log, re.IGNORECASE)
|
|
383
|
+
if m:
|
|
384
|
+
return m.group(1)
|
|
385
|
+
return None
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def worktree_requested(change_dir: Path) -> bool:
|
|
389
|
+
for rel in ("meta/worktree.json", "worktree.json"):
|
|
390
|
+
path = change_dir / rel
|
|
391
|
+
if path.is_file():
|
|
392
|
+
try:
|
|
393
|
+
data = read_json(path)
|
|
394
|
+
if isinstance(data, dict):
|
|
395
|
+
return bool(data.get("requested"))
|
|
396
|
+
except (OSError, json.JSONDecodeError):
|
|
397
|
+
pass
|
|
398
|
+
return False
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
# ---------------------------------------------------------------------------
|
|
402
|
+
# status
|
|
403
|
+
# ---------------------------------------------------------------------------
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def check_status(change_dir: Path) -> dict[str, Any]:
|
|
407
|
+
"""Read-only archive preconditions. Never mutates."""
|
|
408
|
+
blockers: list[dict[str, str]] = []
|
|
409
|
+
warnings: list[dict[str, str]] = []
|
|
410
|
+
checks: dict[str, Any] = {}
|
|
411
|
+
|
|
412
|
+
if not change_dir.is_dir():
|
|
413
|
+
return {
|
|
414
|
+
"ok": False,
|
|
415
|
+
"archivable": False,
|
|
416
|
+
"change_dir": str(change_dir),
|
|
417
|
+
"blockers": [{"code": "missing-change-dir", "message": f"not found: {change_dir}"}],
|
|
418
|
+
"warnings": [],
|
|
419
|
+
"checks": {},
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
project = find_project_root(change_dir)
|
|
423
|
+
checks["project_root"] = str(project)
|
|
424
|
+
|
|
425
|
+
# --- commit pushed ---
|
|
426
|
+
code, out, err = git_run(project, "rev-parse", "--is-inside-work-tree")
|
|
427
|
+
if code != 0:
|
|
428
|
+
warnings.append(
|
|
429
|
+
{
|
|
430
|
+
"code": "git-unavailable",
|
|
431
|
+
"message": f"not a git work tree or git missing: {err or out}",
|
|
432
|
+
}
|
|
433
|
+
)
|
|
434
|
+
checks["commit_pushed"] = None
|
|
435
|
+
else:
|
|
436
|
+
code, out, err = git_run(project, "log", "@{u}..HEAD", "--oneline")
|
|
437
|
+
if code != 0:
|
|
438
|
+
# No upstream configured
|
|
439
|
+
warnings.append(
|
|
440
|
+
{
|
|
441
|
+
"code": "no-upstream",
|
|
442
|
+
"message": err or "no upstream configured; cannot verify push",
|
|
443
|
+
}
|
|
444
|
+
)
|
|
445
|
+
checks["commit_pushed"] = None
|
|
446
|
+
elif out.strip():
|
|
447
|
+
blockers.append(
|
|
448
|
+
{
|
|
449
|
+
"code": "unpushed-commits",
|
|
450
|
+
"message": f"unpushed commits:\n{out}",
|
|
451
|
+
}
|
|
452
|
+
)
|
|
453
|
+
checks["commit_pushed"] = False
|
|
454
|
+
else:
|
|
455
|
+
checks["commit_pushed"] = True
|
|
456
|
+
|
|
457
|
+
# --- final hash ---
|
|
458
|
+
code, head, _ = git_run(project, "rev-parse", "HEAD")
|
|
459
|
+
head_hash = head if code == 0 else None
|
|
460
|
+
checks["head"] = head_hash
|
|
461
|
+
|
|
462
|
+
expected_hash: str | None = None
|
|
463
|
+
hash_source: str | None = None
|
|
464
|
+
ledger = load_ledger(change_dir)
|
|
465
|
+
if worktree_requested(change_dir) and ledger:
|
|
466
|
+
merge_hash = ledger.get("mergeFinalHash") or (
|
|
467
|
+
ledger.get("merge") or {}
|
|
468
|
+
).get("finalHash")
|
|
469
|
+
if merge_hash:
|
|
470
|
+
expected_hash = str(merge_hash)
|
|
471
|
+
hash_source = "verification-ledger.mergeFinalHash"
|
|
472
|
+
if expected_hash is None:
|
|
473
|
+
log_text = load_execution_log(change_dir)
|
|
474
|
+
pushed = extract_final_pushed_hash(log_text)
|
|
475
|
+
if pushed:
|
|
476
|
+
expected_hash = pushed
|
|
477
|
+
hash_source = "execution-log.final-pushed-hash"
|
|
478
|
+
if expected_hash is None and ledger:
|
|
479
|
+
for key in ("finalCommit", "finalHash", "headCommit"):
|
|
480
|
+
if ledger.get(key):
|
|
481
|
+
expected_hash = str(ledger[key])
|
|
482
|
+
hash_source = f"verification-ledger.{key}"
|
|
483
|
+
break
|
|
484
|
+
|
|
485
|
+
checks["expected_final_hash"] = expected_hash
|
|
486
|
+
checks["hash_source"] = hash_source
|
|
487
|
+
|
|
488
|
+
if head_hash and expected_hash:
|
|
489
|
+
# Allow short-hash prefix match
|
|
490
|
+
if head_hash.startswith(expected_hash) or expected_hash.startswith(head_hash[:7]):
|
|
491
|
+
checks["final_hash_match"] = True
|
|
492
|
+
else:
|
|
493
|
+
checks["final_hash_match"] = False
|
|
494
|
+
blockers.append(
|
|
495
|
+
{
|
|
496
|
+
"code": "final-hash-mismatch",
|
|
497
|
+
"message": (
|
|
498
|
+
f"HEAD={head_hash} != expected={expected_hash} "
|
|
499
|
+
f"(source={hash_source})"
|
|
500
|
+
),
|
|
501
|
+
}
|
|
502
|
+
)
|
|
503
|
+
elif head_hash and expected_hash is None:
|
|
504
|
+
warnings.append(
|
|
505
|
+
{
|
|
506
|
+
"code": "final-hash-unknown",
|
|
507
|
+
"message": "could not determine expected final hash from ledger/log",
|
|
508
|
+
}
|
|
509
|
+
)
|
|
510
|
+
checks["final_hash_match"] = None
|
|
511
|
+
else:
|
|
512
|
+
checks["final_hash_match"] = None
|
|
513
|
+
|
|
514
|
+
# --- test / review reports ---
|
|
515
|
+
test_reports = find_test_reports(change_dir)
|
|
516
|
+
review_reports = find_review_reports(change_dir)
|
|
517
|
+
log_text = load_execution_log(change_dir)
|
|
518
|
+
has_review_section = bool(
|
|
519
|
+
re.search(r"harness-review|##\s*review", log_text, re.IGNORECASE)
|
|
520
|
+
)
|
|
521
|
+
|
|
522
|
+
checks["test_reports"] = [str(p.relative_to(change_dir)) for p in test_reports]
|
|
523
|
+
checks["review_reports"] = [str(p.relative_to(change_dir)) for p in review_reports]
|
|
524
|
+
|
|
525
|
+
if test_reports:
|
|
526
|
+
checks["test_report_status"] = "present"
|
|
527
|
+
else:
|
|
528
|
+
checks["test_report_status"] = "missing-mark-skipped"
|
|
529
|
+
warnings.append(
|
|
530
|
+
{
|
|
531
|
+
"code": "test-report-missing",
|
|
532
|
+
"message": (
|
|
533
|
+
"no test-report-*.md; archive must mark verification as "
|
|
534
|
+
"NOT_RUN/USER_SKIPPED (not fabricated pass rates)"
|
|
535
|
+
),
|
|
536
|
+
}
|
|
537
|
+
)
|
|
538
|
+
|
|
539
|
+
if review_reports:
|
|
540
|
+
checks["review_report_status"] = "present"
|
|
541
|
+
elif has_review_section:
|
|
542
|
+
checks["review_report_status"] = "ran-but-not-persisted"
|
|
543
|
+
warnings.append(
|
|
544
|
+
{
|
|
545
|
+
"code": "review-not-persisted",
|
|
546
|
+
"message": (
|
|
547
|
+
"execution-log has review section but no review-report file; "
|
|
548
|
+
"prefer persisting report before archive"
|
|
549
|
+
),
|
|
550
|
+
}
|
|
551
|
+
)
|
|
552
|
+
else:
|
|
553
|
+
checks["review_report_status"] = "not-run"
|
|
554
|
+
warnings.append(
|
|
555
|
+
{
|
|
556
|
+
"code": "review-not-run",
|
|
557
|
+
"message": "no review report; mark reviewSummary as ADVISORY_NOT_RUN",
|
|
558
|
+
}
|
|
559
|
+
)
|
|
560
|
+
|
|
561
|
+
archivable = len(blockers) == 0
|
|
562
|
+
return {
|
|
563
|
+
"ok": True,
|
|
564
|
+
"archivable": archivable,
|
|
565
|
+
"change_dir": str(change_dir),
|
|
566
|
+
"change_name": infer_change_name(change_dir),
|
|
567
|
+
"blockers": blockers,
|
|
568
|
+
"warnings": warnings,
|
|
569
|
+
"checks": checks,
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
# ---------------------------------------------------------------------------
|
|
574
|
+
# collect
|
|
575
|
+
# ---------------------------------------------------------------------------
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
def _deepcopy_json(obj: Any) -> Any:
|
|
579
|
+
return json.loads(json.dumps(obj))
|
|
580
|
+
|
|
581
|
+
|
|
582
|
+
def _na_if_missing(value: Any, *, allow_empty: bool = False) -> Any:
|
|
583
|
+
if value is None:
|
|
584
|
+
return NOT_AVAILABLE
|
|
585
|
+
if not allow_empty and value == "":
|
|
586
|
+
return NOT_AVAILABLE
|
|
587
|
+
return value
|
|
588
|
+
|
|
589
|
+
|
|
590
|
+
def _ledger_unit_tests(ledger: dict[str, Any] | None) -> dict[str, Any]:
|
|
591
|
+
empty = {
|
|
592
|
+
"run": 0,
|
|
593
|
+
"failures": 0,
|
|
594
|
+
"errors": 0,
|
|
595
|
+
"skipped": 0,
|
|
596
|
+
"passRate": NOT_AVAILABLE,
|
|
597
|
+
"source": "not-run",
|
|
598
|
+
}
|
|
599
|
+
if not ledger:
|
|
600
|
+
return empty
|
|
601
|
+
validations = ledger.get("validations") or ledger.get("verification") or {}
|
|
602
|
+
unit = validations.get("unitTest") or validations.get("unitTests") or {}
|
|
603
|
+
if not isinstance(unit, dict):
|
|
604
|
+
return empty
|
|
605
|
+
status = str(unit.get("status") or "").upper()
|
|
606
|
+
evidence = unit.get("evidence") or {}
|
|
607
|
+
if isinstance(evidence, dict):
|
|
608
|
+
run = evidence.get("run", unit.get("run", 0))
|
|
609
|
+
failures = evidence.get("failures", unit.get("failures", 0))
|
|
610
|
+
errors = evidence.get("errors", unit.get("errors", 0))
|
|
611
|
+
skipped = evidence.get("skipped", unit.get("skipped", 0))
|
|
612
|
+
pass_rate = evidence.get("passRate") or unit.get("passRate")
|
|
613
|
+
else:
|
|
614
|
+
run = unit.get("run", 0)
|
|
615
|
+
failures = unit.get("failures", 0)
|
|
616
|
+
errors = unit.get("errors", 0)
|
|
617
|
+
skipped = unit.get("skipped", 0)
|
|
618
|
+
pass_rate = unit.get("passRate")
|
|
619
|
+
|
|
620
|
+
source = "committed"
|
|
621
|
+
if status in {"NOT_RUN", "SKIPPED", "USER_SKIPPED"}:
|
|
622
|
+
source = "not-run"
|
|
623
|
+
elif str(unit.get("reused") or "").lower() in {"true", "1"} or "REUSED" in status:
|
|
624
|
+
source = "committed"
|
|
625
|
+
|
|
626
|
+
result = {
|
|
627
|
+
"run": int(run or 0),
|
|
628
|
+
"failures": int(failures or 0),
|
|
629
|
+
"errors": int(errors or 0),
|
|
630
|
+
"skipped": int(skipped or 0),
|
|
631
|
+
"passRate": pass_rate if pass_rate is not None else NOT_AVAILABLE,
|
|
632
|
+
"source": source,
|
|
633
|
+
}
|
|
634
|
+
if status in {"NOT_RUN", "USER_SKIPPED", "SKIPPED"}:
|
|
635
|
+
result["status"] = status if status != "SKIPPED" else "USER_SKIPPED"
|
|
636
|
+
return result
|
|
637
|
+
|
|
638
|
+
|
|
639
|
+
def _ledger_api_tests(ledger: dict[str, Any] | None) -> dict[str, Any]:
|
|
640
|
+
empty = {
|
|
641
|
+
"status": "NOT_RUN",
|
|
642
|
+
"total": 0,
|
|
643
|
+
"passed": 0,
|
|
644
|
+
"failed": 0,
|
|
645
|
+
"blocked": 0,
|
|
646
|
+
"passRate": NOT_AVAILABLE,
|
|
647
|
+
}
|
|
648
|
+
if not ledger:
|
|
649
|
+
return empty
|
|
650
|
+
validations = ledger.get("validations") or ledger.get("verification") or {}
|
|
651
|
+
api = validations.get("apiTest") or validations.get("apiTests") or {}
|
|
652
|
+
if not isinstance(api, dict):
|
|
653
|
+
return empty
|
|
654
|
+
status = str(api.get("status") or "NOT_RUN").upper()
|
|
655
|
+
if status in {"OK", "PASS", "PASSED", "SUCCESS"}:
|
|
656
|
+
status = "OK"
|
|
657
|
+
elif status in {"SKIP", "SKIPPED"}:
|
|
658
|
+
status = "USER_SKIPPED"
|
|
659
|
+
evidence = api.get("evidence") if isinstance(api.get("evidence"), dict) else {}
|
|
660
|
+
total = evidence.get("total", api.get("total", 0))
|
|
661
|
+
passed = evidence.get("passed", api.get("passed", 0))
|
|
662
|
+
failed = evidence.get("failed", api.get("failed", 0))
|
|
663
|
+
blocked = evidence.get("blocked", api.get("blocked", 0))
|
|
664
|
+
pass_rate = evidence.get("passRate") or api.get("passRate")
|
|
665
|
+
return {
|
|
666
|
+
"status": status,
|
|
667
|
+
"total": int(total or 0),
|
|
668
|
+
"passed": int(passed or 0),
|
|
669
|
+
"failed": int(failed or 0),
|
|
670
|
+
"blocked": int(blocked or 0),
|
|
671
|
+
"passRate": pass_rate if pass_rate is not None else NOT_AVAILABLE,
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
|
|
675
|
+
def _ledger_db_compat(ledger: dict[str, Any] | None) -> str:
|
|
676
|
+
if not ledger:
|
|
677
|
+
return "NOT_RUN"
|
|
678
|
+
validations = ledger.get("validations") or {}
|
|
679
|
+
db = validations.get("dbCompatibility") or validations.get("db") or {}
|
|
680
|
+
if isinstance(db, dict):
|
|
681
|
+
return str(db.get("status") or "NOT_RUN").upper()
|
|
682
|
+
if isinstance(db, str) and db.strip():
|
|
683
|
+
return db.strip().upper()
|
|
684
|
+
top = ledger.get("dbCompatibility")
|
|
685
|
+
if isinstance(top, str) and top.strip():
|
|
686
|
+
return top.strip().upper()
|
|
687
|
+
return "NOT_RUN"
|
|
688
|
+
|
|
689
|
+
|
|
690
|
+
def _parse_durations_from_log(log_text: str) -> dict[str, Any]:
|
|
691
|
+
"""Best-effort parse of harness-* sections from execution-log."""
|
|
692
|
+
stages: list[dict[str, Any]] = []
|
|
693
|
+
# Match both old hand-written and events-rendered phase headers.
|
|
694
|
+
section_re = re.compile(
|
|
695
|
+
r"(?:###\s*\[\d+\]\s*)?harness-(\w+)|##\s+(plan|run|test|review|submit|merge|archive)\b",
|
|
696
|
+
re.IGNORECASE,
|
|
697
|
+
)
|
|
698
|
+
start_re = re.compile(r"\*\*开始\*\*:\s*(.+)")
|
|
699
|
+
end_re = re.compile(r"\*\*结束\*\*:\s*(.+)")
|
|
700
|
+
dur_re = re.compile(r"\*\*耗时\*\*:\s*(.+)")
|
|
701
|
+
result_re = re.compile(r"\*\*结果\*\*:\s*(.+)")
|
|
702
|
+
|
|
703
|
+
lines = log_text.splitlines()
|
|
704
|
+
i = 0
|
|
705
|
+
while i < len(lines):
|
|
706
|
+
m = section_re.search(lines[i])
|
|
707
|
+
if not m:
|
|
708
|
+
i += 1
|
|
709
|
+
continue
|
|
710
|
+
stage = (m.group(1) or m.group(2) or "").lower()
|
|
711
|
+
skill = f"harness-{stage}" if not stage.startswith("harness") else stage
|
|
712
|
+
started = ended = None
|
|
713
|
+
minutes = 0.0
|
|
714
|
+
result = "OK"
|
|
715
|
+
j = i + 1
|
|
716
|
+
while j < len(lines) and not section_re.search(lines[j]):
|
|
717
|
+
sm = start_re.search(lines[j])
|
|
718
|
+
if sm:
|
|
719
|
+
started = sm.group(1).strip()
|
|
720
|
+
em = end_re.search(lines[j])
|
|
721
|
+
if em:
|
|
722
|
+
ended = em.group(1).strip()
|
|
723
|
+
dm = dur_re.search(lines[j])
|
|
724
|
+
if dm:
|
|
725
|
+
raw = dm.group(1).strip()
|
|
726
|
+
mm = re.search(r"(\d+)\s*分", raw)
|
|
727
|
+
ss = re.search(r"(\d+)\s*秒", raw)
|
|
728
|
+
minutes = (int(mm.group(1)) if mm else 0) + (
|
|
729
|
+
(int(ss.group(1)) if ss else 0) / 60.0
|
|
730
|
+
)
|
|
731
|
+
if minutes == 0:
|
|
732
|
+
ms = re.search(r"([\d.]+)\s*s", raw, re.I)
|
|
733
|
+
if ms:
|
|
734
|
+
minutes = float(ms.group(1)) / 60.0
|
|
735
|
+
rm = result_re.search(lines[j])
|
|
736
|
+
if rm:
|
|
737
|
+
raw_r = rm.group(1)
|
|
738
|
+
if "FAIL" in raw_r or "❌" in raw_r:
|
|
739
|
+
result = "FAIL"
|
|
740
|
+
elif "WARN" in raw_r or "🟡" in raw_r:
|
|
741
|
+
result = "WARN"
|
|
742
|
+
else:
|
|
743
|
+
result = "OK"
|
|
744
|
+
j += 1
|
|
745
|
+
stages.append(
|
|
746
|
+
{
|
|
747
|
+
"stage": stage.replace("harness-", ""),
|
|
748
|
+
"skill": skill if skill.startswith("harness-") else f"harness-{stage}",
|
|
749
|
+
"startedAt": started or NOT_AVAILABLE,
|
|
750
|
+
"endedAt": ended or NOT_AVAILABLE,
|
|
751
|
+
"minutes": round(minutes, 2),
|
|
752
|
+
"result": result,
|
|
753
|
+
}
|
|
754
|
+
)
|
|
755
|
+
i = j
|
|
756
|
+
|
|
757
|
+
total = round(sum(float(s["minutes"]) for s in stages), 2)
|
|
758
|
+
return {
|
|
759
|
+
"totalLabel": f"约 {int(round(total))} 分" if total else "约 0 分",
|
|
760
|
+
"totalMinutes": total,
|
|
761
|
+
"stages": stages,
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
|
|
765
|
+
def _skill_calls_from_stages(stages: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
766
|
+
counts: dict[str, dict[str, Any]] = {}
|
|
767
|
+
order: list[str] = []
|
|
768
|
+
for s in stages:
|
|
769
|
+
skill = str(s.get("skill") or s.get("stage") or "unknown")
|
|
770
|
+
if skill not in counts:
|
|
771
|
+
counts[skill] = {"skill": skill, "count": 0, "result": s.get("result") or "OK"}
|
|
772
|
+
order.append(skill)
|
|
773
|
+
counts[skill]["count"] += 1
|
|
774
|
+
counts[skill]["result"] = s.get("result") or counts[skill]["result"]
|
|
775
|
+
return [counts[k] for k in order]
|
|
776
|
+
|
|
777
|
+
|
|
778
|
+
def _phases_from_events_summary(summary: dict[str, Any]) -> dict[str, Any]:
|
|
779
|
+
phases_out: dict[str, Any] = {}
|
|
780
|
+
for name in ("plan", "run", "test", "review", "submit", "archive"):
|
|
781
|
+
phases_out[name] = {"duration_ms": None, "event_count": 0}
|
|
782
|
+
for name, info in (summary.get("phases") or {}).items():
|
|
783
|
+
key = str(name).lower()
|
|
784
|
+
if key not in phases_out:
|
|
785
|
+
phases_out[key] = {"duration_ms": None, "event_count": 0}
|
|
786
|
+
phases_out[key] = {
|
|
787
|
+
"duration_ms": info.get("duration_ms"),
|
|
788
|
+
"event_count": int(info.get("event_count") or 0),
|
|
789
|
+
}
|
|
790
|
+
return phases_out
|
|
791
|
+
|
|
792
|
+
|
|
793
|
+
def _commands_from_events(events: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
794
|
+
out: list[dict[str, Any]] = []
|
|
795
|
+
for e in events:
|
|
796
|
+
if e.get("type") != "command":
|
|
797
|
+
continue
|
|
798
|
+
out.append(
|
|
799
|
+
{
|
|
800
|
+
"command": e.get("command") or "",
|
|
801
|
+
"exit_code": e.get("exit_code"),
|
|
802
|
+
"duration_ms": e.get("duration_ms"),
|
|
803
|
+
"phase": e.get("phase"),
|
|
804
|
+
"timestamp": e.get("timestamp"),
|
|
805
|
+
}
|
|
806
|
+
)
|
|
807
|
+
return out
|
|
808
|
+
|
|
809
|
+
|
|
810
|
+
def _verification_checks_from_events(
|
|
811
|
+
events: list[dict[str, Any]],
|
|
812
|
+
ledger: dict[str, Any] | None,
|
|
813
|
+
) -> list[dict[str, Any]]:
|
|
814
|
+
checks: list[dict[str, Any]] = []
|
|
815
|
+
seen: set[str] = set()
|
|
816
|
+
for e in events:
|
|
817
|
+
if e.get("type") != "verification":
|
|
818
|
+
continue
|
|
819
|
+
name = str(e.get("name") or "unnamed")
|
|
820
|
+
status = str(e.get("status") or "unknown").lower()
|
|
821
|
+
checks.append(
|
|
822
|
+
{
|
|
823
|
+
"name": name,
|
|
824
|
+
"status": status,
|
|
825
|
+
"command": e.get("command") or "",
|
|
826
|
+
"source": "events.ndjson",
|
|
827
|
+
}
|
|
828
|
+
)
|
|
829
|
+
seen.add(name.lower())
|
|
830
|
+
if ledger:
|
|
831
|
+
validations = ledger.get("validations") or {}
|
|
832
|
+
if isinstance(validations, dict):
|
|
833
|
+
for name, info in validations.items():
|
|
834
|
+
if name.lower() in seen:
|
|
835
|
+
continue
|
|
836
|
+
if not isinstance(info, dict):
|
|
837
|
+
continue
|
|
838
|
+
checks.append(
|
|
839
|
+
{
|
|
840
|
+
"name": name,
|
|
841
|
+
"status": str(info.get("status") or "unknown").lower(),
|
|
842
|
+
"command": str(info.get("command") or ""),
|
|
843
|
+
"source": "evidence/verification-ledger.json",
|
|
844
|
+
}
|
|
845
|
+
)
|
|
846
|
+
return checks
|
|
847
|
+
|
|
848
|
+
|
|
849
|
+
def _artifacts_from_events(events: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
850
|
+
out: list[dict[str, Any]] = []
|
|
851
|
+
for e in events:
|
|
852
|
+
if e.get("type") != "artifact":
|
|
853
|
+
continue
|
|
854
|
+
out.append(
|
|
855
|
+
{
|
|
856
|
+
"path": e.get("path") or "",
|
|
857
|
+
"kind": e.get("kind") or "",
|
|
858
|
+
"phase": e.get("phase") or "",
|
|
859
|
+
}
|
|
860
|
+
)
|
|
861
|
+
return out
|
|
862
|
+
|
|
863
|
+
|
|
864
|
+
def _durations_from_event_phases(event_summary: dict[str, Any]) -> dict[str, Any]:
|
|
865
|
+
stages: list[dict[str, Any]] = []
|
|
866
|
+
total_ms = 0
|
|
867
|
+
for name, info in (event_summary.get("phases") or {}).items():
|
|
868
|
+
dur = info.get("duration_ms")
|
|
869
|
+
minutes = round((dur or 0) / 60000, 2) if dur is not None else 0
|
|
870
|
+
if dur:
|
|
871
|
+
total_ms += int(dur)
|
|
872
|
+
stages.append(
|
|
873
|
+
{
|
|
874
|
+
"stage": str(name),
|
|
875
|
+
"skill": f"harness-{name}",
|
|
876
|
+
"startedAt": info.get("started_at") or NOT_AVAILABLE,
|
|
877
|
+
"endedAt": info.get("ended_at") or NOT_AVAILABLE,
|
|
878
|
+
"minutes": minutes,
|
|
879
|
+
"result": "OK",
|
|
880
|
+
}
|
|
881
|
+
)
|
|
882
|
+
total_min = round(total_ms / 60000, 2)
|
|
883
|
+
return {
|
|
884
|
+
"totalLabel": f"约 {int(round(total_min))} 分",
|
|
885
|
+
"totalMinutes": total_min,
|
|
886
|
+
"stages": stages,
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
|
|
890
|
+
def _stage_status_from_sources(
|
|
891
|
+
events: list[dict[str, Any]],
|
|
892
|
+
ledger: dict[str, Any] | None,
|
|
893
|
+
change_dir: Path,
|
|
894
|
+
) -> dict[str, str]:
|
|
895
|
+
status = {
|
|
896
|
+
"plan": "OK",
|
|
897
|
+
"run": "OK",
|
|
898
|
+
"test": "OK",
|
|
899
|
+
"review": "ADVISORY",
|
|
900
|
+
"submit": "OK",
|
|
901
|
+
"archive": "OK",
|
|
902
|
+
}
|
|
903
|
+
# Issues in events can downgrade
|
|
904
|
+
for e in events:
|
|
905
|
+
if e.get("type") != "issue":
|
|
906
|
+
continue
|
|
907
|
+
sev = str(e.get("severity") or "").lower()
|
|
908
|
+
phase = str(e.get("phase") or "").lower()
|
|
909
|
+
if phase in status and sev in {"error", "fail", "failed", "critical"}:
|
|
910
|
+
status[phase] = "FAIL"
|
|
911
|
+
elif phase in status and sev in {"warn", "warning"} and status[phase] == "OK":
|
|
912
|
+
status[phase] = "WARN"
|
|
913
|
+
|
|
914
|
+
api = _ledger_api_tests(ledger)
|
|
915
|
+
db = _ledger_db_compat(ledger)
|
|
916
|
+
if api.get("status") == "USER_SKIPPED":
|
|
917
|
+
status["test"] = "USER_SKIPPED"
|
|
918
|
+
elif db == "BLOCKED_BY_DBA":
|
|
919
|
+
status["test"] = "BLOCKED_BY_DBA"
|
|
920
|
+
elif api.get("status") == "NOT_RUN" and not find_test_reports(change_dir):
|
|
921
|
+
unit = _ledger_unit_tests(ledger)
|
|
922
|
+
if unit.get("source") == "not-run" and unit.get("run", 0) == 0:
|
|
923
|
+
status["test"] = "USER_SKIPPED"
|
|
924
|
+
|
|
925
|
+
if not find_review_reports(change_dir):
|
|
926
|
+
log_text = load_execution_log(change_dir)
|
|
927
|
+
if not re.search(r"harness-review|##\s*review", log_text, re.IGNORECASE):
|
|
928
|
+
status["review"] = "ADVISORY"
|
|
929
|
+
|
|
930
|
+
return status
|
|
931
|
+
|
|
932
|
+
|
|
933
|
+
def _compute_final_status(
|
|
934
|
+
stage_status: dict[str, str],
|
|
935
|
+
verification: dict[str, Any],
|
|
936
|
+
) -> str:
|
|
937
|
+
api = verification.get("apiTests") or {}
|
|
938
|
+
db = str(verification.get("dbCompatibility") or "")
|
|
939
|
+
api_status = str(api.get("status") or "")
|
|
940
|
+
if api_status == "USER_SKIPPED" or db == "BLOCKED_BY_DBA":
|
|
941
|
+
return "CONDITIONAL_OK"
|
|
942
|
+
for v in stage_status.values():
|
|
943
|
+
if v == "FAIL":
|
|
944
|
+
return "FAIL"
|
|
945
|
+
unit = verification.get("unitTests") or {}
|
|
946
|
+
if int(unit.get("failures") or 0) > 0 or int(unit.get("errors") or 0) > 0:
|
|
947
|
+
return "FAIL"
|
|
948
|
+
if int(api.get("failed") or 0) > 0:
|
|
949
|
+
return "FAIL"
|
|
950
|
+
for v in stage_status.values():
|
|
951
|
+
if v in {"WARN", "PARTIAL", "USER_SKIPPED", "BLOCKED_BY_DBA"}:
|
|
952
|
+
return "WARN" if v == "WARN" else "CONDITIONAL_OK"
|
|
953
|
+
return "OK"
|
|
954
|
+
|
|
955
|
+
|
|
956
|
+
def _changed_files_from_git(
|
|
957
|
+
project: Path,
|
|
958
|
+
base: str | None,
|
|
959
|
+
head: str | None,
|
|
960
|
+
) -> tuple[dict[str, Any], list[dict[str, Any]]]:
|
|
961
|
+
diff_stat = {
|
|
962
|
+
"filesChanged": 0,
|
|
963
|
+
"insertions": 0,
|
|
964
|
+
"deletions": 0,
|
|
965
|
+
"range": NOT_AVAILABLE,
|
|
966
|
+
}
|
|
967
|
+
changed: list[dict[str, Any]] = []
|
|
968
|
+
if not base or not head:
|
|
969
|
+
return diff_stat, changed
|
|
970
|
+
rng = f"{base}..{head}"
|
|
971
|
+
code, out, _ = git_run(project, "diff", "--numstat", rng)
|
|
972
|
+
if code != 0 or not out:
|
|
973
|
+
diff_stat["range"] = rng
|
|
974
|
+
return diff_stat, changed
|
|
975
|
+
insertions = deletions = 0
|
|
976
|
+
files = 0
|
|
977
|
+
for line in out.splitlines():
|
|
978
|
+
parts = line.split("\t")
|
|
979
|
+
if len(parts) < 3:
|
|
980
|
+
continue
|
|
981
|
+
ins_s, del_s, path = parts[0], parts[1], parts[2]
|
|
982
|
+
ins = int(ins_s) if ins_s.isdigit() else 0
|
|
983
|
+
dele = int(del_s) if del_s.isdigit() else 0
|
|
984
|
+
insertions += ins
|
|
985
|
+
deletions += dele
|
|
986
|
+
files += 1
|
|
987
|
+
changed.append(
|
|
988
|
+
{
|
|
989
|
+
"path": path,
|
|
990
|
+
"summary": "",
|
|
991
|
+
"insertions": ins,
|
|
992
|
+
"deletions": dele,
|
|
993
|
+
}
|
|
994
|
+
)
|
|
995
|
+
diff_stat = {
|
|
996
|
+
"filesChanged": files,
|
|
997
|
+
"insertions": insertions,
|
|
998
|
+
"deletions": deletions,
|
|
999
|
+
"range": rng,
|
|
1000
|
+
}
|
|
1001
|
+
return diff_stat, changed
|
|
1002
|
+
|
|
1003
|
+
|
|
1004
|
+
def _review_summary(change_dir: Path, existing: dict[str, Any] | None) -> dict[str, Any]:
|
|
1005
|
+
base = {
|
|
1006
|
+
"status": "ADVISORY",
|
|
1007
|
+
"red": 0,
|
|
1008
|
+
"yellow": 0,
|
|
1009
|
+
"redFixed": 0,
|
|
1010
|
+
"redConfirmed": 0,
|
|
1011
|
+
"yellowFixed": 0,
|
|
1012
|
+
"yellowDeferred": 0,
|
|
1013
|
+
"summary": "",
|
|
1014
|
+
}
|
|
1015
|
+
if existing and isinstance(existing.get("reviewSummary"), dict):
|
|
1016
|
+
merged = dict(base)
|
|
1017
|
+
merged.update(existing["reviewSummary"])
|
|
1018
|
+
return merged
|
|
1019
|
+
if not find_review_reports(change_dir):
|
|
1020
|
+
log_text = load_execution_log(change_dir)
|
|
1021
|
+
if not re.search(r"harness-review|##\s*review", log_text, re.IGNORECASE):
|
|
1022
|
+
base["status"] = "ADVISORY_NOT_RUN"
|
|
1023
|
+
return base
|
|
1024
|
+
|
|
1025
|
+
|
|
1026
|
+
def collect_summary_data(
|
|
1027
|
+
change_dir: Path,
|
|
1028
|
+
*,
|
|
1029
|
+
before_manifest: dict[str, Any] | None = None,
|
|
1030
|
+
after_manifest: dict[str, Any] | None = None,
|
|
1031
|
+
compare_result: dict[str, Any] | None = None,
|
|
1032
|
+
write: bool = True,
|
|
1033
|
+
for_replay: bool = False,
|
|
1034
|
+
) -> dict[str, Any]:
|
|
1035
|
+
"""Build schema 2.2 summary-data from events/ledger/log/manifest/reports."""
|
|
1036
|
+
template = load_template()
|
|
1037
|
+
data = _deepcopy_json(template)
|
|
1038
|
+
# Clear placeholder strings from template
|
|
1039
|
+
data["schemaVersion"] = SCHEMA_VERSION
|
|
1040
|
+
change_name = infer_change_name(change_dir)
|
|
1041
|
+
data["changeName"] = change_name
|
|
1042
|
+
|
|
1043
|
+
sources: list[str] = []
|
|
1044
|
+
events: list[dict[str, Any]] = []
|
|
1045
|
+
events_file = he.events_path(change_dir)
|
|
1046
|
+
has_events = events_file.is_file() and events_file.stat().st_size > 0
|
|
1047
|
+
if has_events:
|
|
1048
|
+
try:
|
|
1049
|
+
events = he.load_events(events_file)
|
|
1050
|
+
sources.append("events.ndjson")
|
|
1051
|
+
except ValueError:
|
|
1052
|
+
events = []
|
|
1053
|
+
|
|
1054
|
+
ledger = load_ledger(change_dir)
|
|
1055
|
+
if ledger:
|
|
1056
|
+
sources.append("evidence/verification-ledger.json")
|
|
1057
|
+
|
|
1058
|
+
log_text = load_execution_log(change_dir)
|
|
1059
|
+
if log_text:
|
|
1060
|
+
sources.append("logs/execution-log.md")
|
|
1061
|
+
|
|
1062
|
+
existing = load_existing_summary(change_dir)
|
|
1063
|
+
if existing and for_replay:
|
|
1064
|
+
sources.append("reports/final/summary-data.json")
|
|
1065
|
+
|
|
1066
|
+
# Prefer existing summary fields when replaying old archives (golden-stable).
|
|
1067
|
+
if for_replay and existing:
|
|
1068
|
+
for key in (
|
|
1069
|
+
"businessGoal",
|
|
1070
|
+
"finalStatus",
|
|
1071
|
+
"finalCommit",
|
|
1072
|
+
"finalCommitBranch",
|
|
1073
|
+
"baseCommit",
|
|
1074
|
+
"diffStat",
|
|
1075
|
+
"stageStatus",
|
|
1076
|
+
"durations",
|
|
1077
|
+
"skillCalls",
|
|
1078
|
+
"verification",
|
|
1079
|
+
"timeline",
|
|
1080
|
+
"changedFiles",
|
|
1081
|
+
"artifacts",
|
|
1082
|
+
"reviewSummary",
|
|
1083
|
+
"archiveManifest",
|
|
1084
|
+
"uncommittedTestEvidence",
|
|
1085
|
+
"maintenanceNotes",
|
|
1086
|
+
"knownRisks",
|
|
1087
|
+
"manualActions",
|
|
1088
|
+
):
|
|
1089
|
+
if key in existing:
|
|
1090
|
+
data[key] = _deepcopy_json(existing[key])
|
|
1091
|
+
|
|
1092
|
+
event_summary = he.build_summary(change_dir, events) if events else {
|
|
1093
|
+
"ok": True,
|
|
1094
|
+
"event_count": 0,
|
|
1095
|
+
"phases": {},
|
|
1096
|
+
"issues": [],
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
# businessGoal
|
|
1100
|
+
if not data.get("businessGoal") or str(data.get("businessGoal")).startswith("本次"):
|
|
1101
|
+
if existing and existing.get("businessGoal"):
|
|
1102
|
+
data["businessGoal"] = existing["businessGoal"]
|
|
1103
|
+
else:
|
|
1104
|
+
data["businessGoal"] = NOT_AVAILABLE if for_replay else ""
|
|
1105
|
+
|
|
1106
|
+
# commits
|
|
1107
|
+
project = find_project_root(change_dir)
|
|
1108
|
+
if not data.get("finalCommit") or str(data.get("finalCommit")).startswith("<"):
|
|
1109
|
+
code, head, _ = git_run(project, "rev-parse", "HEAD")
|
|
1110
|
+
if code == 0 and head:
|
|
1111
|
+
data["finalCommit"] = head
|
|
1112
|
+
elif existing and existing.get("finalCommit"):
|
|
1113
|
+
data["finalCommit"] = existing["finalCommit"]
|
|
1114
|
+
else:
|
|
1115
|
+
data["finalCommit"] = NOT_AVAILABLE if for_replay else ""
|
|
1116
|
+
|
|
1117
|
+
if not data.get("baseCommit") or str(data.get("baseCommit")).startswith("<"):
|
|
1118
|
+
if ledger and ledger.get("baseCommit"):
|
|
1119
|
+
data["baseCommit"] = ledger["baseCommit"]
|
|
1120
|
+
elif existing and existing.get("baseCommit"):
|
|
1121
|
+
data["baseCommit"] = existing["baseCommit"]
|
|
1122
|
+
else:
|
|
1123
|
+
data["baseCommit"] = NOT_AVAILABLE if for_replay else ""
|
|
1124
|
+
|
|
1125
|
+
if not data.get("finalCommitBranch") or str(data.get("finalCommitBranch")).startswith("<"):
|
|
1126
|
+
code, branch, _ = git_run(project, "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}")
|
|
1127
|
+
if code == 0 and branch:
|
|
1128
|
+
data["finalCommitBranch"] = branch
|
|
1129
|
+
elif existing and existing.get("finalCommitBranch"):
|
|
1130
|
+
data["finalCommitBranch"] = existing["finalCommitBranch"]
|
|
1131
|
+
else:
|
|
1132
|
+
data["finalCommitBranch"] = NOT_AVAILABLE if for_replay else ""
|
|
1133
|
+
|
|
1134
|
+
# verification
|
|
1135
|
+
if not for_replay or not isinstance(data.get("verification"), dict):
|
|
1136
|
+
unit = _ledger_unit_tests(ledger)
|
|
1137
|
+
api = _ledger_api_tests(ledger)
|
|
1138
|
+
db = _ledger_db_compat(ledger)
|
|
1139
|
+
if not find_test_reports(change_dir) and unit.get("run", 0) == 0:
|
|
1140
|
+
if "status" not in unit:
|
|
1141
|
+
unit["status"] = "NOT_RUN"
|
|
1142
|
+
if api.get("status") in {"", "OK"} and api.get("total", 0) == 0:
|
|
1143
|
+
api["status"] = "NOT_RUN"
|
|
1144
|
+
coverage = NOT_AVAILABLE
|
|
1145
|
+
if existing and isinstance(existing.get("verification"), dict):
|
|
1146
|
+
coverage = existing["verification"].get("coverageDisplay", coverage)
|
|
1147
|
+
data["verification"] = {
|
|
1148
|
+
"unitTests": unit,
|
|
1149
|
+
"apiTests": api,
|
|
1150
|
+
"dbCompatibility": db,
|
|
1151
|
+
"coverageDisplay": coverage,
|
|
1152
|
+
}
|
|
1153
|
+
else:
|
|
1154
|
+
# Ensure nested keys exist for old schema 2.1
|
|
1155
|
+
ver = data.setdefault("verification", {})
|
|
1156
|
+
ver.setdefault("unitTests", {})
|
|
1157
|
+
ver.setdefault("apiTests", {})
|
|
1158
|
+
ver.setdefault("dbCompatibility", ver.get("dbCompatibility", NOT_AVAILABLE))
|
|
1159
|
+
ver.setdefault("coverageDisplay", ver.get("coverageDisplay", NOT_AVAILABLE))
|
|
1160
|
+
|
|
1161
|
+
# stageStatus / finalStatus
|
|
1162
|
+
if not for_replay or not isinstance(data.get("stageStatus"), dict):
|
|
1163
|
+
data["stageStatus"] = _stage_status_from_sources(events, ledger, change_dir)
|
|
1164
|
+
if not for_replay or not data.get("finalStatus") or str(data.get("finalStatus")).startswith("OK |"):
|
|
1165
|
+
data["finalStatus"] = _compute_final_status(
|
|
1166
|
+
data.get("stageStatus") or {},
|
|
1167
|
+
data.get("verification") or {},
|
|
1168
|
+
)
|
|
1169
|
+
|
|
1170
|
+
# durations / skillCalls
|
|
1171
|
+
if events:
|
|
1172
|
+
data["durations"] = _durations_from_event_phases(event_summary)
|
|
1173
|
+
data["skillCalls"] = _skill_calls_from_stages(data["durations"].get("stages") or [])
|
|
1174
|
+
elif log_text and (not for_replay or not data.get("durations")):
|
|
1175
|
+
data["durations"] = _parse_durations_from_log(log_text)
|
|
1176
|
+
data["skillCalls"] = _skill_calls_from_stages(data["durations"].get("stages") or [])
|
|
1177
|
+
elif not data.get("durations"):
|
|
1178
|
+
data["durations"] = {
|
|
1179
|
+
"totalLabel": NOT_AVAILABLE,
|
|
1180
|
+
"totalMinutes": 0,
|
|
1181
|
+
"stages": [],
|
|
1182
|
+
}
|
|
1183
|
+
data["skillCalls"] = []
|
|
1184
|
+
|
|
1185
|
+
# diffStat / changedFiles
|
|
1186
|
+
if not for_replay or not data.get("changedFiles"):
|
|
1187
|
+
base = data.get("baseCommit")
|
|
1188
|
+
head = data.get("finalCommit")
|
|
1189
|
+
if base and head and base != NOT_AVAILABLE and head != NOT_AVAILABLE:
|
|
1190
|
+
diff_stat, changed = _changed_files_from_git(project, str(base), str(head))
|
|
1191
|
+
if changed:
|
|
1192
|
+
data["diffStat"] = diff_stat
|
|
1193
|
+
data["changedFiles"] = changed
|
|
1194
|
+
elif existing and existing.get("changedFiles"):
|
|
1195
|
+
data["changedFiles"] = existing["changedFiles"]
|
|
1196
|
+
data["diffStat"] = existing.get("diffStat") or diff_stat
|
|
1197
|
+
else:
|
|
1198
|
+
data["diffStat"] = diff_stat
|
|
1199
|
+
data["changedFiles"] = []
|
|
1200
|
+
elif existing and existing.get("changedFiles"):
|
|
1201
|
+
data["changedFiles"] = existing["changedFiles"]
|
|
1202
|
+
data["diffStat"] = existing.get("diffStat") or {
|
|
1203
|
+
"filesChanged": len(existing["changedFiles"]),
|
|
1204
|
+
"insertions": 0,
|
|
1205
|
+
"deletions": 0,
|
|
1206
|
+
"range": NOT_AVAILABLE,
|
|
1207
|
+
}
|
|
1208
|
+
else:
|
|
1209
|
+
data["diffStat"] = {
|
|
1210
|
+
"filesChanged": 0,
|
|
1211
|
+
"insertions": 0,
|
|
1212
|
+
"deletions": 0,
|
|
1213
|
+
"range": NOT_AVAILABLE if for_replay else "",
|
|
1214
|
+
}
|
|
1215
|
+
data["changedFiles"] = []
|
|
1216
|
+
|
|
1217
|
+
# artifacts (build products stay empty unless already known; reportPipeline has event artifacts)
|
|
1218
|
+
if not isinstance(data.get("artifacts"), list):
|
|
1219
|
+
data["artifacts"] = []
|
|
1220
|
+
if not for_replay:
|
|
1221
|
+
# Keep empty placeholder for build artifacts; event artifacts go to reportPipeline
|
|
1222
|
+
pass
|
|
1223
|
+
|
|
1224
|
+
data["reviewSummary"] = _review_summary(change_dir, existing if for_replay else None)
|
|
1225
|
+
data.setdefault("timeline", [])
|
|
1226
|
+
data.setdefault("uncommittedTestEvidence", [])
|
|
1227
|
+
|
|
1228
|
+
# Model-owned fields: leave empty placeholders on finalize; preserve on replay
|
|
1229
|
+
if not for_replay:
|
|
1230
|
+
data["maintenanceNotes"] = []
|
|
1231
|
+
data["knownRisks"] = []
|
|
1232
|
+
data["manualActions"] = []
|
|
1233
|
+
else:
|
|
1234
|
+
data.setdefault("maintenanceNotes", [])
|
|
1235
|
+
data.setdefault("knownRisks", [])
|
|
1236
|
+
data.setdefault("manualActions", [])
|
|
1237
|
+
|
|
1238
|
+
# archiveManifest
|
|
1239
|
+
am = {
|
|
1240
|
+
"movedFiles": 0,
|
|
1241
|
+
"generatedFiles": 0,
|
|
1242
|
+
"totalArchiveFiles": 0,
|
|
1243
|
+
"checksumStatus": "OK",
|
|
1244
|
+
}
|
|
1245
|
+
if compare_result:
|
|
1246
|
+
am["movedFiles"] = compare_result.get("movedFiles", 0)
|
|
1247
|
+
am["generatedFiles"] = compare_result.get("generatedFiles", 0)
|
|
1248
|
+
am["totalArchiveFiles"] = compare_result.get("totalArchiveFiles", 0)
|
|
1249
|
+
am["checksumStatus"] = compare_result.get("checksumStatus", "OK")
|
|
1250
|
+
elif after_manifest:
|
|
1251
|
+
am["totalArchiveFiles"] = int(after_manifest.get("fileCount") or 0)
|
|
1252
|
+
elif before_manifest:
|
|
1253
|
+
am["totalArchiveFiles"] = int(before_manifest.get("fileCount") or 0)
|
|
1254
|
+
elif for_replay and isinstance(data.get("archiveManifest"), dict):
|
|
1255
|
+
am = {**am, **data["archiveManifest"]}
|
|
1256
|
+
data["archiveManifest"] = am
|
|
1257
|
+
|
|
1258
|
+
# reportPipeline
|
|
1259
|
+
commands = _commands_from_events(events)
|
|
1260
|
+
if not commands and for_replay:
|
|
1261
|
+
# Cannot invent commands
|
|
1262
|
+
pass
|
|
1263
|
+
verification_checks = _verification_checks_from_events(events, ledger)
|
|
1264
|
+
pipeline_artifacts = _artifacts_from_events(events)
|
|
1265
|
+
if not sources:
|
|
1266
|
+
sources = [NOT_AVAILABLE]
|
|
1267
|
+
|
|
1268
|
+
data["reportPipeline"] = {
|
|
1269
|
+
"schema_version": 1,
|
|
1270
|
+
"generated_at": now_iso(),
|
|
1271
|
+
"event_count": len(events),
|
|
1272
|
+
"sources": sources,
|
|
1273
|
+
"phases": _phases_from_events_summary(event_summary),
|
|
1274
|
+
"commands": commands,
|
|
1275
|
+
"verificationChecks": verification_checks,
|
|
1276
|
+
"artifacts": pipeline_artifacts,
|
|
1277
|
+
"validationIssues": [],
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1280
|
+
# Fill any remaining template placeholders that look like instructions
|
|
1281
|
+
for key in list(data.keys()):
|
|
1282
|
+
val = data[key]
|
|
1283
|
+
if isinstance(val, str) and ("|" in val and "OK" in val and len(val) < 80):
|
|
1284
|
+
# template enum hint left behind
|
|
1285
|
+
if for_replay:
|
|
1286
|
+
data[key] = existing.get(key, NOT_AVAILABLE) if existing else NOT_AVAILABLE
|
|
1287
|
+
|
|
1288
|
+
if write:
|
|
1289
|
+
out_path = change_dir / "reports" / "final" / "summary-data.json"
|
|
1290
|
+
write_json(out_path, data)
|
|
1291
|
+
|
|
1292
|
+
return data
|
|
1293
|
+
|
|
1294
|
+
|
|
1295
|
+
# ---------------------------------------------------------------------------
|
|
1296
|
+
# render
|
|
1297
|
+
# ---------------------------------------------------------------------------
|
|
1298
|
+
|
|
1299
|
+
|
|
1300
|
+
def resolve_node_path(project_root: Path) -> str | None:
|
|
1301
|
+
profile = project_root / ".harness" / "config" / "build-profile.json"
|
|
1302
|
+
if profile.is_file():
|
|
1303
|
+
try:
|
|
1304
|
+
data = read_json(profile)
|
|
1305
|
+
node = (data.get("toolPaths") or {}).get("node")
|
|
1306
|
+
if node and Path(str(node)).exists():
|
|
1307
|
+
return str(node)
|
|
1308
|
+
except (OSError, json.JSONDecodeError, TypeError):
|
|
1309
|
+
pass
|
|
1310
|
+
return shutil.which("node")
|
|
1311
|
+
|
|
1312
|
+
|
|
1313
|
+
def render_fallback_html(summary: dict[str, Any]) -> str:
|
|
1314
|
+
"""Render escaped, deterministic HTML with every validate-required fact.
|
|
1315
|
+
|
|
1316
|
+
Used when the Node renderer is unavailable or fails. No timestamps / random
|
|
1317
|
+
data; all dynamic values are HTML-escaped via _html_escape.
|
|
1318
|
+
"""
|
|
1319
|
+
def esc(v: Any) -> str:
|
|
1320
|
+
return _html_escape("" if v is None else str(v))
|
|
1321
|
+
|
|
1322
|
+
parts: list[str] = [
|
|
1323
|
+
"<!DOCTYPE html>",
|
|
1324
|
+
'<html lang="zh-CN"><head><meta charset="utf-8">',
|
|
1325
|
+
"<title>harness final-summary (python fallback)</title>",
|
|
1326
|
+
"</head><body>",
|
|
1327
|
+
"<h1>变更最终报告(Python fallback 渲染)</h1>",
|
|
1328
|
+
f'<h2 id="changeName">{esc(summary.get("changeName"))}</h2>',
|
|
1329
|
+
f'<p><strong>finalStatus</strong>: '
|
|
1330
|
+
f'<span id="finalStatus">{esc(summary.get("finalStatus"))}</span></p>',
|
|
1331
|
+
]
|
|
1332
|
+
|
|
1333
|
+
pipeline = summary.get("reportPipeline") or {}
|
|
1334
|
+
cmds = pipeline.get("commands") or []
|
|
1335
|
+
parts.append("<h3>Commands</h3><ul>")
|
|
1336
|
+
for c in cmds:
|
|
1337
|
+
parts.append(
|
|
1338
|
+
f"<li><code>{esc(c.get('command'))}</code> "
|
|
1339
|
+
f"exitCode={esc(c.get('exit_code'))}</li>"
|
|
1340
|
+
)
|
|
1341
|
+
parts.append("</ul>")
|
|
1342
|
+
|
|
1343
|
+
ver = summary.get("verification") or {}
|
|
1344
|
+
unit = ver.get("unitTests") or {}
|
|
1345
|
+
api = ver.get("apiTests") or {}
|
|
1346
|
+
parts.append("<h3>Verification</h3>")
|
|
1347
|
+
parts.append(
|
|
1348
|
+
"<p>unitTests: run={run} failures={failures} errors={errors} "
|
|
1349
|
+
"skipped={skipped} passRate={passRate} status={status}</p>".format(
|
|
1350
|
+
run=esc(unit.get("run")),
|
|
1351
|
+
failures=esc(unit.get("failures")),
|
|
1352
|
+
errors=esc(unit.get("errors")),
|
|
1353
|
+
skipped=esc(unit.get("skipped")),
|
|
1354
|
+
passRate=esc(unit.get("passRate")),
|
|
1355
|
+
status=esc(unit.get("status")),
|
|
1356
|
+
)
|
|
1357
|
+
)
|
|
1358
|
+
parts.append(
|
|
1359
|
+
"<p>apiTests: status={status} total={total} passed={passed} "
|
|
1360
|
+
"failed={failed} blocked={blocked}</p>".format(
|
|
1361
|
+
status=esc(api.get("status")),
|
|
1362
|
+
total=esc(api.get("total")),
|
|
1363
|
+
passed=esc(api.get("passed")),
|
|
1364
|
+
failed=esc(api.get("failed")),
|
|
1365
|
+
blocked=esc(api.get("blocked")),
|
|
1366
|
+
)
|
|
1367
|
+
)
|
|
1368
|
+
parts.append(f"<p>dbCompatibility: {esc(ver.get('dbCompatibility'))}</p>")
|
|
1369
|
+
|
|
1370
|
+
parts.append("<h3>Changed Files</h3><ul>")
|
|
1371
|
+
for f in summary.get("changedFiles") or []:
|
|
1372
|
+
parts.append(
|
|
1373
|
+
f"<li>{esc(f.get('path'))} +{esc(f.get('insertions'))} "
|
|
1374
|
+
f"-{esc(f.get('deletions'))}</li>"
|
|
1375
|
+
)
|
|
1376
|
+
parts.append("</ul>")
|
|
1377
|
+
|
|
1378
|
+
am = summary.get("archiveManifest") or {}
|
|
1379
|
+
parts.append("<h3>Archive Manifest</h3>")
|
|
1380
|
+
parts.append(
|
|
1381
|
+
"<p>movedFiles={moved} generatedFiles={gen} totalArchiveFiles={total} "
|
|
1382
|
+
"checksumStatus={cs}</p>".format(
|
|
1383
|
+
moved=esc(am.get("movedFiles")),
|
|
1384
|
+
gen=esc(am.get("generatedFiles")),
|
|
1385
|
+
total=esc(am.get("totalArchiveFiles")),
|
|
1386
|
+
cs=esc(am.get("checksumStatus")),
|
|
1387
|
+
)
|
|
1388
|
+
)
|
|
1389
|
+
|
|
1390
|
+
def _list_section(title: str, items: Any) -> None:
|
|
1391
|
+
parts.append(f"<h3>{esc(title)}</h3><ul>")
|
|
1392
|
+
for it in items or []:
|
|
1393
|
+
parts.append(f"<li>{esc(it)}</li>")
|
|
1394
|
+
parts.append("</ul>")
|
|
1395
|
+
|
|
1396
|
+
_list_section("Known Risks", summary.get("knownRisks"))
|
|
1397
|
+
_list_section("Manual Actions", summary.get("manualActions"))
|
|
1398
|
+
_list_section("Maintenance Notes", summary.get("maintenanceNotes"))
|
|
1399
|
+
|
|
1400
|
+
parts.append("</body></html>")
|
|
1401
|
+
return "\n".join(parts) + "\n"
|
|
1402
|
+
|
|
1403
|
+
|
|
1404
|
+
def render_final_summary(
|
|
1405
|
+
change_dir: Path,
|
|
1406
|
+
summary_path: Path,
|
|
1407
|
+
) -> dict[str, Any]:
|
|
1408
|
+
"""Render final-summary.html via Node; fall back to a Python renderer.
|
|
1409
|
+
|
|
1410
|
+
Returns ``{ok, renderer, fallbackReason, out_path}``:
|
|
1411
|
+
- Node success -> renderer="node".
|
|
1412
|
+
- Node unavailable/timeout/non-zero/no-file -> Python fallback; success ->
|
|
1413
|
+
renderer="python-fallback" (fallbackReason carries the node failure cause).
|
|
1414
|
+
- Both fail or produce no file -> ok=False (caller must restore + exit non-0).
|
|
1415
|
+
"""
|
|
1416
|
+
out_path = change_dir / "reports" / "final" / "final-summary.html"
|
|
1417
|
+
project = find_project_root(change_dir)
|
|
1418
|
+
node = resolve_node_path(project)
|
|
1419
|
+
fallback_reason = ""
|
|
1420
|
+
|
|
1421
|
+
if node and RENDER_SCRIPT.is_file():
|
|
1422
|
+
out_path.parent.mkdir(parents=True, exist_ok=True)
|
|
1423
|
+
try:
|
|
1424
|
+
proc = subprocess.run(
|
|
1425
|
+
[
|
|
1426
|
+
node,
|
|
1427
|
+
str(RENDER_SCRIPT),
|
|
1428
|
+
"--summary",
|
|
1429
|
+
str(summary_path),
|
|
1430
|
+
"--out",
|
|
1431
|
+
str(out_path),
|
|
1432
|
+
],
|
|
1433
|
+
capture_output=True,
|
|
1434
|
+
text=True,
|
|
1435
|
+
encoding="utf-8",
|
|
1436
|
+
errors="replace",
|
|
1437
|
+
timeout=60,
|
|
1438
|
+
check=False,
|
|
1439
|
+
)
|
|
1440
|
+
if proc.returncode == 0 and out_path.is_file():
|
|
1441
|
+
return {
|
|
1442
|
+
"ok": True,
|
|
1443
|
+
"renderer": "node",
|
|
1444
|
+
"fallbackReason": "",
|
|
1445
|
+
"out_path": str(out_path),
|
|
1446
|
+
}
|
|
1447
|
+
fallback_reason = (
|
|
1448
|
+
f"node render exit {proc.returncode}: "
|
|
1449
|
+
f"{(proc.stderr or proc.stdout or '')[:200]}"
|
|
1450
|
+
)
|
|
1451
|
+
except (OSError, subprocess.TimeoutExpired) as exc:
|
|
1452
|
+
fallback_reason = f"node render failed: {exc}"
|
|
1453
|
+
else:
|
|
1454
|
+
fallback_reason = (
|
|
1455
|
+
"node unavailable" if not node else f"renderer missing: {RENDER_SCRIPT}"
|
|
1456
|
+
)
|
|
1457
|
+
|
|
1458
|
+
# Python fallback
|
|
1459
|
+
try:
|
|
1460
|
+
summary = read_json(summary_path)
|
|
1461
|
+
html = render_fallback_html(summary)
|
|
1462
|
+
out_path.parent.mkdir(parents=True, exist_ok=True)
|
|
1463
|
+
out_path.write_text(html, encoding="utf-8", newline="\n")
|
|
1464
|
+
except (OSError, json.JSONDecodeError, ValueError) as exc:
|
|
1465
|
+
return {
|
|
1466
|
+
"ok": False,
|
|
1467
|
+
"renderer": "none",
|
|
1468
|
+
"fallbackReason": f"{fallback_reason}; fallback failed: {exc}",
|
|
1469
|
+
"out_path": str(out_path),
|
|
1470
|
+
}
|
|
1471
|
+
if out_path.is_file():
|
|
1472
|
+
return {
|
|
1473
|
+
"ok": True,
|
|
1474
|
+
"renderer": "python-fallback",
|
|
1475
|
+
"fallbackReason": fallback_reason,
|
|
1476
|
+
"out_path": str(out_path),
|
|
1477
|
+
}
|
|
1478
|
+
return {
|
|
1479
|
+
"ok": False,
|
|
1480
|
+
"renderer": "none",
|
|
1481
|
+
"fallbackReason": f"{fallback_reason}; fallback produced no file",
|
|
1482
|
+
"out_path": str(out_path),
|
|
1483
|
+
}
|
|
1484
|
+
|
|
1485
|
+
|
|
1486
|
+
# ---------------------------------------------------------------------------
|
|
1487
|
+
# validate
|
|
1488
|
+
# ---------------------------------------------------------------------------
|
|
1489
|
+
|
|
1490
|
+
|
|
1491
|
+
def validate_summary(
|
|
1492
|
+
summary: dict[str, Any],
|
|
1493
|
+
html_path: Path | None,
|
|
1494
|
+
*,
|
|
1495
|
+
render_skipped: bool = False,
|
|
1496
|
+
) -> dict[str, Any]:
|
|
1497
|
+
"""Validate final-summary covers summary-data key facts (in-process)."""
|
|
1498
|
+
issues: list[dict[str, str]] = []
|
|
1499
|
+
|
|
1500
|
+
change_id = str(summary.get("changeName") or "")
|
|
1501
|
+
html = ""
|
|
1502
|
+
if html_path and html_path.is_file():
|
|
1503
|
+
try:
|
|
1504
|
+
html = html_path.read_text(encoding="utf-8-sig")
|
|
1505
|
+
except OSError as exc:
|
|
1506
|
+
issues.append(
|
|
1507
|
+
{
|
|
1508
|
+
"code": "missing-final-report",
|
|
1509
|
+
"severity": "error",
|
|
1510
|
+
"message": f"cannot read final-summary: {exc}",
|
|
1511
|
+
}
|
|
1512
|
+
)
|
|
1513
|
+
else:
|
|
1514
|
+
# Task 2 (§4.1 rule 5): 不再存在"没有 HTML 但只 warning"的分支。
|
|
1515
|
+
# 缺 final-summary 恒为 error;finalize 会 restore + exit 非 0。
|
|
1516
|
+
issues.append(
|
|
1517
|
+
{
|
|
1518
|
+
"code": "missing-final-report",
|
|
1519
|
+
"severity": "error",
|
|
1520
|
+
"message": "reports/final/final-summary.html not found",
|
|
1521
|
+
}
|
|
1522
|
+
)
|
|
1523
|
+
|
|
1524
|
+
def has_text(needle: str) -> bool:
|
|
1525
|
+
if not needle or needle == NOT_AVAILABLE:
|
|
1526
|
+
return True
|
|
1527
|
+
return needle in html
|
|
1528
|
+
|
|
1529
|
+
# change id
|
|
1530
|
+
if html and change_id and not has_text(change_id):
|
|
1531
|
+
issues.append(
|
|
1532
|
+
{
|
|
1533
|
+
"code": "missing-change-id",
|
|
1534
|
+
"severity": "error",
|
|
1535
|
+
"message": f"final-summary missing change id '{change_id}'",
|
|
1536
|
+
}
|
|
1537
|
+
)
|
|
1538
|
+
|
|
1539
|
+
# key commands — stock render-summary.mjs does not embed reportPipeline.commands.
|
|
1540
|
+
# Require commands to be present in summary-data; HTML absence is warning only.
|
|
1541
|
+
commands = (summary.get("reportPipeline") or {}).get("commands") or []
|
|
1542
|
+
if html:
|
|
1543
|
+
for cmd in commands[:8]:
|
|
1544
|
+
c = str(cmd.get("command") or "").strip()
|
|
1545
|
+
if not c:
|
|
1546
|
+
continue
|
|
1547
|
+
fragment = c if len(c) <= 60 else c[:60]
|
|
1548
|
+
token = c.split()[-1] if c.split() else c
|
|
1549
|
+
in_html = (
|
|
1550
|
+
fragment in html
|
|
1551
|
+
or _html_escape(fragment) in html
|
|
1552
|
+
or (len(token) >= 4 and (token in html or _html_escape(token) in html))
|
|
1553
|
+
)
|
|
1554
|
+
if not in_html:
|
|
1555
|
+
issues.append(
|
|
1556
|
+
{
|
|
1557
|
+
"code": "missing-command",
|
|
1558
|
+
"severity": "warning",
|
|
1559
|
+
"message": (
|
|
1560
|
+
f"final-summary HTML omits command (renderer may not "
|
|
1561
|
+
f"embed commands): {c}"
|
|
1562
|
+
),
|
|
1563
|
+
}
|
|
1564
|
+
)
|
|
1565
|
+
|
|
1566
|
+
# verification
|
|
1567
|
+
ver = summary.get("verification") or {}
|
|
1568
|
+
unit = ver.get("unitTests") or {}
|
|
1569
|
+
api = ver.get("apiTests") or {}
|
|
1570
|
+
pass_rate = unit.get("passRate")
|
|
1571
|
+
if pass_rate and pass_rate != NOT_AVAILABLE and str(pass_rate) not in html:
|
|
1572
|
+
issues.append(
|
|
1573
|
+
{
|
|
1574
|
+
"code": "missing-verification",
|
|
1575
|
+
"severity": "warning",
|
|
1576
|
+
"message": f"unitTests.passRate '{pass_rate}' not in final-summary",
|
|
1577
|
+
}
|
|
1578
|
+
)
|
|
1579
|
+
api_status = str(api.get("status") or "")
|
|
1580
|
+
if api_status and api_status not in {"", NOT_AVAILABLE} and api_status not in html:
|
|
1581
|
+
# Renderer shows api status; soft warning
|
|
1582
|
+
if api_status in {"USER_SKIPPED", "BLOCKED_BY_DBA", "NOT_RUN", "PARTIAL"}:
|
|
1583
|
+
issues.append(
|
|
1584
|
+
{
|
|
1585
|
+
"code": "missing-verification",
|
|
1586
|
+
"severity": "warning",
|
|
1587
|
+
"message": f"apiTests.status '{api_status}' not visible in final-summary",
|
|
1588
|
+
}
|
|
1589
|
+
)
|
|
1590
|
+
|
|
1591
|
+
# artifacts / summary-data path hints
|
|
1592
|
+
am = summary.get("archiveManifest") or {}
|
|
1593
|
+
total = am.get("totalArchiveFiles")
|
|
1594
|
+
if total is not None and str(total) not in html:
|
|
1595
|
+
issues.append(
|
|
1596
|
+
{
|
|
1597
|
+
"code": "missing-artifact",
|
|
1598
|
+
"severity": "warning",
|
|
1599
|
+
"message": "archiveManifest.totalArchiveFiles not reflected in final-summary",
|
|
1600
|
+
}
|
|
1601
|
+
)
|
|
1602
|
+
|
|
1603
|
+
# risks / manual actions — empty arrays are OK (placeholder)
|
|
1604
|
+
for risk in summary.get("knownRisks") or []:
|
|
1605
|
+
text = str(risk)
|
|
1606
|
+
if text and text not in html and _html_escape(text) not in html:
|
|
1607
|
+
issues.append(
|
|
1608
|
+
{
|
|
1609
|
+
"code": "missing-risk",
|
|
1610
|
+
"severity": "warning",
|
|
1611
|
+
"message": f"knownRisk not in final-summary: {text[:80]}",
|
|
1612
|
+
}
|
|
1613
|
+
)
|
|
1614
|
+
|
|
1615
|
+
# status contradiction
|
|
1616
|
+
final_status = str(summary.get("finalStatus") or "")
|
|
1617
|
+
has_skip = api_status == "USER_SKIPPED" or str(
|
|
1618
|
+
ver.get("dbCompatibility") or ""
|
|
1619
|
+
) == "BLOCKED_BY_DBA"
|
|
1620
|
+
has_fail_ver = int(unit.get("failures") or 0) > 0 or int(unit.get("errors") or 0) > 0
|
|
1621
|
+
has_fail_ver = has_fail_ver or int(api.get("failed") or 0) > 0
|
|
1622
|
+
stage = summary.get("stageStatus") or {}
|
|
1623
|
+
has_fail_stage = any(str(v).upper() == "FAIL" for v in stage.values())
|
|
1624
|
+
|
|
1625
|
+
if (has_skip or has_fail_ver or has_fail_stage) and final_status == "OK":
|
|
1626
|
+
issues.append(
|
|
1627
|
+
{
|
|
1628
|
+
"code": "status-contradiction",
|
|
1629
|
+
"severity": "error",
|
|
1630
|
+
"message": (
|
|
1631
|
+
"finalStatus is pure OK but USER_SKIPPED/BLOCKED_BY_DBA/"
|
|
1632
|
+
"failed verification present"
|
|
1633
|
+
),
|
|
1634
|
+
}
|
|
1635
|
+
)
|
|
1636
|
+
if has_skip and html:
|
|
1637
|
+
if re.search(r">\s*OK\s*<", html) and "CONDITIONAL" not in html.upper():
|
|
1638
|
+
if final_status != "CONDITIONAL_OK":
|
|
1639
|
+
issues.append(
|
|
1640
|
+
{
|
|
1641
|
+
"code": "status-contradiction",
|
|
1642
|
+
"severity": "error",
|
|
1643
|
+
"message": "final-summary shows pure OK despite USER_SKIPPED/BLOCKED",
|
|
1644
|
+
}
|
|
1645
|
+
)
|
|
1646
|
+
else:
|
|
1647
|
+
# No HTML (and not render_skipped handled above): still check data-level status rules
|
|
1648
|
+
ver = summary.get("verification") or {}
|
|
1649
|
+
unit = ver.get("unitTests") or {}
|
|
1650
|
+
api = ver.get("apiTests") or {}
|
|
1651
|
+
api_status = str(api.get("status") or "")
|
|
1652
|
+
final_status = str(summary.get("finalStatus") or "")
|
|
1653
|
+
has_skip = api_status == "USER_SKIPPED" or str(
|
|
1654
|
+
ver.get("dbCompatibility") or ""
|
|
1655
|
+
) == "BLOCKED_BY_DBA"
|
|
1656
|
+
has_fail_ver = int(unit.get("failures") or 0) > 0 or int(unit.get("errors") or 0) > 0
|
|
1657
|
+
has_fail_ver = has_fail_ver or int(api.get("failed") or 0) > 0
|
|
1658
|
+
stage = summary.get("stageStatus") or {}
|
|
1659
|
+
has_fail_stage = any(str(v).upper() == "FAIL" for v in stage.values())
|
|
1660
|
+
if (has_skip or has_fail_ver or has_fail_stage) and final_status == "OK":
|
|
1661
|
+
issues.append(
|
|
1662
|
+
{
|
|
1663
|
+
"code": "status-contradiction",
|
|
1664
|
+
"severity": "error",
|
|
1665
|
+
"message": (
|
|
1666
|
+
"finalStatus is pure OK but USER_SKIPPED/BLOCKED_BY_DBA/"
|
|
1667
|
+
"failed verification present"
|
|
1668
|
+
),
|
|
1669
|
+
}
|
|
1670
|
+
)
|
|
1671
|
+
|
|
1672
|
+
errors = [i for i in issues if i.get("severity") == "error"]
|
|
1673
|
+
warnings = [i for i in issues if i.get("severity") != "error"]
|
|
1674
|
+
return {
|
|
1675
|
+
"ok": len(errors) == 0,
|
|
1676
|
+
"issues": issues,
|
|
1677
|
+
"error_count": len(errors),
|
|
1678
|
+
"warning_count": len(warnings),
|
|
1679
|
+
}
|
|
1680
|
+
|
|
1681
|
+
|
|
1682
|
+
def _html_escape(text: str) -> str:
|
|
1683
|
+
return (
|
|
1684
|
+
text.replace("&", "&")
|
|
1685
|
+
.replace("<", "<")
|
|
1686
|
+
.replace(">", ">")
|
|
1687
|
+
.replace('"', """)
|
|
1688
|
+
.replace("'", "'")
|
|
1689
|
+
)
|
|
1690
|
+
|
|
1691
|
+
|
|
1692
|
+
# ---------------------------------------------------------------------------
|
|
1693
|
+
# knowledge / service post-steps
|
|
1694
|
+
# ---------------------------------------------------------------------------
|
|
1695
|
+
|
|
1696
|
+
|
|
1697
|
+
def run_knowledge_poststeps(project_root: Path) -> dict[str, Any]:
|
|
1698
|
+
results: dict[str, Any] = {"ran": False, "steps": [], "warnings": []}
|
|
1699
|
+
if not KNOWLEDGE_SCRIPT.is_file():
|
|
1700
|
+
results["warnings"].append(f"harness_knowledge.py not found: {KNOWLEDGE_SCRIPT}")
|
|
1701
|
+
return results
|
|
1702
|
+
results["ran"] = True
|
|
1703
|
+
for cmd in ("ingest", "dedupe", "auto-supersede", "reverify-stale"):
|
|
1704
|
+
try:
|
|
1705
|
+
proc = subprocess.run(
|
|
1706
|
+
[
|
|
1707
|
+
sys.executable,
|
|
1708
|
+
str(KNOWLEDGE_SCRIPT),
|
|
1709
|
+
cmd,
|
|
1710
|
+
"--project",
|
|
1711
|
+
str(project_root),
|
|
1712
|
+
"--json",
|
|
1713
|
+
],
|
|
1714
|
+
capture_output=True,
|
|
1715
|
+
text=True,
|
|
1716
|
+
encoding="utf-8",
|
|
1717
|
+
errors="replace",
|
|
1718
|
+
timeout=300,
|
|
1719
|
+
check=False,
|
|
1720
|
+
)
|
|
1721
|
+
step = {
|
|
1722
|
+
"command": cmd,
|
|
1723
|
+
"exit_code": proc.returncode,
|
|
1724
|
+
"ok": proc.returncode == 0,
|
|
1725
|
+
}
|
|
1726
|
+
if proc.returncode != 0:
|
|
1727
|
+
step["stderr"] = (proc.stderr or proc.stdout or "")[:500]
|
|
1728
|
+
results["warnings"].append(f"knowledge {cmd} exit {proc.returncode}")
|
|
1729
|
+
results["steps"].append(step)
|
|
1730
|
+
except (OSError, subprocess.TimeoutExpired) as exc:
|
|
1731
|
+
results["steps"].append({"command": cmd, "ok": False, "error": str(exc)})
|
|
1732
|
+
results["warnings"].append(f"knowledge {cmd} failed: {exc}")
|
|
1733
|
+
return results
|
|
1734
|
+
|
|
1735
|
+
|
|
1736
|
+
def enqueue_maintenance_outbox(project_root: Path, archive_dir: Path) -> dict[str, Any]:
|
|
1737
|
+
"""§8.2: write a pending maintenance-outbox item instead of synchronously
|
|
1738
|
+
running the four knowledge subprocesses. Never rolls back the archive."""
|
|
1739
|
+
pending_dir = (
|
|
1740
|
+
project_root / ".harness" / "knowledge" / "maintenance-outbox" / "pending"
|
|
1741
|
+
)
|
|
1742
|
+
pending_dir.mkdir(parents=True, exist_ok=True)
|
|
1743
|
+
archive_id = archive_dir.name
|
|
1744
|
+
manifest = archive_dir / "evidence" / "archive-manifest-after.json"
|
|
1745
|
+
manifest_hash = "sha256:" + sha256_file(manifest) if manifest.is_file() else ""
|
|
1746
|
+
try:
|
|
1747
|
+
rel = archive_dir.resolve().relative_to(project_root.resolve()).as_posix()
|
|
1748
|
+
except ValueError:
|
|
1749
|
+
rel = str(archive_dir)
|
|
1750
|
+
item = {
|
|
1751
|
+
"schemaVersion": 1,
|
|
1752
|
+
"archiveId": archive_id,
|
|
1753
|
+
"archivePath": rel,
|
|
1754
|
+
"archiveManifestHash": manifest_hash,
|
|
1755
|
+
"status": "pending",
|
|
1756
|
+
"attempts": 0,
|
|
1757
|
+
"createdAt": now_iso(),
|
|
1758
|
+
"lastError": None,
|
|
1759
|
+
}
|
|
1760
|
+
item_path = pending_dir / f"{archive_id}.json"
|
|
1761
|
+
write_json(item_path, item)
|
|
1762
|
+
return {
|
|
1763
|
+
"queued": True,
|
|
1764
|
+
"outboxPath": str(item_path),
|
|
1765
|
+
"archiveId": archive_id,
|
|
1766
|
+
"status": "pending",
|
|
1767
|
+
}
|
|
1768
|
+
|
|
1769
|
+
|
|
1770
|
+
def run_service_stop(change_dir: Path) -> dict[str, Any]:
|
|
1771
|
+
if not SERVICE_SCRIPT.is_file():
|
|
1772
|
+
return {
|
|
1773
|
+
"ran": False,
|
|
1774
|
+
"skipped": True,
|
|
1775
|
+
"warning": f"harness_service.py not found; stop skipped",
|
|
1776
|
+
}
|
|
1777
|
+
try:
|
|
1778
|
+
proc = subprocess.run(
|
|
1779
|
+
[
|
|
1780
|
+
sys.executable,
|
|
1781
|
+
str(SERVICE_SCRIPT),
|
|
1782
|
+
"stop",
|
|
1783
|
+
"--change-dir",
|
|
1784
|
+
str(change_dir),
|
|
1785
|
+
"--if-started-by-ai",
|
|
1786
|
+
"--json",
|
|
1787
|
+
],
|
|
1788
|
+
capture_output=True,
|
|
1789
|
+
text=True,
|
|
1790
|
+
encoding="utf-8",
|
|
1791
|
+
errors="replace",
|
|
1792
|
+
timeout=60,
|
|
1793
|
+
check=False,
|
|
1794
|
+
)
|
|
1795
|
+
return {
|
|
1796
|
+
"ran": True,
|
|
1797
|
+
"skipped": False,
|
|
1798
|
+
"exit_code": proc.returncode,
|
|
1799
|
+
"ok": proc.returncode == 0,
|
|
1800
|
+
"stdout": (proc.stdout or "")[:500],
|
|
1801
|
+
}
|
|
1802
|
+
except (OSError, subprocess.TimeoutExpired) as exc:
|
|
1803
|
+
return {"ran": True, "skipped": False, "ok": False, "warning": str(exc)}
|
|
1804
|
+
|
|
1805
|
+
|
|
1806
|
+
# ---------------------------------------------------------------------------
|
|
1807
|
+
# finalize
|
|
1808
|
+
# ---------------------------------------------------------------------------
|
|
1809
|
+
|
|
1810
|
+
|
|
1811
|
+
def cmd_finalize(
|
|
1812
|
+
change_dir: Path,
|
|
1813
|
+
archive_root: Path,
|
|
1814
|
+
*,
|
|
1815
|
+
skip_ingest: bool = False,
|
|
1816
|
+
) -> tuple[int, dict[str, Any]]:
|
|
1817
|
+
"""Execute the 9-step finalize pipeline. Returns (exit_code, payload)."""
|
|
1818
|
+
warnings: list[str] = []
|
|
1819
|
+
original_change_dir = change_dir.resolve()
|
|
1820
|
+
change_name = original_change_dir.name
|
|
1821
|
+
archive_root = archive_root.resolve()
|
|
1822
|
+
archive_root.mkdir(parents=True, exist_ok=True)
|
|
1823
|
+
archive_dir = archive_root / f"{today_date()}-{change_name}"
|
|
1824
|
+
project_root = find_project_root(original_change_dir)
|
|
1825
|
+
|
|
1826
|
+
payload: dict[str, Any] = {
|
|
1827
|
+
"ok": False,
|
|
1828
|
+
"action": "finalize",
|
|
1829
|
+
"change_dir": str(original_change_dir),
|
|
1830
|
+
"archive_dir": str(archive_dir),
|
|
1831
|
+
"change_name": change_name,
|
|
1832
|
+
"warnings": warnings,
|
|
1833
|
+
"steps": {},
|
|
1834
|
+
}
|
|
1835
|
+
|
|
1836
|
+
if not original_change_dir.is_dir():
|
|
1837
|
+
payload["error"] = f"change dir not found: {original_change_dir}"
|
|
1838
|
+
return 1, payload
|
|
1839
|
+
|
|
1840
|
+
if archive_dir.exists():
|
|
1841
|
+
payload["error"] = f"archive target already exists: {archive_dir}"
|
|
1842
|
+
return 1, payload
|
|
1843
|
+
|
|
1844
|
+
work_dir = original_change_dir
|
|
1845
|
+
|
|
1846
|
+
def _safe_append(**kwargs: Any) -> None:
|
|
1847
|
+
nonlocal work_dir
|
|
1848
|
+
try:
|
|
1849
|
+
append_event(work_dir, **kwargs)
|
|
1850
|
+
except OSError as exc:
|
|
1851
|
+
warnings.append(f"event append failed: {exc}")
|
|
1852
|
+
|
|
1853
|
+
# Step 9 starts here: phase.start
|
|
1854
|
+
_safe_append(phase="archive", type_="phase.start", note="finalize start")
|
|
1855
|
+
|
|
1856
|
+
# --- 1. before-manifest ---
|
|
1857
|
+
before_path = work_dir / "evidence" / "archive-manifest-before.json"
|
|
1858
|
+
try:
|
|
1859
|
+
before_manifest = generate_manifest(work_dir, before_path)
|
|
1860
|
+
payload["steps"]["before_manifest"] = {
|
|
1861
|
+
"ok": True,
|
|
1862
|
+
"path": str(before_path),
|
|
1863
|
+
"fileCount": before_manifest.get("fileCount"),
|
|
1864
|
+
}
|
|
1865
|
+
_safe_append(
|
|
1866
|
+
phase="archive",
|
|
1867
|
+
type_="artifact",
|
|
1868
|
+
path=str(before_path.relative_to(work_dir)).replace("\\", "/"),
|
|
1869
|
+
kind="manifest-before",
|
|
1870
|
+
)
|
|
1871
|
+
_safe_append(
|
|
1872
|
+
phase="archive",
|
|
1873
|
+
type_="command",
|
|
1874
|
+
command="generate_manifest(before)",
|
|
1875
|
+
exit_code=0,
|
|
1876
|
+
note="before-manifest",
|
|
1877
|
+
)
|
|
1878
|
+
except OSError as exc:
|
|
1879
|
+
payload["error"] = f"before-manifest failed: {exc}"
|
|
1880
|
+
_safe_append(
|
|
1881
|
+
phase="archive",
|
|
1882
|
+
type_="issue",
|
|
1883
|
+
code="before-manifest-failed",
|
|
1884
|
+
severity="error",
|
|
1885
|
+
message=str(exc),
|
|
1886
|
+
)
|
|
1887
|
+
return 1, payload
|
|
1888
|
+
|
|
1889
|
+
# --- 2. move ---
|
|
1890
|
+
try:
|
|
1891
|
+
archive_root.mkdir(parents=True, exist_ok=True)
|
|
1892
|
+
shutil.move(str(original_change_dir), str(archive_dir))
|
|
1893
|
+
work_dir = archive_dir
|
|
1894
|
+
payload["steps"]["move"] = {"ok": True, "to": str(archive_dir)}
|
|
1895
|
+
_safe_append(
|
|
1896
|
+
phase="archive",
|
|
1897
|
+
type_="command",
|
|
1898
|
+
command=f"move → {archive_dir.name}",
|
|
1899
|
+
exit_code=0,
|
|
1900
|
+
note="moved change dir to archive",
|
|
1901
|
+
)
|
|
1902
|
+
except OSError as exc:
|
|
1903
|
+
# Move failed: original intact, stop immediately
|
|
1904
|
+
payload["error"] = f"move failed: {exc}"
|
|
1905
|
+
payload["steps"]["move"] = {"ok": False, "error": str(exc)}
|
|
1906
|
+
payload["original_preserved"] = original_change_dir.is_dir()
|
|
1907
|
+
try:
|
|
1908
|
+
append_event(
|
|
1909
|
+
original_change_dir,
|
|
1910
|
+
phase="archive",
|
|
1911
|
+
type_="issue",
|
|
1912
|
+
code="move-failed",
|
|
1913
|
+
severity="error",
|
|
1914
|
+
message=str(exc),
|
|
1915
|
+
)
|
|
1916
|
+
except OSError:
|
|
1917
|
+
pass
|
|
1918
|
+
return 1, payload
|
|
1919
|
+
|
|
1920
|
+
# --- 3. collect ---
|
|
1921
|
+
try:
|
|
1922
|
+
summary = collect_summary_data(
|
|
1923
|
+
work_dir,
|
|
1924
|
+
before_manifest=before_manifest,
|
|
1925
|
+
write=True,
|
|
1926
|
+
for_replay=False,
|
|
1927
|
+
)
|
|
1928
|
+
summary_path = work_dir / "reports" / "final" / "summary-data.json"
|
|
1929
|
+
payload["steps"]["collect"] = {"ok": True, "path": str(summary_path)}
|
|
1930
|
+
_safe_append(
|
|
1931
|
+
phase="archive",
|
|
1932
|
+
type_="artifact",
|
|
1933
|
+
path="reports/final/summary-data.json",
|
|
1934
|
+
kind="summary-data",
|
|
1935
|
+
)
|
|
1936
|
+
except Exception as exc: # noqa: BLE001 — surface collect failures
|
|
1937
|
+
payload["error"] = f"collect failed: {exc}"
|
|
1938
|
+
payload["steps"]["collect"] = {"ok": False, "error": str(exc)}
|
|
1939
|
+
_restore_on_failure(archive_dir, original_change_dir, payload, warnings)
|
|
1940
|
+
return 1, payload
|
|
1941
|
+
|
|
1942
|
+
# --- 4. render (Node, else Python fallback) ---
|
|
1943
|
+
render_result = render_final_summary(work_dir, summary_path)
|
|
1944
|
+
payload["steps"]["render"] = render_result
|
|
1945
|
+
if not render_result.get("ok"):
|
|
1946
|
+
# Node + fallback both failed: restore + exit non-0 (§4.1 rule 4)。
|
|
1947
|
+
# 永不关闭一个没有 final-summary 的归档。
|
|
1948
|
+
msg = str(render_result.get("fallbackReason") or "render failed")
|
|
1949
|
+
payload["error"] = f"final-summary render failed: {msg}"
|
|
1950
|
+
_safe_append(
|
|
1951
|
+
phase="archive",
|
|
1952
|
+
type_="issue",
|
|
1953
|
+
code="render-failed",
|
|
1954
|
+
severity="error",
|
|
1955
|
+
message=msg,
|
|
1956
|
+
)
|
|
1957
|
+
_restore_on_failure(archive_dir, original_change_dir, payload, warnings)
|
|
1958
|
+
_restore_target = original_change_dir if original_change_dir.is_dir() else work_dir
|
|
1959
|
+
try:
|
|
1960
|
+
append_event(
|
|
1961
|
+
_restore_target,
|
|
1962
|
+
phase="archive",
|
|
1963
|
+
type_="phase.end",
|
|
1964
|
+
note="finalize aborted; render failed; original preserved",
|
|
1965
|
+
)
|
|
1966
|
+
except OSError:
|
|
1967
|
+
pass
|
|
1968
|
+
payload["warnings"] = warnings
|
|
1969
|
+
payload["ok"] = False
|
|
1970
|
+
return 1, payload
|
|
1971
|
+
renderer = render_result.get("renderer")
|
|
1972
|
+
if renderer == "python-fallback" and render_result.get("fallbackReason"):
|
|
1973
|
+
warnings.append(
|
|
1974
|
+
f"node render unavailable; used python-fallback: "
|
|
1975
|
+
f"{render_result.get('fallbackReason')}"
|
|
1976
|
+
)
|
|
1977
|
+
_safe_append(
|
|
1978
|
+
phase="archive",
|
|
1979
|
+
type_="artifact",
|
|
1980
|
+
path="reports/final/final-summary.html",
|
|
1981
|
+
kind="final-report",
|
|
1982
|
+
)
|
|
1983
|
+
_safe_append(
|
|
1984
|
+
phase="archive",
|
|
1985
|
+
type_="command",
|
|
1986
|
+
command=f"render-final-summary ({renderer})",
|
|
1987
|
+
exit_code=0,
|
|
1988
|
+
note=f"final-summary rendered by {renderer}",
|
|
1989
|
+
)
|
|
1990
|
+
|
|
1991
|
+
html_path = work_dir / "reports" / "final" / "final-summary.html"
|
|
1992
|
+
|
|
1993
|
+
# --- 5. validate (same process, no re-collect) ---
|
|
1994
|
+
# Refresh summary from disk (collect already wrote it); do not re-collect.
|
|
1995
|
+
try:
|
|
1996
|
+
summary = read_json(summary_path)
|
|
1997
|
+
except (OSError, json.JSONDecodeError):
|
|
1998
|
+
pass
|
|
1999
|
+
validate_result = validate_summary(
|
|
2000
|
+
summary,
|
|
2001
|
+
html_path if html_path.is_file() else None,
|
|
2002
|
+
)
|
|
2003
|
+
payload["steps"]["validate"] = validate_result
|
|
2004
|
+
summary.setdefault("reportPipeline", {})["validationIssues"] = validate_result.get(
|
|
2005
|
+
"issues"
|
|
2006
|
+
) or []
|
|
2007
|
+
try:
|
|
2008
|
+
write_json(summary_path, summary)
|
|
2009
|
+
except OSError as exc:
|
|
2010
|
+
warnings.append(f"could not write validationIssues: {exc}")
|
|
2011
|
+
|
|
2012
|
+
for issue in validate_result.get("issues") or []:
|
|
2013
|
+
_safe_append(
|
|
2014
|
+
phase="archive",
|
|
2015
|
+
type_="issue",
|
|
2016
|
+
code=str(issue.get("code") or "validate"),
|
|
2017
|
+
severity=str(issue.get("severity") or "warning"),
|
|
2018
|
+
message=str(issue.get("message") or ""),
|
|
2019
|
+
)
|
|
2020
|
+
|
|
2021
|
+
# --- 6. after-manifest + compare ---
|
|
2022
|
+
after_path = work_dir / "evidence" / "archive-manifest-after.json"
|
|
2023
|
+
try:
|
|
2024
|
+
after_manifest = generate_manifest(work_dir, after_path)
|
|
2025
|
+
# Re-read before from archive (moved with the tree)
|
|
2026
|
+
before_in_archive = work_dir / "evidence" / "archive-manifest-before.json"
|
|
2027
|
+
if before_in_archive.is_file():
|
|
2028
|
+
before_manifest = read_json(before_in_archive)
|
|
2029
|
+
compare_result = compare_manifests(before_manifest, after_manifest)
|
|
2030
|
+
payload["steps"]["after_manifest"] = {
|
|
2031
|
+
"ok": True,
|
|
2032
|
+
"path": str(after_path),
|
|
2033
|
+
"compare": compare_result,
|
|
2034
|
+
}
|
|
2035
|
+
_safe_append(
|
|
2036
|
+
phase="archive",
|
|
2037
|
+
type_="artifact",
|
|
2038
|
+
path="evidence/archive-manifest-after.json",
|
|
2039
|
+
kind="manifest-after",
|
|
2040
|
+
)
|
|
2041
|
+
except (OSError, json.JSONDecodeError, ValueError) as exc:
|
|
2042
|
+
payload["error"] = f"after-manifest failed: {exc}"
|
|
2043
|
+
payload["steps"]["after_manifest"] = {"ok": False, "error": str(exc)}
|
|
2044
|
+
_restore_on_failure(archive_dir, original_change_dir, payload, warnings)
|
|
2045
|
+
return 1, payload
|
|
2046
|
+
|
|
2047
|
+
# Update archiveManifest in summary-data with compare stats
|
|
2048
|
+
summary["archiveManifest"] = {
|
|
2049
|
+
"movedFiles": compare_result.get("movedFiles", 0),
|
|
2050
|
+
"generatedFiles": compare_result.get("generatedFiles", 0),
|
|
2051
|
+
"totalArchiveFiles": compare_result.get("totalArchiveFiles", 0),
|
|
2052
|
+
"checksumStatus": compare_result.get("checksumStatus", "FAIL"),
|
|
2053
|
+
}
|
|
2054
|
+
try:
|
|
2055
|
+
write_json(summary_path, summary)
|
|
2056
|
+
except OSError as exc:
|
|
2057
|
+
warnings.append(f"could not update archiveManifest: {exc}")
|
|
2058
|
+
|
|
2059
|
+
# The first render is needed so the after-manifest includes a final report,
|
|
2060
|
+
# but it predates the final manifest statistics. Render and validate once
|
|
2061
|
+
# more from the now-final summary so HTML never contradicts summary-data.
|
|
2062
|
+
render_result = render_final_summary(work_dir, summary_path)
|
|
2063
|
+
if not render_result.get("ok"):
|
|
2064
|
+
payload["error"] = f"final summary re-render failed: {render_result.get('error')}"
|
|
2065
|
+
_restore_on_failure(archive_dir, original_change_dir, payload, warnings)
|
|
2066
|
+
return 1, payload
|
|
2067
|
+
validate_result = validate_summary(summary, html_path if html_path.is_file() else None)
|
|
2068
|
+
payload["steps"]["validate"] = validate_result
|
|
2069
|
+
summary.setdefault("reportPipeline", {})["validationIssues"] = validate_result.get("issues") or []
|
|
2070
|
+
write_json(summary_path, summary)
|
|
2071
|
+
|
|
2072
|
+
# --- 7. delete original only if validate+manifest OK ---
|
|
2073
|
+
# After move, "original" is gone; on failure we restore. On success, ensure
|
|
2074
|
+
# the changes path does not linger.
|
|
2075
|
+
validate_ok = bool(validate_result.get("ok"))
|
|
2076
|
+
manifest_ok = bool(compare_result.get("ok"))
|
|
2077
|
+
can_close = validate_ok and manifest_ok
|
|
2078
|
+
|
|
2079
|
+
if not can_close:
|
|
2080
|
+
issues_out = list(validate_result.get("issues") or [])
|
|
2081
|
+
if not manifest_ok:
|
|
2082
|
+
issues_out.append(
|
|
2083
|
+
{
|
|
2084
|
+
"code": "manifest-mismatch",
|
|
2085
|
+
"severity": "error",
|
|
2086
|
+
"message": (
|
|
2087
|
+
f"missing={compare_result.get('missing')} "
|
|
2088
|
+
f"mismatched={compare_result.get('mismatched')}"
|
|
2089
|
+
),
|
|
2090
|
+
}
|
|
2091
|
+
)
|
|
2092
|
+
payload["issues"] = issues_out
|
|
2093
|
+
payload["error"] = "validate or manifest check failed; original change dir restored"
|
|
2094
|
+
payload["steps"]["delete_original"] = {"ok": False, "deleted": False}
|
|
2095
|
+
_restore_on_failure(archive_dir, original_change_dir, payload, warnings)
|
|
2096
|
+
_safe_append_restored = original_change_dir if original_change_dir.is_dir() else None
|
|
2097
|
+
if _safe_append_restored:
|
|
2098
|
+
try:
|
|
2099
|
+
append_event(
|
|
2100
|
+
_safe_append_restored,
|
|
2101
|
+
phase="archive",
|
|
2102
|
+
type_="phase.end",
|
|
2103
|
+
note="finalize aborted; original preserved",
|
|
2104
|
+
)
|
|
2105
|
+
except OSError:
|
|
2106
|
+
pass
|
|
2107
|
+
payload["warnings"] = warnings
|
|
2108
|
+
payload["ok"] = False
|
|
2109
|
+
return 1, payload
|
|
2110
|
+
|
|
2111
|
+
# Success path: original already relocated; confirm absence
|
|
2112
|
+
if original_change_dir.exists():
|
|
2113
|
+
try:
|
|
2114
|
+
shutil.rmtree(original_change_dir)
|
|
2115
|
+
payload["steps"]["delete_original"] = {"ok": True, "deleted": True}
|
|
2116
|
+
except OSError as exc:
|
|
2117
|
+
warnings.append(f"could not remove leftover change dir: {exc}")
|
|
2118
|
+
payload["steps"]["delete_original"] = {"ok": False, "error": str(exc)}
|
|
2119
|
+
else:
|
|
2120
|
+
payload["steps"]["delete_original"] = {
|
|
2121
|
+
"ok": True,
|
|
2122
|
+
"deleted": True,
|
|
2123
|
+
"note": "removed by move",
|
|
2124
|
+
}
|
|
2125
|
+
|
|
2126
|
+
_safe_append(
|
|
2127
|
+
phase="archive",
|
|
2128
|
+
type_="verification",
|
|
2129
|
+
name="archive-closure",
|
|
2130
|
+
status="ok",
|
|
2131
|
+
note="manifest+validate passed; original removed",
|
|
2132
|
+
)
|
|
2133
|
+
|
|
2134
|
+
# --- 8. maintenance outbox + service (§8.2: close no longer runs the four
|
|
2135
|
+
# knowledge subprocesses; it enqueues a pending outbox item and returns) ---
|
|
2136
|
+
if skip_ingest:
|
|
2137
|
+
payload["steps"]["knowledge"] = {"skipped": True, "reason": "--skip-ingest"}
|
|
2138
|
+
payload["knowledgeMaintenance"] = "SKIPPED"
|
|
2139
|
+
else:
|
|
2140
|
+
try:
|
|
2141
|
+
enqueue = enqueue_maintenance_outbox(project_root, work_dir)
|
|
2142
|
+
payload["steps"]["knowledge"] = enqueue
|
|
2143
|
+
payload["knowledgeMaintenance"] = "QUEUED"
|
|
2144
|
+
except OSError as exc:
|
|
2145
|
+
warnings.append(f"maintenance outbox enqueue failed: {exc}")
|
|
2146
|
+
payload["steps"]["knowledge"] = {"queued": False, "error": str(exc)}
|
|
2147
|
+
payload["knowledgeMaintenance"] = "NOT_QUEUED"
|
|
2148
|
+
|
|
2149
|
+
service_result = run_service_stop(work_dir)
|
|
2150
|
+
payload["steps"]["service_stop"] = service_result
|
|
2151
|
+
if service_result.get("warning"):
|
|
2152
|
+
warnings.append(str(service_result["warning"]))
|
|
2153
|
+
|
|
2154
|
+
_safe_append(phase="archive", type_="phase.end", note="finalize complete")
|
|
2155
|
+
|
|
2156
|
+
payload["ok"] = True
|
|
2157
|
+
payload["warnings"] = warnings
|
|
2158
|
+
payload["summary_data"] = str(summary_path)
|
|
2159
|
+
payload["final_summary"] = str(html_path) if html_path.is_file() else None
|
|
2160
|
+
return 0, payload
|
|
2161
|
+
|
|
2162
|
+
|
|
2163
|
+
def _restore_on_failure(
|
|
2164
|
+
archive_dir: Path,
|
|
2165
|
+
original_change_dir: Path,
|
|
2166
|
+
payload: dict[str, Any],
|
|
2167
|
+
warnings: list[str],
|
|
2168
|
+
) -> None:
|
|
2169
|
+
"""Move archive back to changes path so validate errors never lose the original."""
|
|
2170
|
+
if original_change_dir.exists():
|
|
2171
|
+
# Already present (e.g. move never happened)
|
|
2172
|
+
payload["original_preserved"] = True
|
|
2173
|
+
# Clean partial archive if present and distinct
|
|
2174
|
+
if archive_dir.exists() and archive_dir.resolve() != original_change_dir.resolve():
|
|
2175
|
+
warnings.append(
|
|
2176
|
+
f"partial archive left at {archive_dir} (original also present)"
|
|
2177
|
+
)
|
|
2178
|
+
return
|
|
2179
|
+
if not archive_dir.exists():
|
|
2180
|
+
payload["original_preserved"] = False
|
|
2181
|
+
warnings.append("restore failed: neither archive nor original exists")
|
|
2182
|
+
return
|
|
2183
|
+
try:
|
|
2184
|
+
original_change_dir.parent.mkdir(parents=True, exist_ok=True)
|
|
2185
|
+
shutil.move(str(archive_dir), str(original_change_dir))
|
|
2186
|
+
payload["original_preserved"] = True
|
|
2187
|
+
payload["restored_to"] = str(original_change_dir)
|
|
2188
|
+
except OSError as exc:
|
|
2189
|
+
payload["original_preserved"] = False
|
|
2190
|
+
warnings.append(f"restore move failed: {exc}; data at {archive_dir}")
|
|
2191
|
+
|
|
2192
|
+
|
|
2193
|
+
# ---------------------------------------------------------------------------
|
|
2194
|
+
# replay
|
|
2195
|
+
# ---------------------------------------------------------------------------
|
|
2196
|
+
|
|
2197
|
+
|
|
2198
|
+
def cmd_replay(
|
|
2199
|
+
archive_dir: Path,
|
|
2200
|
+
*,
|
|
2201
|
+
out_path: Path | None = None,
|
|
2202
|
+
) -> tuple[int, dict[str, Any]]:
|
|
2203
|
+
"""Read-only collect + validate. Never mutates archive contents."""
|
|
2204
|
+
archive_dir = archive_dir.resolve()
|
|
2205
|
+
if not archive_dir.is_dir():
|
|
2206
|
+
return 1, {"ok": False, "error": f"archive dir not found: {archive_dir}"}
|
|
2207
|
+
|
|
2208
|
+
# Collect without writing into the archive
|
|
2209
|
+
summary = collect_summary_data(archive_dir, write=False, for_replay=True)
|
|
2210
|
+
|
|
2211
|
+
html_path = archive_dir / "reports" / "final" / "final-summary.html"
|
|
2212
|
+
render_skipped = not html_path.is_file()
|
|
2213
|
+
validate_result = validate_summary(
|
|
2214
|
+
summary,
|
|
2215
|
+
html_path if html_path.is_file() else None,
|
|
2216
|
+
render_skipped=render_skipped,
|
|
2217
|
+
)
|
|
2218
|
+
summary.setdefault("reportPipeline", {})["validationIssues"] = (
|
|
2219
|
+
validate_result.get("issues") or []
|
|
2220
|
+
)
|
|
2221
|
+
|
|
2222
|
+
if out_path is not None:
|
|
2223
|
+
# Allowed to write outside the archive
|
|
2224
|
+
out_resolved = out_path.resolve()
|
|
2225
|
+
try:
|
|
2226
|
+
out_resolved.relative_to(archive_dir.resolve())
|
|
2227
|
+
inside = True
|
|
2228
|
+
except ValueError:
|
|
2229
|
+
inside = False
|
|
2230
|
+
if inside:
|
|
2231
|
+
return 1, {
|
|
2232
|
+
"ok": False,
|
|
2233
|
+
"error": "replay refuses to write inside archive dir (read-only)",
|
|
2234
|
+
}
|
|
2235
|
+
write_json(out_resolved, summary)
|
|
2236
|
+
|
|
2237
|
+
payload = {
|
|
2238
|
+
"ok": validate_result.get("ok", False),
|
|
2239
|
+
"action": "replay",
|
|
2240
|
+
"archive_dir": str(archive_dir),
|
|
2241
|
+
"change_name": summary.get("changeName"),
|
|
2242
|
+
"summary_data": summary,
|
|
2243
|
+
"validate": validate_result,
|
|
2244
|
+
"sources": (summary.get("reportPipeline") or {}).get("sources") or [],
|
|
2245
|
+
}
|
|
2246
|
+
# Replay itself is successful as an operation even if validate finds issues;
|
|
2247
|
+
# exit non-zero only on hard failures. Soft: ok mirrors validate.
|
|
2248
|
+
return (0 if payload["ok"] else 1), payload
|
|
2249
|
+
|
|
2250
|
+
|
|
2251
|
+
# ---------------------------------------------------------------------------
|
|
2252
|
+
# CLI
|
|
2253
|
+
# ---------------------------------------------------------------------------
|
|
2254
|
+
|
|
2255
|
+
|
|
2256
|
+
def cmd_status_cli(args: argparse.Namespace) -> int:
|
|
2257
|
+
change_dir = resolve_path(args.change_dir)
|
|
2258
|
+
result = check_status(change_dir)
|
|
2259
|
+
emit_json(result)
|
|
2260
|
+
# Checks completed → exit 0; archivable flag conveys the gate result.
|
|
2261
|
+
return 0 if result.get("ok") else 1
|
|
2262
|
+
|
|
2263
|
+
|
|
2264
|
+
def cmd_finalize_cli(args: argparse.Namespace) -> int:
|
|
2265
|
+
change_dir = resolve_path(args.change_dir)
|
|
2266
|
+
archive_root = resolve_path(args.archive_root)
|
|
2267
|
+
code, payload = cmd_finalize(
|
|
2268
|
+
change_dir,
|
|
2269
|
+
archive_root,
|
|
2270
|
+
skip_ingest=bool(args.skip_ingest),
|
|
2271
|
+
)
|
|
2272
|
+
emit_json(payload)
|
|
2273
|
+
return code
|
|
2274
|
+
|
|
2275
|
+
|
|
2276
|
+
def cmd_replay_cli(args: argparse.Namespace) -> int:
|
|
2277
|
+
archive_dir = resolve_path(args.archive_dir)
|
|
2278
|
+
out_path = resolve_path(args.out) if getattr(args, "out", None) else None
|
|
2279
|
+
code, payload = cmd_replay(archive_dir, out_path=out_path)
|
|
2280
|
+
emit_json(payload)
|
|
2281
|
+
return code
|
|
2282
|
+
|
|
2283
|
+
|
|
2284
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
2285
|
+
parser = argparse.ArgumentParser(
|
|
2286
|
+
prog="harness_archive.py",
|
|
2287
|
+
description="Archive finalize / status / replay (D3)",
|
|
2288
|
+
)
|
|
2289
|
+
sub = parser.add_subparsers(dest="command_name", required=True)
|
|
2290
|
+
|
|
2291
|
+
p_status = sub.add_parser("status", help="pre-archive gate checks (read-only)")
|
|
2292
|
+
p_status.add_argument("--change-dir", required=True)
|
|
2293
|
+
p_status.add_argument("--json", action="store_true", default=True)
|
|
2294
|
+
p_status.set_defaults(func=cmd_status_cli)
|
|
2295
|
+
|
|
2296
|
+
p_fin = sub.add_parser("finalize", help="single-process archive finalize")
|
|
2297
|
+
p_fin.add_argument("--change-dir", required=True)
|
|
2298
|
+
p_fin.add_argument("--archive-root", required=True)
|
|
2299
|
+
p_fin.add_argument("--skip-ingest", action="store_true")
|
|
2300
|
+
p_fin.add_argument("--json", action="store_true", default=True)
|
|
2301
|
+
p_fin.set_defaults(func=cmd_finalize_cli)
|
|
2302
|
+
|
|
2303
|
+
p_rep = sub.add_parser("replay", help="read-only re-collect + validate")
|
|
2304
|
+
p_rep.add_argument("--archive-dir", required=True)
|
|
2305
|
+
p_rep.add_argument("--out", default=None, help="write summary-data JSON outside archive")
|
|
2306
|
+
p_rep.add_argument("--json", action="store_true", default=True)
|
|
2307
|
+
p_rep.set_defaults(func=cmd_replay_cli)
|
|
2308
|
+
|
|
2309
|
+
return parser
|
|
2310
|
+
|
|
2311
|
+
|
|
2312
|
+
def main(argv: list[str] | None = None) -> int:
|
|
2313
|
+
parser = build_parser()
|
|
2314
|
+
args = parser.parse_args(argv)
|
|
2315
|
+
return int(args.func(args))
|
|
2316
|
+
|
|
2317
|
+
|
|
2318
|
+
if __name__ == "__main__":
|
|
2319
|
+
raise SystemExit(main())
|