prizmkit 1.1.139 → 1.1.141

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