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.
Files changed (46) hide show
  1. package/bundled/VERSION.json +3 -3
  2. package/bundled/dev-pipeline/prizmkit_runtime/cli.py +1 -0
  3. package/bundled/dev-pipeline/prizmkit_runtime/runner_classification.py +23 -25
  4. package/bundled/dev-pipeline/prizmkit_runtime/runner_models.py +8 -2
  5. package/bundled/dev-pipeline/prizmkit_runtime/runner_prompts.py +10 -1
  6. package/bundled/dev-pipeline/prizmkit_runtime/runners.py +43 -29
  7. package/bundled/dev-pipeline/scripts/continuation.py +51 -6
  8. package/bundled/dev-pipeline/scripts/update-bug-status.py +57 -14
  9. package/bundled/dev-pipeline/scripts/update-feature-status.py +54 -5
  10. package/bundled/dev-pipeline/scripts/update-refactor-status.py +57 -14
  11. package/bundled/dev-pipeline/templates/bootstrap-tier3.md +2 -2
  12. package/bundled/dev-pipeline/templates/bugfix-bootstrap-prompt.md +2 -1
  13. package/bundled/dev-pipeline/templates/refactor-bootstrap-prompt.md +2 -2
  14. package/bundled/dev-pipeline/templates/sections/bugfix-phase-review.md +3 -3
  15. package/bundled/dev-pipeline/templates/sections/phase-review-agent.md +3 -3
  16. package/bundled/dev-pipeline/templates/sections/phase-review-full.md +4 -4
  17. package/bundled/dev-pipeline/templates/sections/refactor-phase-review.md +3 -3
  18. package/bundled/dev-pipeline/tests/test_auto_skip.py +5 -5
  19. package/bundled/dev-pipeline/tests/test_generate_bootstrap_prompt.py +10 -3
  20. package/bundled/dev-pipeline/tests/test_generate_bugfix_prompt.py +9 -2
  21. package/bundled/dev-pipeline/tests/test_generate_refactor_prompt.py +9 -1
  22. package/bundled/dev-pipeline/tests/test_python_runner_parity.py +222 -18
  23. package/bundled/dev-pipeline/tests/test_unified_cli.py +24 -11
  24. package/bundled/skills/_metadata.json +4 -4
  25. package/bundled/skills/prizmkit-code-review/SKILL.md +49 -48
  26. package/bundled/skills/prizmkit-code-review/references/review-report-template.md +13 -8
  27. package/bundled/skills/prizmkit-code-review/references/reviewer-agent-prompt.md +46 -32
  28. package/bundled/skills/prizmkit-code-review/references/reviewer-workspace-protocol.md +90 -0
  29. package/bundled/skills/prizmkit-test/SKILL.md +160 -155
  30. package/bundled/skills/prizmkit-test/assets/authoritative-records.schema.json +420 -0
  31. package/bundled/skills/prizmkit-test/assets/behavior-risk-matrix.schema.json +116 -0
  32. package/bundled/skills/prizmkit-test/assets/evidence-manifest.schema.json +112 -0
  33. package/bundled/skills/prizmkit-test/assets/evidence-package-template.json +97 -0
  34. package/bundled/skills/prizmkit-test/references/boundary-coverage-protocol.md +76 -192
  35. package/bundled/skills/prizmkit-test/references/contract-mock-protocol.md +65 -0
  36. package/bundled/skills/prizmkit-test/references/evidence-protocol.md +201 -0
  37. package/bundled/skills/prizmkit-test/references/evidence-request-protocol.md +78 -0
  38. package/bundled/skills/prizmkit-test/references/examples.md +65 -108
  39. package/bundled/skills/prizmkit-test/references/service-boundary-test-catalog.md +10 -7
  40. package/bundled/skills/prizmkit-test/references/test-generation-steps.md +90 -82
  41. package/bundled/skills/prizmkit-test/references/test-report-template.md +77 -98
  42. package/bundled/skills/prizmkit-test/references/trusted-evidence-execution.md +97 -0
  43. package/bundled/skills/prizmkit-test/scripts/build_test_evidence.py +739 -0
  44. package/bundled/skills/prizmkit-test/scripts/validate_test_evidence.py +1442 -0
  45. package/package.json +1 -1
  46. 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())