prizmkit 1.1.140 → 1.1.142

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 (83) hide show
  1. package/bundled/VERSION.json +3 -3
  2. package/bundled/dev-pipeline/README.md +1 -1
  3. package/bundled/dev-pipeline/prizmkit_runtime/checkpoint_state.py +165 -125
  4. package/bundled/dev-pipeline/prizmkit_runtime/runner_classification.py +13 -1
  5. package/bundled/dev-pipeline/prizmkit_runtime/runner_models.py +0 -6
  6. package/bundled/dev-pipeline/prizmkit_runtime/runners.py +5 -3
  7. package/bundled/dev-pipeline/prizmkit_runtime/test_result.py +160 -0
  8. package/bundled/dev-pipeline/scripts/check-session-status.py +11 -1
  9. package/bundled/dev-pipeline/scripts/generate-bootstrap-prompt.py +23 -806
  10. package/bundled/dev-pipeline/scripts/generate-bugfix-prompt.py +6 -8
  11. package/bundled/dev-pipeline/scripts/generate-recovery-prompt.py +1 -1
  12. package/bundled/dev-pipeline/scripts/generate-refactor-prompt.py +6 -8
  13. package/bundled/dev-pipeline/scripts/prompt_framework.py +68 -56
  14. package/bundled/dev-pipeline/scripts/update-bug-status.py +21 -2
  15. package/bundled/dev-pipeline/scripts/update-checkpoint.py +52 -131
  16. package/bundled/dev-pipeline/scripts/update-feature-status.py +21 -2
  17. package/bundled/dev-pipeline/scripts/update-refactor-status.py +21 -2
  18. package/bundled/dev-pipeline/templates/bootstrap-prompt.md +3 -3
  19. package/bundled/dev-pipeline/templates/bootstrap-tier3.md +8 -19
  20. package/bundled/dev-pipeline/templates/bugfix-bootstrap-prompt.md +6 -6
  21. package/bundled/dev-pipeline/templates/refactor-bootstrap-prompt.md +5 -5
  22. package/bundled/dev-pipeline/templates/sections/bugfix-phase-commit-report.md +1 -1
  23. package/bundled/dev-pipeline/templates/sections/bugfix-phase-diagnose-plan.md +1 -1
  24. package/bundled/dev-pipeline/templates/sections/bugfix-phase-manual-verification.md +1 -1
  25. package/bundled/dev-pipeline/templates/sections/bugfix-phase-review.md +1 -1
  26. package/bundled/dev-pipeline/templates/sections/bugfix-phase-test.md +13 -10
  27. package/bundled/dev-pipeline/templates/sections/bugfix-reminders.md +1 -1
  28. package/bundled/dev-pipeline/templates/sections/checkpoint-system.md +18 -9
  29. package/bundled/dev-pipeline/templates/sections/headless-commit-authorization.md +1 -1
  30. package/bundled/dev-pipeline/templates/sections/phase-commit-full.md +1 -1
  31. package/bundled/dev-pipeline/templates/sections/phase-commit.md +1 -1
  32. package/bundled/dev-pipeline/templates/sections/phase-context-snapshot-agent-suffix.md +2 -2
  33. package/bundled/dev-pipeline/templates/sections/phase-context-snapshot-base.md +1 -1
  34. package/bundled/dev-pipeline/templates/sections/phase-context-snapshot-lite-suffix.md +1 -1
  35. package/bundled/dev-pipeline/templates/sections/phase-implement-lite.md +1 -1
  36. package/bundled/dev-pipeline/templates/sections/phase-prizmkit-test.md +14 -26
  37. package/bundled/dev-pipeline/templates/sections/phase-review-full.md +2 -2
  38. package/bundled/dev-pipeline/templates/sections/phase-specify-plan-full.md +3 -3
  39. package/bundled/dev-pipeline/templates/sections/refactor-phase-review.md +1 -1
  40. package/bundled/dev-pipeline/templates/sections/refactor-phase-test.md +13 -10
  41. package/bundled/dev-pipeline/templates/sections/refactor-reminders.md +1 -1
  42. package/bundled/dev-pipeline/templates/session-status-schema.json +1 -1
  43. package/bundled/dev-pipeline/tests/test_checkpoint_state.py +494 -413
  44. package/bundled/dev-pipeline/tests/test_generate_bootstrap_prompt.py +144 -684
  45. package/bundled/dev-pipeline/tests/test_generate_bugfix_prompt.py +20 -41
  46. package/bundled/dev-pipeline/tests/test_generate_refactor_prompt.py +24 -24
  47. package/bundled/dev-pipeline/tests/test_python_runner_parity.py +176 -36
  48. package/bundled/skills/_metadata.json +6 -6
  49. package/bundled/skills/prizmkit/SKILL.md +13 -18
  50. package/bundled/skills/prizmkit/references/workflow-state-protocol.md +73 -80
  51. package/bundled/skills/prizmkit-code-review/SKILL.md +7 -7
  52. package/bundled/skills/prizmkit-code-review/references/workflow-state-protocol.md +73 -80
  53. package/bundled/skills/prizmkit-committer/SKILL.md +8 -7
  54. package/bundled/skills/prizmkit-committer/references/workflow-state-protocol.md +73 -80
  55. package/bundled/skills/prizmkit-implement/SKILL.md +3 -2
  56. package/bundled/skills/prizmkit-implement/references/workflow-state-protocol.md +73 -80
  57. package/bundled/skills/prizmkit-plan/SKILL.md +2 -1
  58. package/bundled/skills/prizmkit-plan/references/workflow-state-protocol.md +73 -80
  59. package/bundled/skills/prizmkit-retrospective/SKILL.md +8 -6
  60. package/bundled/skills/prizmkit-retrospective/references/workflow-state-protocol.md +73 -80
  61. package/bundled/skills/prizmkit-test/SKILL.md +217 -189
  62. package/bundled/skills/prizmkit-test/references/external-contract-mock-guidance.md +119 -0
  63. package/bundled/skills/prizmkit-test/references/independent-test-review.md +269 -0
  64. package/bundled/skills/prizmkit-test/references/test-coverage-model.md +193 -0
  65. package/bundled/skills/prizmkit-test/references/test-report-template.md +164 -79
  66. package/bundled/skills/prizmkit-workflow/SKILL.md +28 -36
  67. package/bundled/skills/prizmkit-workflow/references/workflow-state-protocol.md +73 -80
  68. package/package.json +1 -1
  69. package/bundled/skills/prizmkit-test/assets/authoritative-records.schema.json +0 -477
  70. package/bundled/skills/prizmkit-test/assets/behavior-risk-matrix.schema.json +0 -116
  71. package/bundled/skills/prizmkit-test/assets/evidence-manifest.schema.json +0 -111
  72. package/bundled/skills/prizmkit-test/assets/evidence-package-template.json +0 -66
  73. package/bundled/skills/prizmkit-test/references/boundary-coverage-protocol.md +0 -104
  74. package/bundled/skills/prizmkit-test/references/contract-mock-protocol.md +0 -65
  75. package/bundled/skills/prizmkit-test/references/evidence-protocol.md +0 -208
  76. package/bundled/skills/prizmkit-test/references/evidence-request-protocol.md +0 -80
  77. package/bundled/skills/prizmkit-test/references/examples.md +0 -100
  78. package/bundled/skills/prizmkit-test/references/service-boundary-test-catalog.md +0 -228
  79. package/bundled/skills/prizmkit-test/references/test-generation-steps.md +0 -118
  80. package/bundled/skills/prizmkit-test/references/trusted-evidence-execution.md +0 -107
  81. package/bundled/skills/prizmkit-test/references/workflow-state-protocol.md +0 -181
  82. package/bundled/skills/prizmkit-test/scripts/build_test_evidence.py +0 -1930
  83. package/bundled/skills/prizmkit-test/scripts/validate_test_evidence.py +0 -1847
@@ -1,1930 +0,0 @@
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 init [--baseline COMMIT]
6
- python3 build_test_evidence.py --project-root ROOT --evidence-dir DIR capture-change
7
- python3 build_test_evidence.py --project-root ROOT --evidence-dir DIR inventory --request REQUEST
8
- python3 build_test_evidence.py --project-root ROOT --evidence-dir DIR prepare-tests --request REQUEST
9
- python3 build_test_evidence.py --project-root ROOT --evidence-dir DIR execute --request REQUEST
10
- python3 build_test_evidence.py --project-root ROOT --evidence-dir DIR execute --replay-receipt RECEIPT
11
- python3 build_test_evidence.py --project-root ROOT --evidence-dir DIR finalize --request REQUEST
12
- python3 build_test_evidence.py --project-root ROOT --evidence-dir DIR render-report
13
- python3 build_test_evidence.py --project-root ROOT --evidence-dir DIR resume --manifest MANIFEST --inventory INVENTORY
14
-
15
- Only locator arguments and evidence mechanics are fixed. Test commands, working directories,
16
- timeouts, attempts, concurrency, tool probes, test layers, and framework-specific options come
17
- from model-authored requests after project inspection.
18
- """
19
-
20
- from __future__ import annotations
21
-
22
- import argparse
23
- import datetime as dt
24
- import fnmatch
25
- import hashlib
26
- import json
27
- import os
28
- import shutil
29
- import signal
30
- import subprocess
31
- import sys
32
- import uuid
33
- from pathlib import Path
34
- from typing import Any
35
-
36
- STAGES = [
37
- "CHANGE_CLASSIFY", "SCOPE_DISCOVER", "CONTRACT_MODEL", "TEST_PLAN",
38
- "INFRA_READY", "TEST_BUILD", "EXECUTE_PROVE", "EVIDENCE_PACKAGE",
39
- "EVIDENCE_VALIDATE",
40
- ]
41
- CATEGORIES = ("source", "tests", "contracts", "lockfiles")
42
- LAYERS = (
43
- "focused", "module-component", "contract-integration",
44
- "affected-module-regression", "regression-ring",
45
- )
46
- BLOCKED_EXTERNAL_CLASSES = {"production", "unknown"}
47
- REPAIR_SCOPES = {
48
- "test-infrastructure", "production", "runtime", "schema", "dependency", "public-interface",
49
- }
50
- INVENTORY_RESERVED_ROOTS = (
51
- ".prizmkit/state",
52
- ".prizmkit/test/evidence",
53
- ".prizmkit/" + "dev-" + "pipeline",
54
- ".claude/worktrees",
55
- ".agents/worktrees",
56
- ".codebuddy/worktrees",
57
- ".codex/worktrees",
58
- )
59
-
60
- _IGNORED_DIR_NAMES = {".git", "__pycache__", ".pytest_cache", ".mypy_cache"}
61
- FINAL_RECORDS = {"manifest.json", "validation.json", "verdict.json", "test-report.md"}
62
- MAX_PROTOCOL_PACKAGE_ATTEMPTS = 2
63
- PROTOCOL_STATE_NAME = ".protocol-recovery.json"
64
- DEFAULT_EXECUTION_TIMEOUT_SECONDS = 300.0
65
- MAX_EXECUTION_TIMEOUT_SECONDS = 1800.0
66
- MAX_EXECUTION_OUTPUT_BYTES = 16 * 1024 * 1024
67
- CANONICAL_EVIDENCE_ROOT = (".prizmkit", "test", "evidence")
68
-
69
-
70
- class RequestError(Exception):
71
- """Raised when a request would produce unsafe or ambiguous evidence."""
72
-
73
-
74
- def canonical_bytes(value: Any) -> bytes:
75
- return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8")
76
-
77
-
78
- def canonical_sha256(value: Any) -> str:
79
- return hashlib.sha256(canonical_bytes(value)).hexdigest()
80
-
81
-
82
- def file_sha256(path: Path) -> str:
83
- digest = hashlib.sha256()
84
- with path.open("rb") as handle:
85
- for chunk in iter(lambda: handle.read(1024 * 1024), b""):
86
- digest.update(chunk)
87
- return digest.hexdigest()
88
-
89
-
90
- def write_json_atomic(path: Path, value: Any) -> None:
91
- path.parent.mkdir(parents=True, exist_ok=True)
92
- temporary = path.with_name(f".{path.name}.{uuid.uuid4().hex}.tmp")
93
- temporary.write_text(json.dumps(value, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
94
- os.replace(temporary, path)
95
-
96
-
97
- def load_json(path: Path) -> Any:
98
- try:
99
- return json.loads(path.read_text(encoding="utf-8"))
100
- except FileNotFoundError as exc:
101
- raise RequestError(f"file does not exist: {path}") from exc
102
- except json.JSONDecodeError as exc:
103
- raise RequestError(f"invalid JSON: {path}: {exc}") from exc
104
-
105
-
106
- def confined(root: Path, value: str, *, must_exist: bool = False, directory: bool = False) -> Path:
107
- if not isinstance(value, str) or not value:
108
- raise RequestError("path must be a non-empty string")
109
- candidate = (root / value).resolve() if not Path(value).is_absolute() else Path(value).resolve()
110
- try:
111
- candidate.relative_to(root.resolve())
112
- except ValueError as exc:
113
- raise RequestError(f"path escapes root: {value}") from exc
114
- if must_exist and not candidate.exists():
115
- raise RequestError(f"path does not exist: {value}")
116
- if directory and candidate.exists() and not candidate.is_dir():
117
- raise RequestError(f"path is not a directory: {value}")
118
- return candidate
119
-
120
-
121
- def canonical_evidence_root(project_root: Path) -> Path:
122
- return project_root.joinpath(*CANONICAL_EVIDENCE_ROOT).resolve()
123
-
124
-
125
- def require_canonical_evidence_dir(project_root: Path, evidence_dir: Path) -> None:
126
- root = canonical_evidence_root(project_root)
127
- try:
128
- evidence_dir.resolve().relative_to(root)
129
- except ValueError as exc:
130
- raise RequestError(
131
- "evidence directory must be under .prizmkit/test/evidence"
132
- ) from exc
133
- if evidence_dir.resolve() == root:
134
- raise RequestError("evidence directory must be a package child of .prizmkit/test/evidence")
135
-
136
-
137
- def max_timeout(value: Any) -> float:
138
- if value is None:
139
- return DEFAULT_EXECUTION_TIMEOUT_SECONDS
140
- if isinstance(value, bool) or not isinstance(value, (int, float)) or value <= 0:
141
- raise RequestError("timeout_seconds must be a positive number")
142
- if value > MAX_EXECUTION_TIMEOUT_SECONDS:
143
- raise RequestError(
144
- f"timeout_seconds must not exceed {MAX_EXECUTION_TIMEOUT_SECONDS:g} seconds"
145
- )
146
- return float(value)
147
-
148
-
149
- def evidence_relative(evidence_dir: Path, path: Path) -> str:
150
- try:
151
- return path.resolve().relative_to(evidence_dir.resolve()).as_posix()
152
- except ValueError as exc:
153
- raise RequestError(f"evidence output escapes evidence directory: {path}") from exc
154
-
155
-
156
- def request_file(evidence_dir: Path, value: str) -> Path:
157
- return confined(evidence_dir, value, must_exist=True)
158
-
159
-
160
- def receipt_file(evidence_dir: Path, value: str) -> Path:
161
- direct = confined(evidence_dir, value)
162
- if direct.is_file():
163
- return direct
164
- return confined(evidence_dir, f"receipts/{value}", must_exist=True)
165
-
166
-
167
- def ensure_object(value: Any, label: str) -> dict[str, Any]:
168
- if not isinstance(value, dict):
169
- raise RequestError(f"{label} must be an object")
170
- return value
171
-
172
-
173
- def ensure_string_list(value: Any, label: str, *, allow_empty: bool = True) -> list[str]:
174
- if not isinstance(value, list) or (not allow_empty and not value):
175
- raise RequestError(f"{label} must be a{' non-empty' if not allow_empty else ''} string array")
176
- if any(not isinstance(item, str) or not item for item in value):
177
- raise RequestError(f"{label} contains a non-string or empty value")
178
- return value
179
-
180
-
181
- def normalize_relative(value: str) -> str:
182
- normalized = Path(value).as_posix()
183
- if normalized == "." or normalized.startswith("../") or "/../" in normalized:
184
- raise RequestError(f"path is not project-relative: {value}")
185
- return normalized
186
-
187
-
188
- def is_reserved_inventory_path(relative: str) -> bool:
189
- path = Path(relative)
190
- if any(part in _IGNORED_DIR_NAMES for part in path.parts):
191
- return True
192
- return any(path == Path(prefix) or Path(prefix) in path.parents for prefix in INVENTORY_RESERVED_ROOTS)
193
-
194
-
195
- def project_git(project_root: Path, *args: str, check: bool = False) -> subprocess.CompletedProcess[bytes]:
196
- result = subprocess.run(
197
- ["git", "-C", str(project_root), *args],
198
- capture_output=True,
199
- check=False,
200
- )
201
- if check and result.returncode != 0:
202
- raise RequestError(f"git {' '.join(args)} failed: {result.stderr.decode(errors='replace')}")
203
- return result
204
-
205
-
206
- def git_baseline(project_root: Path, requested: str | None = None) -> str:
207
- baseline = requested or "HEAD"
208
- result = project_git(project_root, "rev-parse", "--verify", baseline)
209
- if result.returncode != 0:
210
- raise RequestError(f"baseline commit is not resolvable: {baseline}")
211
- return result.stdout.decode().strip()
212
-
213
-
214
- def git_status_entries(project_root: Path) -> list[dict[str, Any]]:
215
- result = project_git(project_root, "status", "--porcelain=v1", "-z", "--untracked-files=all", check=True)
216
- raw = result.stdout
217
- entries: list[dict[str, Any]] = []
218
- tokens = raw.split(b"\0")
219
- index = 0
220
- while index < len(tokens):
221
- token = tokens[index]
222
- index += 1
223
- if not token:
224
- continue
225
- status = token[:2].decode(errors="replace")
226
- path = token[3:].decode(errors="replace")
227
- old_path = None
228
- if "R" in status or "C" in status:
229
- if index >= len(tokens):
230
- raise RequestError("git status returned an incomplete rename/copy record")
231
- old_path = tokens[index].decode(errors="replace")
232
- index += 1
233
- relative = normalize_relative(path)
234
- if not is_reserved_inventory_path(relative):
235
- record: dict[str, Any] = {"path": relative, "status": status, "old_path": old_path}
236
- entries.append(record)
237
- if old_path is not None:
238
- old_relative = normalize_relative(old_path)
239
- if not is_reserved_inventory_path(old_relative):
240
- entries.append({"path": old_relative, "status": status, "old_path": old_relative, "role": "source"})
241
- return sorted(entries, key=lambda item: (item["path"], item.get("old_path") or ""))
242
-
243
-
244
- def git_diff_entries(project_root: Path, baseline: str) -> list[dict[str, Any]]:
245
- result = project_git(
246
- project_root, "diff", "--name-status", "-z", "--find-renames", "--find-copies",
247
- baseline, "--", ".",
248
- ":!.prizmkit/state/**", ":!.prizmkit/test/evidence/**", ":!.prizmkit/" + "dev-" + "pipeline/**",
249
- ":!.claude/worktrees/**", ":!.agents/worktrees/**", ":!.codebuddy/worktrees/**", ":!.codex/worktrees/**",
250
- check=True,
251
- )
252
- tokens = result.stdout.split(b"\0")
253
- entries: list[dict[str, Any]] = []
254
- index = 0
255
- while index < len(tokens):
256
- status_token = tokens[index].decode(errors="replace")
257
- index += 1
258
- if not status_token:
259
- continue
260
- if index >= len(tokens):
261
- raise RequestError("git diff returned an incomplete changed-file record")
262
- first = normalize_relative(tokens[index].decode(errors="replace"))
263
- index += 1
264
- status = status_token[0]
265
- old_path = None
266
- if status in {"R", "C"}:
267
- if index >= len(tokens):
268
- raise RequestError("git diff returned an incomplete rename/copy record")
269
- old_path = first
270
- first = normalize_relative(tokens[index].decode(errors="replace"))
271
- index += 1
272
- if not is_reserved_inventory_path(first):
273
- entries.append({"path": first, "status": status, "old_path": old_path})
274
- if old_path and not is_reserved_inventory_path(old_path):
275
- entries.append({"path": old_path, "status": status, "old_path": old_path, "role": "source"})
276
- return entries
277
-
278
-
279
- def merge_changed_file_entries(
280
- baseline_entries: list[dict[str, Any]],
281
- working_entries: list[dict[str, Any]],
282
- ) -> list[dict[str, Any]]:
283
- merged: dict[tuple[str, str | None, str | None], dict[str, Any]] = {}
284
- for entry in baseline_entries + working_entries:
285
- key = (entry["path"], entry.get("old_path"), entry.get("role"))
286
- existing = merged.get(key)
287
- if existing is None:
288
- merged[key] = dict(entry)
289
- continue
290
- statuses = {existing.get("status", "").strip(), entry.get("status", "").strip()}
291
- if "D" in statuses:
292
- existing["status"] = "D"
293
- elif "R" in statuses:
294
- existing["status"] = "R"
295
- elif "C" in statuses:
296
- existing["status"] = "C"
297
- elif statuses:
298
- existing["status"] = "M"
299
- return sorted(merged.values(), key=lambda item: (item["path"], item.get("old_path") or "", item.get("role") or ""))
300
-
301
-
302
- def canonical_change_patch(project_root: Path, baseline: str) -> tuple[bytes, list[dict[str, Any]]]:
303
- status_entries = git_status_entries(project_root)
304
- baseline_entries = git_diff_entries(project_root, baseline)
305
- status_entries = merge_changed_file_entries(baseline_entries, status_entries)
306
- tracked = project_git(
307
- project_root, "diff", "--binary", "--full-index", "--find-renames", "--find-copies",
308
- baseline, "--", ".",
309
- ":!.prizmkit/state/**", ":!.prizmkit/test/evidence/**", ":!.prizmkit/" + "dev-" + "pipeline/**",
310
- ":!.claude/worktrees/**", ":!.agents/worktrees/**", ":!.codebuddy/worktrees/**", ":!.codex/worktrees/**",
311
- )
312
- if tracked.returncode != 0:
313
- raise RequestError(f"cannot capture git diff: {tracked.stderr.decode(errors='replace')}")
314
- chunks = [tracked.stdout]
315
- untracked = [
316
- item["path"] for item in status_entries
317
- if item["status"].strip() == "??" and item.get("old_path") is None
318
- ]
319
- for relative in sorted(untracked):
320
- candidate = confined(project_root, relative, must_exist=True)
321
- if candidate.is_file():
322
- result = subprocess.run(
323
- ["git", "diff", "--no-index", "--binary", "/dev/null", "--", relative],
324
- cwd=project_root,
325
- capture_output=True,
326
- check=False,
327
- )
328
- if result.returncode not in (0, 1):
329
- raise RequestError(f"cannot capture untracked file: {relative}")
330
- chunks.append(result.stdout)
331
- patch = b"".join(chunk for chunk in chunks if chunk)
332
- return patch, status_entries
333
-
334
-
335
- def write_text_atomic(path: Path, value: str) -> None:
336
- path.parent.mkdir(parents=True, exist_ok=True)
337
- temporary = path.with_name(f".{path.name}.{uuid.uuid4().hex}.tmp")
338
- temporary.write_text(value, encoding="utf-8")
339
- os.replace(temporary, path)
340
-
341
-
342
- def validate_external_targets(value: Any) -> list[dict[str, Any]]:
343
- if not isinstance(value, list):
344
- raise RequestError("external_targets must be an array")
345
- for target in value:
346
- if not isinstance(target, dict):
347
- raise RequestError("external target must be an object")
348
- required = {"name", "external", "classification", "endpoint_evidence", "allow_evidence", "deny_evidence"}
349
- if set(target) != required:
350
- raise RequestError(f"external target fields must be exactly {sorted(required)}")
351
- if not isinstance(target["external"], bool):
352
- raise RequestError("external target external flag must be boolean")
353
- for key in ("endpoint_evidence", "allow_evidence", "deny_evidence"):
354
- ensure_string_list(target[key], f"external target {key}", allow_empty=(key != "endpoint_evidence"))
355
- if target["external"] and target["classification"] in BLOCKED_EXTERNAL_CLASSES:
356
- raise RequestError(f"execution blocked for external target classified {target['classification']}: {target['name']}")
357
- if target["external"] and target["classification"] not in {"isolated", "test", "staging"}:
358
- raise RequestError(f"invalid external target classification: {target['classification']}")
359
- if target["external"] and not target["allow_evidence"]:
360
- raise RequestError(f"external target lacks explicit allow evidence: {target['name']}")
361
- if target["external"] and target["deny_evidence"]:
362
- raise RequestError(f"external target matched deny evidence: {target['name']}")
363
- return value
364
-
365
-
366
- def load_change_capture(
367
- project_root: Path,
368
- evidence_dir: Path,
369
- *,
370
- require_fresh: bool = False,
371
- ) -> dict[str, Any]:
372
- capture_path = evidence_dir / "change-capture.json"
373
- if not capture_path.is_file():
374
- raise RequestError("canonical change capture is required; run capture-change first")
375
- capture = ensure_object(load_json(capture_path), "change capture")
376
- baseline = capture.get("baseline_commit")
377
- patch_path = evidence_dir / "source-change.patch"
378
- if (
379
- capture.get("capture_format") != "prizmkit-canonical-change-v1"
380
- or not isinstance(baseline, str)
381
- or not patch_path.is_file()
382
- or capture.get("patch_path") != "source-change.patch"
383
- or capture.get("patch_sha256") != file_sha256(patch_path)
384
- or not isinstance(capture.get("changed_files"), list)
385
- ):
386
- raise RequestError("canonical change capture is incomplete or inconsistent")
387
- if require_fresh:
388
- live_patch, live_entries = canonical_change_patch(project_root, baseline)
389
- live_patch_sha256 = hashlib.sha256(live_patch).hexdigest()
390
- if (
391
- live_patch_sha256 != capture["patch_sha256"]
392
- or live_entries != capture["changed_files"]
393
- ):
394
- raise RequestError(
395
- "canonical change capture is stale; rerun capture-change and inventory after repository changes"
396
- )
397
- return capture
398
-
399
-
400
- def captured_changed_paths(capture: dict[str, Any]) -> list[str]:
401
- paths = {
402
- item["path"]
403
- for item in capture.get("changed_files", [])
404
- if isinstance(item, dict) and isinstance(item.get("path"), str)
405
- }
406
- return sorted(paths)
407
-
408
-
409
- def plan_for_execution(evidence_dir: Path) -> dict[str, Any] | None:
410
- path = evidence_dir / "test-plan.json"
411
- if not path.exists():
412
- return None
413
- return ensure_object(load_json(path), "test plan")
414
-
415
-
416
- def test_plan_entries(plan: dict[str, Any] | None) -> list[dict[str, Any]]:
417
- if not isinstance(plan, dict) or not isinstance(plan.get("tests"), list):
418
- raise RequestError("builder-generated test plan is required before execution")
419
- entries = [item for item in plan["tests"] if isinstance(item, dict)]
420
- if len(entries) != len(plan["tests"]):
421
- raise RequestError("test plan contains a non-object test entry")
422
- return entries
423
-
424
-
425
- def validate_execution_request(request: Any, *, plan: dict[str, Any] | None = None) -> dict[str, Any]:
426
- request = ensure_object(request, "execution request")
427
- required = {
428
- "request_version", "purpose", "command", "cwd", "environment",
429
- "tool_version_commands", "layer", "external_targets",
430
- }
431
- optional = {"timeout_seconds", "attempt_policy", "concurrency", "test_ids", "test_paths", "auto_bind"}
432
- if not required <= set(request) or set(request) - required - optional:
433
- raise RequestError(f"execution request fields must contain {sorted(required)} and only supported optional fields")
434
- if request["request_version"] != "1.0":
435
- raise RequestError("unsupported execution request version")
436
- command = ensure_string_list(request["command"], "command", allow_empty=False)
437
- if not command[0]:
438
- raise RequestError("command executable is empty")
439
- if not isinstance(request["cwd"], str):
440
- raise RequestError("cwd must be a string")
441
- environment = ensure_object(request["environment"], "environment")
442
- if any(not isinstance(key, str) or not isinstance(value, str) for key, value in environment.items()):
443
- raise RequestError("environment must contain string keys and complete string values")
444
- probes = ensure_object(request["tool_version_commands"], "tool_version_commands")
445
- for name, probe in probes.items():
446
- if not isinstance(name, str):
447
- raise RequestError("tool probe names must be strings")
448
- ensure_string_list(probe, f"tool probe {name}", allow_empty=False)
449
- has_ids = "test_ids" in request
450
- has_paths = "test_paths" in request
451
- has_auto_bind = "auto_bind" in request
452
- if has_auto_bind and not isinstance(request["auto_bind"], bool):
453
- raise RequestError("execution request auto_bind must be boolean")
454
- binding_modes = int(has_ids) + int(has_paths) + int(request.get("auto_bind") is True)
455
- if binding_modes == 0:
456
- raise RequestError("execution request must provide test_ids, test_paths, or auto_bind=true")
457
- if binding_modes != 1 or (has_auto_bind and request["auto_bind"] is False):
458
- raise RequestError("execution request binding modes test_ids, test_paths, and auto_bind=true are mutually exclusive")
459
- if has_ids:
460
- test_ids = ensure_string_list(request["test_ids"], "test_ids", allow_empty=False)
461
- if len(set(test_ids)) != len(test_ids):
462
- raise RequestError("execution request test_ids contains duplicates")
463
- if has_paths:
464
- test_paths = ensure_string_list(request["test_paths"], "test_paths", allow_empty=False)
465
- if len(set(test_paths)) != len(test_paths):
466
- raise RequestError("execution request test_paths contains duplicates")
467
- if request["layer"] not in LAYERS and request["layer"] != "lightweight":
468
- raise RequestError(f"unknown execution layer: {request['layer']}")
469
- if plan is not None and has_ids:
470
- tests = test_plan_entries(plan)
471
- known = {item.get("id") for item in tests}
472
- unknown = sorted(set(request["test_ids"]) - known)
473
- if unknown:
474
- raise RequestError(f"execution request references unknown test IDs: {', '.join(unknown)}")
475
- test_map = {item.get("id"): item for item in tests}
476
- invalid_layer = sorted(
477
- test_id for test_id in request["test_ids"]
478
- if request["layer"] not in (test_map.get(test_id, {}).get("layers") or [])
479
- )
480
- if invalid_layer:
481
- raise RequestError(f"execution layer is not planned for test IDs: {', '.join(invalid_layer)}")
482
- validate_external_targets(request["external_targets"])
483
- if "timeout_seconds" in request and (isinstance(request["timeout_seconds"], bool) or not isinstance(request["timeout_seconds"], (int, float)) or request["timeout_seconds"] <= 0):
484
- raise RequestError("timeout_seconds must be a positive number")
485
- return request
486
-
487
-
488
- def normalize_command_scope_path(project_root: Path, cwd: Path, value: str) -> str | None:
489
- if not isinstance(value, str) or not value or value.startswith("-"):
490
- return None
491
- candidate = Path(value)
492
- resolved = candidate.resolve() if candidate.is_absolute() else (cwd / candidate).resolve()
493
- try:
494
- return resolved.relative_to(project_root.resolve()).as_posix()
495
- except ValueError:
496
- return None
497
-
498
-
499
- def command_scope_test_ids(project_root: Path, request: dict[str, Any], tests: list[dict[str, Any]]) -> list[str]:
500
- cwd = confined(project_root, request["cwd"] or ".", must_exist=True, directory=True)
501
- command = request["command"]
502
- executable = Path(command[0]).name.lower()
503
- subcommands = {item for item in command[1:] if not item.startswith("-")}
504
- path_tokens: list[str] = []
505
- after_separator = False
506
- skip_option_value = False
507
- options_with_values = {
508
- "-C", "--config", "--config-file", "--project", "--package", "-p",
509
- "--root", "--rootdir", "--cwd", "--testPathPattern", "--testNamePattern",
510
- }
511
- scope_affecting_options = {
512
- "-C", "--config", "--config-file", "--project", "--package", "--root", "--rootdir",
513
- "--cwd", "--testPathPattern", "--testNamePattern", "--grep", "-k", "--filter", "-t",
514
- }
515
- for token in command[1:]:
516
- option_name = token.split("=", 1)[0]
517
- if option_name in scope_affecting_options:
518
- raise RequestError(
519
- "command scope cannot be inferred when the native command uses a test-selection "
520
- f"option: {option_name}"
521
- )
522
- if skip_option_value:
523
- skip_option_value = False
524
- continue
525
- if token == "--":
526
- after_separator = True
527
- continue
528
- if token in options_with_values:
529
- skip_option_value = True
530
- continue
531
- if token.startswith("-"):
532
- continue
533
- if token in {"test", "run", "watch", "coverage", "all", "serial", "parallel"} and not after_separator:
534
- continue
535
- if after_separator or token.startswith((".", "/")) or "/" in token or any(mark in token for mark in "*?[]"):
536
- path_tokens.append(token)
537
-
538
- package_wide = (
539
- executable in {"pytest", "vitest", "jest", "mocha", "busted"}
540
- or (executable in {"python", "python3", "py.test"} and "pytest" in command)
541
- or (executable in {"npm", "npm.cmd", "pnpm", "pnpm.cmd", "yarn", "yarn.cmd"} and "test" in subcommands)
542
- or (executable in {"go", "cargo", "mvn", "gradle", "gradlew"} and "test" in subcommands)
543
- )
544
- if not path_tokens:
545
- if package_wide:
546
- return sorted({item["id"] for item in tests})
547
- raise RequestError(
548
- "command scope cannot be inferred from the native command; use a recognized package-wide command or an explicit test path in the command"
549
- )
550
-
551
- matched_paths: set[str] = set()
552
- for token in path_tokens:
553
- recursive_scope = token == "..." or token.endswith("/...")
554
- scope_token = token[:-3].rstrip("/") if recursive_scope else token
555
- normalized = normalize_command_scope_path(project_root, cwd, scope_token or ".")
556
- if normalized is None:
557
- continue
558
- has_glob = any(mark in normalized for mark in "*?[]")
559
- for test in tests:
560
- test_path = test["project_path"]
561
- within_scope = normalized in {"", "."} or test_path == normalized or path_is_below_or_equal(test_path, normalized)
562
- if (has_glob and fnmatch.fnmatch(test_path, normalized)) or (recursive_scope and within_scope) or (
563
- not has_glob and not recursive_scope and within_scope
564
- ):
565
- matched_paths.add(test_path)
566
- if not matched_paths:
567
- raise RequestError(
568
- "native command scope does not cover any prepared test"
569
- )
570
- return [item["id"] for item in tests if item["project_path"] in matched_paths]
571
-
572
-
573
- def resolve_execution_test_ids(project_root: Path, evidence_dir: Path, request: dict[str, Any]) -> dict[str, Any]:
574
- plan = plan_for_execution(evidence_dir)
575
- if plan is None:
576
- if "test_ids" not in request:
577
- raise RequestError("builder-generated test plan is required before path-based or auto-bound execution")
578
- return request
579
- if (evidence_dir / "lifecycle.json").is_file() and not (evidence_dir / "test-preparation.json").is_file():
580
- raise RequestError("canonical lifecycle requires prepare-tests before execute")
581
- tests = test_plan_entries(plan)
582
- by_id = {item["id"]: item for item in tests}
583
- by_path = {item["project_path"]: item for item in tests}
584
- if "test_ids" in request:
585
- declared = list(request["test_ids"])
586
- elif "test_paths" in request:
587
- unknown_paths = sorted(set(request["test_paths"]) - set(by_path))
588
- if unknown_paths:
589
- raise RequestError(f"execution request references unknown test paths: {', '.join(unknown_paths)}")
590
- declared = [by_path[path]["id"] for path in request["test_paths"]]
591
- else:
592
- declared = None
593
- inferred = command_scope_test_ids(project_root, request, tests)
594
- if declared is not None and set(declared) != set(inferred):
595
- raise RequestError(
596
- "execution request binding does not exactly match native command scope "
597
- f"(declared: {', '.join(sorted(declared))}; inferred: {', '.join(sorted(inferred))})"
598
- )
599
- resolved = inferred
600
- if not resolved:
601
- raise RequestError("execution request resolved no prepared tests")
602
- unknown = sorted(set(resolved) - set(by_id))
603
- if unknown:
604
- raise RequestError(f"execution request references unknown test IDs: {', '.join(unknown)}")
605
- invalid_layer = sorted(
606
- test_id for test_id in resolved
607
- if request["layer"] not in (by_id[test_id].get("layers") or [])
608
- )
609
- if invalid_layer:
610
- raise RequestError(f"execution layer is not planned for test IDs: {', '.join(invalid_layer)}")
611
- request.pop("test_paths", None)
612
- request.pop("auto_bind", None)
613
- request["test_ids"] = resolved
614
- return request
615
-
616
-
617
- def collect_matches(project_root: Path, patterns: list[str], exclusions: set[str]) -> list[dict[str, str]]:
618
- project_resolved = project_root.resolve()
619
- paths: set[Path] = set()
620
- for pattern in patterns:
621
- if Path(pattern).is_absolute() or ".." in Path(pattern).parts:
622
- raise RequestError(f"inventory pattern is not project-relative: {pattern}")
623
- for candidate in project_root.glob(pattern):
624
- if candidate.is_file():
625
- paths.add(candidate.resolve())
626
- entries = []
627
- for candidate in sorted(paths):
628
- relative = candidate.relative_to(project_resolved).as_posix()
629
- if relative not in exclusions and not is_reserved_inventory_path(relative):
630
- entries.append({"path": relative, "sha256": file_sha256(candidate)})
631
- return entries
632
-
633
-
634
- def validate_category_disjointness(output_categories: dict[str, list[dict[str, str]]]) -> None:
635
- membership: dict[str, str] = {}
636
- for category, entries in output_categories.items():
637
- for entry in entries:
638
- path = entry["path"]
639
- previous = membership.get(path)
640
- if previous is not None:
641
- raise RequestError(f"inventory path appears in multiple categories: {path} ({previous}, {category})")
642
- membership[path] = category
643
-
644
-
645
- def validate_module_root_request(
646
- project_root: Path,
647
- evidence_dir: Path,
648
- module_roots: list[Any],
649
- changed_files: list[str],
650
- ) -> tuple[list[str], dict[str, list[str]], dict[str, list[str]]]:
651
- normalized_roots: list[str] = []
652
- explicit_changed: dict[str, list[str]] = {}
653
- for item in module_roots:
654
- if isinstance(item, str):
655
- relative = normalize_relative(item)
656
- changed_set: list[str] = []
657
- elif isinstance(item, dict) and set(item) <= {"path", "changed_files"} and isinstance(item.get("path"), str):
658
- relative = normalize_relative(item["path"])
659
- changed_set = ensure_string_list(item.get("changed_files", []), f"module root {relative} changed_files")
660
- else:
661
- raise RequestError("module_roots entries must be paths or {path, changed_files} objects")
662
- confined(project_root, relative, must_exist=True)
663
- if relative in normalized_roots:
664
- raise RequestError(f"duplicate module root: {relative}")
665
- for changed in changed_set:
666
- changed = normalize_relative(changed)
667
- if not path_is_below_or_equal(changed, relative):
668
- raise RequestError(f"module root changed file lies outside root: {changed}")
669
- if changed not in changed_files:
670
- raise RequestError(f"module root changed file is not in changed_files: {changed}")
671
- normalized_roots.append(relative)
672
- explicit_changed[relative] = sorted(changed_set)
673
- enumerated = enumerate_module_root_files(project_root, evidence_dir, normalized_roots)
674
- return normalized_roots, explicit_changed, enumerated
675
-
676
-
677
- def path_is_below_or_equal(path: str, root: str) -> bool:
678
- normalized_path = Path(path)
679
- normalized_root = Path(root)
680
- return normalized_path == normalized_root or normalized_root in normalized_path.parents
681
-
682
-
683
- def enumerate_module_root_files(
684
- project_root: Path,
685
- evidence_dir: Path,
686
- module_roots: list[str],
687
- ) -> dict[str, list[str]]:
688
- project_resolved = project_root.resolve()
689
- evidence_resolved = evidence_dir.resolve()
690
- result: dict[str, list[str]] = {}
691
- for relative in module_roots:
692
- root = confined(project_root, relative, must_exist=True)
693
- candidates = [root] if root.is_file() else root.rglob("*")
694
- files: list[str] = []
695
- for candidate in candidates:
696
- if not candidate.is_file() or ".git" in candidate.parts or "__pycache__" in candidate.parts:
697
- continue
698
- resolved = candidate.resolve()
699
- if resolved == evidence_resolved or evidence_resolved in resolved.parents:
700
- continue
701
- files.append(resolved.relative_to(project_resolved).as_posix())
702
- result[relative] = sorted(files)
703
- return result
704
-
705
-
706
- def run_inventory(project_root: Path, evidence_dir: Path, request_path: Path, output_name: str) -> Path:
707
- request = ensure_object(load_json(request_path), "inventory request")
708
- required = {"request_version", "categories", "changed_files", "module_roots", "exclusions", "discovery_evidence", "plan_inputs"}
709
- if set(request) != required or request["request_version"] != "1.0":
710
- raise RequestError(f"inventory request fields must be exactly {sorted(required)} with version 1.0")
711
- categories = ensure_object(request["categories"], "inventory categories")
712
- if set(categories) != set(CATEGORIES):
713
- raise RequestError(f"inventory categories must be exactly {list(CATEGORIES)}")
714
- exclusions = request["exclusions"]
715
- if not isinstance(exclusions, list):
716
- raise RequestError("inventory exclusions must be an array")
717
- exclusion_paths: set[str] = set()
718
- for exclusion in exclusions:
719
- if not isinstance(exclusion, dict) or set(exclusion) != {"path", "reason", "evidence"}:
720
- raise RequestError("each inventory exclusion requires path, reason, and evidence")
721
- relative = normalize_relative(exclusion["path"])
722
- if is_reserved_inventory_path(relative):
723
- raise RequestError(f"reserved inventory path must be automatically excluded: {relative}")
724
- confined(project_root, relative)
725
- if not isinstance(exclusion["reason"], str) or len(exclusion["reason"].strip()) < 8:
726
- raise RequestError("inventory exclusion reason is too short")
727
- if not isinstance(exclusion["evidence"], list) or not exclusion["evidence"]:
728
- raise RequestError("inventory exclusion lacks evidence")
729
- exclusion_paths.add(relative)
730
- changed_files = sorted({normalize_relative(item) for item in ensure_string_list(request["changed_files"], "changed_files", allow_empty=False)})
731
- if len(changed_files) != len(request["changed_files"]):
732
- raise RequestError("changed_files contains duplicates")
733
- capture_path = evidence_dir / "change-capture.json"
734
- captured_deleted: set[str] = set()
735
- if capture_path.is_file():
736
- capture = ensure_object(load_json(capture_path), "change capture")
737
- for entry in capture.get("changed_files", []):
738
- if isinstance(entry, dict) and entry.get("status", "").strip() == "D":
739
- captured_deleted.add(entry.get("path"))
740
- if isinstance(entry, dict) and entry.get("role") == "source":
741
- captured_deleted.add(entry.get("path"))
742
- for relative in changed_files:
743
- confined(
744
- project_root,
745
- relative,
746
- must_exist=relative not in exclusion_paths and relative not in captured_deleted,
747
- )
748
- raw_module_roots = request["module_roots"]
749
- if not isinstance(raw_module_roots, list) or not raw_module_roots:
750
- raise RequestError("module_roots must be a non-empty array")
751
- module_roots, explicit_changed, module_root_files = validate_module_root_request(
752
- project_root, evidence_dir, raw_module_roots, changed_files,
753
- )
754
- output_categories = {
755
- name: collect_matches(project_root, ensure_string_list(categories[name], f"category {name}"), exclusion_paths)
756
- for name in CATEGORIES
757
- }
758
- validate_category_disjointness(output_categories)
759
- inventoried_paths = {
760
- entry["path"]
761
- for entries in output_categories.values()
762
- for entry in entries
763
- }
764
- capture_path = evidence_dir / "change-capture.json"
765
- if capture_path.is_file():
766
- capture = load_change_capture(project_root, evidence_dir, require_fresh=True)
767
- captured_paths = set(captured_changed_paths(capture))
768
- if captured_paths != set(changed_files):
769
- missing = sorted(captured_paths - set(changed_files))
770
- extra = sorted(set(changed_files) - captured_paths)
771
- details = []
772
- if missing:
773
- details.append("missing from inventory request: " + ", ".join(missing))
774
- if extra:
775
- details.append("not present in canonical capture: " + ", ".join(extra))
776
- raise RequestError("inventory changed_files must exactly match canonical capture (" + "; ".join(details) + ")")
777
- captured_deleted = {
778
- item["path"]
779
- for item in capture.get("changed_files", [])
780
- if isinstance(item, dict)
781
- and isinstance(item.get("path"), str)
782
- and (str(item.get("status", "")).strip() == "D" or item.get("role") == "source")
783
- }
784
- unaccounted = sorted(
785
- captured_paths
786
- - inventoried_paths
787
- - exclusion_paths
788
- - captured_deleted
789
- )
790
- if unaccounted:
791
- raise RequestError(
792
- "canonical changed files are neither inventoried nor explicitly excluded: "
793
- + ", ".join(unaccounted)
794
- )
795
- elif (evidence_dir / "lifecycle.json").is_file():
796
- raise RequestError("canonical lifecycle requires capture-change before inventory")
797
- missing_from_inventory = sorted({
798
- relative
799
- for files in module_root_files.values()
800
- for relative in files
801
- if relative not in inventoried_paths and relative not in exclusion_paths
802
- })
803
- if missing_from_inventory:
804
- raise RequestError(
805
- "module root contains files that are neither inventoried nor excluded: "
806
- + ", ".join(missing_from_inventory[:20])
807
- )
808
- for root, changed_set in explicit_changed.items():
809
- if changed_set and not set(changed_set) <= set(module_root_files[root]):
810
- raise RequestError(f"module root changed_files contains a path outside enumerated root: {root}")
811
- output = {
812
- "inventory_request_path": evidence_relative(evidence_dir, request_path),
813
- "inventory_request_sha256": file_sha256(request_path),
814
- "categories": output_categories,
815
- "changed_files": changed_files,
816
- "module_roots": module_roots,
817
- "module_root_files": module_root_files,
818
- "module_root_changed_files": explicit_changed,
819
- "exclusions": sorted(exclusion_paths),
820
- "discovery_evidence": request["discovery_evidence"],
821
- "plan_inputs": ensure_object(request["plan_inputs"], "plan_inputs"),
822
- }
823
- output_path = confined(evidence_dir, output_name)
824
- write_json_atomic(output_path, output)
825
- return output_path
826
-
827
-
828
- def run_process(argv: list[str], cwd: Path, environment: dict[str, str], timeout: float | None) -> tuple[int, bytes, bytes]:
829
- timeout_value = max_timeout(timeout)
830
- creationflags = 0
831
- process_kwargs: dict[str, Any] = {}
832
- if os.name == "nt":
833
- creationflags = getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0)
834
- else:
835
- process_kwargs["start_new_session"] = True
836
- process = subprocess.Popen(
837
- argv, cwd=cwd, env=environment, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
838
- creationflags=creationflags, **process_kwargs,
839
- )
840
- try:
841
- stdout, stderr = process.communicate(timeout=timeout_value)
842
- return process.returncode, stdout, stderr
843
- except subprocess.TimeoutExpired:
844
- if os.name == "nt":
845
- subprocess.run(
846
- ["taskkill", "/PID", str(process.pid), "/T", "/F"],
847
- stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False,
848
- )
849
- else:
850
- try:
851
- os.killpg(process.pid, signal.SIGTERM)
852
- process.wait(timeout=1)
853
- except (ProcessLookupError, subprocess.TimeoutExpired):
854
- try:
855
- os.killpg(process.pid, signal.SIGKILL)
856
- except ProcessLookupError:
857
- pass
858
- stdout, stderr = process.communicate()
859
- return 124, stdout, stderr + f"\nPrizmKit runner timeout after {timeout_value:g} seconds\n".encode()
860
-
861
-
862
- def limit_output(value: bytes, label: str) -> bytes:
863
- if len(value) <= MAX_EXECUTION_OUTPUT_BYTES:
864
- return value
865
- marker = f"\nPrizmKit runner {label} truncated after {MAX_EXECUTION_OUTPUT_BYTES} bytes\n".encode()
866
- return value[:MAX_EXECUTION_OUTPUT_BYTES - len(marker)] + marker
867
-
868
-
869
- def run_probe(argv: list[str], cwd: Path, environment: dict[str, str], timeout: float | None) -> dict[str, Any]:
870
- try:
871
- exit_code, stdout, stderr = run_process(argv, cwd, environment, timeout)
872
- return {
873
- "command": argv, "exit_code": exit_code,
874
- "stdout": limit_output(stdout, "probe stdout").decode(errors="replace"),
875
- "stderr": limit_output(stderr, "probe stderr").decode(errors="replace"),
876
- }
877
- except OSError as exc:
878
- return {"command": argv, "error": str(exc)}
879
-
880
-
881
- def append_receipt(evidence_dir: Path, receipt: dict[str, Any]) -> None:
882
- receipt_path = evidence_dir / "receipts" / f"{receipt['execution_id']}.json"
883
- if receipt_path.exists():
884
- raise RequestError(f"receipt already exists: {receipt_path.name}")
885
- write_json_atomic(receipt_path, receipt)
886
- path = evidence_dir / "executions.json"
887
- existing: Any = load_json(path) if path.exists() else []
888
- if not isinstance(existing, list):
889
- raise RequestError("executions.json must remain an array")
890
- existing.append(receipt)
891
- write_json_atomic(path, existing)
892
-
893
-
894
- def ensure_runner_snapshot(evidence_dir: Path) -> tuple[str, str]:
895
- source = Path(__file__).resolve()
896
- digest = file_sha256(source)
897
- relative = f"runner/build_test_evidence-{digest}.py"
898
- destination = evidence_dir / relative
899
- if destination.exists():
900
- if file_sha256(destination) != digest:
901
- raise RequestError("archived runner snapshot hash mismatch")
902
- else:
903
- destination.parent.mkdir(parents=True, exist_ok=True)
904
- shutil.copy2(source, destination)
905
- return relative, digest
906
-
907
-
908
- def execute_request(
909
- project_root: Path,
910
- evidence_dir: Path,
911
- request_path: Path,
912
- *,
913
- replay_of: str | None = None,
914
- selected_execution: bool = True,
915
- ) -> dict[str, Any]:
916
- request_value = load_json(request_path)
917
- original_test_ids = list(request_value["test_ids"]) if isinstance(request_value, dict) and isinstance(request_value.get("test_ids"), list) else None
918
- original_binding_fields = {
919
- key for key in ("test_ids", "test_paths", "auto_bind")
920
- if isinstance(request_value, dict) and key in request_value
921
- }
922
- request = validate_execution_request(request_value, plan=plan_for_execution(evidence_dir))
923
- request = resolve_execution_test_ids(project_root, evidence_dir, request)
924
- if original_binding_fields != {"test_ids"} or original_test_ids != request["test_ids"]:
925
- write_json_atomic(request_path, request)
926
- cwd = confined(project_root, request["cwd"] or ".", must_exist=True, directory=True)
927
- complete_environment = {
928
- key: value for key, value in os.environ.items()
929
- if key in {"PATH", "HOME", "TMPDIR", "TMP", "TEMP", "GOMODCACHE", "GOPATH", "GOCACHE", "NPM_CONFIG_CACHE", "PIP_CACHE_DIR"}
930
- }
931
- complete_environment.setdefault("PATH", os.defpath)
932
- if os.name == "nt" and os.environ.get("SYSTEMROOT"):
933
- complete_environment["SYSTEMROOT"] = os.environ["SYSTEMROOT"]
934
- complete_environment.update(request["environment"])
935
- timeout = max_timeout(request.get("timeout_seconds"))
936
- tools = {
937
- name: run_probe(argv, cwd, complete_environment, timeout)
938
- for name, argv in request["tool_version_commands"].items()
939
- }
940
- probes_reliable = all(
941
- isinstance(result, dict)
942
- and result.get("exit_code") == 0
943
- and "error" not in result
944
- for result in tools.values()
945
- )
946
- execution_id = str(uuid.uuid4())
947
- runner_path, runner_hash = ensure_runner_snapshot(evidence_dir)
948
- raw_dir = evidence_dir / "raw"
949
- raw_dir.mkdir(parents=True, exist_ok=True)
950
- stdout_path = raw_dir / f"{execution_id}.stdout"
951
- stderr_path = raw_dir / f"{execution_id}.stderr"
952
- started = dt.datetime.now(dt.timezone.utc).isoformat()
953
- timed_out = False
954
- output_limited = False
955
- execution_error = False
956
- try:
957
- exit_code, stdout, stderr = run_process(
958
- request["command"], cwd, complete_environment, timeout,
959
- )
960
- timed_out = exit_code == 124 and b"PrizmKit runner timeout" in stderr
961
- output_limited = (
962
- len(stdout) > MAX_EXECUTION_OUTPUT_BYTES
963
- or len(stderr) > MAX_EXECUTION_OUTPUT_BYTES
964
- )
965
- except OSError as exc:
966
- exit_code = 127
967
- stdout = b""
968
- stderr = f"PrizmKit runner execution error: {exc}\n".encode()
969
- execution_error = True
970
- stdout_path.write_bytes(limit_output(stdout, "stdout"))
971
- stderr_path.write_bytes(limit_output(stderr, "stderr"))
972
- finished = dt.datetime.now(dt.timezone.utc).isoformat()
973
- existing = load_json(evidence_dir / "executions.json") if (evidence_dir / "executions.json").exists() else []
974
- if not isinstance(existing, list):
975
- raise RequestError("executions.json must remain an array")
976
- same_request_attempts = sum(1 for item in existing if isinstance(item, dict) and item.get("request_sha256") == file_sha256(request_path))
977
- receipt = {
978
- "receipt_format": "prizmkit-runner-generated-v1",
979
- "runner_path": runner_path,
980
- "runner_sha256": runner_hash,
981
- "execution_id": execution_id,
982
- "attempt_index": same_request_attempts + 1,
983
- "request_path": evidence_relative(evidence_dir, request_path),
984
- "request_sha256": file_sha256(request_path),
985
- "purpose": request["purpose"],
986
- "command": request["command"],
987
- "cwd": request["cwd"],
988
- "environment": complete_environment,
989
- "tool_versions": tools,
990
- "layer": request["layer"],
991
- "test_ids": request["test_ids"],
992
- "external_targets": request["external_targets"],
993
- "timeout_seconds": timeout,
994
- "exit_code": exit_code,
995
- "stdout_path": evidence_relative(evidence_dir, stdout_path),
996
- "stderr_path": evidence_relative(evidence_dir, stderr_path),
997
- "stdout_sha256": file_sha256(stdout_path),
998
- "stderr_sha256": file_sha256(stderr_path),
999
- "selected_execution": selected_execution,
1000
- "reliable": probes_reliable and not timed_out and not output_limited and not execution_error,
1001
- "started_at": started,
1002
- "finished_at": finished,
1003
- "replay_of": replay_of,
1004
- "isolation": {"kind": "project-direct"},
1005
- }
1006
- append_receipt(evidence_dir, receipt)
1007
- return receipt
1008
-
1009
-
1010
- def run_capture_change(
1011
- project_root: Path,
1012
- evidence_dir: Path,
1013
- baseline: str | None,
1014
- output_name: str,
1015
- ) -> Path:
1016
- lifecycle = load_lifecycle(evidence_dir, required=True)
1017
- expected_baseline = lifecycle.get("baseline_commit")
1018
- resolved_baseline = git_baseline(project_root, baseline)
1019
- if isinstance(expected_baseline, str) and resolved_baseline != expected_baseline:
1020
- raise RequestError(
1021
- "capture baseline does not match the baseline pinned during init"
1022
- )
1023
- patch, status_entries = canonical_change_patch(project_root, resolved_baseline)
1024
- patch_path = confined(evidence_dir, "source-change.patch")
1025
- patch_path.parent.mkdir(parents=True, exist_ok=True)
1026
- patch_path.write_bytes(patch)
1027
- capture = {
1028
- "capture_format": "prizmkit-canonical-change-v1",
1029
- "baseline_commit": resolved_baseline,
1030
- "patch_path": evidence_relative(evidence_dir, patch_path),
1031
- "patch_sha256": file_sha256(patch_path),
1032
- "changed_files": status_entries,
1033
- }
1034
- output = confined(evidence_dir, output_name)
1035
- write_json_atomic(output, capture)
1036
- return output
1037
-
1038
-
1039
- def run_init(project_root: Path, evidence_dir: Path, baseline: str | None, output_name: str) -> Path:
1040
- require_canonical_evidence_dir(project_root, evidence_dir)
1041
- align_protocol_state(project_root, protocol_run_key(project_root, baseline))
1042
- require_protocol_budget(project_root, package_initialization=True)
1043
- if evidence_dir.exists() and any(evidence_dir.iterdir()):
1044
- if (evidence_dir / "manifest.json").exists():
1045
- raise RequestError("evidence package is finalized; initialize a new evidence directory")
1046
- raise RequestError("evidence directory is not empty; initialize a new evidence directory")
1047
- evidence_dir.mkdir(parents=True, exist_ok=True)
1048
- resolved_baseline = git_baseline(project_root, baseline)
1049
- update_protocol_state(project_root, event="package-initialized", increment="package_initializations")
1050
- for directory in ("requests", "receipts", "raw", "generated-tests", "contracts", "runner"):
1051
- (evidence_dir / directory).mkdir(parents=True, exist_ok=True)
1052
- write_json_atomic(evidence_dir / "executions.json", [])
1053
- write_json_atomic(evidence_dir / "lifecycle.json", {
1054
- "lifecycle_format": "prizmkit-canonical-lifecycle-v1",
1055
- "status": "initialized",
1056
- "baseline_commit": resolved_baseline,
1057
- "artifact_dir": None,
1058
- "finalized": False,
1059
- "immutable_identity": None,
1060
- })
1061
- output = confined(evidence_dir, output_name)
1062
- write_json_atomic(output, {
1063
- "command": "init",
1064
- "evidence_dir": ".",
1065
- "baseline_commit": resolved_baseline,
1066
- "next": "capture-change",
1067
- })
1068
- return output
1069
-
1070
-
1071
- def protocol_state_path(project_root: Path) -> Path:
1072
- return canonical_evidence_root(project_root) / PROTOCOL_STATE_NAME
1073
-
1074
-
1075
- def align_protocol_state(project_root: Path, run_key: str) -> None:
1076
- state_path = protocol_state_path(project_root)
1077
- if state_path.exists():
1078
- state = ensure_object(load_json(state_path), "protocol recovery state")
1079
- if state.get("run_key") == run_key:
1080
- return
1081
- write_json_atomic(state_path, {"run_key": run_key})
1082
-
1083
-
1084
- def update_protocol_state(
1085
- project_root: Path,
1086
- *,
1087
- event: str,
1088
- error: str | None = None,
1089
- increment: str | None = None,
1090
- ) -> dict[str, Any]:
1091
- state_path = protocol_state_path(project_root)
1092
- state: dict[str, Any] = {}
1093
- if state_path.exists():
1094
- state = ensure_object(load_json(state_path), "protocol recovery state")
1095
- if increment is not None:
1096
- count = state.get(increment, 0)
1097
- if not isinstance(count, int) or isinstance(count, bool):
1098
- count = 0
1099
- state[increment] = count + 1
1100
- state["last_event"] = event
1101
- state["last_error"] = error
1102
- state["max_package_initializations"] = MAX_PROTOCOL_PACKAGE_ATTEMPTS
1103
- state["max_preflight_failures"] = MAX_PROTOCOL_PACKAGE_ATTEMPTS
1104
- state["blocked"] = (
1105
- state.get("package_initializations", 0) >= MAX_PROTOCOL_PACKAGE_ATTEMPTS
1106
- or state.get("preflight_failures", 0) >= MAX_PROTOCOL_PACKAGE_ATTEMPTS
1107
- )
1108
- write_json_atomic(state_path, state)
1109
- return state
1110
-
1111
-
1112
- def require_protocol_budget(project_root: Path, *, package_initialization: bool = False) -> None:
1113
- state_path = protocol_state_path(project_root)
1114
- if not state_path.exists():
1115
- return
1116
- state = ensure_object(load_json(state_path), "protocol recovery state")
1117
- if package_initialization and state.get("package_initializations", 0) >= MAX_PROTOCOL_PACKAGE_ATTEMPTS:
1118
- raise RequestError(
1119
- "evidence protocol package budget exhausted; stop and return TEST_BLOCKED "
1120
- "instead of creating another evidence package"
1121
- )
1122
- if state.get("preflight_failures", 0) >= MAX_PROTOCOL_PACKAGE_ATTEMPTS:
1123
- raise RequestError(
1124
- "evidence protocol preflight budget exhausted; return TEST_BLOCKED "
1125
- "instead of continuing semantic record repair"
1126
- )
1127
-
1128
-
1129
- def protocol_run_key(project_root: Path, baseline: str | None) -> str:
1130
- resolved_baseline = git_baseline(project_root, baseline)
1131
- patch, _ = canonical_change_patch(project_root, resolved_baseline)
1132
- return canonical_sha256({
1133
- "baseline_commit": resolved_baseline,
1134
- "working_diff_sha256": hashlib.sha256(patch).hexdigest(),
1135
- })
1136
-
1137
-
1138
- def semantic_record_paths(change_class: str) -> set[str]:
1139
- common = {"change-classification.json", "scope.json"}
1140
- if change_class == "behavior":
1141
- return common | {"behavior-risk-matrix.json", "infrastructure-changes.json"}
1142
- return common | {"lightweight-verification.json"}
1143
-
1144
-
1145
- def require_semantic_record_files(evidence_dir: Path, change_class: str) -> None:
1146
- missing = sorted(
1147
- relative for relative in semantic_record_paths(change_class)
1148
- if not (evidence_dir / relative).is_file()
1149
- )
1150
- if missing:
1151
- raise RequestError(
1152
- "pre-finalize semantic records are incomplete: " + ", ".join(missing)
1153
- + "; keep this package mutable and repair it before finalize"
1154
- )
1155
-
1156
-
1157
- def preflight_semantic_records(evidence_dir: Path, change_class: str) -> None:
1158
- require_semantic_record_files(evidence_dir, change_class)
1159
- validator_path = Path(__file__).resolve().with_name("validate_test_evidence.py")
1160
- try:
1161
- import importlib.util
1162
- spec = importlib.util.spec_from_file_location("prizmkit_test_validator_preflight", validator_path)
1163
- if spec is None or spec.loader is None:
1164
- raise RequestError("cannot load semantic preflight validator")
1165
- module = importlib.util.module_from_spec(spec)
1166
- spec.loader.exec_module(module)
1167
- records_schema = module.load_schema("authoritative-records.schema.json")
1168
- matrix_schema = module.load_schema("behavior-risk-matrix.schema.json")
1169
- errors: list[str] = []
1170
- definitions = {
1171
- "change-classification.json": "changeClassification",
1172
- "scope.json": "scope",
1173
- "infrastructure-changes.json": "infrastructureChanges",
1174
- "lightweight-verification.json": "lightweightVerification",
1175
- }
1176
- for relative in sorted(semantic_record_paths(change_class)):
1177
- value = load_json(evidence_dir / relative)
1178
- if relative == "behavior-risk-matrix.json":
1179
- module.validate_schema(value, matrix_schema, matrix_schema, "behavior-risk-matrix", errors)
1180
- else:
1181
- module.validate_definition(value, definitions[relative], records_schema, relative, errors)
1182
- if errors:
1183
- raise RequestError("semantic preflight failed: " + "; ".join(errors[:20]))
1184
- except ImportError as exc:
1185
- raise RequestError(f"cannot run semantic preflight: {exc}") from exc
1186
-
1187
-
1188
- def selected_layer_blockers(plan: dict[str, Any] | None, executions: list[Any]) -> list[str]:
1189
- if not isinstance(plan, dict):
1190
- return []
1191
- selected_layers = {
1192
- item.get("layer")
1193
- for item in executions
1194
- if isinstance(item, dict) and item.get("selected_execution") is True
1195
- }
1196
- blockers: list[str] = []
1197
- for layer in plan.get("layers", []):
1198
- if not isinstance(layer, dict) or layer.get("required") is not True:
1199
- continue
1200
- name = layer.get("name")
1201
- if name not in selected_layers:
1202
- blockers.append(f"required layer has no selected execution: {name}")
1203
- return blockers
1204
-
1205
-
1206
- def load_lifecycle(evidence_dir: Path, *, required: bool = False) -> dict[str, Any]:
1207
- path = evidence_dir / "lifecycle.json"
1208
- if not path.exists():
1209
- if required:
1210
- raise RequestError("evidence package is not initialized; run init first")
1211
- return {"lifecycle_format": "legacy-compatible", "status": "legacy"}
1212
- return ensure_object(load_json(path), "lifecycle")
1213
-
1214
-
1215
- def require_mutable_lifecycle(evidence_dir: Path) -> dict[str, Any]:
1216
- lifecycle_path = evidence_dir / "lifecycle.json"
1217
- if not lifecycle_path.exists():
1218
- if (evidence_dir / "manifest.json").exists():
1219
- raise RequestError(
1220
- "evidence package is finalized and immutable; re-init a new evidence package"
1221
- )
1222
- return {"lifecycle_format": "legacy-compatible", "status": "legacy"}
1223
- lifecycle = load_lifecycle(evidence_dir, required=True)
1224
- if lifecycle.get("finalized") is True:
1225
- raise RequestError(
1226
- "evidence package is finalized and immutable; re-init a new evidence package"
1227
- )
1228
- return lifecycle
1229
-
1230
-
1231
- def run_render_report(evidence_dir: Path, output_name: str) -> Path:
1232
- manifest = ensure_object(load_json(evidence_dir / "manifest.json"), "manifest")
1233
- verdict = ensure_object(load_json(evidence_dir / "verdict.json"), "verdict")
1234
- validation_path = evidence_dir / "validation.json"
1235
- validation = ensure_object(load_json(validation_path), "validation") if validation_path.exists() else None
1236
- lifecycle_path = evidence_dir / "lifecycle.json"
1237
- lifecycle = ensure_object(load_json(lifecycle_path), "lifecycle") if lifecycle_path.exists() else {}
1238
- executions = load_json(evidence_dir / "executions.json")
1239
- artifact_dir = lifecycle.get("artifact_dir")
1240
- lines = [
1241
- "# Test Evidence Report",
1242
- "",
1243
- "Derived from structured evidence",
1244
- f"Evidence ID: {manifest.get('evidence_id')}",
1245
- f"Verdict: {verdict.get('verdict')}",
1246
- f"Validator result: {validation.get('result') if validation else 'pending'}",
1247
- "sensitivity=project-controlled",
1248
- "The project owns access control, retention, and upload policy.",
1249
- "Mocked code-level evidence does not verify a real deployed environment.",
1250
- "",
1251
- "## Scope",
1252
- f"- Artifact Dir: {artifact_dir or 'not provided'}",
1253
- "",
1254
- f"Execution receipts: {len(executions) if isinstance(executions, list) else 0}",
1255
- "The legacy test-report interface is not supported.",
1256
- "",
1257
- ]
1258
- output = confined(evidence_dir, output_name)
1259
- rendered = "\n".join(lines)
1260
- lifecycle_finalized = lifecycle.get("finalized") is True
1261
- if lifecycle_finalized:
1262
- if not output.is_file() or output.read_text(encoding="utf-8") != rendered:
1263
- raise RequestError(
1264
- "evidence package is finalized and immutable; re-init a new package before changing the report"
1265
- )
1266
- return output
1267
- write_text_atomic(output, rendered)
1268
- return output
1269
-
1270
-
1271
- def aggregate_inventory(entries: list[dict[str, str]]) -> str:
1272
- return canonical_sha256(sorted(entries, key=lambda item: item["path"]))
1273
-
1274
-
1275
- def changed_test_statuses(evidence_dir: Path) -> dict[str, str]:
1276
- capture = load_change_capture(Path("."), evidence_dir)
1277
- inventory = ensure_object(load_json(evidence_dir / "target-inventory.json"), "target inventory")
1278
- test_paths = {
1279
- item.get("path")
1280
- for item in inventory.get("categories", {}).get("tests", [])
1281
- if isinstance(item, dict) and isinstance(item.get("path"), str)
1282
- }
1283
- statuses: dict[str, str] = {}
1284
- for entry in capture.get("changed_files", []):
1285
- if not isinstance(entry, dict) or not isinstance(entry.get("path"), str):
1286
- continue
1287
- relative = entry["path"]
1288
- if relative not in test_paths or entry.get("role") == "source":
1289
- continue
1290
- status = str(entry.get("status", "")).strip()
1291
- if status == "??" or status.startswith("A"):
1292
- statuses[relative] = "added"
1293
- elif status.startswith("M") or status.endswith("M"):
1294
- statuses[relative] = "modified"
1295
- elif status.startswith("R") or status.startswith("C"):
1296
- statuses[relative] = "modified"
1297
- return statuses
1298
-
1299
-
1300
- def stable_test_id(project_path: str, selector: str | None = None) -> str:
1301
- identity = project_path if not selector else f"{project_path}::{selector}"
1302
- return f"native-test-{canonical_sha256(identity)[:16]}"
1303
-
1304
-
1305
- def prepare_test_layers(request: dict[str, Any], existing_plan: dict[str, Any] | None = None) -> list[dict[str, Any]]:
1306
- layers = request.get("layers")
1307
- if layers is None and isinstance(existing_plan, dict):
1308
- layers = existing_plan.get("layers")
1309
- if not isinstance(layers, list) or len(layers) != len(LAYERS):
1310
- raise RequestError("test preparation must assess all five layers exactly once")
1311
- normalized: list[dict[str, Any]] = []
1312
- seen: set[str] = set()
1313
- for item in layers:
1314
- if not isinstance(item, dict) or set(item) != {"name", "required", "not_applicable"}:
1315
- raise RequestError("test preparation layer entries require name, required, and not_applicable")
1316
- name = item.get("name")
1317
- if name not in LAYERS or name in seen:
1318
- raise RequestError(f"invalid or duplicate test preparation layer: {name}")
1319
- seen.add(name)
1320
- if not isinstance(item.get("required"), bool):
1321
- raise RequestError(f"test preparation layer required must be boolean: {name}")
1322
- if item["required"] is True and item.get("not_applicable") is not None:
1323
- raise RequestError(f"required test preparation layer cannot be N/A: {name}")
1324
- if item["required"] is False and not isinstance(item.get("not_applicable"), dict):
1325
- raise RequestError(f"omitted test preparation layer needs structured N/A: {name}")
1326
- normalized.append(item)
1327
- return normalized
1328
-
1329
-
1330
- def run_prepare_tests(project_root: Path, evidence_dir: Path, request_path: Path, output_name: str) -> Path:
1331
- request = ensure_object(load_json(request_path), "test preparation request")
1332
- allowed = {"request_version", "layers", "tests"}
1333
- if set(request) != allowed or request.get("request_version") != "1.0":
1334
- raise RequestError("test preparation request must contain request_version, layers, and tests only")
1335
- requested = request.get("tests")
1336
- if not isinstance(requested, list) or not requested:
1337
- raise RequestError("test preparation request tests must be a non-empty array")
1338
- existing_plan_path = evidence_dir / "test-plan.json"
1339
- existing_plan = ensure_object(load_json(existing_plan_path), "existing test plan") if existing_plan_path.is_file() else None
1340
- prepared_layers = prepare_test_layers(request, existing_plan)
1341
- inventory = ensure_object(load_json(evidence_dir / "target-inventory.json"), "target inventory")
1342
- inventory_paths = {
1343
- item.get("path") for item in inventory.get("categories", {}).get("tests", [])
1344
- if isinstance(item, dict) and isinstance(item.get("path"), str)
1345
- }
1346
- changed = changed_test_statuses(evidence_dir)
1347
- changed_native_tests = set(changed) & inventory_paths
1348
- selected_paths: set[str] = set()
1349
- normalized: list[dict[str, Any]] = []
1350
- for item in requested:
1351
- if not isinstance(item, dict):
1352
- raise RequestError("test preparation entries must be objects")
1353
- allowed_item = {"project_path", "behavior_ids", "layers", "selector", "runner"}
1354
- if set(item) - allowed_item:
1355
- raise RequestError("test preparation entries may not contain IDs, status, snapshot, or hash fields")
1356
- project_path = normalize_relative(item.get("project_path", ""))
1357
- if project_path in selected_paths:
1358
- raise RequestError(f"duplicate native test path: {project_path}")
1359
- selected_paths.add(project_path)
1360
- if project_path not in inventory_paths:
1361
- raise RequestError(f"native test is not in the test inventory: {project_path}")
1362
- source = confined(project_root, project_path, must_exist=True)
1363
- behavior_ids = ensure_string_list(item.get("behavior_ids"), f"test {project_path} behavior_ids", allow_empty=False)
1364
- layers = ensure_string_list(item.get("layers"), f"test {project_path} layers", allow_empty=False)
1365
- unknown_layers = sorted(set(layers) - set(LAYERS))
1366
- if unknown_layers:
1367
- raise RequestError(f"test {project_path} uses unknown layers: {', '.join(unknown_layers)}")
1368
- if len(layers) != len(set(layers)):
1369
- raise RequestError(f"test {project_path} contains duplicate layers")
1370
- selector = item.get("selector")
1371
- if selector is not None and not isinstance(selector, str):
1372
- raise RequestError(f"test {project_path} selector must be a string")
1373
- status = changed.get(project_path, "existing")
1374
- entry = {
1375
- "id": stable_test_id(project_path, selector),
1376
- "behavior_ids": behavior_ids,
1377
- "change_status": status,
1378
- "project_path": project_path,
1379
- "snapshot_path": None,
1380
- "inventory_path": project_path,
1381
- "layers": layers,
1382
- }
1383
- if selector is not None:
1384
- entry["selector"] = selector
1385
- if isinstance(item.get("runner"), str):
1386
- entry["runner"] = item["runner"]
1387
- normalized.append(entry)
1388
- unbound = sorted(changed_native_tests - selected_paths)
1389
- if unbound:
1390
- raise RequestError("changed native tests are not bound by test preparation: " + ", ".join(unbound))
1391
- generated = evidence_dir / "generated-tests"
1392
- generated.mkdir(parents=True, exist_ok=True)
1393
- used_snapshots: set[str] = set()
1394
- for entry in normalized:
1395
- if entry["change_status"] in {"added", "modified"}:
1396
- destination_relative = f"generated-tests/{Path(entry['project_path']).name}"
1397
- if destination_relative in used_snapshots:
1398
- destination_relative = f"generated-tests/{entry['id']}-{Path(entry['project_path']).name}"
1399
- destination = confined(evidence_dir, destination_relative)
1400
- destination.parent.mkdir(parents=True, exist_ok=True)
1401
- shutil.copy2(project_root / entry["project_path"], destination)
1402
- entry["snapshot_path"] = destination_relative
1403
- used_snapshots.add(destination_relative)
1404
- plan = {
1405
- "target_hashes": {},
1406
- "plan_inputs": inventory.get("plan_inputs", {}),
1407
- "layers": prepared_layers,
1408
- "tests": normalized,
1409
- }
1410
- existing_plan = evidence_dir / "test-plan.json"
1411
- if existing_plan.is_file():
1412
- prior = ensure_object(load_json(existing_plan), "existing test plan")
1413
- plan["target_hashes"] = prior.get("target_hashes", {})
1414
- plan["layers"] = prior.get("layers", prepared_layers)
1415
- write_json_atomic(evidence_dir / "test-plan.json", plan)
1416
- write_json_atomic(evidence_dir / output_name, {
1417
- "format": "prizmkit-native-test-preparation-v1",
1418
- "request_path": evidence_relative(evidence_dir, request_path),
1419
- "request_sha256": file_sha256(request_path),
1420
- "tests": normalized,
1421
- "changed_test_statuses": changed,
1422
- })
1423
- return evidence_dir / output_name
1424
-
1425
-
1426
- def normalize_test_snapshots(project_root: Path, evidence_dir: Path, plan: dict[str, Any]) -> None:
1427
- tests = plan.get("tests")
1428
- if not isinstance(tests, list):
1429
- return
1430
- captured_statuses = changed_test_statuses(evidence_dir) if (evidence_dir / "change-capture.json").is_file() else {}
1431
- inventory = load_json(evidence_dir / "target-inventory.json") if (evidence_dir / "target-inventory.json").is_file() else {}
1432
- inventory_paths = {
1433
- item.get("path") for item in inventory.get("categories", {}).get("tests", [])
1434
- if isinstance(item, dict) and isinstance(item.get("path"), str)
1435
- }
1436
- selected_paths = {test.get("project_path") for test in tests if isinstance(test, dict)}
1437
- unbound = sorted((set(captured_statuses) & inventory_paths) - selected_paths)
1438
- if unbound:
1439
- raise RequestError("changed native tests are not bound by test plan: " + ", ".join(unbound))
1440
- generated = evidence_dir / "generated-tests"
1441
- generated.mkdir(parents=True, exist_ok=True)
1442
- for test in tests:
1443
- if not isinstance(test, dict):
1444
- continue
1445
- project_path = test.get("project_path")
1446
- if not isinstance(project_path, str):
1447
- continue
1448
- status = captured_statuses.get(project_path, "existing") if captured_statuses else test.get("change_status", test.get("origin", "existing"))
1449
- if status not in {"existing", "added", "modified"}:
1450
- raise RequestError(f"unknown test change_status: {test.get('id')}")
1451
- test["change_status"] = status
1452
- if status == "existing":
1453
- test["snapshot_path"] = None
1454
- continue
1455
- source = confined(project_root, project_path, must_exist=True)
1456
- destination_relative = test.get("snapshot_path")
1457
- if not isinstance(destination_relative, str) or not destination_relative.startswith("generated-tests/"):
1458
- destination_relative = f"generated-tests/{Path(project_path).name}"
1459
- destination = confined(evidence_dir, destination_relative)
1460
- destination.parent.mkdir(parents=True, exist_ok=True)
1461
- shutil.copy2(source, destination)
1462
- test["snapshot_path"] = destination_relative
1463
- write_json_atomic(evidence_dir / "test-plan.json", plan)
1464
-
1465
-
1466
- def stage_output_owner(relative: str, change_class: str) -> str:
1467
- if relative in {"change-classification.json"}:
1468
- return "CHANGE_CLASSIFY"
1469
- if relative in {"scope.json", "target-inventory.json", "environment.json", "source-change.patch", "change-capture.json"} or relative.startswith("requests/"):
1470
- return "SCOPE_DISCOVER"
1471
- if relative in {"behavior-risk-matrix.json"}:
1472
- return "CONTRACT_MODEL"
1473
- if relative in {"test-plan.json"} or relative == "test-preparation.json":
1474
- return "TEST_PLAN"
1475
- if relative in {"infrastructure-changes.json"} or relative.startswith("contracts/"):
1476
- return "INFRA_READY"
1477
- if relative.startswith("generated-tests/"):
1478
- return "TEST_BUILD"
1479
- if relative == "executions.json" or relative.startswith("receipts/") or relative.startswith("raw/") or relative.startswith("runner/"):
1480
- return "EXECUTE_PROVE"
1481
- if relative in FINAL_RECORDS or relative == "lifecycle.json" or relative == "init.json" or relative == "finalize.json":
1482
- return "EVIDENCE_PACKAGE"
1483
- return "EVIDENCE_PACKAGE"
1484
-
1485
-
1486
- def build_manifest(evidence_dir: Path, *, evidence_id: str, identity: dict[str, Any], target_hashes: dict[str, str], change_class: str, baseline_commit: str, final_verdict: str, attested: bool = False) -> dict[str, Any]:
1487
- file_paths = sorted(
1488
- path.relative_to(evidence_dir).as_posix()
1489
- for path in evidence_dir.rglob("*")
1490
- if path.is_file() and path.name != "manifest.json"
1491
- )
1492
- files = [
1493
- {"path": relative, "sha256": file_sha256(evidence_dir / relative), "produced_by": stage_output_owner(relative, change_class)}
1494
- for relative in file_paths
1495
- ]
1496
- outputs_by_stage = {stage: [] for stage in STAGES}
1497
- for entry in files:
1498
- outputs_by_stage.setdefault(entry["produced_by"], []).append(entry["path"])
1499
- stages = []
1500
- for stage in STAGES:
1501
- outputs = sorted(outputs_by_stage.get(stage, []))
1502
- stages.append({
1503
- "name": stage,
1504
- "status": "complete" if stage != "EVIDENCE_VALIDATE" or attested else "pending",
1505
- "outputs": outputs,
1506
- "not_applicable": None,
1507
- })
1508
- return {
1509
- "protocol_version": "1.0",
1510
- "evidence_id": evidence_id,
1511
- "evidence_id_inputs": identity,
1512
- "sensitivity": "project-controlled",
1513
- "environment_claim": "mocked-code-level-only",
1514
- "change_class": change_class,
1515
- "baseline_commit": baseline_commit,
1516
- "working_diff_sha256": identity["working_diff_sha256"],
1517
- "target_hashes": target_hashes,
1518
- "stages": stages,
1519
- "files": files,
1520
- "final_verdict": final_verdict,
1521
- "compatibility": "legacy-test-report-interface-not-supported",
1522
- }
1523
-
1524
-
1525
- def write_handoff_pointer(evidence_dir: Path, artifact_dir: str | None, project_root: Path | None = None) -> None:
1526
- if not artifact_dir:
1527
- return
1528
- artifact = Path(artifact_dir)
1529
- if not artifact.is_absolute() and project_root is not None:
1530
- artifact = project_root / artifact
1531
- artifact = artifact.resolve()
1532
- if not artifact.is_dir():
1533
- raise RequestError(f"artifact_dir does not exist: {artifact_dir}")
1534
- pointer = artifact / "test-report-path.txt"
1535
- write_text_atomic(pointer, str((evidence_dir / "test-report.md").resolve()) + "\n")
1536
-
1537
-
1538
- def run_finalize(project_root: Path, evidence_dir: Path, request: dict[str, Any], output_name: str) -> Path:
1539
- require_canonical_evidence_dir(project_root, evidence_dir)
1540
- require_protocol_budget(project_root)
1541
- allowed = {"request_version", "change_class", "baseline_commit", "scope_path", "environment_path", "artifact_dir", "blockers", "repair_scope"}
1542
- if set(request) - allowed:
1543
- raise RequestError(f"finalize request contains unsupported fields: {sorted(set(request) - allowed)}")
1544
- if request.get("request_version", "1.0") != "1.0":
1545
- raise RequestError("unsupported finalize request version")
1546
- if request.get("repair_scope") is not None and request.get("repair_scope") not in REPAIR_SCOPES:
1547
- raise RequestError("repair_scope must identify an evidence-backed repair domain")
1548
- lifecycle = load_lifecycle(evidence_dir, required=True)
1549
- if lifecycle.get("finalized") is True:
1550
- raise RequestError("evidence identity is finalized; re-init a new package before changing scope or source")
1551
- capture_path = evidence_dir / "change-capture.json"
1552
- if not capture_path.exists():
1553
- run_capture_change(project_root, evidence_dir, request.get("baseline_commit"), "change-capture.json")
1554
- capture = load_change_capture(project_root, evidence_dir, require_fresh=True)
1555
- lifecycle_baseline = lifecycle.get("baseline_commit")
1556
- baseline = capture.get("baseline_commit")
1557
- if not isinstance(lifecycle_baseline, str) or baseline != lifecycle_baseline:
1558
- raise RequestError("canonical change capture baseline does not match init baseline")
1559
- patch_path = evidence_dir / "source-change.patch"
1560
- if not isinstance(baseline, str) or not patch_path.is_file():
1561
- raise RequestError("canonical change capture is incomplete")
1562
- change_class = request.get("change_class", "behavior")
1563
- if change_class not in {"behavior", "lightweight"}:
1564
- raise RequestError("change_class must be behavior or lightweight")
1565
- try:
1566
- require_semantic_record_files(evidence_dir, change_class)
1567
- except RequestError as exc:
1568
- state = update_protocol_state(
1569
- project_root,
1570
- event="preflight-failed",
1571
- error=str(exc),
1572
- increment="preflight_failures",
1573
- )
1574
- if state.get("blocked"):
1575
- raise RequestError(
1576
- str(exc) + "; evidence protocol recovery budget exhausted; return TEST_BLOCKED "
1577
- "and do not initialize another package"
1578
- ) from exc
1579
- raise
1580
-
1581
- artifact_dir = request.get("artifact_dir")
1582
- if artifact_dir is not None and not isinstance(artifact_dir, str):
1583
- raise RequestError("artifact_dir must be a project-relative directory")
1584
- if artifact_dir is not None:
1585
- artifact = confined(project_root, artifact_dir, must_exist=True, directory=True)
1586
- lifecycle["artifact_dir"] = artifact.relative_to(project_root).as_posix()
1587
- scope_relative = request.get("scope_path", "scope.json")
1588
- scope = ensure_object(load_json(request_file(evidence_dir, scope_relative)), "scope")
1589
- inventory = ensure_object(load_json(evidence_dir / "target-inventory.json"), "target inventory")
1590
- environment_path = evidence_dir / request.get("environment_path", "environment.json")
1591
- environment = load_json(environment_path) if environment_path.exists() else {}
1592
- plan = load_json(evidence_dir / "test-plan.json") if (evidence_dir / "test-plan.json").exists() else None
1593
- if isinstance(plan, dict):
1594
- normalize_test_snapshots(project_root, evidence_dir, plan)
1595
- categories = inventory.get("categories", {})
1596
- target_hashes = {
1597
- category: aggregate_inventory(categories.get(category, []))
1598
- for category in CATEGORIES
1599
- }
1600
- target_hashes["environment"] = canonical_sha256(environment)
1601
- target_hashes["plan"] = canonical_sha256(inventory.get("plan_inputs", {}))
1602
- if isinstance(plan, dict):
1603
- plan["target_hashes"] = target_hashes
1604
- plan["plan_inputs"] = inventory.get("plan_inputs", {})
1605
- write_json_atomic(evidence_dir / "test-plan.json", plan)
1606
- scope["target_hashes"] = target_hashes
1607
- write_json_atomic(evidence_dir / scope_relative, scope)
1608
- try:
1609
- preflight_semantic_records(evidence_dir, change_class)
1610
- except RequestError as exc:
1611
- state = update_protocol_state(
1612
- project_root,
1613
- event="preflight-failed",
1614
- error=str(exc),
1615
- increment="preflight_failures",
1616
- )
1617
- if state.get("blocked"):
1618
- raise RequestError(
1619
- str(exc) + "; evidence protocol recovery budget exhausted; return TEST_BLOCKED "
1620
- "and do not initialize another package"
1621
- ) from exc
1622
- raise
1623
- identity = {
1624
- "baseline_commit": baseline,
1625
- "working_diff_sha256": file_sha256(patch_path),
1626
- "scope_sha256": canonical_sha256(scope),
1627
- }
1628
- evidence_id = canonical_sha256(identity)
1629
- destination = evidence_dir.parent / evidence_id
1630
- executions = load_json(evidence_dir / "executions.json") if (evidence_dir / "executions.json").exists() else []
1631
- blockers = list(request.get("blockers", [])) if isinstance(request.get("blockers", []), list) else []
1632
- blockers.extend(selected_layer_blockers(plan, executions if isinstance(executions, list) else []))
1633
- required_records = {
1634
- "change-classification.json", "scope.json", "target-inventory.json", "environment.json",
1635
- "source-change.patch",
1636
- }
1637
- if change_class == "behavior":
1638
- required_records.update({
1639
- "behavior-risk-matrix.json", "test-plan.json", "test-preparation.json",
1640
- "infrastructure-changes.json",
1641
- })
1642
- else:
1643
- required_records.add("lightweight-verification.json")
1644
- missing_records = sorted(
1645
- relative for relative in required_records
1646
- if not (evidence_dir / relative).is_file()
1647
- )
1648
- if missing_records:
1649
- state = update_protocol_state(
1650
- project_root,
1651
- event="finalize-precondition-failed",
1652
- error="required evidence records are missing: " + ", ".join(missing_records),
1653
- increment="preflight_failures",
1654
- )
1655
- suffix = ""
1656
- if state.get("blocked"):
1657
- suffix = "; evidence protocol recovery budget exhausted; return TEST_BLOCKED"
1658
- raise RequestError(
1659
- "pre-finalize package records are incomplete: " + ", ".join(missing_records) + suffix
1660
- )
1661
- selected_unreliable = [
1662
- item for item in executions
1663
- if isinstance(item, dict)
1664
- and item.get("selected_execution") is True
1665
- and item.get("reliable") is not True
1666
- ]
1667
- for execution in selected_unreliable:
1668
- blockers.append(
1669
- "selected execution is unreliable: "
1670
- + str(execution.get("execution_id", "unknown-execution"))
1671
- )
1672
- selected_failures = [
1673
- item for item in executions
1674
- if isinstance(item, dict)
1675
- and item.get("selected_execution") is True
1676
- and item.get("reliable") is True
1677
- and item.get("exit_code") != 0
1678
- ]
1679
- if selected_failures and not request.get("repair_scope"):
1680
- blockers.append("reliable failure exists but repair_scope is not evidence-backed")
1681
- verdict = (
1682
- "TEST_BLOCKED" if blockers
1683
- else "TEST_FAIL" if selected_failures and request.get("repair_scope")
1684
- else "TEST_PASS"
1685
- )
1686
- verdict_record: dict[str, Any] = {
1687
- "verdict": verdict,
1688
- "testing_domain_only": True,
1689
- "authorizes_commit_or_release": False,
1690
- "real_environment_validated": False,
1691
- "repairs_business_defects": False,
1692
- "code_evidence_replayable": verdict == "TEST_PASS",
1693
- "blockers": blockers if verdict == "TEST_BLOCKED" else [],
1694
- "reproduced_failures": [item["execution_id"] for item in selected_failures] if verdict == "TEST_FAIL" else [],
1695
- }
1696
- if verdict == "TEST_FAIL" and request.get("repair_scope"):
1697
- verdict_record["repair_scope"] = request["repair_scope"]
1698
- write_json_atomic(evidence_dir / "verdict.json", verdict_record)
1699
- write_json_atomic(evidence_dir / "validation.json", {})
1700
- write_json_atomic(evidence_dir / "lifecycle.json", {
1701
- **lifecycle,
1702
- "status": "finalizing",
1703
- "finalized": False,
1704
- "immutable_identity": identity,
1705
- })
1706
- # First manifest: pinned before render-report so the report can reference it.
1707
- manifest = build_manifest(
1708
- evidence_dir, evidence_id=evidence_id, identity=identity,
1709
- target_hashes=target_hashes, change_class=change_class,
1710
- baseline_commit=baseline, final_verdict=verdict,
1711
- attested=False,
1712
- )
1713
- write_json_atomic(evidence_dir / "manifest.json", manifest)
1714
- run_render_report(evidence_dir, "test-report.md")
1715
- write_json_atomic(evidence_dir / "lifecycle.json", {
1716
- **lifecycle,
1717
- "status": "payload-finalized",
1718
- "finalized": False,
1719
- "immutable_identity": identity,
1720
- })
1721
- output = confined(evidence_dir, output_name)
1722
- final_evidence_relative = destination.relative_to(project_root).as_posix()
1723
- write_json_atomic(output, {
1724
- "command": "finalize",
1725
- "evidence_id": evidence_id,
1726
- "evidence_dir": final_evidence_relative,
1727
- "manifest_path": f"{final_evidence_relative}/manifest.json",
1728
- "verdict_path": f"{final_evidence_relative}/verdict.json",
1729
- "report_path": f"{final_evidence_relative}/test-report.md",
1730
- "validation_path": f"{final_evidence_relative}/validation.json",
1731
- "next": "validate_test_evidence.py --attest",
1732
- })
1733
- # Second manifest: re-hashes after render-report produces test-report.md.
1734
- manifest = build_manifest(
1735
- evidence_dir, evidence_id=evidence_id, identity=identity,
1736
- target_hashes=target_hashes, change_class=change_class,
1737
- baseline_commit=baseline, final_verdict=verdict,
1738
- attested=False,
1739
- )
1740
- write_json_atomic(evidence_dir / "manifest.json", manifest)
1741
- if destination != evidence_dir:
1742
- if destination.exists():
1743
- try:
1744
- existing_man = load_json(destination / "manifest.json")
1745
- if isinstance(existing_man, dict) and existing_man.get("final_verdict") == "TEST_PASS":
1746
- write_handoff_pointer(destination, lifecycle.get("artifact_dir"), project_root)
1747
- return destination / output_name
1748
- except Exception:
1749
- pass
1750
- raise RequestError(
1751
- f"evidence identity already exists at {destination}; re-init a new package"
1752
- )
1753
- os.replace(evidence_dir, destination)
1754
- final_evidence_dir = destination if destination != evidence_dir else evidence_dir
1755
- write_handoff_pointer(final_evidence_dir, lifecycle.get("artifact_dir"), project_root)
1756
- update_protocol_state(
1757
- project_root,
1758
- event="finalized",
1759
- error="; ".join(verdict_record.get("blockers", [])) if verdict == "TEST_BLOCKED" else None,
1760
- )
1761
- return final_evidence_dir / output_name
1762
-
1763
-
1764
- def run_resume(
1765
- project_root: Path,
1766
- evidence_dir: Path,
1767
- manifest_path: Path,
1768
- inventory_path: Path,
1769
- output_name: str,
1770
- ) -> Path:
1771
- manifest = ensure_object(load_json(manifest_path), "manifest")
1772
- inventory = ensure_object(load_json(inventory_path), "inventory")
1773
- lifecycle = load_lifecycle(evidence_dir)
1774
- if lifecycle.get("finalized") is True:
1775
- raise RequestError(
1776
- "evidence package is finalized and immutable; re-init a new evidence package before resume"
1777
- )
1778
- categories = ensure_object(inventory.get("categories"), "inventory categories")
1779
- environment_path = evidence_dir / "environment.json"
1780
- environment = load_json(environment_path) if environment_path.exists() else {}
1781
- aggregate = lambda entries: canonical_sha256(sorted(entries, key=lambda item: item["path"]))
1782
- live_categories: dict[str, list[dict[str, str]]] = {}
1783
- for category in CATEGORIES:
1784
- live_entries: list[dict[str, str]] = []
1785
- for entry in categories.get(category, []):
1786
- if not isinstance(entry, dict) or not isinstance(entry.get("path"), str):
1787
- raise RequestError(f"invalid resume inventory entry: {category}")
1788
- candidate = confined(project_root, entry["path"], must_exist=True)
1789
- live_entries.append({"path": entry["path"], "sha256": file_sha256(candidate)})
1790
- live_categories[category] = live_entries
1791
- current = {
1792
- "source": aggregate(live_categories["source"]),
1793
- "tests": aggregate(live_categories["tests"]),
1794
- "contracts": aggregate(live_categories["contracts"]),
1795
- "lockfiles": aggregate(live_categories["lockfiles"]),
1796
- "environment": canonical_sha256(environment),
1797
- "plan": canonical_sha256(inventory.get("plan_inputs", {})),
1798
- }
1799
- changed = sorted(key for key, value in current.items() if manifest.get("target_hashes", {}).get(key) != value)
1800
- first_invalid: str | None = None
1801
- reasons: list[str] = []
1802
- dependency_stage = {
1803
- "source": "SCOPE_DISCOVER", "tests": "TEST_PLAN", "contracts": "CONTRACT_MODEL",
1804
- "lockfiles": "INFRA_READY", "environment": "INFRA_READY", "plan": "TEST_PLAN",
1805
- }
1806
- if changed:
1807
- first_invalid = min((dependency_stage[key] for key in changed), key=STAGES.index)
1808
- reasons.append(f"target hashes changed: {', '.join(changed)}")
1809
- files = {entry.get("path"): entry for entry in manifest.get("files", []) if isinstance(entry, dict)}
1810
- for stage in manifest.get("stages", []):
1811
- if not isinstance(stage, dict):
1812
- continue
1813
- for relative in stage.get("outputs", []):
1814
- candidate = confined(evidence_dir, relative)
1815
- entry = files.get(relative)
1816
- if not candidate.is_file() or not entry or file_sha256(candidate) != entry.get("sha256"):
1817
- stage_name = stage.get("name")
1818
- if stage_name in STAGES and (first_invalid is None or STAGES.index(stage_name) < STAGES.index(first_invalid)):
1819
- first_invalid = stage_name
1820
- reasons.append(f"missing or drifted stage output: {relative}")
1821
- invalidated = STAGES[STAGES.index(first_invalid):] if first_invalid else []
1822
- decision = {
1823
- "decision_format": "prizmkit-resume-invalidation-v1",
1824
- "manifest_path": evidence_relative(evidence_dir, manifest_path),
1825
- "manifest_sha256": file_sha256(manifest_path),
1826
- "inventory_path": evidence_relative(evidence_dir, inventory_path),
1827
- "inventory_sha256": file_sha256(inventory_path),
1828
- "current_target_hashes": current,
1829
- "changed_hashes": changed,
1830
- "first_invalid_stage": first_invalid,
1831
- "invalidated_stages": invalidated,
1832
- "preserve_prior_executions": True,
1833
- "reasons": reasons,
1834
- "semantic_review_required": True,
1835
- }
1836
- output = confined(evidence_dir, output_name)
1837
- write_json_atomic(output, decision)
1838
- return output
1839
-
1840
-
1841
- def main() -> int:
1842
- parser = argparse.ArgumentParser(description="Build trusted, request-driven test evidence")
1843
- parser.add_argument("--project-root", required=True)
1844
- parser.add_argument("--evidence-dir", required=True)
1845
- subparsers = parser.add_subparsers(dest="subcommand", required=True)
1846
- init = subparsers.add_parser("init")
1847
- init.add_argument("--baseline")
1848
- init.add_argument("--output", default="init.json")
1849
- capture = subparsers.add_parser("capture-change")
1850
- capture.add_argument("--baseline")
1851
- capture.add_argument("--output", default="change-capture.json")
1852
- inventory = subparsers.add_parser("inventory")
1853
- inventory.add_argument("--request", required=True)
1854
- inventory.add_argument("--output", default="target-inventory.json")
1855
- prepare = subparsers.add_parser("prepare-tests")
1856
- prepare.add_argument("--request", required=True)
1857
- prepare.add_argument("--output", default="test-preparation.json")
1858
- execute = subparsers.add_parser("execute")
1859
- group = execute.add_mutually_exclusive_group(required=True)
1860
- group.add_argument("--request")
1861
- group.add_argument("--replay-receipt")
1862
- finalize = subparsers.add_parser("finalize")
1863
- finalize.add_argument("--request", required=True, help="semantic finalization request")
1864
- finalize.add_argument("--output", default="finalize.json")
1865
- report = subparsers.add_parser("render-report")
1866
- report.add_argument("--output", default="test-report.md")
1867
- resume = subparsers.add_parser("resume")
1868
- resume.add_argument("--manifest", required=True)
1869
- resume.add_argument("--inventory", required=True)
1870
- resume.add_argument("--output", default="resume-decision.json")
1871
- args = parser.parse_args()
1872
- project_root = Path(args.project_root).resolve()
1873
- evidence_dir = Path(args.evidence_dir).resolve()
1874
- try:
1875
- if not project_root.is_dir():
1876
- raise RequestError(f"project root does not exist: {project_root}")
1877
- require_canonical_evidence_dir(project_root, evidence_dir)
1878
- if args.subcommand == "init":
1879
- output = run_init(project_root, evidence_dir, args.baseline, args.output)
1880
- print(f"Evidence initialized: {output}")
1881
- elif args.subcommand == "capture-change":
1882
- require_mutable_lifecycle(evidence_dir)
1883
- output = run_capture_change(project_root, evidence_dir, args.baseline, args.output)
1884
- print(f"Change captured: {output}")
1885
- elif args.subcommand == "inventory":
1886
- require_mutable_lifecycle(evidence_dir)
1887
- output = run_inventory(project_root, evidence_dir, request_file(evidence_dir, args.request), args.output)
1888
- print(f"Inventory written: {output}")
1889
- elif args.subcommand == "prepare-tests":
1890
- require_mutable_lifecycle(evidence_dir)
1891
- output = run_prepare_tests(project_root, evidence_dir, request_file(evidence_dir, args.request), args.output)
1892
- print(f"Native tests prepared: {output}")
1893
- elif args.subcommand == "execute":
1894
- require_mutable_lifecycle(evidence_dir)
1895
- if args.request:
1896
- receipt = execute_request(project_root, evidence_dir, request_file(evidence_dir, args.request))
1897
- else:
1898
- prior_path = receipt_file(evidence_dir, args.replay_receipt)
1899
- prior = ensure_object(load_json(prior_path), "replay receipt")
1900
- if prior.get("receipt_format") != "prizmkit-runner-generated-v1":
1901
- raise RequestError("replay source is not a runner-generated receipt")
1902
- original_request = request_file(evidence_dir, prior.get("request_path", ""))
1903
- if file_sha256(original_request) != prior.get("request_sha256"):
1904
- raise RequestError("recorded request hash no longer matches replay source")
1905
- receipt = execute_request(project_root, evidence_dir, original_request, replay_of=prior.get("execution_id"))
1906
- print(json.dumps(receipt, ensure_ascii=False))
1907
- elif args.subcommand == "render-report":
1908
- load_lifecycle(evidence_dir)
1909
- output = run_render_report(evidence_dir, args.output)
1910
- print(f"Report rendered: {output}")
1911
- elif args.subcommand == "finalize":
1912
- load_lifecycle(evidence_dir, required=True)
1913
- request = ensure_object(load_json(request_file(evidence_dir, args.request)), "finalize request")
1914
- output = run_finalize(project_root, evidence_dir, request, args.output)
1915
- print(f"Evidence finalized: {output}")
1916
- elif args.subcommand == "resume":
1917
- load_lifecycle(evidence_dir)
1918
- output = run_resume(
1919
- project_root, evidence_dir, request_file(evidence_dir, args.manifest),
1920
- request_file(evidence_dir, args.inventory), args.output,
1921
- )
1922
- print(f"Resume decision written: {output}")
1923
- return 0
1924
- except (RequestError, OSError, ValueError) as exc:
1925
- print(f"Test evidence builder: BLOCKED\n- {exc}", file=sys.stderr)
1926
- return 2
1927
-
1928
-
1929
- if __name__ == "__main__":
1930
- sys.exit(main())