prizmkit 1.1.131 → 1.1.135

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 (65) hide show
  1. package/bundled/VERSION.json +3 -3
  2. package/bundled/adapters/claude/command-adapter.js +0 -2
  3. package/bundled/dev-pipeline/assets/skill-subagent-integration.md +11 -6
  4. package/bundled/dev-pipeline/prizmkit_runtime/gitops.py +2 -9
  5. package/bundled/dev-pipeline/prizmkit_runtime/processes.py +21 -1
  6. package/bundled/dev-pipeline/prizmkit_runtime/runners.py +104 -17
  7. package/bundled/dev-pipeline/prizmkit_runtime/sessions.py +3 -2
  8. package/bundled/dev-pipeline/scripts/generate-bootstrap-prompt.py +15 -13
  9. package/bundled/dev-pipeline/scripts/generate-bugfix-prompt.py +3 -13
  10. package/bundled/dev-pipeline/scripts/generate-recovery-prompt.py +5 -4
  11. package/bundled/dev-pipeline/scripts/generate-refactor-prompt.py +1 -1
  12. package/bundled/dev-pipeline/templates/bootstrap-prompt.md +4 -4
  13. package/bundled/dev-pipeline/templates/bootstrap-tier3.md +9 -9
  14. package/bundled/dev-pipeline/templates/bugfix-bootstrap-prompt.md +4 -7
  15. package/bundled/dev-pipeline/templates/refactor-bootstrap-prompt.md +3 -3
  16. package/bundled/dev-pipeline/templates/sections/bugfix-phase-commit-report.md +2 -2
  17. package/bundled/dev-pipeline/templates/sections/bugfix-phase-manual-verification.md +5 -10
  18. package/bundled/dev-pipeline/templates/sections/bugfix-phase-review.md +5 -5
  19. package/bundled/dev-pipeline/templates/sections/bugfix-session-status.md +1 -1
  20. package/bundled/dev-pipeline/templates/sections/context-budget-rules.md +1 -1
  21. package/bundled/dev-pipeline/templates/sections/headless-commit-authorization.md +2 -3
  22. package/bundled/dev-pipeline/templates/sections/phase-commit-full.md +2 -2
  23. package/bundled/dev-pipeline/templates/sections/phase-commit.md +2 -2
  24. package/bundled/dev-pipeline/templates/sections/phase-review-agent.md +5 -5
  25. package/bundled/dev-pipeline/templates/sections/phase-review-full.md +5 -5
  26. package/bundled/dev-pipeline/templates/sections/refactor-phase-commit-report.md +1 -1
  27. package/bundled/dev-pipeline/templates/sections/refactor-phase-review.md +5 -5
  28. package/bundled/dev-pipeline/templates/sections/refactor-reminders.md +1 -1
  29. package/bundled/dev-pipeline/templates/sections/subagent-timeout-recovery.md +1 -1
  30. package/bundled/dev-pipeline/templates/sections/task-contract.md +4 -0
  31. package/bundled/dev-pipeline/tests/test_generate_bootstrap_prompt.py +39 -26
  32. package/bundled/dev-pipeline/tests/test_generate_bugfix_prompt.py +7 -10
  33. package/bundled/dev-pipeline/tests/test_generate_refactor_prompt.py +3 -3
  34. package/bundled/dev-pipeline/tests/test_python_runner_parity.py +156 -5
  35. package/bundled/dev-pipeline/tests/test_unified_cli.py +34 -7
  36. package/bundled/skills/_metadata.json +3 -3
  37. package/bundled/skills/prizmkit/references/workflow-state-protocol.md +3 -3
  38. package/bundled/skills/prizmkit-code-review/SKILL.md +22 -6
  39. package/bundled/skills/prizmkit-code-review/references/independent-code-review.md +243 -0
  40. package/bundled/skills/prizmkit-code-review/references/review-report-template.md +41 -0
  41. package/bundled/skills/prizmkit-code-review/references/workflow-state-protocol.md +3 -3
  42. package/bundled/skills/prizmkit-code-review/scripts/render_review_report.py +93 -1
  43. package/bundled/skills/prizmkit-committer/SKILL.md +6 -13
  44. package/bundled/skills/prizmkit-committer/references/workflow-state-protocol.md +3 -3
  45. package/bundled/skills/prizmkit-implement/references/workflow-state-protocol.md +3 -3
  46. package/bundled/skills/prizmkit-plan/SKILL.md +17 -3
  47. package/bundled/skills/prizmkit-plan/assets/plan-template.md +18 -0
  48. package/bundled/skills/prizmkit-plan/references/independent-plan-review.md +242 -0
  49. package/bundled/skills/prizmkit-plan/references/review-plan-spec-loop.md +4 -4
  50. package/bundled/skills/prizmkit-plan/references/workflow-state-protocol.md +3 -3
  51. package/bundled/skills/prizmkit-retrospective/references/workflow-state-protocol.md +3 -3
  52. package/bundled/skills/prizmkit-test/SKILL.md +26 -3
  53. package/bundled/skills/prizmkit-test/assets/authoritative-records.schema.json +147 -14
  54. package/bundled/skills/prizmkit-test/assets/evidence-manifest.schema.json +1 -1
  55. package/bundled/skills/prizmkit-test/assets/evidence-package-template.json +15 -0
  56. package/bundled/skills/prizmkit-test/references/evidence-protocol.md +6 -1
  57. package/bundled/skills/prizmkit-test/references/evidence-request-protocol.md +8 -1
  58. package/bundled/skills/prizmkit-test/references/test-generation-steps.md +5 -2
  59. package/bundled/skills/prizmkit-test/references/trusted-evidence-execution.md +15 -0
  60. package/bundled/skills/prizmkit-test/references/workflow-state-protocol.md +3 -3
  61. package/bundled/skills/prizmkit-test/scripts/build_test_evidence.py +1144 -27
  62. package/bundled/skills/prizmkit-test/scripts/validate_test_evidence.py +336 -18
  63. package/bundled/skills/prizmkit-workflow/SKILL.md +1 -1
  64. package/bundled/skills/prizmkit-workflow/references/workflow-state-protocol.md +3 -3
  65. package/package.json +1 -1
@@ -2,10 +2,15 @@
2
2
  """Build runner-generated PrizmKit test evidence from schema-shaped requests.
3
3
 
4
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
5
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
6
9
  python3 build_test_evidence.py --project-root ROOT --evidence-dir DIR execute --request REQUEST
7
10
  python3 build_test_evidence.py --project-root ROOT --evidence-dir DIR execute --replay-receipt RECEIPT
8
11
  python3 build_test_evidence.py --project-root ROOT --evidence-dir DIR differential --request REQUEST
12
+ python3 build_test_evidence.py --project-root ROOT --evidence-dir DIR finalize --request REQUEST
13
+ python3 build_test_evidence.py --project-root ROOT --evidence-dir DIR render-report
9
14
  python3 build_test_evidence.py --project-root ROOT --evidence-dir DIR resume --manifest MANIFEST --inventory INVENTORY
10
15
 
11
16
  Only locator arguments and evidence mechanics are fixed. Test commands, working directories,
@@ -17,6 +22,7 @@ from __future__ import annotations
17
22
 
18
23
  import argparse
19
24
  import datetime as dt
25
+ import fnmatch
20
26
  import hashlib
21
27
  import json
22
28
  import os
@@ -35,13 +41,23 @@ STAGES = [
35
41
  "EVIDENCE_VALIDATE",
36
42
  ]
37
43
  CATEGORIES = ("source", "tests", "contracts", "lockfiles")
44
+ LAYERS = (
45
+ "focused", "module-component", "contract-integration",
46
+ "affected-module-regression", "regression-ring",
47
+ )
38
48
  BLOCKED_EXTERNAL_CLASSES = {"production", "unknown"}
39
49
  SNAPSHOT_VOLATILE_ROOTS = (
40
50
  ".prizmkit/state",
41
51
  ".prizmkit/test/evidence",
52
+ ".prizmkit/" + "dev-" + "pipeline",
42
53
  ".claude/worktrees",
54
+ ".agents/worktrees",
55
+ ".codebuddy/worktrees",
56
+ ".codex/worktrees",
43
57
  )
44
- SNAPSHOT_VOLATILE_NAMES = {".git", "__pycache__"}
58
+ SNAPSHOT_VOLATILE_NAMES = {".git", "__pycache__", ".pytest_cache", ".mypy_cache"}
59
+ INVENTORY_RESERVED_ROOTS = SNAPSHOT_VOLATILE_ROOTS
60
+ FINAL_RECORDS = {"manifest.json", "validation.json", "verdict.json", "test-report.md"}
45
61
 
46
62
 
47
63
  class RequestError(Exception):
@@ -127,6 +143,167 @@ def ensure_string_list(value: Any, label: str, *, allow_empty: bool = True) -> l
127
143
  return value
128
144
 
129
145
 
146
+ def normalize_relative(value: str) -> str:
147
+ normalized = Path(value).as_posix()
148
+ if normalized == "." or normalized.startswith("../") or "/../" in normalized:
149
+ raise RequestError(f"path is not project-relative: {value}")
150
+ return normalized
151
+
152
+
153
+ def is_reserved_inventory_path(relative: str) -> bool:
154
+ path = Path(relative)
155
+ if any(part in SNAPSHOT_VOLATILE_NAMES for part in path.parts):
156
+ return True
157
+ return any(path == Path(prefix) or Path(prefix) in path.parents for prefix in INVENTORY_RESERVED_ROOTS)
158
+
159
+
160
+ def project_git(project_root: Path, *args: str, check: bool = False) -> subprocess.CompletedProcess[bytes]:
161
+ result = subprocess.run(
162
+ ["git", "-C", str(project_root), *args],
163
+ capture_output=True,
164
+ check=False,
165
+ )
166
+ if check and result.returncode != 0:
167
+ raise RequestError(f"git {' '.join(args)} failed: {result.stderr.decode(errors='replace')}")
168
+ return result
169
+
170
+
171
+ def git_baseline(project_root: Path, requested: str | None = None) -> str:
172
+ baseline = requested or "HEAD"
173
+ result = project_git(project_root, "rev-parse", "--verify", baseline)
174
+ if result.returncode != 0:
175
+ raise RequestError(f"baseline commit is not resolvable: {baseline}")
176
+ return result.stdout.decode().strip()
177
+
178
+
179
+ def git_status_entries(project_root: Path) -> list[dict[str, Any]]:
180
+ result = project_git(project_root, "status", "--porcelain=v1", "-z", "--untracked-files=all", check=True)
181
+ raw = result.stdout
182
+ entries: list[dict[str, Any]] = []
183
+ tokens = raw.split(b"\0")
184
+ index = 0
185
+ while index < len(tokens):
186
+ token = tokens[index]
187
+ index += 1
188
+ if not token:
189
+ continue
190
+ status = token[:2].decode(errors="replace")
191
+ path = token[3:].decode(errors="replace")
192
+ old_path = None
193
+ if "R" in status or "C" in status:
194
+ if index >= len(tokens):
195
+ raise RequestError("git status returned an incomplete rename/copy record")
196
+ old_path = tokens[index].decode(errors="replace")
197
+ index += 1
198
+ relative = normalize_relative(path)
199
+ if not is_reserved_inventory_path(relative):
200
+ record: dict[str, Any] = {"path": relative, "status": status, "old_path": old_path}
201
+ entries.append(record)
202
+ if old_path is not None:
203
+ old_relative = normalize_relative(old_path)
204
+ if not is_reserved_inventory_path(old_relative):
205
+ entries.append({"path": old_relative, "status": status, "old_path": old_relative, "role": "source"})
206
+ return sorted(entries, key=lambda item: (item["path"], item.get("old_path") or ""))
207
+
208
+
209
+ def git_diff_entries(project_root: Path, baseline: str) -> list[dict[str, Any]]:
210
+ result = project_git(
211
+ project_root, "diff", "--name-status", "-z", "--find-renames", "--find-copies",
212
+ baseline, "--", ".",
213
+ ":!.prizmkit/state/**", ":!.prizmkit/test/evidence/**", ":!.prizmkit/" + "dev-" + "pipeline/**",
214
+ ":!.claude/worktrees/**", ":!.agents/worktrees/**", ":!.codebuddy/worktrees/**", ":!.codex/worktrees/**",
215
+ check=True,
216
+ )
217
+ tokens = result.stdout.split(b"\0")
218
+ entries: list[dict[str, Any]] = []
219
+ index = 0
220
+ while index < len(tokens):
221
+ status_token = tokens[index].decode(errors="replace")
222
+ index += 1
223
+ if not status_token:
224
+ continue
225
+ if index >= len(tokens):
226
+ raise RequestError("git diff returned an incomplete changed-file record")
227
+ first = normalize_relative(tokens[index].decode(errors="replace"))
228
+ index += 1
229
+ status = status_token[0]
230
+ old_path = None
231
+ if status in {"R", "C"}:
232
+ if index >= len(tokens):
233
+ raise RequestError("git diff returned an incomplete rename/copy record")
234
+ old_path = first
235
+ first = normalize_relative(tokens[index].decode(errors="replace"))
236
+ index += 1
237
+ if not is_reserved_inventory_path(first):
238
+ entries.append({"path": first, "status": status, "old_path": old_path})
239
+ if old_path and not is_reserved_inventory_path(old_path):
240
+ entries.append({"path": old_path, "status": status, "old_path": old_path, "role": "source"})
241
+ return entries
242
+
243
+
244
+ def merge_changed_file_entries(
245
+ baseline_entries: list[dict[str, Any]],
246
+ working_entries: list[dict[str, Any]],
247
+ ) -> list[dict[str, Any]]:
248
+ merged: dict[tuple[str, str | None, str | None], dict[str, Any]] = {}
249
+ for entry in baseline_entries + working_entries:
250
+ key = (entry["path"], entry.get("old_path"), entry.get("role"))
251
+ existing = merged.get(key)
252
+ if existing is None:
253
+ merged[key] = dict(entry)
254
+ continue
255
+ statuses = {existing.get("status", "").strip(), entry.get("status", "").strip()}
256
+ if "D" in statuses:
257
+ existing["status"] = "D"
258
+ elif "R" in statuses:
259
+ existing["status"] = "R"
260
+ elif "C" in statuses:
261
+ existing["status"] = "C"
262
+ elif statuses:
263
+ existing["status"] = "M"
264
+ return sorted(merged.values(), key=lambda item: (item["path"], item.get("old_path") or "", item.get("role") or ""))
265
+
266
+
267
+ def canonical_change_patch(project_root: Path, baseline: str) -> tuple[bytes, list[dict[str, Any]]]:
268
+ status_entries = git_status_entries(project_root)
269
+ baseline_entries = git_diff_entries(project_root, baseline)
270
+ status_entries = merge_changed_file_entries(baseline_entries, status_entries)
271
+ tracked = project_git(
272
+ project_root, "diff", "--binary", "--full-index", "--find-renames", "--find-copies",
273
+ baseline, "--", ".",
274
+ ":!.prizmkit/state/**", ":!.prizmkit/test/evidence/**", ":!.prizmkit/" + "dev-" + "pipeline/**",
275
+ ":!.claude/worktrees/**", ":!.agents/worktrees/**", ":!.codebuddy/worktrees/**", ":!.codex/worktrees/**",
276
+ )
277
+ if tracked.returncode != 0:
278
+ raise RequestError(f"cannot capture git diff: {tracked.stderr.decode(errors='replace')}")
279
+ chunks = [tracked.stdout]
280
+ untracked = [
281
+ item["path"] for item in status_entries
282
+ if item["status"].strip() == "??" and item.get("old_path") is None
283
+ ]
284
+ for relative in sorted(untracked):
285
+ candidate = confined(project_root, relative, must_exist=True)
286
+ if candidate.is_file():
287
+ result = subprocess.run(
288
+ ["git", "diff", "--no-index", "--binary", "/dev/null", "--", relative],
289
+ cwd=project_root,
290
+ capture_output=True,
291
+ check=False,
292
+ )
293
+ if result.returncode not in (0, 1):
294
+ raise RequestError(f"cannot capture untracked file: {relative}")
295
+ chunks.append(result.stdout)
296
+ patch = b"".join(chunk for chunk in chunks if chunk)
297
+ return patch, status_entries
298
+
299
+
300
+ def write_text_atomic(path: Path, value: str) -> None:
301
+ path.parent.mkdir(parents=True, exist_ok=True)
302
+ temporary = path.with_name(f".{path.name}.{uuid.uuid4().hex}.tmp")
303
+ temporary.write_text(value, encoding="utf-8")
304
+ os.replace(temporary, path)
305
+
306
+
130
307
  def validate_external_targets(value: Any) -> list[dict[str, Any]]:
131
308
  if not isinstance(value, list):
132
309
  raise RequestError("external_targets must be an array")
@@ -151,13 +328,73 @@ def validate_external_targets(value: Any) -> list[dict[str, Any]]:
151
328
  return value
152
329
 
153
330
 
154
- def validate_execution_request(request: Any) -> dict[str, Any]:
331
+ def load_change_capture(
332
+ project_root: Path,
333
+ evidence_dir: Path,
334
+ *,
335
+ require_fresh: bool = False,
336
+ ) -> dict[str, Any]:
337
+ capture_path = evidence_dir / "change-capture.json"
338
+ if not capture_path.is_file():
339
+ raise RequestError("canonical change capture is required; run capture-change first")
340
+ capture = ensure_object(load_json(capture_path), "change capture")
341
+ baseline = capture.get("baseline_commit")
342
+ patch_path = evidence_dir / "source-change.patch"
343
+ if (
344
+ capture.get("capture_format") != "prizmkit-canonical-change-v1"
345
+ or not isinstance(baseline, str)
346
+ or not patch_path.is_file()
347
+ or capture.get("patch_path") != "source-change.patch"
348
+ or capture.get("patch_sha256") != file_sha256(patch_path)
349
+ or not isinstance(capture.get("changed_files"), list)
350
+ ):
351
+ raise RequestError("canonical change capture is incomplete or inconsistent")
352
+ if require_fresh:
353
+ live_patch, live_entries = canonical_change_patch(project_root, baseline)
354
+ live_patch_sha256 = hashlib.sha256(live_patch).hexdigest()
355
+ if (
356
+ live_patch_sha256 != capture["patch_sha256"]
357
+ or live_entries != capture["changed_files"]
358
+ or tree_sha256(project_root, [evidence_dir]) != capture.get("source_tree_sha256")
359
+ ):
360
+ raise RequestError(
361
+ "canonical change capture is stale; rerun capture-change and inventory after repository changes"
362
+ )
363
+ return capture
364
+
365
+
366
+ def captured_changed_paths(capture: dict[str, Any]) -> list[str]:
367
+ paths = {
368
+ item["path"]
369
+ for item in capture.get("changed_files", [])
370
+ if isinstance(item, dict) and isinstance(item.get("path"), str)
371
+ }
372
+ return sorted(paths)
373
+
374
+
375
+ def plan_for_execution(evidence_dir: Path) -> dict[str, Any] | None:
376
+ path = evidence_dir / "test-plan.json"
377
+ if not path.exists():
378
+ return None
379
+ return ensure_object(load_json(path), "test plan")
380
+
381
+
382
+ def test_plan_entries(plan: dict[str, Any] | None) -> list[dict[str, Any]]:
383
+ if not isinstance(plan, dict) or not isinstance(plan.get("tests"), list):
384
+ raise RequestError("builder-generated test plan is required before execution")
385
+ entries = [item for item in plan["tests"] if isinstance(item, dict)]
386
+ if len(entries) != len(plan["tests"]):
387
+ raise RequestError("test plan contains a non-object test entry")
388
+ return entries
389
+
390
+
391
+ def validate_execution_request(request: Any, *, plan: dict[str, Any] | None = None) -> dict[str, Any]:
155
392
  request = ensure_object(request, "execution request")
156
393
  required = {
157
394
  "request_version", "purpose", "command", "cwd", "environment",
158
- "tool_version_commands", "layer", "test_ids", "external_targets",
395
+ "tool_version_commands", "layer", "external_targets",
159
396
  }
160
- optional = {"timeout_seconds", "attempt_policy", "concurrency"}
397
+ optional = {"timeout_seconds", "attempt_policy", "concurrency", "test_ids", "test_paths", "auto_bind"}
161
398
  if not required <= set(request) or set(request) - required - optional:
162
399
  raise RequestError(f"execution request fields must contain {sorted(required)} and only supported optional fields")
163
400
  if request["request_version"] != "1.0":
@@ -175,13 +412,152 @@ def validate_execution_request(request: Any) -> dict[str, Any]:
175
412
  if not isinstance(name, str):
176
413
  raise RequestError("tool probe names must be strings")
177
414
  ensure_string_list(probe, f"tool probe {name}", allow_empty=False)
178
- ensure_string_list(request["test_ids"], "test_ids")
415
+ has_ids = "test_ids" in request
416
+ has_paths = "test_paths" in request
417
+ auto_bind = request.get("auto_bind") is True
418
+ if not has_ids and not has_paths and not auto_bind:
419
+ raise RequestError("execution request must provide test_ids, test_paths, or auto_bind=true")
420
+ if has_ids:
421
+ test_ids = ensure_string_list(request["test_ids"], "test_ids", allow_empty=False)
422
+ if len(set(test_ids)) != len(test_ids):
423
+ raise RequestError("execution request test_ids contains duplicates")
424
+ if has_paths:
425
+ test_paths = ensure_string_list(request["test_paths"], "test_paths", allow_empty=False)
426
+ if len(set(test_paths)) != len(test_paths):
427
+ raise RequestError("execution request test_paths contains duplicates")
428
+ if "auto_bind" in request and not isinstance(request["auto_bind"], bool):
429
+ raise RequestError("execution request auto_bind must be boolean")
430
+ if request["layer"] not in LAYERS and request["layer"] != "lightweight":
431
+ raise RequestError(f"unknown execution layer: {request['layer']}")
432
+ if plan is not None and has_ids:
433
+ tests = test_plan_entries(plan)
434
+ known = {item.get("id") for item in tests}
435
+ unknown = sorted(set(request["test_ids"]) - known)
436
+ if unknown:
437
+ raise RequestError(f"execution request references unknown test IDs: {', '.join(unknown)}")
438
+ test_map = {item.get("id"): item for item in tests}
439
+ invalid_layer = sorted(
440
+ test_id for test_id in request["test_ids"]
441
+ if request["layer"] not in (test_map.get(test_id, {}).get("layers") or [])
442
+ )
443
+ if invalid_layer:
444
+ raise RequestError(f"execution layer is not planned for test IDs: {', '.join(invalid_layer)}")
179
445
  validate_external_targets(request["external_targets"])
180
446
  if "timeout_seconds" in request and (isinstance(request["timeout_seconds"], bool) or not isinstance(request["timeout_seconds"], (int, float)) or request["timeout_seconds"] <= 0):
181
447
  raise RequestError("timeout_seconds must be a positive number")
182
448
  return request
183
449
 
184
450
 
451
+ def normalize_command_scope_path(project_root: Path, cwd: Path, value: str) -> str | None:
452
+ if not isinstance(value, str) or not value or value.startswith("-"):
453
+ return None
454
+ candidate = Path(value)
455
+ resolved = candidate.resolve() if candidate.is_absolute() else (cwd / candidate).resolve()
456
+ try:
457
+ return resolved.relative_to(project_root.resolve()).as_posix()
458
+ except ValueError:
459
+ return None
460
+
461
+
462
+ def command_scope_test_ids(project_root: Path, request: dict[str, Any], tests: list[dict[str, Any]]) -> list[str]:
463
+ cwd = confined(project_root, request["cwd"] or ".", must_exist=True, directory=True)
464
+ command = request["command"]
465
+ executable = Path(command[0]).name.lower()
466
+ subcommands = {item for item in command[1:] if not item.startswith("-")}
467
+ path_tokens: list[str] = []
468
+ after_separator = False
469
+ skip_option_value = False
470
+ options_with_values = {
471
+ "-C", "--config", "--config-file", "--project", "--package", "-p",
472
+ "--root", "--rootdir", "--cwd", "--testPathPattern", "--testNamePattern",
473
+ }
474
+ for token in command[1:]:
475
+ if skip_option_value:
476
+ skip_option_value = False
477
+ continue
478
+ if token == "--":
479
+ after_separator = True
480
+ continue
481
+ if token in options_with_values:
482
+ skip_option_value = True
483
+ continue
484
+ if token.startswith("-"):
485
+ continue
486
+ if token in {"test", "run", "watch", "coverage", "all", "serial", "parallel"} and not after_separator:
487
+ continue
488
+ if after_separator or token.startswith((".", "/")) or "/" in token or any(mark in token for mark in "*?[]"):
489
+ path_tokens.append(token)
490
+
491
+ package_wide = (
492
+ executable in {"pytest", "vitest", "jest", "mocha", "busted"}
493
+ or (executable in {"python", "python3", "py.test"} and "pytest" in command)
494
+ or (executable in {"npm", "npm.cmd", "pnpm", "pnpm.cmd", "yarn", "yarn.cmd"} and "test" in subcommands)
495
+ or (executable in {"go", "cargo", "mvn", "gradle", "gradlew"} and "test" in subcommands)
496
+ )
497
+ if not path_tokens:
498
+ if package_wide:
499
+ return sorted({item["id"] for item in tests})
500
+ raise RequestError(
501
+ "auto_bind cannot infer native test scope from command; provide test_paths or use a recognized package-wide test command"
502
+ )
503
+
504
+ matched_paths: set[str] = set()
505
+ for token in path_tokens:
506
+ recursive_scope = token == "..." or token.endswith("/...")
507
+ scope_token = token[:-3].rstrip("/") if recursive_scope else token
508
+ normalized = normalize_command_scope_path(project_root, cwd, scope_token or ".")
509
+ if normalized is None:
510
+ continue
511
+ has_glob = any(mark in normalized for mark in "*?[]")
512
+ for test in tests:
513
+ test_path = test["project_path"]
514
+ within_scope = normalized in {"", "."} or test_path == normalized or path_is_below_or_equal(test_path, normalized)
515
+ if (has_glob and fnmatch.fnmatch(test_path, normalized)) or (recursive_scope and within_scope) or (
516
+ not has_glob and not recursive_scope and within_scope
517
+ ):
518
+ matched_paths.add(test_path)
519
+ if not matched_paths:
520
+ raise RequestError(
521
+ "auto_bind command scope does not cover any prepared native test; provide test_paths explicitly"
522
+ )
523
+ return [item["id"] for item in tests if item["project_path"] in matched_paths]
524
+
525
+
526
+ def resolve_execution_test_ids(project_root: Path, evidence_dir: Path, request: dict[str, Any]) -> dict[str, Any]:
527
+ plan = plan_for_execution(evidence_dir)
528
+ if plan is None:
529
+ if "test_ids" not in request:
530
+ raise RequestError("builder-generated test plan is required before path-based or auto-bound execution")
531
+ return request
532
+ if (evidence_dir / "lifecycle.json").is_file() and not (evidence_dir / "test-preparation.json").is_file():
533
+ raise RequestError("canonical lifecycle requires prepare-tests before execute or differential")
534
+ tests = test_plan_entries(plan)
535
+ by_id = {item["id"]: item for item in tests}
536
+ by_path = {item["project_path"]: item for item in tests}
537
+ if "test_ids" in request:
538
+ resolved = list(request["test_ids"])
539
+ elif "test_paths" in request:
540
+ unknown_paths = sorted(set(request["test_paths"]) - set(by_path))
541
+ if unknown_paths:
542
+ raise RequestError(f"execution request references unknown test paths: {', '.join(unknown_paths)}")
543
+ resolved = [by_path[path]["id"] for path in request["test_paths"]]
544
+ else:
545
+ resolved = command_scope_test_ids(project_root, request, tests)
546
+ if not resolved:
547
+ raise RequestError("execution request resolved no prepared tests")
548
+ unknown = sorted(set(resolved) - set(by_id))
549
+ if unknown:
550
+ raise RequestError(f"execution request references unknown test IDs: {', '.join(unknown)}")
551
+ invalid_layer = sorted(
552
+ test_id for test_id in resolved
553
+ if request["layer"] not in (by_id[test_id].get("layers") or [])
554
+ )
555
+ if invalid_layer:
556
+ raise RequestError(f"execution layer is not planned for test IDs: {', '.join(invalid_layer)}")
557
+ request["test_ids"] = resolved
558
+ return request
559
+
560
+
185
561
  def is_volatile_snapshot_path(root: Path, candidate: Path, excluded: list[Path] | None = None) -> bool:
186
562
  try:
187
563
  relative = candidate.relative_to(root)
@@ -205,6 +581,7 @@ def tree_sha256(root: Path, excluded: list[Path] | None = None) -> str:
205
581
 
206
582
 
207
583
  def collect_matches(project_root: Path, patterns: list[str], exclusions: set[str]) -> list[dict[str, str]]:
584
+ project_resolved = project_root.resolve()
208
585
  paths: set[Path] = set()
209
586
  for pattern in patterns:
210
587
  if Path(pattern).is_absolute() or ".." in Path(pattern).parts:
@@ -214,17 +591,67 @@ def collect_matches(project_root: Path, patterns: list[str], exclusions: set[str
214
591
  paths.add(candidate.resolve())
215
592
  entries = []
216
593
  for candidate in sorted(paths):
217
- relative = candidate.relative_to(project_root).as_posix()
218
- if relative not in exclusions:
594
+ relative = candidate.relative_to(project_resolved).as_posix()
595
+ if relative not in exclusions and not is_reserved_inventory_path(relative):
219
596
  entries.append({"path": relative, "sha256": file_sha256(candidate)})
220
597
  return entries
221
598
 
222
599
 
600
+ def validate_category_disjointness(output_categories: dict[str, list[dict[str, str]]]) -> None:
601
+ membership: dict[str, str] = {}
602
+ for category, entries in output_categories.items():
603
+ for entry in entries:
604
+ path = entry["path"]
605
+ previous = membership.get(path)
606
+ if previous is not None:
607
+ raise RequestError(f"inventory path appears in multiple categories: {path} ({previous}, {category})")
608
+ membership[path] = category
609
+
610
+
611
+ def validate_module_root_request(
612
+ project_root: Path,
613
+ evidence_dir: Path,
614
+ module_roots: list[Any],
615
+ changed_files: list[str],
616
+ ) -> tuple[list[str], dict[str, list[str]], dict[str, list[str]]]:
617
+ normalized_roots: list[str] = []
618
+ explicit_changed: dict[str, list[str]] = {}
619
+ for item in module_roots:
620
+ if isinstance(item, str):
621
+ relative = normalize_relative(item)
622
+ changed_set: list[str] = []
623
+ elif isinstance(item, dict) and set(item) <= {"path", "changed_files"} and isinstance(item.get("path"), str):
624
+ relative = normalize_relative(item["path"])
625
+ changed_set = ensure_string_list(item.get("changed_files", []), f"module root {relative} changed_files")
626
+ else:
627
+ raise RequestError("module_roots entries must be paths or {path, changed_files} objects")
628
+ confined(project_root, relative, must_exist=True)
629
+ if relative in normalized_roots:
630
+ raise RequestError(f"duplicate module root: {relative}")
631
+ for changed in changed_set:
632
+ changed = normalize_relative(changed)
633
+ if not path_is_below_or_equal(changed, relative):
634
+ raise RequestError(f"module root changed file lies outside root: {changed}")
635
+ if changed not in changed_files:
636
+ raise RequestError(f"module root changed file is not in changed_files: {changed}")
637
+ normalized_roots.append(relative)
638
+ explicit_changed[relative] = sorted(changed_set)
639
+ enumerated = enumerate_module_root_files(project_root, evidence_dir, normalized_roots)
640
+ return normalized_roots, explicit_changed, enumerated
641
+
642
+
643
+ def path_is_below_or_equal(path: str, root: str) -> bool:
644
+ normalized_path = Path(path)
645
+ normalized_root = Path(root)
646
+ return normalized_path == normalized_root or normalized_root in normalized_path.parents
647
+
648
+
223
649
  def enumerate_module_root_files(
224
650
  project_root: Path,
225
651
  evidence_dir: Path,
226
652
  module_roots: list[str],
227
653
  ) -> dict[str, list[str]]:
654
+ project_resolved = project_root.resolve()
228
655
  evidence_resolved = evidence_dir.resolve()
229
656
  result: dict[str, list[str]] = {}
230
657
  for relative in module_roots:
@@ -237,7 +664,7 @@ def enumerate_module_root_files(
237
664
  resolved = candidate.resolve()
238
665
  if resolved == evidence_resolved or evidence_resolved in resolved.parents:
239
666
  continue
240
- files.append(resolved.relative_to(project_root).as_posix())
667
+ files.append(resolved.relative_to(project_resolved).as_posix())
241
668
  result[relative] = sorted(files)
242
669
  return result
243
670
 
@@ -257,28 +684,82 @@ def run_inventory(project_root: Path, evidence_dir: Path, request_path: Path, ou
257
684
  for exclusion in exclusions:
258
685
  if not isinstance(exclusion, dict) or set(exclusion) != {"path", "reason", "evidence"}:
259
686
  raise RequestError("each inventory exclusion requires path, reason, and evidence")
260
- confined(project_root, exclusion["path"])
687
+ relative = normalize_relative(exclusion["path"])
688
+ if is_reserved_inventory_path(relative):
689
+ raise RequestError(f"reserved inventory path must be automatically excluded: {relative}")
690
+ confined(project_root, relative)
261
691
  if not isinstance(exclusion["reason"], str) or len(exclusion["reason"].strip()) < 8:
262
692
  raise RequestError("inventory exclusion reason is too short")
263
693
  if not isinstance(exclusion["evidence"], list) or not exclusion["evidence"]:
264
694
  raise RequestError("inventory exclusion lacks evidence")
265
- exclusion_paths.add(Path(exclusion["path"]).as_posix())
266
- changed_files = ensure_string_list(request["changed_files"], "changed_files", allow_empty=False)
267
- module_roots = ensure_string_list(request["module_roots"], "module_roots", allow_empty=False)
695
+ exclusion_paths.add(relative)
696
+ changed_files = sorted({normalize_relative(item) for item in ensure_string_list(request["changed_files"], "changed_files", allow_empty=False)})
697
+ if len(changed_files) != len(request["changed_files"]):
698
+ raise RequestError("changed_files contains duplicates")
699
+ capture_path = evidence_dir / "change-capture.json"
700
+ captured_deleted: set[str] = set()
701
+ if capture_path.is_file():
702
+ capture = ensure_object(load_json(capture_path), "change capture")
703
+ for entry in capture.get("changed_files", []):
704
+ if isinstance(entry, dict) and entry.get("status", "").strip() == "D":
705
+ captured_deleted.add(entry.get("path"))
706
+ if isinstance(entry, dict) and entry.get("role") == "source":
707
+ captured_deleted.add(entry.get("path"))
268
708
  for relative in changed_files:
269
- confined(project_root, relative, must_exist=relative not in exclusion_paths)
270
- for relative in module_roots:
271
- confined(project_root, relative, must_exist=True)
709
+ confined(
710
+ project_root,
711
+ relative,
712
+ must_exist=relative not in exclusion_paths and relative not in captured_deleted,
713
+ )
714
+ raw_module_roots = request["module_roots"]
715
+ if not isinstance(raw_module_roots, list) or not raw_module_roots:
716
+ raise RequestError("module_roots must be a non-empty array")
717
+ module_roots, explicit_changed, module_root_files = validate_module_root_request(
718
+ project_root, evidence_dir, raw_module_roots, changed_files,
719
+ )
272
720
  output_categories = {
273
721
  name: collect_matches(project_root, ensure_string_list(categories[name], f"category {name}"), exclusion_paths)
274
722
  for name in CATEGORIES
275
723
  }
724
+ validate_category_disjointness(output_categories)
276
725
  inventoried_paths = {
277
726
  entry["path"]
278
727
  for entries in output_categories.values()
279
728
  for entry in entries
280
729
  }
281
- module_root_files = enumerate_module_root_files(project_root, evidence_dir, module_roots)
730
+ capture_path = evidence_dir / "change-capture.json"
731
+ if capture_path.is_file():
732
+ capture = load_change_capture(project_root, evidence_dir, require_fresh=True)
733
+ captured_paths = set(captured_changed_paths(capture))
734
+ if captured_paths != set(changed_files):
735
+ missing = sorted(captured_paths - set(changed_files))
736
+ extra = sorted(set(changed_files) - captured_paths)
737
+ details = []
738
+ if missing:
739
+ details.append("missing from inventory request: " + ", ".join(missing))
740
+ if extra:
741
+ details.append("not present in canonical capture: " + ", ".join(extra))
742
+ raise RequestError("inventory changed_files must exactly match canonical capture (" + "; ".join(details) + ")")
743
+ captured_deleted = {
744
+ item["path"]
745
+ for item in capture.get("changed_files", [])
746
+ if isinstance(item, dict)
747
+ and isinstance(item.get("path"), str)
748
+ and (str(item.get("status", "")).strip() == "D" or item.get("role") == "source")
749
+ }
750
+ unaccounted = sorted(
751
+ captured_paths
752
+ - inventoried_paths
753
+ - exclusion_paths
754
+ - captured_deleted
755
+ )
756
+ if unaccounted:
757
+ raise RequestError(
758
+ "canonical changed files are neither inventoried nor explicitly excluded: "
759
+ + ", ".join(unaccounted)
760
+ )
761
+ elif (evidence_dir / "lifecycle.json").is_file():
762
+ raise RequestError("canonical lifecycle requires capture-change before inventory")
282
763
  missing_from_inventory = sorted({
283
764
  relative
284
765
  for files in module_root_files.values()
@@ -290,6 +771,9 @@ def run_inventory(project_root: Path, evidence_dir: Path, request_path: Path, ou
290
771
  "module root contains files that are neither inventoried nor excluded: "
291
772
  + ", ".join(missing_from_inventory[:20])
292
773
  )
774
+ for root, changed_set in explicit_changed.items():
775
+ if changed_set and not set(changed_set) <= set(module_root_files[root]):
776
+ raise RequestError(f"module root changed_files contains a path outside enumerated root: {root}")
293
777
  output = {
294
778
  "inventory_request_path": evidence_relative(evidence_dir, request_path),
295
779
  "inventory_request_sha256": file_sha256(request_path),
@@ -297,6 +781,7 @@ def run_inventory(project_root: Path, evidence_dir: Path, request_path: Path, ou
297
781
  "changed_files": changed_files,
298
782
  "module_roots": module_roots,
299
783
  "module_root_files": module_root_files,
784
+ "module_root_changed_files": explicit_changed,
300
785
  "exclusions": sorted(exclusion_paths),
301
786
  "discovery_evidence": request["discovery_evidence"],
302
787
  "plan_inputs": ensure_object(request["plan_inputs"], "plan_inputs"),
@@ -390,7 +875,12 @@ def execute_request(
390
875
  isolation: dict[str, Any] | None = None,
391
876
  selected_execution: bool = True,
392
877
  ) -> dict[str, Any]:
393
- request = validate_execution_request(load_json(request_path))
878
+ request_value = load_json(request_path)
879
+ original_test_ids = list(request_value["test_ids"]) if isinstance(request_value, dict) and isinstance(request_value.get("test_ids"), list) else None
880
+ request = validate_execution_request(request_value, plan=plan_for_execution(evidence_dir))
881
+ request = resolve_execution_test_ids(project_root, evidence_dir, request)
882
+ if original_test_ids != request["test_ids"]:
883
+ write_json_atomic(request_path, request)
394
884
  root = execution_root or project_root
395
885
  cwd = confined(root, request["cwd"] or ".", must_exist=True, directory=True)
396
886
  complete_environment = {"PATH": os.environ.get("PATH", os.defpath)}
@@ -501,12 +991,67 @@ def materialize_request(evidence_dir: Path, name: str, request: dict[str, Any])
501
991
  return path
502
992
 
503
993
 
994
+ def validate_not_applicable(value: Any) -> dict[str, Any]:
995
+ value = ensure_object(value, "differential N/A decision")
996
+ required = {"reason", "rationale", "evidence", "considered_signals", "conflicts"}
997
+ if set(value) != required:
998
+ raise RequestError(f"differential N/A fields must be exactly {sorted(required)}")
999
+ if value["reason"] not in {"new-behavior", "textual-contract"}:
1000
+ raise RequestError("differential N/A reason must be new-behavior or textual-contract")
1001
+ if not isinstance(value["rationale"], str) or len(value["rationale"].strip()) < 16:
1002
+ raise RequestError("differential N/A rationale is too short")
1003
+ ensure_string_list(value["evidence"], "differential N/A evidence", allow_empty=False)
1004
+ ensure_string_list(value["considered_signals"], "differential N/A considered_signals", allow_empty=False)
1005
+ if not isinstance(value["conflicts"], list):
1006
+ raise RequestError("differential N/A conflicts must be an array")
1007
+ if value["reason"] == "new-behavior" and "no stable pre-change observable" not in value["rationale"].lower():
1008
+ raise RequestError("new-behavior differential N/A must explain that no stable pre-change observable exists")
1009
+ if value["reason"] == "textual-contract" and "textual" not in value["rationale"].lower():
1010
+ raise RequestError("textual-contract differential N/A must explain why behavior is textual")
1011
+ return value
1012
+
1013
+
1014
+ def append_proof(evidence_dir: Path, proof: dict[str, Any]) -> None:
1015
+ proof_path = evidence_dir / "differential-proof.json"
1016
+ proof_record = load_json(proof_path) if proof_path.exists() else {"proofs": []}
1017
+ if not isinstance(proof_record, dict) or not isinstance(proof_record.get("proofs"), list):
1018
+ raise RequestError("differential-proof.json has invalid append target")
1019
+ if any(item.get("behavior_id") == proof.get("behavior_id") for item in proof_record["proofs"] if isinstance(item, dict)):
1020
+ raise RequestError(f"duplicate differential proof: {proof.get('behavior_id')}")
1021
+ proof_record["proofs"].append(proof)
1022
+ write_json_atomic(proof_path, proof_record)
1023
+
1024
+
504
1025
  def run_differential(project_root: Path, evidence_dir: Path, request_path: Path) -> dict[str, Any]:
505
1026
  request = ensure_object(load_json(request_path), "differential request")
506
1027
  required = {"request_version", "behavior_id", "method", "execution_request", "baseline_commit", "current_tree_sha256", "mutation_patch_path", "test_overlay_paths", "expected_failure_signals"}
1028
+ na_required = {"request_version", "behavior_id", "method", "not_applicable"}
1029
+ if set(request) == na_required:
1030
+ if request["request_version"] != "1.0" or request["method"] != "not-applicable":
1031
+ raise RequestError("invalid differential N/A request")
1032
+ not_applicable = validate_not_applicable(request["not_applicable"])
1033
+ proof = {
1034
+ "behavior_id": request["behavior_id"], "classification": "NOT_APPLICABLE",
1035
+ "method": None, "differential_request_path": evidence_relative(evidence_dir, request_path),
1036
+ "differential_request_sha256": file_sha256(request_path), "baseline_commit": None,
1037
+ "current_tree_sha256": None, "baseline_execution_id": None, "mutation_execution_id": None,
1038
+ "current_execution_id": None, "failure_reason_matched": False, "cleanup_succeeded": True,
1039
+ "project_tree_before_sha256": None, "project_tree_after_sha256": None, "isolation_tree_sha256": None,
1040
+ "mutation_apply_sha256": None, "mutation_restore_sha256": None, "not_applicable": not_applicable,
1041
+ }
1042
+ append_proof(evidence_dir, proof)
1043
+ return proof
507
1044
  if set(request) != required or request["request_version"] != "1.0":
508
1045
  raise RequestError(f"differential request fields must be exactly {sorted(required)} with version 1.0")
509
- execution_request = validate_execution_request(request["execution_request"])
1046
+ execution_request = resolve_execution_test_ids(
1047
+ project_root,
1048
+ evidence_dir,
1049
+ validate_execution_request(
1050
+ request["execution_request"], plan=plan_for_execution(evidence_dir),
1051
+ ),
1052
+ )
1053
+ request["execution_request"] = execution_request
1054
+ write_json_atomic(request_path, request)
510
1055
  expected_failure_signals = ensure_string_list(request["expected_failure_signals"], "expected_failure_signals", allow_empty=False)
511
1056
  overlay_paths = ensure_string_list(request["test_overlay_paths"], "test_overlay_paths")
512
1057
  for relative in overlay_paths:
@@ -597,15 +1142,545 @@ def run_differential(project_root: Path, evidence_dir: Path, request_path: Path)
597
1142
  "mutation_restore_sha256": mutation_restore_sha,
598
1143
  "not_applicable": None,
599
1144
  }
600
- proof_path = evidence_dir / "differential-proof.json"
601
- proof_record = load_json(proof_path) if proof_path.exists() else {"proofs": []}
602
- if not isinstance(proof_record, dict) or not isinstance(proof_record.get("proofs"), list):
603
- raise RequestError("differential-proof.json has invalid append target")
604
- proof_record["proofs"].append(proof)
605
- write_json_atomic(proof_path, proof_record)
1145
+ append_proof(evidence_dir, proof)
606
1146
  return proof
607
1147
 
608
1148
 
1149
+ def run_capture_change(
1150
+ project_root: Path,
1151
+ evidence_dir: Path,
1152
+ baseline: str | None,
1153
+ output_name: str,
1154
+ ) -> Path:
1155
+ resolved_baseline = git_baseline(project_root, baseline)
1156
+ patch, status_entries = canonical_change_patch(project_root, resolved_baseline)
1157
+ patch_path = confined(evidence_dir, "source-change.patch")
1158
+ patch_path.parent.mkdir(parents=True, exist_ok=True)
1159
+ patch_path.write_bytes(patch)
1160
+ capture = {
1161
+ "capture_format": "prizmkit-canonical-change-v1",
1162
+ "baseline_commit": resolved_baseline,
1163
+ "patch_path": evidence_relative(evidence_dir, patch_path),
1164
+ "patch_sha256": file_sha256(patch_path),
1165
+ "changed_files": status_entries,
1166
+ "source_tree_sha256": tree_sha256(project_root, [evidence_dir]),
1167
+ }
1168
+ output = confined(evidence_dir, output_name)
1169
+ write_json_atomic(output, capture)
1170
+ return output
1171
+
1172
+
1173
+ def run_init(project_root: Path, evidence_dir: Path, baseline: str | None, output_name: str) -> Path:
1174
+ if evidence_dir.exists() and any(evidence_dir.iterdir()):
1175
+ if (evidence_dir / "manifest.json").exists():
1176
+ raise RequestError("evidence package is finalized; initialize a new evidence directory")
1177
+ raise RequestError("evidence directory is not empty; initialize a new evidence directory")
1178
+ evidence_dir.mkdir(parents=True, exist_ok=True)
1179
+ for directory in ("requests", "receipts", "raw", "generated-tests", "contracts", "runner"):
1180
+ (evidence_dir / directory).mkdir(parents=True, exist_ok=True)
1181
+ write_json_atomic(evidence_dir / "executions.json", [])
1182
+ write_json_atomic(evidence_dir / "differential-proof.json", {"proofs": []})
1183
+ write_json_atomic(evidence_dir / "lifecycle.json", {
1184
+ "lifecycle_format": "prizmkit-canonical-lifecycle-v1",
1185
+ "status": "initialized",
1186
+ "baseline_commit": git_baseline(project_root, baseline),
1187
+ "artifact_dir": None,
1188
+ "finalized": False,
1189
+ "immutable_identity": None,
1190
+ })
1191
+ output = confined(evidence_dir, output_name)
1192
+ write_json_atomic(output, {
1193
+ "command": "init",
1194
+ "evidence_dir": ".",
1195
+ "baseline_commit": git_baseline(project_root, baseline),
1196
+ "next": "capture-change",
1197
+ })
1198
+ return output
1199
+
1200
+
1201
+ def load_lifecycle(evidence_dir: Path, *, required: bool = False) -> dict[str, Any]:
1202
+ path = evidence_dir / "lifecycle.json"
1203
+ if not path.exists():
1204
+ if required:
1205
+ raise RequestError("evidence package is not initialized; run init first")
1206
+ return {"lifecycle_format": "legacy-compatible", "status": "legacy"}
1207
+ return ensure_object(load_json(path), "lifecycle")
1208
+
1209
+
1210
+ def require_mutable_lifecycle(evidence_dir: Path) -> dict[str, Any]:
1211
+ lifecycle_path = evidence_dir / "lifecycle.json"
1212
+ if not lifecycle_path.exists():
1213
+ if (evidence_dir / "manifest.json").exists():
1214
+ raise RequestError(
1215
+ "evidence package is finalized and immutable; re-init a new evidence package"
1216
+ )
1217
+ return {"lifecycle_format": "legacy-compatible", "status": "legacy"}
1218
+ lifecycle = load_lifecycle(evidence_dir, required=True)
1219
+ if lifecycle.get("finalized") is True:
1220
+ raise RequestError(
1221
+ "evidence package is finalized and immutable; re-init a new evidence package"
1222
+ )
1223
+ return lifecycle
1224
+
1225
+
1226
+ def run_render_report(evidence_dir: Path, output_name: str) -> Path:
1227
+ manifest = ensure_object(load_json(evidence_dir / "manifest.json"), "manifest")
1228
+ verdict = ensure_object(load_json(evidence_dir / "verdict.json"), "verdict")
1229
+ validation_path = evidence_dir / "validation.json"
1230
+ validation = ensure_object(load_json(validation_path), "validation") if validation_path.exists() else None
1231
+ lifecycle_path = evidence_dir / "lifecycle.json"
1232
+ lifecycle = ensure_object(load_json(lifecycle_path), "lifecycle") if lifecycle_path.exists() else {}
1233
+ proofs_path = evidence_dir / "differential-proof.json"
1234
+ proofs = load_json(proofs_path).get("proofs", []) if proofs_path.exists() else []
1235
+ executions = load_json(evidence_dir / "executions.json")
1236
+ artifact_dir = lifecycle.get("artifact_dir")
1237
+ lines = [
1238
+ "# Test Evidence Report",
1239
+ "",
1240
+ "Derived from structured evidence",
1241
+ f"Evidence ID: {manifest.get('evidence_id')}",
1242
+ f"Verdict: {verdict.get('verdict')}",
1243
+ f"Validator result: {validation.get('result') if validation else 'pending'}",
1244
+ "sensitivity=project-controlled",
1245
+ "The project owns access control, retention, and upload policy.",
1246
+ "Mocked code-level evidence does not verify a real deployed environment.",
1247
+ "",
1248
+ "## Scope",
1249
+ f"- Artifact Dir: {artifact_dir or 'not provided'}",
1250
+ "",
1251
+ f"Execution receipts: {len(executions) if isinstance(executions, list) else 0}",
1252
+ f"Differential proofs: {len(proofs) if isinstance(proofs, list) else 0}",
1253
+ "The legacy test-report interface is not supported.",
1254
+ "",
1255
+ ]
1256
+ output = confined(evidence_dir, output_name)
1257
+ rendered = "\n".join(lines)
1258
+ lifecycle_finalized = lifecycle.get("finalized") is True
1259
+ if lifecycle_finalized:
1260
+ if not output.is_file() or output.read_text(encoding="utf-8") != rendered:
1261
+ raise RequestError(
1262
+ "evidence package is finalized and immutable; re-init a new package before changing the report"
1263
+ )
1264
+ return output
1265
+ write_text_atomic(output, rendered)
1266
+ return output
1267
+
1268
+
1269
+ def aggregate_inventory(entries: list[dict[str, str]]) -> str:
1270
+ return canonical_sha256(sorted(entries, key=lambda item: item["path"]))
1271
+
1272
+
1273
+ def changed_test_statuses(evidence_dir: Path) -> dict[str, str]:
1274
+ capture = load_change_capture(Path("."), evidence_dir)
1275
+ inventory = ensure_object(load_json(evidence_dir / "target-inventory.json"), "target inventory")
1276
+ test_paths = {
1277
+ item.get("path")
1278
+ for item in inventory.get("categories", {}).get("tests", [])
1279
+ if isinstance(item, dict) and isinstance(item.get("path"), str)
1280
+ }
1281
+ statuses: dict[str, str] = {}
1282
+ for entry in capture.get("changed_files", []):
1283
+ if not isinstance(entry, dict) or not isinstance(entry.get("path"), str):
1284
+ continue
1285
+ relative = entry["path"]
1286
+ if relative not in test_paths or entry.get("role") == "source":
1287
+ continue
1288
+ status = str(entry.get("status", "")).strip()
1289
+ if status == "??" or status.startswith("A"):
1290
+ statuses[relative] = "added"
1291
+ elif status.startswith("M") or status.endswith("M"):
1292
+ statuses[relative] = "modified"
1293
+ elif status.startswith("R") or status.startswith("C"):
1294
+ statuses[relative] = "modified"
1295
+ return statuses
1296
+
1297
+
1298
+ def stable_test_id(project_path: str, selector: str | None = None) -> str:
1299
+ identity = project_path if not selector else f"{project_path}::{selector}"
1300
+ return f"native-test-{canonical_sha256(identity)[:16]}"
1301
+
1302
+
1303
+ def prepare_test_layers(request: dict[str, Any], existing_plan: dict[str, Any] | None = None) -> list[dict[str, Any]]:
1304
+ layers = request.get("layers")
1305
+ if layers is None and isinstance(existing_plan, dict):
1306
+ layers = existing_plan.get("layers")
1307
+ if not isinstance(layers, list) or len(layers) != len(LAYERS):
1308
+ raise RequestError("test preparation must assess all five layers exactly once")
1309
+ normalized: list[dict[str, Any]] = []
1310
+ seen: set[str] = set()
1311
+ for item in layers:
1312
+ if not isinstance(item, dict) or set(item) != {"name", "required", "not_applicable"}:
1313
+ raise RequestError("test preparation layer entries require name, required, and not_applicable")
1314
+ name = item.get("name")
1315
+ if name not in LAYERS or name in seen:
1316
+ raise RequestError(f"invalid or duplicate test preparation layer: {name}")
1317
+ seen.add(name)
1318
+ if not isinstance(item.get("required"), bool):
1319
+ raise RequestError(f"test preparation layer required must be boolean: {name}")
1320
+ if item["required"] is True and item.get("not_applicable") is not None:
1321
+ raise RequestError(f"required test preparation layer cannot be N/A: {name}")
1322
+ if item["required"] is False and not isinstance(item.get("not_applicable"), dict):
1323
+ raise RequestError(f"omitted test preparation layer needs structured N/A: {name}")
1324
+ normalized.append(item)
1325
+ return normalized
1326
+
1327
+
1328
+ def run_prepare_tests(project_root: Path, evidence_dir: Path, request_path: Path, output_name: str) -> Path:
1329
+ request = ensure_object(load_json(request_path), "test preparation request")
1330
+ allowed = {"request_version", "layers", "tests"}
1331
+ if set(request) != allowed or request.get("request_version") != "1.0":
1332
+ raise RequestError("test preparation request must contain request_version, layers, and tests only")
1333
+ requested = request.get("tests")
1334
+ if not isinstance(requested, list) or not requested:
1335
+ raise RequestError("test preparation request tests must be a non-empty array")
1336
+ existing_plan_path = evidence_dir / "test-plan.json"
1337
+ existing_plan = ensure_object(load_json(existing_plan_path), "existing test plan") if existing_plan_path.is_file() else None
1338
+ prepared_layers = prepare_test_layers(request, existing_plan)
1339
+ inventory = ensure_object(load_json(evidence_dir / "target-inventory.json"), "target inventory")
1340
+ inventory_paths = {
1341
+ item.get("path") for item in inventory.get("categories", {}).get("tests", [])
1342
+ if isinstance(item, dict) and isinstance(item.get("path"), str)
1343
+ }
1344
+ changed = changed_test_statuses(evidence_dir)
1345
+ changed_native_tests = set(changed) & inventory_paths
1346
+ selected_paths: set[str] = set()
1347
+ normalized: list[dict[str, Any]] = []
1348
+ for item in requested:
1349
+ if not isinstance(item, dict):
1350
+ raise RequestError("test preparation entries must be objects")
1351
+ allowed_item = {"project_path", "behavior_ids", "layers", "selector", "runner"}
1352
+ if set(item) - allowed_item:
1353
+ raise RequestError("test preparation entries may not contain IDs, status, snapshot, or hash fields")
1354
+ project_path = normalize_relative(item.get("project_path", ""))
1355
+ if project_path in selected_paths:
1356
+ raise RequestError(f"duplicate native test path: {project_path}")
1357
+ selected_paths.add(project_path)
1358
+ if project_path not in inventory_paths:
1359
+ raise RequestError(f"native test is not in the test inventory: {project_path}")
1360
+ source = confined(project_root, project_path, must_exist=True)
1361
+ behavior_ids = ensure_string_list(item.get("behavior_ids"), f"test {project_path} behavior_ids", allow_empty=False)
1362
+ layers = ensure_string_list(item.get("layers"), f"test {project_path} layers", allow_empty=False)
1363
+ unknown_layers = sorted(set(layers) - set(LAYERS))
1364
+ if unknown_layers:
1365
+ raise RequestError(f"test {project_path} uses unknown layers: {', '.join(unknown_layers)}")
1366
+ if len(layers) != len(set(layers)):
1367
+ raise RequestError(f"test {project_path} contains duplicate layers")
1368
+ selector = item.get("selector")
1369
+ if selector is not None and not isinstance(selector, str):
1370
+ raise RequestError(f"test {project_path} selector must be a string")
1371
+ status = changed.get(project_path, "existing")
1372
+ entry = {
1373
+ "id": stable_test_id(project_path, selector),
1374
+ "behavior_ids": behavior_ids,
1375
+ "change_status": status,
1376
+ "project_path": project_path,
1377
+ "snapshot_path": None,
1378
+ "inventory_path": project_path,
1379
+ "layers": layers,
1380
+ }
1381
+ if selector is not None:
1382
+ entry["selector"] = selector
1383
+ if isinstance(item.get("runner"), str):
1384
+ entry["runner"] = item["runner"]
1385
+ normalized.append(entry)
1386
+ unbound = sorted(changed_native_tests - selected_paths)
1387
+ if unbound:
1388
+ raise RequestError("changed native tests are not bound by test preparation: " + ", ".join(unbound))
1389
+ generated = evidence_dir / "generated-tests"
1390
+ generated.mkdir(parents=True, exist_ok=True)
1391
+ used_snapshots: set[str] = set()
1392
+ for entry in normalized:
1393
+ if entry["change_status"] in {"added", "modified"}:
1394
+ destination_relative = f"generated-tests/{Path(entry['project_path']).name}"
1395
+ if destination_relative in used_snapshots:
1396
+ destination_relative = f"generated-tests/{entry['id']}-{Path(entry['project_path']).name}"
1397
+ destination = confined(evidence_dir, destination_relative)
1398
+ destination.parent.mkdir(parents=True, exist_ok=True)
1399
+ shutil.copy2(project_root / entry["project_path"], destination)
1400
+ entry["snapshot_path"] = destination_relative
1401
+ used_snapshots.add(destination_relative)
1402
+ plan = {
1403
+ "target_hashes": {},
1404
+ "plan_inputs": inventory.get("plan_inputs", {}),
1405
+ "layers": prepared_layers,
1406
+ "tests": normalized,
1407
+ }
1408
+ existing_plan = evidence_dir / "test-plan.json"
1409
+ if existing_plan.is_file():
1410
+ prior = ensure_object(load_json(existing_plan), "existing test plan")
1411
+ plan["target_hashes"] = prior.get("target_hashes", {})
1412
+ plan["layers"] = prior.get("layers", prepared_layers)
1413
+ write_json_atomic(evidence_dir / "test-plan.json", plan)
1414
+ write_json_atomic(evidence_dir / output_name, {
1415
+ "format": "prizmkit-native-test-preparation-v1",
1416
+ "request_path": evidence_relative(evidence_dir, request_path),
1417
+ "request_sha256": file_sha256(request_path),
1418
+ "tests": normalized,
1419
+ "changed_test_statuses": changed,
1420
+ })
1421
+ return evidence_dir / output_name
1422
+
1423
+
1424
+ def normalize_test_snapshots(project_root: Path, evidence_dir: Path, plan: dict[str, Any]) -> None:
1425
+ tests = plan.get("tests")
1426
+ if not isinstance(tests, list):
1427
+ return
1428
+ captured_statuses = changed_test_statuses(evidence_dir) if (evidence_dir / "change-capture.json").is_file() else {}
1429
+ inventory = load_json(evidence_dir / "target-inventory.json") if (evidence_dir / "target-inventory.json").is_file() else {}
1430
+ inventory_paths = {
1431
+ item.get("path") for item in inventory.get("categories", {}).get("tests", [])
1432
+ if isinstance(item, dict) and isinstance(item.get("path"), str)
1433
+ }
1434
+ selected_paths = {test.get("project_path") for test in tests if isinstance(test, dict)}
1435
+ unbound = sorted((set(captured_statuses) & inventory_paths) - selected_paths)
1436
+ if unbound:
1437
+ raise RequestError("changed native tests are not bound by test plan: " + ", ".join(unbound))
1438
+ generated = evidence_dir / "generated-tests"
1439
+ generated.mkdir(parents=True, exist_ok=True)
1440
+ for test in tests:
1441
+ if not isinstance(test, dict):
1442
+ continue
1443
+ project_path = test.get("project_path")
1444
+ if not isinstance(project_path, str):
1445
+ continue
1446
+ status = captured_statuses.get(project_path, "existing") if captured_statuses else test.get("change_status", test.get("origin", "existing"))
1447
+ if status not in {"existing", "added", "modified"}:
1448
+ raise RequestError(f"unknown test change_status: {test.get('id')}")
1449
+ test["change_status"] = status
1450
+ if status == "existing":
1451
+ test["snapshot_path"] = None
1452
+ continue
1453
+ source = confined(project_root, project_path, must_exist=True)
1454
+ destination_relative = test.get("snapshot_path")
1455
+ if not isinstance(destination_relative, str) or not destination_relative.startswith("generated-tests/"):
1456
+ destination_relative = f"generated-tests/{Path(project_path).name}"
1457
+ destination = confined(evidence_dir, destination_relative)
1458
+ destination.parent.mkdir(parents=True, exist_ok=True)
1459
+ shutil.copy2(source, destination)
1460
+ test["snapshot_path"] = destination_relative
1461
+ write_json_atomic(evidence_dir / "test-plan.json", plan)
1462
+
1463
+
1464
+ def stage_output_owner(relative: str, change_class: str) -> str:
1465
+ if relative in {"change-classification.json"}:
1466
+ return "CHANGE_CLASSIFY"
1467
+ if relative in {"scope.json", "target-inventory.json", "environment.json", "source-change.patch", "change-capture.json"} or relative.startswith("requests/"):
1468
+ return "SCOPE_DISCOVER"
1469
+ if relative in {"behavior-risk-matrix.json"}:
1470
+ return "CONTRACT_MODEL"
1471
+ if relative in {"test-plan.json"} or relative == "test-preparation.json":
1472
+ return "TEST_PLAN"
1473
+ if relative in {"infrastructure-changes.json"} or relative.startswith("contracts/"):
1474
+ return "INFRA_READY"
1475
+ if relative.startswith("generated-tests/"):
1476
+ return "TEST_BUILD"
1477
+ if relative in {"executions.json", "differential-proof.json"} or relative.startswith("receipts/") or relative.startswith("raw/") or relative.startswith("runner/"):
1478
+ return "EXECUTE_PROVE"
1479
+ if relative in FINAL_RECORDS or relative == "lifecycle.json" or relative == "init.json" or relative == "finalize.json":
1480
+ return "EVIDENCE_PACKAGE"
1481
+ return "EVIDENCE_PACKAGE"
1482
+
1483
+
1484
+ 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]:
1485
+ file_paths = sorted(
1486
+ path.relative_to(evidence_dir).as_posix()
1487
+ for path in evidence_dir.rglob("*")
1488
+ if path.is_file() and path.name != "manifest.json"
1489
+ )
1490
+ files = [
1491
+ {"path": relative, "sha256": file_sha256(evidence_dir / relative), "produced_by": stage_output_owner(relative, change_class)}
1492
+ for relative in file_paths
1493
+ ]
1494
+ outputs_by_stage = {stage: [] for stage in STAGES}
1495
+ for entry in files:
1496
+ outputs_by_stage.setdefault(entry["produced_by"], []).append(entry["path"])
1497
+ predecessor_files: list[dict[str, str]] = []
1498
+ stages = []
1499
+ for stage in STAGES:
1500
+ outputs = sorted(outputs_by_stage.get(stage, []))
1501
+ stages.append({
1502
+ "name": stage,
1503
+ "status": "complete",
1504
+ "input_sha256": canonical_sha256({
1505
+ "stage": stage,
1506
+ "target_hashes": target_hashes,
1507
+ "predecessor_outputs": sorted(predecessor_files, key=lambda item: item["path"]),
1508
+ }),
1509
+ "outputs": outputs,
1510
+ "not_applicable": None,
1511
+ })
1512
+ predecessor_files.extend(
1513
+ {"path": output, "sha256": next(item["sha256"] for item in files if item["path"] == output)}
1514
+ for output in outputs if output not in FINAL_RECORDS
1515
+ )
1516
+ return {
1517
+ "protocol_version": "1.0",
1518
+ "evidence_id": evidence_id,
1519
+ "evidence_id_inputs": identity,
1520
+ "sensitivity": "project-controlled",
1521
+ "environment_claim": "mocked-code-level-only",
1522
+ "change_class": change_class,
1523
+ "baseline_commit": baseline_commit,
1524
+ "working_diff_sha256": identity["working_diff_sha256"],
1525
+ "target_hashes": target_hashes,
1526
+ "stages": stages,
1527
+ "files": files,
1528
+ "final_verdict": final_verdict,
1529
+ "compatibility": "legacy-test-report-interface-not-supported",
1530
+ }
1531
+
1532
+
1533
+ def write_handoff_pointer(evidence_dir: Path, artifact_dir: str | None, project_root: Path | None = None) -> None:
1534
+ if not artifact_dir:
1535
+ return
1536
+ artifact = Path(artifact_dir)
1537
+ if not artifact.is_absolute() and project_root is not None:
1538
+ artifact = project_root / artifact
1539
+ artifact = artifact.resolve()
1540
+ if not artifact.is_dir():
1541
+ raise RequestError(f"artifact_dir does not exist: {artifact_dir}")
1542
+ pointer = artifact / "test-report-path.txt"
1543
+ write_text_atomic(pointer, str((evidence_dir / "test-report.md").resolve()) + "\n")
1544
+
1545
+
1546
+ def run_finalize(project_root: Path, evidence_dir: Path, request: dict[str, Any], output_name: str) -> Path:
1547
+ allowed = {"request_version", "change_class", "baseline_commit", "scope_path", "environment_path", "artifact_dir", "blockers", "repair_scope"}
1548
+ if set(request) - allowed:
1549
+ raise RequestError(f"finalize request contains unsupported fields: {sorted(set(request) - allowed)}")
1550
+ if request.get("request_version", "1.0") != "1.0":
1551
+ raise RequestError("unsupported finalize request version")
1552
+ lifecycle = load_lifecycle(evidence_dir, required=True)
1553
+ if lifecycle.get("finalized") is True:
1554
+ raise RequestError("evidence identity is finalized; re-init a new package before changing scope or source")
1555
+ capture_path = evidence_dir / "change-capture.json"
1556
+ if not capture_path.exists():
1557
+ run_capture_change(project_root, evidence_dir, request.get("baseline_commit"), "change-capture.json")
1558
+ capture = ensure_object(load_json(capture_path), "change capture")
1559
+ baseline = capture.get("baseline_commit")
1560
+ patch_path = evidence_dir / "source-change.patch"
1561
+ if not isinstance(baseline, str) or not patch_path.is_file():
1562
+ raise RequestError("canonical change capture is incomplete")
1563
+ change_class = request.get("change_class", "behavior")
1564
+ if change_class not in {"behavior", "lightweight"}:
1565
+ raise RequestError("change_class must be behavior or lightweight")
1566
+ artifact_dir = request.get("artifact_dir")
1567
+ if artifact_dir is not None and not isinstance(artifact_dir, str):
1568
+ raise RequestError("artifact_dir must be a project-relative directory")
1569
+ if artifact_dir is not None:
1570
+ artifact = confined(project_root, artifact_dir, must_exist=True, directory=True)
1571
+ lifecycle["artifact_dir"] = artifact.relative_to(project_root).as_posix()
1572
+ scope_relative = request.get("scope_path", "scope.json")
1573
+ scope = ensure_object(load_json(request_file(evidence_dir, scope_relative)), "scope")
1574
+ inventory = ensure_object(load_json(evidence_dir / "target-inventory.json"), "target inventory")
1575
+ environment_path = evidence_dir / request.get("environment_path", "environment.json")
1576
+ environment = load_json(environment_path) if environment_path.exists() else {}
1577
+ plan = load_json(evidence_dir / "test-plan.json") if (evidence_dir / "test-plan.json").exists() else None
1578
+ if isinstance(plan, dict):
1579
+ normalize_test_snapshots(project_root, evidence_dir, plan)
1580
+ categories = inventory.get("categories", {})
1581
+ target_hashes = {
1582
+ category: aggregate_inventory(categories.get(category, []))
1583
+ for category in CATEGORIES
1584
+ }
1585
+ target_hashes["environment"] = canonical_sha256(environment)
1586
+ target_hashes["plan"] = canonical_sha256(inventory.get("plan_inputs", {}))
1587
+ if isinstance(plan, dict):
1588
+ plan["target_hashes"] = target_hashes
1589
+ plan["plan_inputs"] = inventory.get("plan_inputs", {})
1590
+ write_json_atomic(evidence_dir / "test-plan.json", plan)
1591
+ scope["target_hashes"] = target_hashes
1592
+ write_json_atomic(evidence_dir / scope_relative, scope)
1593
+ identity = {
1594
+ "baseline_commit": baseline,
1595
+ "working_diff_sha256": file_sha256(patch_path),
1596
+ "scope_sha256": canonical_sha256(scope),
1597
+ }
1598
+ evidence_id = canonical_sha256(identity)
1599
+ executions = load_json(evidence_dir / "executions.json") if (evidence_dir / "executions.json").exists() else []
1600
+ blockers = list(request.get("blockers", [])) if isinstance(request.get("blockers", []), list) else []
1601
+ required_records = {
1602
+ "change-classification.json", "scope.json", "target-inventory.json", "environment.json",
1603
+ "source-change.patch",
1604
+ }
1605
+ if change_class == "behavior":
1606
+ required_records.update({
1607
+ "behavior-risk-matrix.json", "test-plan.json", "test-preparation.json",
1608
+ "infrastructure-changes.json", "differential-proof.json",
1609
+ })
1610
+ else:
1611
+ required_records.add("lightweight-verification.json")
1612
+ missing_records = sorted(
1613
+ relative for relative in required_records
1614
+ if not (evidence_dir / relative).is_file()
1615
+ )
1616
+ if missing_records:
1617
+ blockers.append(
1618
+ "required evidence records are missing: " + ", ".join(missing_records)
1619
+ )
1620
+ selected_failures = [item for item in executions if isinstance(item, dict) and item.get("selected_execution") is True and item.get("reliable") is True and item.get("exit_code") != 0]
1621
+ if selected_failures and not request.get("repair_scope"):
1622
+ blockers.append("reliable failure exists but repair_scope is not evidence-backed")
1623
+ verdict = "TEST_FAIL" if selected_failures and request.get("repair_scope") else ("TEST_BLOCKED" if blockers else "TEST_PASS")
1624
+ verdict_record: dict[str, Any] = {
1625
+ "verdict": verdict,
1626
+ "testing_domain_only": True,
1627
+ "authorizes_commit_or_release": False,
1628
+ "real_environment_validated": False,
1629
+ "repairs_business_defects": False,
1630
+ "code_evidence_replayable": verdict == "TEST_PASS",
1631
+ "blockers": blockers if verdict == "TEST_BLOCKED" else [],
1632
+ "reproduced_failures": [item["execution_id"] for item in selected_failures] if verdict == "TEST_FAIL" else [],
1633
+ }
1634
+ if verdict == "TEST_FAIL" and request.get("repair_scope"):
1635
+ verdict_record["repair_scope"] = request["repair_scope"]
1636
+ write_json_atomic(evidence_dir / "verdict.json", verdict_record)
1637
+ write_json_atomic(evidence_dir / "validation.json", {})
1638
+ write_json_atomic(evidence_dir / "lifecycle.json", {
1639
+ **lifecycle,
1640
+ "status": "finalizing",
1641
+ "finalized": False,
1642
+ "immutable_identity": identity,
1643
+ })
1644
+ manifest = build_manifest(
1645
+ evidence_dir, evidence_id=evidence_id, identity=identity,
1646
+ target_hashes=target_hashes, change_class=change_class,
1647
+ baseline_commit=baseline, final_verdict=verdict,
1648
+ )
1649
+ write_json_atomic(evidence_dir / "manifest.json", manifest)
1650
+ run_render_report(evidence_dir, "test-report.md")
1651
+ write_json_atomic(evidence_dir / "lifecycle.json", {
1652
+ **lifecycle,
1653
+ "status": "finalized",
1654
+ "finalized": True,
1655
+ "immutable_identity": identity,
1656
+ })
1657
+ output = confined(evidence_dir, output_name)
1658
+ write_json_atomic(output, {
1659
+ "command": "finalize",
1660
+ "evidence_id": evidence_id,
1661
+ "manifest_path": "manifest.json",
1662
+ "verdict_path": "verdict.json",
1663
+ "report_path": "test-report.md",
1664
+ "validation_path": "validation.json",
1665
+ "next": "validate_test_evidence.py --attest",
1666
+ })
1667
+ manifest = build_manifest(
1668
+ evidence_dir, evidence_id=evidence_id, identity=identity,
1669
+ target_hashes=target_hashes, change_class=change_class,
1670
+ baseline_commit=baseline, final_verdict=verdict,
1671
+ )
1672
+ write_json_atomic(evidence_dir / "manifest.json", manifest)
1673
+ destination = evidence_dir.parent / evidence_id
1674
+ if destination != evidence_dir:
1675
+ if destination.exists():
1676
+ raise RequestError(
1677
+ f"evidence identity already exists at {destination}; re-init a new package"
1678
+ )
1679
+ os.replace(evidence_dir, destination)
1680
+ write_handoff_pointer(destination, lifecycle.get("artifact_dir"), project_root)
1681
+ return destination / output_name
1682
+
1683
+
609
1684
  def run_resume(
610
1685
  project_root: Path,
611
1686
  evidence_dir: Path,
@@ -615,6 +1690,11 @@ def run_resume(
615
1690
  ) -> Path:
616
1691
  manifest = ensure_object(load_json(manifest_path), "manifest")
617
1692
  inventory = ensure_object(load_json(inventory_path), "inventory")
1693
+ lifecycle = load_lifecycle(evidence_dir)
1694
+ if lifecycle.get("finalized") is True:
1695
+ raise RequestError(
1696
+ "evidence package is finalized and immutable; re-init a new evidence package before resume"
1697
+ )
618
1698
  categories = ensure_object(inventory.get("categories"), "inventory categories")
619
1699
  environment_path = evidence_dir / "environment.json"
620
1700
  environment = load_json(environment_path) if environment_path.exists() else {}
@@ -683,15 +1763,29 @@ def main() -> int:
683
1763
  parser.add_argument("--project-root", required=True)
684
1764
  parser.add_argument("--evidence-dir", required=True)
685
1765
  subparsers = parser.add_subparsers(dest="subcommand", required=True)
1766
+ init = subparsers.add_parser("init")
1767
+ init.add_argument("--baseline")
1768
+ init.add_argument("--output", default="init.json")
1769
+ capture = subparsers.add_parser("capture-change")
1770
+ capture.add_argument("--baseline")
1771
+ capture.add_argument("--output", default="change-capture.json")
686
1772
  inventory = subparsers.add_parser("inventory")
687
1773
  inventory.add_argument("--request", required=True)
688
1774
  inventory.add_argument("--output", default="target-inventory.json")
1775
+ prepare = subparsers.add_parser("prepare-tests")
1776
+ prepare.add_argument("--request", required=True)
1777
+ prepare.add_argument("--output", default="test-preparation.json")
689
1778
  execute = subparsers.add_parser("execute")
690
1779
  group = execute.add_mutually_exclusive_group(required=True)
691
1780
  group.add_argument("--request")
692
1781
  group.add_argument("--replay-receipt")
693
1782
  differential = subparsers.add_parser("differential")
694
1783
  differential.add_argument("--request", required=True)
1784
+ finalize = subparsers.add_parser("finalize")
1785
+ finalize.add_argument("--request", required=True, help="semantic finalization request")
1786
+ finalize.add_argument("--output", default="finalize.json")
1787
+ report = subparsers.add_parser("render-report")
1788
+ report.add_argument("--output", default="test-report.md")
695
1789
  resume = subparsers.add_parser("resume")
696
1790
  resume.add_argument("--manifest", required=True)
697
1791
  resume.add_argument("--inventory", required=True)
@@ -703,11 +1797,23 @@ def main() -> int:
703
1797
  if not project_root.is_dir():
704
1798
  raise RequestError(f"project root does not exist: {project_root}")
705
1799
  evidence_dir.relative_to(project_root)
706
- evidence_dir.mkdir(parents=True, exist_ok=True)
707
- if args.subcommand == "inventory":
1800
+ if args.subcommand == "init":
1801
+ output = run_init(project_root, evidence_dir, args.baseline, args.output)
1802
+ print(f"Evidence initialized: {output}")
1803
+ elif args.subcommand == "capture-change":
1804
+ require_mutable_lifecycle(evidence_dir)
1805
+ output = run_capture_change(project_root, evidence_dir, args.baseline, args.output)
1806
+ print(f"Change captured: {output}")
1807
+ elif args.subcommand == "inventory":
1808
+ require_mutable_lifecycle(evidence_dir)
708
1809
  output = run_inventory(project_root, evidence_dir, request_file(evidence_dir, args.request), args.output)
709
1810
  print(f"Inventory written: {output}")
1811
+ elif args.subcommand == "prepare-tests":
1812
+ require_mutable_lifecycle(evidence_dir)
1813
+ output = run_prepare_tests(project_root, evidence_dir, request_file(evidence_dir, args.request), args.output)
1814
+ print(f"Native tests prepared: {output}")
710
1815
  elif args.subcommand == "execute":
1816
+ require_mutable_lifecycle(evidence_dir)
711
1817
  if args.request:
712
1818
  receipt = execute_request(project_root, evidence_dir, request_file(evidence_dir, args.request))
713
1819
  else:
@@ -721,9 +1827,20 @@ def main() -> int:
721
1827
  receipt = execute_request(project_root, evidence_dir, original_request, replay_of=prior.get("execution_id"))
722
1828
  print(json.dumps(receipt, ensure_ascii=False))
723
1829
  elif args.subcommand == "differential":
1830
+ require_mutable_lifecycle(evidence_dir)
724
1831
  proof = run_differential(project_root, evidence_dir, request_file(evidence_dir, args.request))
725
1832
  print(json.dumps(proof, ensure_ascii=False))
726
- else:
1833
+ elif args.subcommand == "render-report":
1834
+ load_lifecycle(evidence_dir)
1835
+ output = run_render_report(evidence_dir, args.output)
1836
+ print(f"Report rendered: {output}")
1837
+ elif args.subcommand == "finalize":
1838
+ load_lifecycle(evidence_dir, required=True)
1839
+ request = ensure_object(load_json(request_file(evidence_dir, args.request)), "finalize request")
1840
+ output = run_finalize(project_root, evidence_dir, request, args.output)
1841
+ print(f"Evidence finalized: {output}")
1842
+ elif args.subcommand == "resume":
1843
+ load_lifecycle(evidence_dir)
727
1844
  output = run_resume(
728
1845
  project_root, evidence_dir, request_file(evidence_dir, args.manifest),
729
1846
  request_file(evidence_dir, args.inventory), args.output,