prizmkit 1.1.120 → 1.1.122
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/bundled/VERSION.json +3 -3
- package/bundled/dev-pipeline/prizmkit_runtime/cli.py +1 -0
- package/bundled/dev-pipeline/prizmkit_runtime/runner_classification.py +23 -25
- package/bundled/dev-pipeline/prizmkit_runtime/runner_models.py +8 -2
- package/bundled/dev-pipeline/prizmkit_runtime/runner_prompts.py +10 -1
- package/bundled/dev-pipeline/prizmkit_runtime/runners.py +43 -29
- package/bundled/dev-pipeline/scripts/continuation.py +51 -6
- package/bundled/dev-pipeline/scripts/update-bug-status.py +57 -14
- package/bundled/dev-pipeline/scripts/update-feature-status.py +54 -5
- package/bundled/dev-pipeline/scripts/update-refactor-status.py +57 -14
- package/bundled/dev-pipeline/templates/bootstrap-tier3.md +2 -2
- package/bundled/dev-pipeline/templates/bugfix-bootstrap-prompt.md +2 -1
- package/bundled/dev-pipeline/templates/refactor-bootstrap-prompt.md +2 -2
- package/bundled/dev-pipeline/templates/sections/bugfix-phase-review.md +3 -3
- package/bundled/dev-pipeline/templates/sections/phase-review-agent.md +3 -3
- package/bundled/dev-pipeline/templates/sections/phase-review-full.md +4 -4
- package/bundled/dev-pipeline/templates/sections/refactor-phase-review.md +3 -3
- package/bundled/dev-pipeline/tests/test_auto_skip.py +5 -5
- package/bundled/dev-pipeline/tests/test_generate_bootstrap_prompt.py +10 -3
- package/bundled/dev-pipeline/tests/test_generate_bugfix_prompt.py +9 -2
- package/bundled/dev-pipeline/tests/test_generate_refactor_prompt.py +9 -1
- package/bundled/dev-pipeline/tests/test_python_runner_parity.py +222 -18
- package/bundled/dev-pipeline/tests/test_unified_cli.py +24 -11
- package/bundled/skills/_metadata.json +4 -4
- package/bundled/skills/prizmkit-code-review/SKILL.md +49 -48
- package/bundled/skills/prizmkit-code-review/references/review-report-template.md +13 -8
- package/bundled/skills/prizmkit-code-review/references/reviewer-agent-prompt.md +46 -32
- package/bundled/skills/prizmkit-code-review/references/reviewer-workspace-protocol.md +90 -0
- package/bundled/skills/prizmkit-test/SKILL.md +160 -155
- package/bundled/skills/prizmkit-test/assets/authoritative-records.schema.json +420 -0
- package/bundled/skills/prizmkit-test/assets/behavior-risk-matrix.schema.json +116 -0
- package/bundled/skills/prizmkit-test/assets/evidence-manifest.schema.json +112 -0
- package/bundled/skills/prizmkit-test/assets/evidence-package-template.json +97 -0
- package/bundled/skills/prizmkit-test/references/boundary-coverage-protocol.md +76 -192
- package/bundled/skills/prizmkit-test/references/contract-mock-protocol.md +65 -0
- package/bundled/skills/prizmkit-test/references/evidence-protocol.md +201 -0
- package/bundled/skills/prizmkit-test/references/evidence-request-protocol.md +78 -0
- package/bundled/skills/prizmkit-test/references/examples.md +65 -108
- package/bundled/skills/prizmkit-test/references/service-boundary-test-catalog.md +10 -7
- package/bundled/skills/prizmkit-test/references/test-generation-steps.md +90 -82
- package/bundled/skills/prizmkit-test/references/test-report-template.md +77 -98
- package/bundled/skills/prizmkit-test/references/trusted-evidence-execution.md +97 -0
- package/bundled/skills/prizmkit-test/scripts/build_test_evidence.py +739 -0
- package/bundled/skills/prizmkit-test/scripts/validate_test_evidence.py +1442 -0
- package/package.json +1 -1
- package/bundled/skills/prizmkit-test/scripts/validate_boundary_report.py +0 -347
|
@@ -0,0 +1,739 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Build runner-generated PrizmKit test evidence from schema-shaped requests.
|
|
3
|
+
|
|
4
|
+
Usage:
|
|
5
|
+
python3 build_test_evidence.py --project-root ROOT --evidence-dir DIR inventory --request REQUEST
|
|
6
|
+
python3 build_test_evidence.py --project-root ROOT --evidence-dir DIR execute --request REQUEST
|
|
7
|
+
python3 build_test_evidence.py --project-root ROOT --evidence-dir DIR execute --replay-receipt RECEIPT
|
|
8
|
+
python3 build_test_evidence.py --project-root ROOT --evidence-dir DIR differential --request REQUEST
|
|
9
|
+
python3 build_test_evidence.py --project-root ROOT --evidence-dir DIR resume --manifest MANIFEST --inventory INVENTORY
|
|
10
|
+
|
|
11
|
+
Only locator arguments and evidence mechanics are fixed. Test commands, working directories,
|
|
12
|
+
timeouts, attempts, concurrency, tool probes, test layers, and framework-specific options come
|
|
13
|
+
from model-authored requests after project inspection.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import argparse
|
|
19
|
+
import datetime as dt
|
|
20
|
+
import hashlib
|
|
21
|
+
import json
|
|
22
|
+
import os
|
|
23
|
+
import shutil
|
|
24
|
+
import signal
|
|
25
|
+
import subprocess
|
|
26
|
+
import sys
|
|
27
|
+
import tempfile
|
|
28
|
+
import uuid
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
from typing import Any
|
|
31
|
+
|
|
32
|
+
STAGES = [
|
|
33
|
+
"CHANGE_CLASSIFY", "SCOPE_DISCOVER", "CONTRACT_MODEL", "TEST_PLAN",
|
|
34
|
+
"INFRA_READY", "TEST_BUILD", "EXECUTE_PROVE", "EVIDENCE_PACKAGE",
|
|
35
|
+
"EVIDENCE_VALIDATE",
|
|
36
|
+
]
|
|
37
|
+
CATEGORIES = ("source", "tests", "contracts", "lockfiles")
|
|
38
|
+
BLOCKED_EXTERNAL_CLASSES = {"production", "unknown"}
|
|
39
|
+
SNAPSHOT_VOLATILE_ROOTS = (
|
|
40
|
+
".prizmkit/state",
|
|
41
|
+
".prizmkit/test/evidence",
|
|
42
|
+
".claude/worktrees",
|
|
43
|
+
)
|
|
44
|
+
SNAPSHOT_VOLATILE_NAMES = {".git", "__pycache__"}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class RequestError(Exception):
|
|
48
|
+
"""Raised when a request would produce unsafe or ambiguous evidence."""
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def canonical_bytes(value: Any) -> bytes:
|
|
52
|
+
return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def canonical_sha256(value: Any) -> str:
|
|
56
|
+
return hashlib.sha256(canonical_bytes(value)).hexdigest()
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def file_sha256(path: Path) -> str:
|
|
60
|
+
digest = hashlib.sha256()
|
|
61
|
+
with path.open("rb") as handle:
|
|
62
|
+
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
|
63
|
+
digest.update(chunk)
|
|
64
|
+
return digest.hexdigest()
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def write_json_atomic(path: Path, value: Any) -> None:
|
|
68
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
69
|
+
temporary = path.with_name(f".{path.name}.{uuid.uuid4().hex}.tmp")
|
|
70
|
+
temporary.write_text(json.dumps(value, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
|
71
|
+
os.replace(temporary, path)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def load_json(path: Path) -> Any:
|
|
75
|
+
try:
|
|
76
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
77
|
+
except FileNotFoundError as exc:
|
|
78
|
+
raise RequestError(f"file does not exist: {path}") from exc
|
|
79
|
+
except json.JSONDecodeError as exc:
|
|
80
|
+
raise RequestError(f"invalid JSON: {path}: {exc}") from exc
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def confined(root: Path, value: str, *, must_exist: bool = False, directory: bool = False) -> Path:
|
|
84
|
+
if not isinstance(value, str) or not value:
|
|
85
|
+
raise RequestError("path must be a non-empty string")
|
|
86
|
+
candidate = (root / value).resolve() if not Path(value).is_absolute() else Path(value).resolve()
|
|
87
|
+
try:
|
|
88
|
+
candidate.relative_to(root.resolve())
|
|
89
|
+
except ValueError as exc:
|
|
90
|
+
raise RequestError(f"path escapes root: {value}") from exc
|
|
91
|
+
if must_exist and not candidate.exists():
|
|
92
|
+
raise RequestError(f"path does not exist: {value}")
|
|
93
|
+
if directory and candidate.exists() and not candidate.is_dir():
|
|
94
|
+
raise RequestError(f"path is not a directory: {value}")
|
|
95
|
+
return candidate
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def evidence_relative(evidence_dir: Path, path: Path) -> str:
|
|
99
|
+
try:
|
|
100
|
+
return path.resolve().relative_to(evidence_dir.resolve()).as_posix()
|
|
101
|
+
except ValueError as exc:
|
|
102
|
+
raise RequestError(f"evidence output escapes evidence directory: {path}") from exc
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def request_file(evidence_dir: Path, value: str) -> Path:
|
|
106
|
+
return confined(evidence_dir, value, must_exist=True)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def receipt_file(evidence_dir: Path, value: str) -> Path:
|
|
110
|
+
direct = confined(evidence_dir, value)
|
|
111
|
+
if direct.is_file():
|
|
112
|
+
return direct
|
|
113
|
+
return confined(evidence_dir, f"receipts/{value}", must_exist=True)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def ensure_object(value: Any, label: str) -> dict[str, Any]:
|
|
117
|
+
if not isinstance(value, dict):
|
|
118
|
+
raise RequestError(f"{label} must be an object")
|
|
119
|
+
return value
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def ensure_string_list(value: Any, label: str, *, allow_empty: bool = True) -> list[str]:
|
|
123
|
+
if not isinstance(value, list) or (not allow_empty and not value):
|
|
124
|
+
raise RequestError(f"{label} must be a{' non-empty' if not allow_empty else ''} string array")
|
|
125
|
+
if any(not isinstance(item, str) or not item for item in value):
|
|
126
|
+
raise RequestError(f"{label} contains a non-string or empty value")
|
|
127
|
+
return value
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def validate_external_targets(value: Any) -> list[dict[str, Any]]:
|
|
131
|
+
if not isinstance(value, list):
|
|
132
|
+
raise RequestError("external_targets must be an array")
|
|
133
|
+
for target in value:
|
|
134
|
+
if not isinstance(target, dict):
|
|
135
|
+
raise RequestError("external target must be an object")
|
|
136
|
+
required = {"name", "external", "classification", "endpoint_evidence", "allow_evidence", "deny_evidence"}
|
|
137
|
+
if set(target) != required:
|
|
138
|
+
raise RequestError(f"external target fields must be exactly {sorted(required)}")
|
|
139
|
+
if not isinstance(target["external"], bool):
|
|
140
|
+
raise RequestError("external target external flag must be boolean")
|
|
141
|
+
for key in ("endpoint_evidence", "allow_evidence", "deny_evidence"):
|
|
142
|
+
ensure_string_list(target[key], f"external target {key}", allow_empty=(key != "endpoint_evidence"))
|
|
143
|
+
if target["external"] and target["classification"] in BLOCKED_EXTERNAL_CLASSES:
|
|
144
|
+
raise RequestError(f"execution blocked for external target classified {target['classification']}: {target['name']}")
|
|
145
|
+
if target["external"] and target["classification"] not in {"isolated", "test", "staging"}:
|
|
146
|
+
raise RequestError(f"invalid external target classification: {target['classification']}")
|
|
147
|
+
if target["external"] and not target["allow_evidence"]:
|
|
148
|
+
raise RequestError(f"external target lacks explicit allow evidence: {target['name']}")
|
|
149
|
+
if target["external"] and target["deny_evidence"]:
|
|
150
|
+
raise RequestError(f"external target matched deny evidence: {target['name']}")
|
|
151
|
+
return value
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def validate_execution_request(request: Any) -> dict[str, Any]:
|
|
155
|
+
request = ensure_object(request, "execution request")
|
|
156
|
+
required = {
|
|
157
|
+
"request_version", "purpose", "command", "cwd", "environment",
|
|
158
|
+
"tool_version_commands", "layer", "test_ids", "external_targets",
|
|
159
|
+
}
|
|
160
|
+
optional = {"timeout_seconds", "attempt_policy", "concurrency"}
|
|
161
|
+
if not required <= set(request) or set(request) - required - optional:
|
|
162
|
+
raise RequestError(f"execution request fields must contain {sorted(required)} and only supported optional fields")
|
|
163
|
+
if request["request_version"] != "1.0":
|
|
164
|
+
raise RequestError("unsupported execution request version")
|
|
165
|
+
command = ensure_string_list(request["command"], "command", allow_empty=False)
|
|
166
|
+
if not command[0]:
|
|
167
|
+
raise RequestError("command executable is empty")
|
|
168
|
+
if not isinstance(request["cwd"], str):
|
|
169
|
+
raise RequestError("cwd must be a string")
|
|
170
|
+
environment = ensure_object(request["environment"], "environment")
|
|
171
|
+
if any(not isinstance(key, str) or not isinstance(value, str) for key, value in environment.items()):
|
|
172
|
+
raise RequestError("environment must contain string keys and complete string values")
|
|
173
|
+
probes = ensure_object(request["tool_version_commands"], "tool_version_commands")
|
|
174
|
+
for name, probe in probes.items():
|
|
175
|
+
if not isinstance(name, str):
|
|
176
|
+
raise RequestError("tool probe names must be strings")
|
|
177
|
+
ensure_string_list(probe, f"tool probe {name}", allow_empty=False)
|
|
178
|
+
ensure_string_list(request["test_ids"], "test_ids")
|
|
179
|
+
validate_external_targets(request["external_targets"])
|
|
180
|
+
if "timeout_seconds" in request and (isinstance(request["timeout_seconds"], bool) or not isinstance(request["timeout_seconds"], (int, float)) or request["timeout_seconds"] <= 0):
|
|
181
|
+
raise RequestError("timeout_seconds must be a positive number")
|
|
182
|
+
return request
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def is_volatile_snapshot_path(root: Path, candidate: Path, excluded: list[Path] | None = None) -> bool:
|
|
186
|
+
try:
|
|
187
|
+
relative = candidate.relative_to(root)
|
|
188
|
+
except ValueError:
|
|
189
|
+
return True
|
|
190
|
+
if any(part in SNAPSHOT_VOLATILE_NAMES for part in relative.parts):
|
|
191
|
+
return True
|
|
192
|
+
if any(relative == Path(prefix) or Path(prefix) in relative.parents for prefix in SNAPSHOT_VOLATILE_ROOTS):
|
|
193
|
+
return True
|
|
194
|
+
resolved = candidate.resolve()
|
|
195
|
+
return any(resolved == item.resolve() or item.resolve() in resolved.parents for item in (excluded or []))
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def tree_sha256(root: Path, excluded: list[Path] | None = None) -> str:
|
|
199
|
+
entries: list[dict[str, str]] = []
|
|
200
|
+
for candidate in sorted(root.rglob("*")):
|
|
201
|
+
if not candidate.is_file() or is_volatile_snapshot_path(root, candidate, excluded):
|
|
202
|
+
continue
|
|
203
|
+
entries.append({"path": candidate.relative_to(root).as_posix(), "sha256": file_sha256(candidate)})
|
|
204
|
+
return canonical_sha256(entries)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def collect_matches(project_root: Path, patterns: list[str], exclusions: set[str]) -> list[dict[str, str]]:
|
|
208
|
+
paths: set[Path] = set()
|
|
209
|
+
for pattern in patterns:
|
|
210
|
+
if Path(pattern).is_absolute() or ".." in Path(pattern).parts:
|
|
211
|
+
raise RequestError(f"inventory pattern is not project-relative: {pattern}")
|
|
212
|
+
for candidate in project_root.glob(pattern):
|
|
213
|
+
if candidate.is_file():
|
|
214
|
+
paths.add(candidate.resolve())
|
|
215
|
+
entries = []
|
|
216
|
+
for candidate in sorted(paths):
|
|
217
|
+
relative = candidate.relative_to(project_root).as_posix()
|
|
218
|
+
if relative not in exclusions:
|
|
219
|
+
entries.append({"path": relative, "sha256": file_sha256(candidate)})
|
|
220
|
+
return entries
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def enumerate_module_root_files(
|
|
224
|
+
project_root: Path,
|
|
225
|
+
evidence_dir: Path,
|
|
226
|
+
module_roots: list[str],
|
|
227
|
+
) -> dict[str, list[str]]:
|
|
228
|
+
evidence_resolved = evidence_dir.resolve()
|
|
229
|
+
result: dict[str, list[str]] = {}
|
|
230
|
+
for relative in module_roots:
|
|
231
|
+
root = confined(project_root, relative, must_exist=True)
|
|
232
|
+
candidates = [root] if root.is_file() else root.rglob("*")
|
|
233
|
+
files: list[str] = []
|
|
234
|
+
for candidate in candidates:
|
|
235
|
+
if not candidate.is_file() or ".git" in candidate.parts or "__pycache__" in candidate.parts:
|
|
236
|
+
continue
|
|
237
|
+
resolved = candidate.resolve()
|
|
238
|
+
if resolved == evidence_resolved or evidence_resolved in resolved.parents:
|
|
239
|
+
continue
|
|
240
|
+
files.append(resolved.relative_to(project_root).as_posix())
|
|
241
|
+
result[relative] = sorted(files)
|
|
242
|
+
return result
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def run_inventory(project_root: Path, evidence_dir: Path, request_path: Path, output_name: str) -> Path:
|
|
246
|
+
request = ensure_object(load_json(request_path), "inventory request")
|
|
247
|
+
required = {"request_version", "categories", "changed_files", "module_roots", "exclusions", "discovery_evidence", "plan_inputs"}
|
|
248
|
+
if set(request) != required or request["request_version"] != "1.0":
|
|
249
|
+
raise RequestError(f"inventory request fields must be exactly {sorted(required)} with version 1.0")
|
|
250
|
+
categories = ensure_object(request["categories"], "inventory categories")
|
|
251
|
+
if set(categories) != set(CATEGORIES):
|
|
252
|
+
raise RequestError(f"inventory categories must be exactly {list(CATEGORIES)}")
|
|
253
|
+
exclusions = request["exclusions"]
|
|
254
|
+
if not isinstance(exclusions, list):
|
|
255
|
+
raise RequestError("inventory exclusions must be an array")
|
|
256
|
+
exclusion_paths: set[str] = set()
|
|
257
|
+
for exclusion in exclusions:
|
|
258
|
+
if not isinstance(exclusion, dict) or set(exclusion) != {"path", "reason", "evidence"}:
|
|
259
|
+
raise RequestError("each inventory exclusion requires path, reason, and evidence")
|
|
260
|
+
confined(project_root, exclusion["path"])
|
|
261
|
+
if not isinstance(exclusion["reason"], str) or len(exclusion["reason"].strip()) < 8:
|
|
262
|
+
raise RequestError("inventory exclusion reason is too short")
|
|
263
|
+
if not isinstance(exclusion["evidence"], list) or not exclusion["evidence"]:
|
|
264
|
+
raise RequestError("inventory exclusion lacks evidence")
|
|
265
|
+
exclusion_paths.add(Path(exclusion["path"]).as_posix())
|
|
266
|
+
changed_files = ensure_string_list(request["changed_files"], "changed_files", allow_empty=False)
|
|
267
|
+
module_roots = ensure_string_list(request["module_roots"], "module_roots", allow_empty=False)
|
|
268
|
+
for relative in changed_files:
|
|
269
|
+
confined(project_root, relative, must_exist=relative not in exclusion_paths)
|
|
270
|
+
for relative in module_roots:
|
|
271
|
+
confined(project_root, relative, must_exist=True)
|
|
272
|
+
output_categories = {
|
|
273
|
+
name: collect_matches(project_root, ensure_string_list(categories[name], f"category {name}"), exclusion_paths)
|
|
274
|
+
for name in CATEGORIES
|
|
275
|
+
}
|
|
276
|
+
inventoried_paths = {
|
|
277
|
+
entry["path"]
|
|
278
|
+
for entries in output_categories.values()
|
|
279
|
+
for entry in entries
|
|
280
|
+
}
|
|
281
|
+
module_root_files = enumerate_module_root_files(project_root, evidence_dir, module_roots)
|
|
282
|
+
missing_from_inventory = sorted({
|
|
283
|
+
relative
|
|
284
|
+
for files in module_root_files.values()
|
|
285
|
+
for relative in files
|
|
286
|
+
if relative not in inventoried_paths and relative not in exclusion_paths
|
|
287
|
+
})
|
|
288
|
+
if missing_from_inventory:
|
|
289
|
+
raise RequestError(
|
|
290
|
+
"module root contains files that are neither inventoried nor excluded: "
|
|
291
|
+
+ ", ".join(missing_from_inventory[:20])
|
|
292
|
+
)
|
|
293
|
+
output = {
|
|
294
|
+
"inventory_request_path": evidence_relative(evidence_dir, request_path),
|
|
295
|
+
"inventory_request_sha256": file_sha256(request_path),
|
|
296
|
+
"categories": output_categories,
|
|
297
|
+
"changed_files": changed_files,
|
|
298
|
+
"module_roots": module_roots,
|
|
299
|
+
"module_root_files": module_root_files,
|
|
300
|
+
"exclusions": sorted(exclusion_paths),
|
|
301
|
+
"discovery_evidence": request["discovery_evidence"],
|
|
302
|
+
"plan_inputs": ensure_object(request["plan_inputs"], "plan_inputs"),
|
|
303
|
+
}
|
|
304
|
+
output_path = confined(evidence_dir, output_name)
|
|
305
|
+
write_json_atomic(output_path, output)
|
|
306
|
+
return output_path
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def run_process(argv: list[str], cwd: Path, environment: dict[str, str], timeout: float | None) -> tuple[int, bytes, bytes]:
|
|
310
|
+
creationflags = 0
|
|
311
|
+
process_kwargs: dict[str, Any] = {}
|
|
312
|
+
if os.name == "nt":
|
|
313
|
+
creationflags = getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0)
|
|
314
|
+
else:
|
|
315
|
+
process_kwargs["start_new_session"] = True
|
|
316
|
+
process = subprocess.Popen(
|
|
317
|
+
argv, cwd=cwd, env=environment, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
|
318
|
+
creationflags=creationflags, **process_kwargs,
|
|
319
|
+
)
|
|
320
|
+
try:
|
|
321
|
+
stdout, stderr = process.communicate(timeout=timeout)
|
|
322
|
+
return process.returncode, stdout, stderr
|
|
323
|
+
except subprocess.TimeoutExpired:
|
|
324
|
+
if os.name == "nt":
|
|
325
|
+
subprocess.run(
|
|
326
|
+
["taskkill", "/PID", str(process.pid), "/T", "/F"],
|
|
327
|
+
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False,
|
|
328
|
+
)
|
|
329
|
+
else:
|
|
330
|
+
try:
|
|
331
|
+
os.killpg(process.pid, signal.SIGTERM)
|
|
332
|
+
process.wait(timeout=1)
|
|
333
|
+
except (ProcessLookupError, subprocess.TimeoutExpired):
|
|
334
|
+
try:
|
|
335
|
+
os.killpg(process.pid, signal.SIGKILL)
|
|
336
|
+
except ProcessLookupError:
|
|
337
|
+
pass
|
|
338
|
+
stdout, stderr = process.communicate()
|
|
339
|
+
return 124, stdout, stderr + f"\nPrizmKit runner timeout after {timeout} seconds\n".encode()
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def run_probe(argv: list[str], cwd: Path, environment: dict[str, str], timeout: float | None) -> dict[str, Any]:
|
|
343
|
+
try:
|
|
344
|
+
exit_code, stdout, stderr = run_process(argv, cwd, environment, timeout)
|
|
345
|
+
return {
|
|
346
|
+
"command": argv, "exit_code": exit_code,
|
|
347
|
+
"stdout": stdout.decode(errors="replace"), "stderr": stderr.decode(errors="replace"),
|
|
348
|
+
}
|
|
349
|
+
except OSError as exc:
|
|
350
|
+
return {"command": argv, "error": str(exc)}
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def append_receipt(evidence_dir: Path, receipt: dict[str, Any]) -> None:
|
|
354
|
+
receipt_path = evidence_dir / "receipts" / f"{receipt['execution_id']}.json"
|
|
355
|
+
if receipt_path.exists():
|
|
356
|
+
raise RequestError(f"receipt already exists: {receipt_path.name}")
|
|
357
|
+
write_json_atomic(receipt_path, receipt)
|
|
358
|
+
path = evidence_dir / "executions.json"
|
|
359
|
+
existing: Any = load_json(path) if path.exists() else []
|
|
360
|
+
if not isinstance(existing, list):
|
|
361
|
+
raise RequestError("executions.json must remain an array")
|
|
362
|
+
previous = canonical_sha256(existing[-1]) if existing else None
|
|
363
|
+
if receipt["previous_receipt_sha256"] != previous:
|
|
364
|
+
raise RequestError("receipt chain mismatch")
|
|
365
|
+
existing.append(receipt)
|
|
366
|
+
write_json_atomic(path, existing)
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def ensure_runner_snapshot(evidence_dir: Path) -> tuple[str, str]:
|
|
370
|
+
source = Path(__file__).resolve()
|
|
371
|
+
digest = file_sha256(source)
|
|
372
|
+
relative = f"runner/build_test_evidence-{digest}.py"
|
|
373
|
+
destination = evidence_dir / relative
|
|
374
|
+
if destination.exists():
|
|
375
|
+
if file_sha256(destination) != digest:
|
|
376
|
+
raise RequestError("archived runner snapshot hash mismatch")
|
|
377
|
+
else:
|
|
378
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
379
|
+
shutil.copy2(source, destination)
|
|
380
|
+
return relative, digest
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def execute_request(
|
|
384
|
+
project_root: Path,
|
|
385
|
+
evidence_dir: Path,
|
|
386
|
+
request_path: Path,
|
|
387
|
+
*,
|
|
388
|
+
execution_root: Path | None = None,
|
|
389
|
+
replay_of: str | None = None,
|
|
390
|
+
isolation: dict[str, Any] | None = None,
|
|
391
|
+
selected_execution: bool = True,
|
|
392
|
+
) -> dict[str, Any]:
|
|
393
|
+
request = validate_execution_request(load_json(request_path))
|
|
394
|
+
root = execution_root or project_root
|
|
395
|
+
cwd = confined(root, request["cwd"] or ".", must_exist=True, directory=True)
|
|
396
|
+
complete_environment = {"PATH": os.environ.get("PATH", os.defpath)}
|
|
397
|
+
if os.name == "nt" and os.environ.get("SYSTEMROOT"):
|
|
398
|
+
complete_environment["SYSTEMROOT"] = os.environ["SYSTEMROOT"]
|
|
399
|
+
complete_environment.update(request["environment"])
|
|
400
|
+
timeout = request.get("timeout_seconds")
|
|
401
|
+
tools = {
|
|
402
|
+
name: run_probe(argv, cwd, complete_environment, timeout)
|
|
403
|
+
for name, argv in request["tool_version_commands"].items()
|
|
404
|
+
}
|
|
405
|
+
probes_reliable = all(
|
|
406
|
+
isinstance(result, dict)
|
|
407
|
+
and result.get("exit_code") == 0
|
|
408
|
+
and "error" not in result
|
|
409
|
+
for result in tools.values()
|
|
410
|
+
)
|
|
411
|
+
execution_id = str(uuid.uuid4())
|
|
412
|
+
runner_path, runner_hash = ensure_runner_snapshot(evidence_dir)
|
|
413
|
+
raw_dir = evidence_dir / "raw"
|
|
414
|
+
raw_dir.mkdir(parents=True, exist_ok=True)
|
|
415
|
+
stdout_path = raw_dir / f"{execution_id}.stdout"
|
|
416
|
+
stderr_path = raw_dir / f"{execution_id}.stderr"
|
|
417
|
+
started = dt.datetime.now(dt.timezone.utc).isoformat()
|
|
418
|
+
timed_out = False
|
|
419
|
+
execution_error = False
|
|
420
|
+
try:
|
|
421
|
+
exit_code, stdout, stderr = run_process(
|
|
422
|
+
request["command"], cwd, complete_environment, timeout,
|
|
423
|
+
)
|
|
424
|
+
timed_out = exit_code == 124 and b"PrizmKit runner timeout" in stderr
|
|
425
|
+
except OSError as exc:
|
|
426
|
+
exit_code = 127
|
|
427
|
+
stdout = b""
|
|
428
|
+
stderr = f"PrizmKit runner execution error: {exc}\n".encode()
|
|
429
|
+
execution_error = True
|
|
430
|
+
stdout_path.write_bytes(stdout)
|
|
431
|
+
stderr_path.write_bytes(stderr)
|
|
432
|
+
finished = dt.datetime.now(dt.timezone.utc).isoformat()
|
|
433
|
+
existing = load_json(evidence_dir / "executions.json") if (evidence_dir / "executions.json").exists() else []
|
|
434
|
+
if not isinstance(existing, list):
|
|
435
|
+
raise RequestError("executions.json must remain an array")
|
|
436
|
+
previous = canonical_sha256(existing[-1]) if existing else None
|
|
437
|
+
same_request_attempts = sum(1 for item in existing if isinstance(item, dict) and item.get("request_sha256") == file_sha256(request_path))
|
|
438
|
+
receipt = {
|
|
439
|
+
"receipt_format": "prizmkit-runner-generated-v1",
|
|
440
|
+
"runner_path": runner_path,
|
|
441
|
+
"runner_sha256": runner_hash,
|
|
442
|
+
"execution_id": execution_id,
|
|
443
|
+
"attempt_index": same_request_attempts + 1,
|
|
444
|
+
"request_path": evidence_relative(evidence_dir, request_path),
|
|
445
|
+
"request_sha256": file_sha256(request_path),
|
|
446
|
+
"purpose": request["purpose"],
|
|
447
|
+
"command": request["command"],
|
|
448
|
+
"cwd": request["cwd"],
|
|
449
|
+
"environment": complete_environment,
|
|
450
|
+
"tool_versions": tools,
|
|
451
|
+
"layer": request["layer"],
|
|
452
|
+
"test_ids": request["test_ids"],
|
|
453
|
+
"external_targets": request["external_targets"],
|
|
454
|
+
"exit_code": exit_code,
|
|
455
|
+
"stdout_path": evidence_relative(evidence_dir, stdout_path),
|
|
456
|
+
"stderr_path": evidence_relative(evidence_dir, stderr_path),
|
|
457
|
+
"stdout_sha256": file_sha256(stdout_path),
|
|
458
|
+
"stderr_sha256": file_sha256(stderr_path),
|
|
459
|
+
"selected_execution": selected_execution,
|
|
460
|
+
"reliable": probes_reliable and not timed_out and not execution_error,
|
|
461
|
+
"started_at": started,
|
|
462
|
+
"finished_at": finished,
|
|
463
|
+
"previous_receipt_sha256": previous,
|
|
464
|
+
"replay_of": replay_of,
|
|
465
|
+
"isolation": isolation,
|
|
466
|
+
}
|
|
467
|
+
append_receipt(evidence_dir, receipt)
|
|
468
|
+
return receipt
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
def copy_project_tree(project_root: Path, destination: Path, evidence_dir: Path) -> None:
|
|
472
|
+
def ignore(directory: str, names: list[str]) -> set[str]:
|
|
473
|
+
directory_relative = Path(directory).resolve().relative_to(project_root.resolve())
|
|
474
|
+
ignored: set[str] = set()
|
|
475
|
+
for name in names:
|
|
476
|
+
relative = directory_relative / name
|
|
477
|
+
candidate = project_root / relative
|
|
478
|
+
if is_volatile_snapshot_path(project_root, candidate, [evidence_dir]):
|
|
479
|
+
ignored.add(name)
|
|
480
|
+
return ignored
|
|
481
|
+
|
|
482
|
+
shutil.copytree(project_root, destination, ignore=ignore)
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
def git_archive_baseline(project_root: Path, baseline_commit: str, destination: Path) -> None:
|
|
486
|
+
result = subprocess.run(
|
|
487
|
+
["git", "-C", str(project_root), "archive", "--format=tar", baseline_commit],
|
|
488
|
+
capture_output=True, check=False,
|
|
489
|
+
)
|
|
490
|
+
if result.returncode != 0:
|
|
491
|
+
raise RequestError(f"cannot archive baseline commit {baseline_commit}: {result.stderr.decode(errors='replace')}")
|
|
492
|
+
destination.mkdir(parents=True)
|
|
493
|
+
extracted = subprocess.run(["tar", "-xf", "-", "-C", str(destination)], input=result.stdout, capture_output=True, check=False)
|
|
494
|
+
if extracted.returncode != 0:
|
|
495
|
+
raise RequestError(f"cannot extract baseline archive: {extracted.stderr.decode(errors='replace')}")
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
def materialize_request(evidence_dir: Path, name: str, request: dict[str, Any]) -> Path:
|
|
499
|
+
path = evidence_dir / "requests" / name
|
|
500
|
+
write_json_atomic(path, request)
|
|
501
|
+
return path
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
def run_differential(project_root: Path, evidence_dir: Path, request_path: Path) -> dict[str, Any]:
|
|
505
|
+
request = ensure_object(load_json(request_path), "differential request")
|
|
506
|
+
required = {"request_version", "behavior_id", "method", "execution_request", "baseline_commit", "current_tree_sha256", "mutation_patch_path", "test_overlay_paths", "expected_failure_signals"}
|
|
507
|
+
if set(request) != required or request["request_version"] != "1.0":
|
|
508
|
+
raise RequestError(f"differential request fields must be exactly {sorted(required)} with version 1.0")
|
|
509
|
+
execution_request = validate_execution_request(request["execution_request"])
|
|
510
|
+
expected_failure_signals = ensure_string_list(request["expected_failure_signals"], "expected_failure_signals", allow_empty=False)
|
|
511
|
+
overlay_paths = ensure_string_list(request["test_overlay_paths"], "test_overlay_paths")
|
|
512
|
+
for relative in overlay_paths:
|
|
513
|
+
confined(project_root, relative, must_exist=True)
|
|
514
|
+
project_before = tree_sha256(project_root, [evidence_dir])
|
|
515
|
+
if project_before != request["current_tree_sha256"]:
|
|
516
|
+
raise RequestError("current project tree does not match differential request")
|
|
517
|
+
current_request_path = materialize_request(evidence_dir, f"differential-{uuid.uuid4().hex}.execution.json", execution_request)
|
|
518
|
+
baseline_receipt = None
|
|
519
|
+
mutation_receipt = None
|
|
520
|
+
current_receipt = None
|
|
521
|
+
isolation_tree = None
|
|
522
|
+
mutation_apply_sha = None
|
|
523
|
+
mutation_restore_sha = None
|
|
524
|
+
cleanup_succeeded = False
|
|
525
|
+
try:
|
|
526
|
+
with tempfile.TemporaryDirectory(prefix="prizmkit-differential-") as temporary:
|
|
527
|
+
temporary_root = Path(temporary)
|
|
528
|
+
current_root = temporary_root / "current"
|
|
529
|
+
copy_project_tree(project_root, current_root, evidence_dir)
|
|
530
|
+
isolation_tree = tree_sha256(current_root)
|
|
531
|
+
if request["method"] == "baseline":
|
|
532
|
+
failing_root = temporary_root / "baseline"
|
|
533
|
+
git_archive_baseline(project_root, request["baseline_commit"], failing_root)
|
|
534
|
+
for relative in overlay_paths:
|
|
535
|
+
source = confined(project_root, relative, must_exist=True)
|
|
536
|
+
destination = confined(failing_root, relative)
|
|
537
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
538
|
+
shutil.copy2(source, destination)
|
|
539
|
+
baseline_receipt = execute_request(
|
|
540
|
+
project_root, evidence_dir, current_request_path, execution_root=failing_root,
|
|
541
|
+
isolation={"kind": "baseline", "tree_sha256": tree_sha256(failing_root), "baseline_commit": request["baseline_commit"]},
|
|
542
|
+
selected_execution=False,
|
|
543
|
+
)
|
|
544
|
+
elif request["method"] == "controlled-mutation":
|
|
545
|
+
failing_root = temporary_root / "mutation"
|
|
546
|
+
shutil.copytree(current_root, failing_root)
|
|
547
|
+
patch_value = request["mutation_patch_path"]
|
|
548
|
+
if not isinstance(patch_value, str) or not patch_value:
|
|
549
|
+
raise RequestError("controlled mutation requires mutation_patch_path")
|
|
550
|
+
patch_path = request_file(evidence_dir, patch_value)
|
|
551
|
+
applied = subprocess.run(["git", "apply", "--whitespace=nowarn", str(patch_path)], cwd=failing_root, capture_output=True, check=False)
|
|
552
|
+
if applied.returncode != 0:
|
|
553
|
+
raise RequestError(f"controlled mutation could not be applied: {applied.stderr.decode(errors='replace')}")
|
|
554
|
+
mutation_apply_sha = tree_sha256(failing_root)
|
|
555
|
+
mutation_receipt = execute_request(
|
|
556
|
+
project_root, evidence_dir, current_request_path, execution_root=failing_root,
|
|
557
|
+
isolation={"kind": "controlled-mutation", "tree_sha256": tree_sha256(failing_root), "patch_sha256": file_sha256(patch_path)},
|
|
558
|
+
selected_execution=False,
|
|
559
|
+
)
|
|
560
|
+
mutation_restore_sha = tree_sha256(current_root)
|
|
561
|
+
else:
|
|
562
|
+
raise RequestError("differential method must be baseline or controlled-mutation")
|
|
563
|
+
current_receipt = execute_request(
|
|
564
|
+
project_root, evidence_dir, current_request_path, execution_root=current_root,
|
|
565
|
+
isolation={"kind": "current", "tree_sha256": tree_sha256(current_root)},
|
|
566
|
+
)
|
|
567
|
+
cleanup_succeeded = True
|
|
568
|
+
finally:
|
|
569
|
+
project_after = tree_sha256(project_root, [evidence_dir])
|
|
570
|
+
failing_receipt = baseline_receipt or mutation_receipt
|
|
571
|
+
failing_output = b""
|
|
572
|
+
if failing_receipt:
|
|
573
|
+
failing_output = (evidence_dir / failing_receipt["stdout_path"]).read_bytes() + (evidence_dir / failing_receipt["stderr_path"]).read_bytes()
|
|
574
|
+
failure_reason_matched = any(signal.encode() in failing_output for signal in expected_failure_signals)
|
|
575
|
+
proven = bool(
|
|
576
|
+
failing_receipt and current_receipt and failing_receipt["exit_code"] != 0
|
|
577
|
+
and current_receipt["exit_code"] == 0 and failure_reason_matched
|
|
578
|
+
and cleanup_succeeded and project_before == project_after
|
|
579
|
+
)
|
|
580
|
+
proof = {
|
|
581
|
+
"behavior_id": request["behavior_id"],
|
|
582
|
+
"classification": "PROVEN" if proven else "UNPROVEN",
|
|
583
|
+
"method": request["method"],
|
|
584
|
+
"differential_request_path": evidence_relative(evidence_dir, request_path),
|
|
585
|
+
"differential_request_sha256": file_sha256(request_path),
|
|
586
|
+
"baseline_commit": request["baseline_commit"],
|
|
587
|
+
"current_tree_sha256": request["current_tree_sha256"],
|
|
588
|
+
"baseline_execution_id": baseline_receipt["execution_id"] if baseline_receipt else None,
|
|
589
|
+
"mutation_execution_id": mutation_receipt["execution_id"] if mutation_receipt else None,
|
|
590
|
+
"current_execution_id": current_receipt["execution_id"] if current_receipt else None,
|
|
591
|
+
"failure_reason_matched": failure_reason_matched,
|
|
592
|
+
"cleanup_succeeded": cleanup_succeeded,
|
|
593
|
+
"project_tree_before_sha256": project_before,
|
|
594
|
+
"project_tree_after_sha256": project_after,
|
|
595
|
+
"isolation_tree_sha256": isolation_tree,
|
|
596
|
+
"mutation_apply_sha256": mutation_apply_sha,
|
|
597
|
+
"mutation_restore_sha256": mutation_restore_sha,
|
|
598
|
+
"not_applicable": None,
|
|
599
|
+
}
|
|
600
|
+
proof_path = evidence_dir / "differential-proof.json"
|
|
601
|
+
proof_record = load_json(proof_path) if proof_path.exists() else {"proofs": []}
|
|
602
|
+
if not isinstance(proof_record, dict) or not isinstance(proof_record.get("proofs"), list):
|
|
603
|
+
raise RequestError("differential-proof.json has invalid append target")
|
|
604
|
+
proof_record["proofs"].append(proof)
|
|
605
|
+
write_json_atomic(proof_path, proof_record)
|
|
606
|
+
return proof
|
|
607
|
+
|
|
608
|
+
|
|
609
|
+
def run_resume(
|
|
610
|
+
project_root: Path,
|
|
611
|
+
evidence_dir: Path,
|
|
612
|
+
manifest_path: Path,
|
|
613
|
+
inventory_path: Path,
|
|
614
|
+
output_name: str,
|
|
615
|
+
) -> Path:
|
|
616
|
+
manifest = ensure_object(load_json(manifest_path), "manifest")
|
|
617
|
+
inventory = ensure_object(load_json(inventory_path), "inventory")
|
|
618
|
+
categories = ensure_object(inventory.get("categories"), "inventory categories")
|
|
619
|
+
environment_path = evidence_dir / "environment.json"
|
|
620
|
+
environment = load_json(environment_path) if environment_path.exists() else {}
|
|
621
|
+
aggregate = lambda entries: canonical_sha256(sorted(entries, key=lambda item: item["path"]))
|
|
622
|
+
live_categories: dict[str, list[dict[str, str]]] = {}
|
|
623
|
+
for category in CATEGORIES:
|
|
624
|
+
live_entries: list[dict[str, str]] = []
|
|
625
|
+
for entry in categories.get(category, []):
|
|
626
|
+
if not isinstance(entry, dict) or not isinstance(entry.get("path"), str):
|
|
627
|
+
raise RequestError(f"invalid resume inventory entry: {category}")
|
|
628
|
+
candidate = confined(project_root, entry["path"], must_exist=True)
|
|
629
|
+
live_entries.append({"path": entry["path"], "sha256": file_sha256(candidate)})
|
|
630
|
+
live_categories[category] = live_entries
|
|
631
|
+
current = {
|
|
632
|
+
"source": aggregate(live_categories["source"]),
|
|
633
|
+
"tests": aggregate(live_categories["tests"]),
|
|
634
|
+
"contracts": aggregate(live_categories["contracts"]),
|
|
635
|
+
"lockfiles": aggregate(live_categories["lockfiles"]),
|
|
636
|
+
"environment": canonical_sha256(environment),
|
|
637
|
+
"plan": canonical_sha256(inventory.get("plan_inputs", {})),
|
|
638
|
+
}
|
|
639
|
+
changed = sorted(key for key, value in current.items() if manifest.get("target_hashes", {}).get(key) != value)
|
|
640
|
+
first_invalid: str | None = None
|
|
641
|
+
reasons: list[str] = []
|
|
642
|
+
dependency_stage = {
|
|
643
|
+
"source": "SCOPE_DISCOVER", "tests": "TEST_PLAN", "contracts": "CONTRACT_MODEL",
|
|
644
|
+
"lockfiles": "INFRA_READY", "environment": "INFRA_READY", "plan": "TEST_PLAN",
|
|
645
|
+
}
|
|
646
|
+
if changed:
|
|
647
|
+
first_invalid = min((dependency_stage[key] for key in changed), key=STAGES.index)
|
|
648
|
+
reasons.append(f"target hashes changed: {', '.join(changed)}")
|
|
649
|
+
files = {entry.get("path"): entry for entry in manifest.get("files", []) if isinstance(entry, dict)}
|
|
650
|
+
for stage in manifest.get("stages", []):
|
|
651
|
+
if not isinstance(stage, dict):
|
|
652
|
+
continue
|
|
653
|
+
for relative in stage.get("outputs", []):
|
|
654
|
+
candidate = confined(evidence_dir, relative)
|
|
655
|
+
entry = files.get(relative)
|
|
656
|
+
if not candidate.is_file() or not entry or file_sha256(candidate) != entry.get("sha256"):
|
|
657
|
+
stage_name = stage.get("name")
|
|
658
|
+
if stage_name in STAGES and (first_invalid is None or STAGES.index(stage_name) < STAGES.index(first_invalid)):
|
|
659
|
+
first_invalid = stage_name
|
|
660
|
+
reasons.append(f"missing or drifted stage output: {relative}")
|
|
661
|
+
invalidated = STAGES[STAGES.index(first_invalid):] if first_invalid else []
|
|
662
|
+
decision = {
|
|
663
|
+
"decision_format": "prizmkit-resume-invalidation-v1",
|
|
664
|
+
"manifest_path": evidence_relative(evidence_dir, manifest_path),
|
|
665
|
+
"manifest_sha256": file_sha256(manifest_path),
|
|
666
|
+
"inventory_path": evidence_relative(evidence_dir, inventory_path),
|
|
667
|
+
"inventory_sha256": file_sha256(inventory_path),
|
|
668
|
+
"current_target_hashes": current,
|
|
669
|
+
"changed_hashes": changed,
|
|
670
|
+
"first_invalid_stage": first_invalid,
|
|
671
|
+
"invalidated_stages": invalidated,
|
|
672
|
+
"preserve_prior_executions": True,
|
|
673
|
+
"reasons": reasons,
|
|
674
|
+
"semantic_review_required": True,
|
|
675
|
+
}
|
|
676
|
+
output = confined(evidence_dir, output_name)
|
|
677
|
+
write_json_atomic(output, decision)
|
|
678
|
+
return output
|
|
679
|
+
|
|
680
|
+
|
|
681
|
+
def main() -> int:
|
|
682
|
+
parser = argparse.ArgumentParser(description="Build trusted, request-driven test evidence")
|
|
683
|
+
parser.add_argument("--project-root", required=True)
|
|
684
|
+
parser.add_argument("--evidence-dir", required=True)
|
|
685
|
+
subparsers = parser.add_subparsers(dest="subcommand", required=True)
|
|
686
|
+
inventory = subparsers.add_parser("inventory")
|
|
687
|
+
inventory.add_argument("--request", required=True)
|
|
688
|
+
inventory.add_argument("--output", default="target-inventory.json")
|
|
689
|
+
execute = subparsers.add_parser("execute")
|
|
690
|
+
group = execute.add_mutually_exclusive_group(required=True)
|
|
691
|
+
group.add_argument("--request")
|
|
692
|
+
group.add_argument("--replay-receipt")
|
|
693
|
+
differential = subparsers.add_parser("differential")
|
|
694
|
+
differential.add_argument("--request", required=True)
|
|
695
|
+
resume = subparsers.add_parser("resume")
|
|
696
|
+
resume.add_argument("--manifest", required=True)
|
|
697
|
+
resume.add_argument("--inventory", required=True)
|
|
698
|
+
resume.add_argument("--output", default="resume-decision.json")
|
|
699
|
+
args = parser.parse_args()
|
|
700
|
+
project_root = Path(args.project_root).resolve()
|
|
701
|
+
evidence_dir = Path(args.evidence_dir).resolve()
|
|
702
|
+
try:
|
|
703
|
+
if not project_root.is_dir():
|
|
704
|
+
raise RequestError(f"project root does not exist: {project_root}")
|
|
705
|
+
evidence_dir.relative_to(project_root)
|
|
706
|
+
evidence_dir.mkdir(parents=True, exist_ok=True)
|
|
707
|
+
if args.subcommand == "inventory":
|
|
708
|
+
output = run_inventory(project_root, evidence_dir, request_file(evidence_dir, args.request), args.output)
|
|
709
|
+
print(f"Inventory written: {output}")
|
|
710
|
+
elif args.subcommand == "execute":
|
|
711
|
+
if args.request:
|
|
712
|
+
receipt = execute_request(project_root, evidence_dir, request_file(evidence_dir, args.request))
|
|
713
|
+
else:
|
|
714
|
+
prior_path = receipt_file(evidence_dir, args.replay_receipt)
|
|
715
|
+
prior = ensure_object(load_json(prior_path), "replay receipt")
|
|
716
|
+
if prior.get("receipt_format") != "prizmkit-runner-generated-v1":
|
|
717
|
+
raise RequestError("replay source is not a runner-generated receipt")
|
|
718
|
+
original_request = request_file(evidence_dir, prior.get("request_path", ""))
|
|
719
|
+
if file_sha256(original_request) != prior.get("request_sha256"):
|
|
720
|
+
raise RequestError("recorded request hash no longer matches replay source")
|
|
721
|
+
receipt = execute_request(project_root, evidence_dir, original_request, replay_of=prior.get("execution_id"))
|
|
722
|
+
print(json.dumps(receipt, ensure_ascii=False))
|
|
723
|
+
elif args.subcommand == "differential":
|
|
724
|
+
proof = run_differential(project_root, evidence_dir, request_file(evidence_dir, args.request))
|
|
725
|
+
print(json.dumps(proof, ensure_ascii=False))
|
|
726
|
+
else:
|
|
727
|
+
output = run_resume(
|
|
728
|
+
project_root, evidence_dir, request_file(evidence_dir, args.manifest),
|
|
729
|
+
request_file(evidence_dir, args.inventory), args.output,
|
|
730
|
+
)
|
|
731
|
+
print(f"Resume decision written: {output}")
|
|
732
|
+
return 0
|
|
733
|
+
except (RequestError, OSError, ValueError) as exc:
|
|
734
|
+
print(f"Test evidence builder: BLOCKED\n- {exc}", file=sys.stderr)
|
|
735
|
+
return 2
|
|
736
|
+
|
|
737
|
+
|
|
738
|
+
if __name__ == "__main__":
|
|
739
|
+
sys.exit(main())
|