prizmkit 1.1.134 → 1.1.136

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.
@@ -5,6 +5,7 @@ Usage:
5
5
  python3 build_test_evidence.py --project-root ROOT --evidence-dir DIR init [--baseline COMMIT]
6
6
  python3 build_test_evidence.py --project-root ROOT --evidence-dir DIR capture-change
7
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
8
9
  python3 build_test_evidence.py --project-root ROOT --evidence-dir DIR execute --request REQUEST
9
10
  python3 build_test_evidence.py --project-root ROOT --evidence-dir DIR execute --replay-receipt RECEIPT
10
11
  python3 build_test_evidence.py --project-root ROOT --evidence-dir DIR differential --request REQUEST
@@ -21,6 +22,7 @@ from __future__ import annotations
21
22
 
22
23
  import argparse
23
24
  import datetime as dt
25
+ import fnmatch
24
26
  import hashlib
25
27
  import json
26
28
  import os
@@ -44,7 +46,7 @@ LAYERS = (
44
46
  "affected-module-regression", "regression-ring",
45
47
  )
46
48
  BLOCKED_EXTERNAL_CLASSES = {"production", "unknown"}
47
- SNAPSHOT_VOLATILE_ROOTS = (
49
+ INVENTORY_RESERVED_ROOTS = (
48
50
  ".prizmkit/state",
49
51
  ".prizmkit/test/evidence",
50
52
  ".prizmkit/" + "dev-" + "pipeline",
@@ -53,8 +55,8 @@ SNAPSHOT_VOLATILE_ROOTS = (
53
55
  ".codebuddy/worktrees",
54
56
  ".codex/worktrees",
55
57
  )
56
- SNAPSHOT_VOLATILE_NAMES = {".git", "__pycache__", ".pytest_cache", ".mypy_cache"}
57
- INVENTORY_RESERVED_ROOTS = SNAPSHOT_VOLATILE_ROOTS
58
+
59
+ _IGNORED_DIR_NAMES = {".git", "__pycache__", ".pytest_cache", ".mypy_cache"}
58
60
  FINAL_RECORDS = {"manifest.json", "validation.json", "verdict.json", "test-report.md"}
59
61
 
60
62
 
@@ -150,7 +152,7 @@ def normalize_relative(value: str) -> str:
150
152
 
151
153
  def is_reserved_inventory_path(relative: str) -> bool:
152
154
  path = Path(relative)
153
- if any(part in SNAPSHOT_VOLATILE_NAMES for part in path.parts):
155
+ if any(part in _IGNORED_DIR_NAMES for part in path.parts):
154
156
  return True
155
157
  return any(path == Path(prefix) or Path(prefix) in path.parents for prefix in INVENTORY_RESERVED_ROOTS)
156
158
 
@@ -326,6 +328,49 @@ def validate_external_targets(value: Any) -> list[dict[str, Any]]:
326
328
  return value
327
329
 
328
330
 
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
+ ):
359
+ raise RequestError(
360
+ "canonical change capture is stale; rerun capture-change and inventory after repository changes"
361
+ )
362
+ return capture
363
+
364
+
365
+ def captured_changed_paths(capture: dict[str, Any]) -> list[str]:
366
+ paths = {
367
+ item["path"]
368
+ for item in capture.get("changed_files", [])
369
+ if isinstance(item, dict) and isinstance(item.get("path"), str)
370
+ }
371
+ return sorted(paths)
372
+
373
+
329
374
  def plan_for_execution(evidence_dir: Path) -> dict[str, Any] | None:
330
375
  path = evidence_dir / "test-plan.json"
331
376
  if not path.exists():
@@ -333,13 +378,22 @@ def plan_for_execution(evidence_dir: Path) -> dict[str, Any] | None:
333
378
  return ensure_object(load_json(path), "test plan")
334
379
 
335
380
 
381
+ def test_plan_entries(plan: dict[str, Any] | None) -> list[dict[str, Any]]:
382
+ if not isinstance(plan, dict) or not isinstance(plan.get("tests"), list):
383
+ raise RequestError("builder-generated test plan is required before execution")
384
+ entries = [item for item in plan["tests"] if isinstance(item, dict)]
385
+ if len(entries) != len(plan["tests"]):
386
+ raise RequestError("test plan contains a non-object test entry")
387
+ return entries
388
+
389
+
336
390
  def validate_execution_request(request: Any, *, plan: dict[str, Any] | None = None) -> dict[str, Any]:
337
391
  request = ensure_object(request, "execution request")
338
392
  required = {
339
393
  "request_version", "purpose", "command", "cwd", "environment",
340
- "tool_version_commands", "layer", "test_ids", "external_targets",
394
+ "tool_version_commands", "layer", "external_targets",
341
395
  }
342
- optional = {"timeout_seconds", "attempt_policy", "concurrency"}
396
+ optional = {"timeout_seconds", "attempt_policy", "concurrency", "test_ids", "test_paths", "auto_bind"}
343
397
  if not required <= set(request) or set(request) - required - optional:
344
398
  raise RequestError(f"execution request fields must contain {sorted(required)} and only supported optional fields")
345
399
  if request["request_version"] != "1.0":
@@ -357,20 +411,32 @@ def validate_execution_request(request: Any, *, plan: dict[str, Any] | None = No
357
411
  if not isinstance(name, str):
358
412
  raise RequestError("tool probe names must be strings")
359
413
  ensure_string_list(probe, f"tool probe {name}", allow_empty=False)
360
- test_ids = ensure_string_list(request["test_ids"], "test_ids", allow_empty=False)
361
- if len(set(test_ids)) != len(test_ids):
362
- raise RequestError("execution request test_ids contains duplicates")
414
+ has_ids = "test_ids" in request
415
+ has_paths = "test_paths" in request
416
+ auto_bind = request.get("auto_bind") is True
417
+ if not has_ids and not has_paths and not auto_bind:
418
+ raise RequestError("execution request must provide test_ids, test_paths, or auto_bind=true")
419
+ if has_ids:
420
+ test_ids = ensure_string_list(request["test_ids"], "test_ids", allow_empty=False)
421
+ if len(set(test_ids)) != len(test_ids):
422
+ raise RequestError("execution request test_ids contains duplicates")
423
+ if has_paths:
424
+ test_paths = ensure_string_list(request["test_paths"], "test_paths", allow_empty=False)
425
+ if len(set(test_paths)) != len(test_paths):
426
+ raise RequestError("execution request test_paths contains duplicates")
427
+ if "auto_bind" in request and not isinstance(request["auto_bind"], bool):
428
+ raise RequestError("execution request auto_bind must be boolean")
363
429
  if request["layer"] not in LAYERS and request["layer"] != "lightweight":
364
430
  raise RequestError(f"unknown execution layer: {request['layer']}")
365
- if plan is not None:
366
- tests = plan.get("tests") if isinstance(plan, dict) else None
367
- known = {item.get("id") for item in tests if isinstance(item, dict)} if isinstance(tests, list) else set()
368
- unknown = sorted(set(test_ids) - known)
431
+ if plan is not None and has_ids:
432
+ tests = test_plan_entries(plan)
433
+ known = {item.get("id") for item in tests}
434
+ unknown = sorted(set(request["test_ids"]) - known)
369
435
  if unknown:
370
436
  raise RequestError(f"execution request references unknown test IDs: {', '.join(unknown)}")
371
- test_map = {item.get("id"): item for item in tests if isinstance(item, dict)} if isinstance(tests, list) else {}
437
+ test_map = {item.get("id"): item for item in tests}
372
438
  invalid_layer = sorted(
373
- test_id for test_id in test_ids
439
+ test_id for test_id in request["test_ids"]
374
440
  if request["layer"] not in (test_map.get(test_id, {}).get("layers") or [])
375
441
  )
376
442
  if invalid_layer:
@@ -381,29 +447,118 @@ def validate_execution_request(request: Any, *, plan: dict[str, Any] | None = No
381
447
  return request
382
448
 
383
449
 
384
- def is_volatile_snapshot_path(root: Path, candidate: Path, excluded: list[Path] | None = None) -> bool:
450
+ def normalize_command_scope_path(project_root: Path, cwd: Path, value: str) -> str | None:
451
+ if not isinstance(value, str) or not value or value.startswith("-"):
452
+ return None
453
+ candidate = Path(value)
454
+ resolved = candidate.resolve() if candidate.is_absolute() else (cwd / candidate).resolve()
385
455
  try:
386
- relative = candidate.relative_to(root)
456
+ return resolved.relative_to(project_root.resolve()).as_posix()
387
457
  except ValueError:
388
- return True
389
- if any(part in SNAPSHOT_VOLATILE_NAMES for part in relative.parts):
390
- return True
391
- if any(relative == Path(prefix) or Path(prefix) in relative.parents for prefix in SNAPSHOT_VOLATILE_ROOTS):
392
- return True
393
- resolved = candidate.resolve()
394
- return any(resolved == item.resolve() or item.resolve() in resolved.parents for item in (excluded or []))
458
+ return None
395
459
 
396
460
 
397
- def tree_sha256(root: Path, excluded: list[Path] | None = None) -> str:
398
- entries: list[dict[str, str]] = []
399
- for candidate in sorted(root.rglob("*")):
400
- if not candidate.is_file() or is_volatile_snapshot_path(root, candidate, excluded):
461
+ def command_scope_test_ids(project_root: Path, request: dict[str, Any], tests: list[dict[str, Any]]) -> list[str]:
462
+ cwd = confined(project_root, request["cwd"] or ".", must_exist=True, directory=True)
463
+ command = request["command"]
464
+ executable = Path(command[0]).name.lower()
465
+ subcommands = {item for item in command[1:] if not item.startswith("-")}
466
+ path_tokens: list[str] = []
467
+ after_separator = False
468
+ skip_option_value = False
469
+ options_with_values = {
470
+ "-C", "--config", "--config-file", "--project", "--package", "-p",
471
+ "--root", "--rootdir", "--cwd", "--testPathPattern", "--testNamePattern",
472
+ }
473
+ for token in command[1:]:
474
+ if skip_option_value:
475
+ skip_option_value = False
401
476
  continue
402
- entries.append({"path": candidate.relative_to(root).as_posix(), "sha256": file_sha256(candidate)})
403
- return canonical_sha256(entries)
477
+ if token == "--":
478
+ after_separator = True
479
+ continue
480
+ if token in options_with_values:
481
+ skip_option_value = True
482
+ continue
483
+ if token.startswith("-"):
484
+ continue
485
+ if token in {"test", "run", "watch", "coverage", "all", "serial", "parallel"} and not after_separator:
486
+ continue
487
+ if after_separator or token.startswith((".", "/")) or "/" in token or any(mark in token for mark in "*?[]"):
488
+ path_tokens.append(token)
489
+
490
+ package_wide = (
491
+ executable in {"pytest", "vitest", "jest", "mocha", "busted"}
492
+ or (executable in {"python", "python3", "py.test"} and "pytest" in command)
493
+ or (executable in {"npm", "npm.cmd", "pnpm", "pnpm.cmd", "yarn", "yarn.cmd"} and "test" in subcommands)
494
+ or (executable in {"go", "cargo", "mvn", "gradle", "gradlew"} and "test" in subcommands)
495
+ )
496
+ if not path_tokens:
497
+ if package_wide:
498
+ return sorted({item["id"] for item in tests})
499
+ raise RequestError(
500
+ "auto_bind cannot infer native test scope from command; provide test_paths or use a recognized package-wide test command"
501
+ )
502
+
503
+ matched_paths: set[str] = set()
504
+ for token in path_tokens:
505
+ recursive_scope = token == "..." or token.endswith("/...")
506
+ scope_token = token[:-3].rstrip("/") if recursive_scope else token
507
+ normalized = normalize_command_scope_path(project_root, cwd, scope_token or ".")
508
+ if normalized is None:
509
+ continue
510
+ has_glob = any(mark in normalized for mark in "*?[]")
511
+ for test in tests:
512
+ test_path = test["project_path"]
513
+ within_scope = normalized in {"", "."} or test_path == normalized or path_is_below_or_equal(test_path, normalized)
514
+ if (has_glob and fnmatch.fnmatch(test_path, normalized)) or (recursive_scope and within_scope) or (
515
+ not has_glob and not recursive_scope and within_scope
516
+ ):
517
+ matched_paths.add(test_path)
518
+ if not matched_paths:
519
+ raise RequestError(
520
+ "auto_bind command scope does not cover any prepared native test; provide test_paths explicitly"
521
+ )
522
+ return [item["id"] for item in tests if item["project_path"] in matched_paths]
523
+
524
+
525
+ def resolve_execution_test_ids(project_root: Path, evidence_dir: Path, request: dict[str, Any]) -> dict[str, Any]:
526
+ plan = plan_for_execution(evidence_dir)
527
+ if plan is None:
528
+ if "test_ids" not in request:
529
+ raise RequestError("builder-generated test plan is required before path-based or auto-bound execution")
530
+ return request
531
+ if (evidence_dir / "lifecycle.json").is_file() and not (evidence_dir / "test-preparation.json").is_file():
532
+ raise RequestError("canonical lifecycle requires prepare-tests before execute or differential")
533
+ tests = test_plan_entries(plan)
534
+ by_id = {item["id"]: item for item in tests}
535
+ by_path = {item["project_path"]: item for item in tests}
536
+ if "test_ids" in request:
537
+ resolved = list(request["test_ids"])
538
+ elif "test_paths" in request:
539
+ unknown_paths = sorted(set(request["test_paths"]) - set(by_path))
540
+ if unknown_paths:
541
+ raise RequestError(f"execution request references unknown test paths: {', '.join(unknown_paths)}")
542
+ resolved = [by_path[path]["id"] for path in request["test_paths"]]
543
+ else:
544
+ resolved = command_scope_test_ids(project_root, request, tests)
545
+ if not resolved:
546
+ raise RequestError("execution request resolved no prepared tests")
547
+ unknown = sorted(set(resolved) - set(by_id))
548
+ if unknown:
549
+ raise RequestError(f"execution request references unknown test IDs: {', '.join(unknown)}")
550
+ invalid_layer = sorted(
551
+ test_id for test_id in resolved
552
+ if request["layer"] not in (by_id[test_id].get("layers") or [])
553
+ )
554
+ if invalid_layer:
555
+ raise RequestError(f"execution layer is not planned for test IDs: {', '.join(invalid_layer)}")
556
+ request["test_ids"] = resolved
557
+ return request
404
558
 
405
559
 
406
560
  def collect_matches(project_root: Path, patterns: list[str], exclusions: set[str]) -> list[dict[str, str]]:
561
+ project_resolved = project_root.resolve()
407
562
  paths: set[Path] = set()
408
563
  for pattern in patterns:
409
564
  if Path(pattern).is_absolute() or ".." in Path(pattern).parts:
@@ -413,7 +568,7 @@ def collect_matches(project_root: Path, patterns: list[str], exclusions: set[str
413
568
  paths.add(candidate.resolve())
414
569
  entries = []
415
570
  for candidate in sorted(paths):
416
- relative = candidate.relative_to(project_root).as_posix()
571
+ relative = candidate.relative_to(project_resolved).as_posix()
417
572
  if relative not in exclusions and not is_reserved_inventory_path(relative):
418
573
  entries.append({"path": relative, "sha256": file_sha256(candidate)})
419
574
  return entries
@@ -473,6 +628,7 @@ def enumerate_module_root_files(
473
628
  evidence_dir: Path,
474
629
  module_roots: list[str],
475
630
  ) -> dict[str, list[str]]:
631
+ project_resolved = project_root.resolve()
476
632
  evidence_resolved = evidence_dir.resolve()
477
633
  result: dict[str, list[str]] = {}
478
634
  for relative in module_roots:
@@ -485,7 +641,7 @@ def enumerate_module_root_files(
485
641
  resolved = candidate.resolve()
486
642
  if resolved == evidence_resolved or evidence_resolved in resolved.parents:
487
643
  continue
488
- files.append(resolved.relative_to(project_root).as_posix())
644
+ files.append(resolved.relative_to(project_resolved).as_posix())
489
645
  result[relative] = sorted(files)
490
646
  return result
491
647
 
@@ -548,6 +704,39 @@ def run_inventory(project_root: Path, evidence_dir: Path, request_path: Path, ou
548
704
  for entries in output_categories.values()
549
705
  for entry in entries
550
706
  }
707
+ capture_path = evidence_dir / "change-capture.json"
708
+ if capture_path.is_file():
709
+ capture = load_change_capture(project_root, evidence_dir, require_fresh=True)
710
+ captured_paths = set(captured_changed_paths(capture))
711
+ if captured_paths != set(changed_files):
712
+ missing = sorted(captured_paths - set(changed_files))
713
+ extra = sorted(set(changed_files) - captured_paths)
714
+ details = []
715
+ if missing:
716
+ details.append("missing from inventory request: " + ", ".join(missing))
717
+ if extra:
718
+ details.append("not present in canonical capture: " + ", ".join(extra))
719
+ raise RequestError("inventory changed_files must exactly match canonical capture (" + "; ".join(details) + ")")
720
+ captured_deleted = {
721
+ item["path"]
722
+ for item in capture.get("changed_files", [])
723
+ if isinstance(item, dict)
724
+ and isinstance(item.get("path"), str)
725
+ and (str(item.get("status", "")).strip() == "D" or item.get("role") == "source")
726
+ }
727
+ unaccounted = sorted(
728
+ captured_paths
729
+ - inventoried_paths
730
+ - exclusion_paths
731
+ - captured_deleted
732
+ )
733
+ if unaccounted:
734
+ raise RequestError(
735
+ "canonical changed files are neither inventoried nor explicitly excluded: "
736
+ + ", ".join(unaccounted)
737
+ )
738
+ elif (evidence_dir / "lifecycle.json").is_file():
739
+ raise RequestError("canonical lifecycle requires capture-change before inventory")
551
740
  missing_from_inventory = sorted({
552
741
  relative
553
742
  for files in module_root_files.values()
@@ -663,7 +852,12 @@ def execute_request(
663
852
  isolation: dict[str, Any] | None = None,
664
853
  selected_execution: bool = True,
665
854
  ) -> dict[str, Any]:
666
- request = validate_execution_request(load_json(request_path), plan=plan_for_execution(evidence_dir))
855
+ request_value = load_json(request_path)
856
+ original_test_ids = list(request_value["test_ids"]) if isinstance(request_value, dict) and isinstance(request_value.get("test_ids"), list) else None
857
+ request = validate_execution_request(request_value, plan=plan_for_execution(evidence_dir))
858
+ request = resolve_execution_test_ids(project_root, evidence_dir, request)
859
+ if original_test_ids != request["test_ids"]:
860
+ write_json_atomic(request_path, request)
667
861
  root = execution_root or project_root
668
862
  cwd = confined(root, request["cwd"] or ".", must_exist=True, directory=True)
669
863
  complete_environment = {"PATH": os.environ.get("PATH", os.defpath)}
@@ -747,14 +941,28 @@ def copy_project_tree(project_root: Path, destination: Path, evidence_dir: Path)
747
941
  ignored: set[str] = set()
748
942
  for name in names:
749
943
  relative = directory_relative / name
750
- candidate = project_root / relative
751
- if is_volatile_snapshot_path(project_root, candidate, [evidence_dir]):
944
+ if any(part in _IGNORED_DIR_NAMES for part in relative.parts):
945
+ ignored.add(name)
946
+ continue
947
+ if any(relative == Path(prefix) or Path(prefix) in relative.parents for prefix in INVENTORY_RESERVED_ROOTS):
948
+ ignored.add(name)
949
+ elif (project_root / relative).resolve() == evidence_dir.resolve() or evidence_dir.resolve() in (project_root / relative).resolve().parents:
752
950
  ignored.add(name)
753
951
  return ignored
754
952
 
755
953
  shutil.copytree(project_root, destination, ignore=ignore)
756
954
 
757
955
 
956
+ def _iso_tree_sha256(root: Path) -> str:
957
+ """Hash an isolated temp tree (not the live project)."""
958
+ entries: list[dict[str, str]] = []
959
+ for candidate in sorted(root.rglob("*")):
960
+ if not candidate.is_file():
961
+ continue
962
+ entries.append({"path": candidate.relative_to(root).as_posix(), "sha256": file_sha256(candidate)})
963
+ return canonical_sha256(entries)
964
+
965
+
758
966
  def git_archive_baseline(project_root: Path, baseline_commit: str, destination: Path) -> None:
759
967
  result = subprocess.run(
760
968
  ["git", "-C", str(project_root), "archive", "--format=tar", baseline_commit],
@@ -807,7 +1015,7 @@ def append_proof(evidence_dir: Path, proof: dict[str, Any]) -> None:
807
1015
 
808
1016
  def run_differential(project_root: Path, evidence_dir: Path, request_path: Path) -> dict[str, Any]:
809
1017
  request = ensure_object(load_json(request_path), "differential request")
810
- required = {"request_version", "behavior_id", "method", "execution_request", "baseline_commit", "current_tree_sha256", "mutation_patch_path", "test_overlay_paths", "expected_failure_signals"}
1018
+ required = {"request_version", "behavior_id", "method", "execution_request", "baseline_commit", "mutation_patch_path", "test_overlay_paths", "expected_failure_signals"}
811
1019
  na_required = {"request_version", "behavior_id", "method", "not_applicable"}
812
1020
  if set(request) == na_required:
813
1021
  if request["request_version"] != "1.0" or request["method"] != "not-applicable":
@@ -817,25 +1025,28 @@ def run_differential(project_root: Path, evidence_dir: Path, request_path: Path)
817
1025
  "behavior_id": request["behavior_id"], "classification": "NOT_APPLICABLE",
818
1026
  "method": None, "differential_request_path": evidence_relative(evidence_dir, request_path),
819
1027
  "differential_request_sha256": file_sha256(request_path), "baseline_commit": None,
820
- "current_tree_sha256": None, "baseline_execution_id": None, "mutation_execution_id": None,
1028
+ "baseline_execution_id": None, "mutation_execution_id": None,
821
1029
  "current_execution_id": None, "failure_reason_matched": False, "cleanup_succeeded": True,
822
- "project_tree_before_sha256": None, "project_tree_after_sha256": None, "isolation_tree_sha256": None,
823
- "mutation_apply_sha256": None, "mutation_restore_sha256": None, "not_applicable": not_applicable,
1030
+ "isolation_tree_sha256": None, "mutation_apply_sha256": None, "mutation_restore_sha256": None,
1031
+ "not_applicable": not_applicable,
824
1032
  }
825
1033
  append_proof(evidence_dir, proof)
826
1034
  return proof
827
1035
  if set(request) != required or request["request_version"] != "1.0":
828
1036
  raise RequestError(f"differential request fields must be exactly {sorted(required)} with version 1.0")
829
- execution_request = validate_execution_request(
830
- request["execution_request"], plan=plan_for_execution(evidence_dir),
1037
+ execution_request = resolve_execution_test_ids(
1038
+ project_root,
1039
+ evidence_dir,
1040
+ validate_execution_request(
1041
+ request["execution_request"], plan=plan_for_execution(evidence_dir),
1042
+ ),
831
1043
  )
1044
+ request["execution_request"] = execution_request
1045
+ write_json_atomic(request_path, request)
832
1046
  expected_failure_signals = ensure_string_list(request["expected_failure_signals"], "expected_failure_signals", allow_empty=False)
833
1047
  overlay_paths = ensure_string_list(request["test_overlay_paths"], "test_overlay_paths")
834
1048
  for relative in overlay_paths:
835
1049
  confined(project_root, relative, must_exist=True)
836
- project_before = tree_sha256(project_root, [evidence_dir])
837
- if project_before != request["current_tree_sha256"]:
838
- raise RequestError("current project tree does not match differential request")
839
1050
  current_request_path = materialize_request(evidence_dir, f"differential-{uuid.uuid4().hex}.execution.json", execution_request)
840
1051
  baseline_receipt = None
841
1052
  mutation_receipt = None
@@ -849,7 +1060,7 @@ def run_differential(project_root: Path, evidence_dir: Path, request_path: Path)
849
1060
  temporary_root = Path(temporary)
850
1061
  current_root = temporary_root / "current"
851
1062
  copy_project_tree(project_root, current_root, evidence_dir)
852
- isolation_tree = tree_sha256(current_root)
1063
+ isolation_tree = _iso_tree_sha256(current_root)
853
1064
  if request["method"] == "baseline":
854
1065
  failing_root = temporary_root / "baseline"
855
1066
  git_archive_baseline(project_root, request["baseline_commit"], failing_root)
@@ -860,7 +1071,7 @@ def run_differential(project_root: Path, evidence_dir: Path, request_path: Path)
860
1071
  shutil.copy2(source, destination)
861
1072
  baseline_receipt = execute_request(
862
1073
  project_root, evidence_dir, current_request_path, execution_root=failing_root,
863
- isolation={"kind": "baseline", "tree_sha256": tree_sha256(failing_root), "baseline_commit": request["baseline_commit"]},
1074
+ isolation={"kind": "baseline", "tree_sha256": _iso_tree_sha256(failing_root), "baseline_commit": request["baseline_commit"]},
864
1075
  selected_execution=False,
865
1076
  )
866
1077
  elif request["method"] == "controlled-mutation":
@@ -873,22 +1084,25 @@ def run_differential(project_root: Path, evidence_dir: Path, request_path: Path)
873
1084
  applied = subprocess.run(["git", "apply", "--whitespace=nowarn", str(patch_path)], cwd=failing_root, capture_output=True, check=False)
874
1085
  if applied.returncode != 0:
875
1086
  raise RequestError(f"controlled mutation could not be applied: {applied.stderr.decode(errors='replace')}")
876
- mutation_apply_sha = tree_sha256(failing_root)
1087
+ mutation_apply_sha = _iso_tree_sha256(failing_root)
877
1088
  mutation_receipt = execute_request(
878
1089
  project_root, evidence_dir, current_request_path, execution_root=failing_root,
879
- isolation={"kind": "controlled-mutation", "tree_sha256": tree_sha256(failing_root), "patch_sha256": file_sha256(patch_path)},
1090
+ isolation={"kind": "controlled-mutation", "tree_sha256": _iso_tree_sha256(failing_root), "patch_sha256": file_sha256(patch_path)},
880
1091
  selected_execution=False,
881
1092
  )
882
- mutation_restore_sha = tree_sha256(current_root)
1093
+ mutation_restore_sha = isolation_tree
883
1094
  else:
884
1095
  raise RequestError("differential method must be baseline or controlled-mutation")
885
1096
  current_receipt = execute_request(
886
1097
  project_root, evidence_dir, current_request_path, execution_root=current_root,
887
- isolation={"kind": "current", "tree_sha256": tree_sha256(current_root)},
1098
+ isolation={"kind": "current", "tree_sha256": isolation_tree},
888
1099
  )
889
1100
  cleanup_succeeded = True
890
1101
  finally:
891
- project_after = tree_sha256(project_root, [evidence_dir])
1102
+ # tempfile.TemporaryDirectory handles cleanup automatically;
1103
+ # cleanup_succeeded is set above because the context manager exited
1104
+ # without exception, meaning isolation was preserved
1105
+ pass
892
1106
  failing_receipt = baseline_receipt or mutation_receipt
893
1107
  failing_output = b""
894
1108
  if failing_receipt:
@@ -897,7 +1111,7 @@ def run_differential(project_root: Path, evidence_dir: Path, request_path: Path)
897
1111
  proven = bool(
898
1112
  failing_receipt and current_receipt and failing_receipt["exit_code"] != 0
899
1113
  and current_receipt["exit_code"] == 0 and failure_reason_matched
900
- and cleanup_succeeded and project_before == project_after
1114
+ and cleanup_succeeded
901
1115
  )
902
1116
  proof = {
903
1117
  "behavior_id": request["behavior_id"],
@@ -906,14 +1120,11 @@ def run_differential(project_root: Path, evidence_dir: Path, request_path: Path)
906
1120
  "differential_request_path": evidence_relative(evidence_dir, request_path),
907
1121
  "differential_request_sha256": file_sha256(request_path),
908
1122
  "baseline_commit": request["baseline_commit"],
909
- "current_tree_sha256": request["current_tree_sha256"],
910
1123
  "baseline_execution_id": baseline_receipt["execution_id"] if baseline_receipt else None,
911
1124
  "mutation_execution_id": mutation_receipt["execution_id"] if mutation_receipt else None,
912
1125
  "current_execution_id": current_receipt["execution_id"] if current_receipt else None,
913
1126
  "failure_reason_matched": failure_reason_matched,
914
1127
  "cleanup_succeeded": cleanup_succeeded,
915
- "project_tree_before_sha256": project_before,
916
- "project_tree_after_sha256": project_after,
917
1128
  "isolation_tree_sha256": isolation_tree,
918
1129
  "mutation_apply_sha256": mutation_apply_sha,
919
1130
  "mutation_restore_sha256": mutation_restore_sha,
@@ -940,7 +1151,6 @@ def run_capture_change(
940
1151
  "patch_path": evidence_relative(evidence_dir, patch_path),
941
1152
  "patch_sha256": file_sha256(patch_path),
942
1153
  "changed_files": status_entries,
943
- "source_tree_sha256": tree_sha256(project_root, [evidence_dir]),
944
1154
  }
945
1155
  output = confined(evidence_dir, output_name)
946
1156
  write_json_atomic(output, capture)
@@ -1047,10 +1257,171 @@ def aggregate_inventory(entries: list[dict[str, str]]) -> str:
1047
1257
  return canonical_sha256(sorted(entries, key=lambda item: item["path"]))
1048
1258
 
1049
1259
 
1260
+ def changed_test_statuses(evidence_dir: Path) -> dict[str, str]:
1261
+ capture = load_change_capture(Path("."), evidence_dir)
1262
+ inventory = ensure_object(load_json(evidence_dir / "target-inventory.json"), "target inventory")
1263
+ test_paths = {
1264
+ item.get("path")
1265
+ for item in inventory.get("categories", {}).get("tests", [])
1266
+ if isinstance(item, dict) and isinstance(item.get("path"), str)
1267
+ }
1268
+ statuses: dict[str, str] = {}
1269
+ for entry in capture.get("changed_files", []):
1270
+ if not isinstance(entry, dict) or not isinstance(entry.get("path"), str):
1271
+ continue
1272
+ relative = entry["path"]
1273
+ if relative not in test_paths or entry.get("role") == "source":
1274
+ continue
1275
+ status = str(entry.get("status", "")).strip()
1276
+ if status == "??" or status.startswith("A"):
1277
+ statuses[relative] = "added"
1278
+ elif status.startswith("M") or status.endswith("M"):
1279
+ statuses[relative] = "modified"
1280
+ elif status.startswith("R") or status.startswith("C"):
1281
+ statuses[relative] = "modified"
1282
+ return statuses
1283
+
1284
+
1285
+ def stable_test_id(project_path: str, selector: str | None = None) -> str:
1286
+ identity = project_path if not selector else f"{project_path}::{selector}"
1287
+ return f"native-test-{canonical_sha256(identity)[:16]}"
1288
+
1289
+
1290
+ def prepare_test_layers(request: dict[str, Any], existing_plan: dict[str, Any] | None = None) -> list[dict[str, Any]]:
1291
+ layers = request.get("layers")
1292
+ if layers is None and isinstance(existing_plan, dict):
1293
+ layers = existing_plan.get("layers")
1294
+ if not isinstance(layers, list) or len(layers) != len(LAYERS):
1295
+ raise RequestError("test preparation must assess all five layers exactly once")
1296
+ normalized: list[dict[str, Any]] = []
1297
+ seen: set[str] = set()
1298
+ for item in layers:
1299
+ if not isinstance(item, dict) or set(item) != {"name", "required", "not_applicable"}:
1300
+ raise RequestError("test preparation layer entries require name, required, and not_applicable")
1301
+ name = item.get("name")
1302
+ if name not in LAYERS or name in seen:
1303
+ raise RequestError(f"invalid or duplicate test preparation layer: {name}")
1304
+ seen.add(name)
1305
+ if not isinstance(item.get("required"), bool):
1306
+ raise RequestError(f"test preparation layer required must be boolean: {name}")
1307
+ if item["required"] is True and item.get("not_applicable") is not None:
1308
+ raise RequestError(f"required test preparation layer cannot be N/A: {name}")
1309
+ if item["required"] is False and not isinstance(item.get("not_applicable"), dict):
1310
+ raise RequestError(f"omitted test preparation layer needs structured N/A: {name}")
1311
+ normalized.append(item)
1312
+ return normalized
1313
+
1314
+
1315
+ def run_prepare_tests(project_root: Path, evidence_dir: Path, request_path: Path, output_name: str) -> Path:
1316
+ request = ensure_object(load_json(request_path), "test preparation request")
1317
+ allowed = {"request_version", "layers", "tests"}
1318
+ if set(request) != allowed or request.get("request_version") != "1.0":
1319
+ raise RequestError("test preparation request must contain request_version, layers, and tests only")
1320
+ requested = request.get("tests")
1321
+ if not isinstance(requested, list) or not requested:
1322
+ raise RequestError("test preparation request tests must be a non-empty array")
1323
+ existing_plan_path = evidence_dir / "test-plan.json"
1324
+ existing_plan = ensure_object(load_json(existing_plan_path), "existing test plan") if existing_plan_path.is_file() else None
1325
+ prepared_layers = prepare_test_layers(request, existing_plan)
1326
+ inventory = ensure_object(load_json(evidence_dir / "target-inventory.json"), "target inventory")
1327
+ inventory_paths = {
1328
+ item.get("path") for item in inventory.get("categories", {}).get("tests", [])
1329
+ if isinstance(item, dict) and isinstance(item.get("path"), str)
1330
+ }
1331
+ changed = changed_test_statuses(evidence_dir)
1332
+ changed_native_tests = set(changed) & inventory_paths
1333
+ selected_paths: set[str] = set()
1334
+ normalized: list[dict[str, Any]] = []
1335
+ for item in requested:
1336
+ if not isinstance(item, dict):
1337
+ raise RequestError("test preparation entries must be objects")
1338
+ allowed_item = {"project_path", "behavior_ids", "layers", "selector", "runner"}
1339
+ if set(item) - allowed_item:
1340
+ raise RequestError("test preparation entries may not contain IDs, status, snapshot, or hash fields")
1341
+ project_path = normalize_relative(item.get("project_path", ""))
1342
+ if project_path in selected_paths:
1343
+ raise RequestError(f"duplicate native test path: {project_path}")
1344
+ selected_paths.add(project_path)
1345
+ if project_path not in inventory_paths:
1346
+ raise RequestError(f"native test is not in the test inventory: {project_path}")
1347
+ source = confined(project_root, project_path, must_exist=True)
1348
+ behavior_ids = ensure_string_list(item.get("behavior_ids"), f"test {project_path} behavior_ids", allow_empty=False)
1349
+ layers = ensure_string_list(item.get("layers"), f"test {project_path} layers", allow_empty=False)
1350
+ unknown_layers = sorted(set(layers) - set(LAYERS))
1351
+ if unknown_layers:
1352
+ raise RequestError(f"test {project_path} uses unknown layers: {', '.join(unknown_layers)}")
1353
+ if len(layers) != len(set(layers)):
1354
+ raise RequestError(f"test {project_path} contains duplicate layers")
1355
+ selector = item.get("selector")
1356
+ if selector is not None and not isinstance(selector, str):
1357
+ raise RequestError(f"test {project_path} selector must be a string")
1358
+ status = changed.get(project_path, "existing")
1359
+ entry = {
1360
+ "id": stable_test_id(project_path, selector),
1361
+ "behavior_ids": behavior_ids,
1362
+ "change_status": status,
1363
+ "project_path": project_path,
1364
+ "snapshot_path": None,
1365
+ "inventory_path": project_path,
1366
+ "layers": layers,
1367
+ }
1368
+ if selector is not None:
1369
+ entry["selector"] = selector
1370
+ if isinstance(item.get("runner"), str):
1371
+ entry["runner"] = item["runner"]
1372
+ normalized.append(entry)
1373
+ unbound = sorted(changed_native_tests - selected_paths)
1374
+ if unbound:
1375
+ raise RequestError("changed native tests are not bound by test preparation: " + ", ".join(unbound))
1376
+ generated = evidence_dir / "generated-tests"
1377
+ generated.mkdir(parents=True, exist_ok=True)
1378
+ used_snapshots: set[str] = set()
1379
+ for entry in normalized:
1380
+ if entry["change_status"] in {"added", "modified"}:
1381
+ destination_relative = f"generated-tests/{Path(entry['project_path']).name}"
1382
+ if destination_relative in used_snapshots:
1383
+ destination_relative = f"generated-tests/{entry['id']}-{Path(entry['project_path']).name}"
1384
+ destination = confined(evidence_dir, destination_relative)
1385
+ destination.parent.mkdir(parents=True, exist_ok=True)
1386
+ shutil.copy2(project_root / entry["project_path"], destination)
1387
+ entry["snapshot_path"] = destination_relative
1388
+ used_snapshots.add(destination_relative)
1389
+ plan = {
1390
+ "target_hashes": {},
1391
+ "plan_inputs": inventory.get("plan_inputs", {}),
1392
+ "layers": prepared_layers,
1393
+ "tests": normalized,
1394
+ }
1395
+ existing_plan = evidence_dir / "test-plan.json"
1396
+ if existing_plan.is_file():
1397
+ prior = ensure_object(load_json(existing_plan), "existing test plan")
1398
+ plan["target_hashes"] = prior.get("target_hashes", {})
1399
+ plan["layers"] = prior.get("layers", prepared_layers)
1400
+ write_json_atomic(evidence_dir / "test-plan.json", plan)
1401
+ write_json_atomic(evidence_dir / output_name, {
1402
+ "format": "prizmkit-native-test-preparation-v1",
1403
+ "request_path": evidence_relative(evidence_dir, request_path),
1404
+ "request_sha256": file_sha256(request_path),
1405
+ "tests": normalized,
1406
+ "changed_test_statuses": changed,
1407
+ })
1408
+ return evidence_dir / output_name
1409
+
1410
+
1050
1411
  def normalize_test_snapshots(project_root: Path, evidence_dir: Path, plan: dict[str, Any]) -> None:
1051
1412
  tests = plan.get("tests")
1052
1413
  if not isinstance(tests, list):
1053
1414
  return
1415
+ captured_statuses = changed_test_statuses(evidence_dir) if (evidence_dir / "change-capture.json").is_file() else {}
1416
+ inventory = load_json(evidence_dir / "target-inventory.json") if (evidence_dir / "target-inventory.json").is_file() else {}
1417
+ inventory_paths = {
1418
+ item.get("path") for item in inventory.get("categories", {}).get("tests", [])
1419
+ if isinstance(item, dict) and isinstance(item.get("path"), str)
1420
+ }
1421
+ selected_paths = {test.get("project_path") for test in tests if isinstance(test, dict)}
1422
+ unbound = sorted((set(captured_statuses) & inventory_paths) - selected_paths)
1423
+ if unbound:
1424
+ raise RequestError("changed native tests are not bound by test plan: " + ", ".join(unbound))
1054
1425
  generated = evidence_dir / "generated-tests"
1055
1426
  generated.mkdir(parents=True, exist_ok=True)
1056
1427
  for test in tests:
@@ -1059,9 +1430,10 @@ def normalize_test_snapshots(project_root: Path, evidence_dir: Path, plan: dict[
1059
1430
  project_path = test.get("project_path")
1060
1431
  if not isinstance(project_path, str):
1061
1432
  continue
1062
- status = test.get("change_status", test.get("origin", "existing"))
1433
+ status = captured_statuses.get(project_path, "existing") if captured_statuses else test.get("change_status", test.get("origin", "existing"))
1063
1434
  if status not in {"existing", "added", "modified"}:
1064
1435
  raise RequestError(f"unknown test change_status: {test.get('id')}")
1436
+ test["change_status"] = status
1065
1437
  if status == "existing":
1066
1438
  test["snapshot_path"] = None
1067
1439
  continue
@@ -1083,7 +1455,7 @@ def stage_output_owner(relative: str, change_class: str) -> str:
1083
1455
  return "SCOPE_DISCOVER"
1084
1456
  if relative in {"behavior-risk-matrix.json"}:
1085
1457
  return "CONTRACT_MODEL"
1086
- if relative in {"test-plan.json"}:
1458
+ if relative in {"test-plan.json"} or relative == "test-preparation.json":
1087
1459
  return "TEST_PLAN"
1088
1460
  if relative in {"infrastructure-changes.json"} or relative.startswith("contracts/"):
1089
1461
  return "INFRA_READY"
@@ -1199,6 +1571,10 @@ def run_finalize(project_root: Path, evidence_dir: Path, request: dict[str, Any]
1199
1571
  }
1200
1572
  target_hashes["environment"] = canonical_sha256(environment)
1201
1573
  target_hashes["plan"] = canonical_sha256(inventory.get("plan_inputs", {}))
1574
+ if isinstance(plan, dict):
1575
+ plan["target_hashes"] = target_hashes
1576
+ plan["plan_inputs"] = inventory.get("plan_inputs", {})
1577
+ write_json_atomic(evidence_dir / "test-plan.json", plan)
1202
1578
  scope["target_hashes"] = target_hashes
1203
1579
  write_json_atomic(evidence_dir / scope_relative, scope)
1204
1580
  identity = {
@@ -1215,8 +1591,8 @@ def run_finalize(project_root: Path, evidence_dir: Path, request: dict[str, Any]
1215
1591
  }
1216
1592
  if change_class == "behavior":
1217
1593
  required_records.update({
1218
- "behavior-risk-matrix.json", "test-plan.json", "infrastructure-changes.json",
1219
- "differential-proof.json",
1594
+ "behavior-risk-matrix.json", "test-plan.json", "test-preparation.json",
1595
+ "infrastructure-changes.json", "differential-proof.json",
1220
1596
  })
1221
1597
  else:
1222
1598
  required_records.add("lightweight-verification.json")
@@ -1252,6 +1628,7 @@ def run_finalize(project_root: Path, evidence_dir: Path, request: dict[str, Any]
1252
1628
  "finalized": False,
1253
1629
  "immutable_identity": identity,
1254
1630
  })
1631
+ # First manifest: pinned before render-report so the report can reference it.
1255
1632
  manifest = build_manifest(
1256
1633
  evidence_dir, evidence_id=evidence_id, identity=identity,
1257
1634
  target_hashes=target_hashes, change_class=change_class,
@@ -1275,6 +1652,7 @@ def run_finalize(project_root: Path, evidence_dir: Path, request: dict[str, Any]
1275
1652
  "validation_path": "validation.json",
1276
1653
  "next": "validate_test_evidence.py --attest",
1277
1654
  })
1655
+ # Second manifest: re-hashes after render-report produces test-report.md.
1278
1656
  manifest = build_manifest(
1279
1657
  evidence_dir, evidence_id=evidence_id, identity=identity,
1280
1658
  target_hashes=target_hashes, change_class=change_class,
@@ -1284,6 +1662,13 @@ def run_finalize(project_root: Path, evidence_dir: Path, request: dict[str, Any]
1284
1662
  destination = evidence_dir.parent / evidence_id
1285
1663
  if destination != evidence_dir:
1286
1664
  if destination.exists():
1665
+ try:
1666
+ existing_man = load_json(destination / "manifest.json")
1667
+ if isinstance(existing_man, dict) and existing_man.get("final_verdict") == "TEST_PASS":
1668
+ write_handoff_pointer(destination, lifecycle.get("artifact_dir"), project_root)
1669
+ return destination / output_name
1670
+ except Exception:
1671
+ pass
1287
1672
  raise RequestError(
1288
1673
  f"evidence identity already exists at {destination}; re-init a new package"
1289
1674
  )
@@ -1383,6 +1768,9 @@ def main() -> int:
1383
1768
  inventory = subparsers.add_parser("inventory")
1384
1769
  inventory.add_argument("--request", required=True)
1385
1770
  inventory.add_argument("--output", default="target-inventory.json")
1771
+ prepare = subparsers.add_parser("prepare-tests")
1772
+ prepare.add_argument("--request", required=True)
1773
+ prepare.add_argument("--output", default="test-preparation.json")
1386
1774
  execute = subparsers.add_parser("execute")
1387
1775
  group = execute.add_mutually_exclusive_group(required=True)
1388
1776
  group.add_argument("--request")
@@ -1404,7 +1792,10 @@ def main() -> int:
1404
1792
  try:
1405
1793
  if not project_root.is_dir():
1406
1794
  raise RequestError(f"project root does not exist: {project_root}")
1407
- evidence_dir.relative_to(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}")
1408
1799
  if args.subcommand == "init":
1409
1800
  output = run_init(project_root, evidence_dir, args.baseline, args.output)
1410
1801
  print(f"Evidence initialized: {output}")
@@ -1416,6 +1807,10 @@ def main() -> int:
1416
1807
  require_mutable_lifecycle(evidence_dir)
1417
1808
  output = run_inventory(project_root, evidence_dir, request_file(evidence_dir, args.request), args.output)
1418
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}")
1419
1814
  elif args.subcommand == "execute":
1420
1815
  require_mutable_lifecycle(evidence_dir)
1421
1816
  if args.request: