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.
@@ -45,23 +45,13 @@ COMMON_RECORDS = {
45
45
  "source-change.patch", "test-report.md",
46
46
  }
47
47
  BEHAVIOR_RECORDS = {
48
- "behavior-risk-matrix.json", "test-plan.json", "infrastructure-changes.json",
49
- "differential-proof.json",
48
+ "behavior-risk-matrix.json", "test-plan.json",
49
+ "infrastructure-changes.json", "differential-proof.json",
50
50
  }
51
51
  LIGHTWEIGHT_RECORDS = {"lightweight-verification.json"}
52
52
  SHA256_RE = re.compile(r"^[a-f0-9]{64}$")
53
53
  UUID_RE = re.compile(r"^[a-f0-9-]{36}$")
54
54
  BLOCKED_EXTERNAL_CLASSES = {"production", "unknown"}
55
- SNAPSHOT_VOLATILE_ROOTS = (
56
- ".prizmkit/state",
57
- ".prizmkit/test/evidence",
58
- ".prizmkit/" + "dev-" + "pipeline",
59
- ".claude/worktrees",
60
- ".agents/worktrees",
61
- ".codebuddy/worktrees",
62
- ".codex/worktrees",
63
- )
64
- SNAPSHOT_VOLATILE_NAMES = {".git", "__pycache__", ".pytest_cache", ".mypy_cache"}
65
55
  RISK_CONFLICT_PATTERNS = {
66
56
  "permission": re.compile(r"auth|tenant|role|permission|access|acl|rbac", re.I),
67
57
  "concurrency": re.compile(r"lock|shared[ _-]?state|worker|queue|concurr|parallel|race", re.I),
@@ -776,7 +766,9 @@ def validate_requests_and_receipts(
776
766
  if not isinstance(request, dict):
777
767
  errors.append(f"request must be an object: {relative}")
778
768
  continue
779
- if request.get("method") == "not-applicable":
769
+ if set(request) == {"request_version", "layers", "tests"}:
770
+ validate_definition(request, "testPreparationRequest", records_schema, relative, errors)
771
+ elif request.get("method") == "not-applicable":
780
772
  validate_definition(request, "differentialRequest", records_schema, relative, errors)
781
773
  not_applicable = request.get("not_applicable")
782
774
  if isinstance(not_applicable, dict):
@@ -935,13 +927,98 @@ def validate_execution_request_semantics(
935
927
  environment = value.get("environment")
936
928
  require(isinstance(environment, dict) and all(isinstance(key, str) and isinstance(item, str) for key, item in environment.items()), f"{location}: environment must contain complete string values", errors)
937
929
  test_ids = value.get("test_ids")
938
- require(isinstance(test_ids, list) and all(isinstance(item, str) and item for item in test_ids), f"{location}: test_ids must be string IDs", errors)
930
+ test_paths = value.get("test_paths")
931
+ auto_bind = value.get("auto_bind") is True
932
+ require(
933
+ (isinstance(test_ids, list) and all(isinstance(item, str) and item for item in test_ids))
934
+ or (isinstance(test_paths, list) and all(isinstance(item, str) and item for item in test_paths))
935
+ or auto_bind,
936
+ f"{location}: execution must provide test_ids, test_paths, or auto_bind",
937
+ errors,
938
+ )
939
939
  if receipt:
940
940
  require(type(value.get("selected_execution")) is bool, f"{location}: selected_execution must be boolean", errors)
941
941
  require(type(value.get("reliable")) is bool, f"{location}: reliable must be boolean", errors)
942
942
  require(type(value.get("exit_code")) is int and not isinstance(value.get("exit_code"), bool), f"{location}: exit_code must be an integer", errors)
943
943
 
944
944
 
945
+ def changed_test_statuses(root: Path) -> dict[str, str]:
946
+ capture_path = root / "change-capture.json"
947
+ if not capture_path.is_file():
948
+ return {}
949
+ try:
950
+ capture = load_json(capture_path)
951
+ except EvidenceError:
952
+ return {}
953
+ statuses: dict[str, str] = {}
954
+ for item in capture.get("changed_files", []) if isinstance(capture, dict) else []:
955
+ if not isinstance(item, dict) or not isinstance(item.get("path"), str) or item.get("role") == "source":
956
+ continue
957
+ status = str(item.get("status", "")).strip()
958
+ if status == "??" or status.startswith("A"):
959
+ statuses[item["path"]] = "added"
960
+ elif status.startswith("M") or status.endswith("M") or status.startswith(("R", "C")):
961
+ statuses[item["path"]] = "modified"
962
+ return statuses
963
+
964
+
965
+ def validate_canonical_capture(
966
+ root: Path,
967
+ project_root: Path,
968
+ capture: dict[str, Any],
969
+ inventory: dict[str, Any],
970
+ scope: dict[str, Any],
971
+ errors: list[str],
972
+ ) -> None:
973
+ patch_relative = capture.get("patch_path")
974
+ patch = safe_path(root, patch_relative, errors, "canonical change patch") if isinstance(patch_relative, str) else None
975
+ require(patch_relative == "source-change.patch", "canonical capture patch_path must be source-change.patch", errors)
976
+ if patch is not None:
977
+ require(file_sha256(patch) == capture.get("patch_sha256"), "canonical capture patch hash mismatch", errors)
978
+ captured_paths = {
979
+ item.get("path")
980
+ for item in capture.get("changed_files", [])
981
+ if isinstance(item, dict) and isinstance(item.get("path"), str)
982
+ }
983
+ inventory_changed = set(inventory.get("changed_files", [])) if isinstance(inventory.get("changed_files"), list) else set()
984
+ scope_changed = set(scope.get("changed_files", [])) if isinstance(scope.get("changed_files"), list) else set()
985
+ require(captured_paths == inventory_changed, "canonical capture and inventory changed_files do not match", errors)
986
+ require(captured_paths == scope_changed, "canonical capture and scope changed_files do not match", errors)
987
+
988
+
989
+ def validate_test_preparation(
990
+ root: Path,
991
+ project_root: Path,
992
+ preparation: dict[str, Any],
993
+ plan: dict[str, Any],
994
+ inventory_by_category: dict[str, dict[str, str]],
995
+ entry_map: dict[str, dict[str, Any]],
996
+ records_schema: dict[str, Any],
997
+ errors: list[str],
998
+ ) -> None:
999
+ validate_definition(preparation, "testPreparation", records_schema, "test-preparation", errors)
1000
+ request_relative = preparation.get("request_path")
1001
+ request_path = require_manifest_entry(request_relative, entry_map, root, errors, "test preparation request") if isinstance(request_relative, str) else None
1002
+ if request_path is not None:
1003
+ require(file_sha256(request_path) == preparation.get("request_sha256"), "test preparation request hash mismatch", errors)
1004
+ prepared_tests = preparation.get("tests")
1005
+ require(prepared_tests == plan.get("tests"), "test preparation tests do not match test plan", errors)
1006
+ statuses = preparation.get("changed_test_statuses")
1007
+ require(isinstance(statuses, dict), "test preparation changed_test_statuses must be an object", errors)
1008
+ if not isinstance(statuses, dict):
1009
+ return
1010
+ for relative, status in statuses.items():
1011
+ require(relative in inventory_by_category.get("tests", {}), f"test preparation status is not in test inventory: {relative}", errors)
1012
+ require(status in {"added", "modified"}, f"test preparation has invalid changed test status: {relative}", errors)
1013
+ for test in prepared_tests if isinstance(prepared_tests, list) else []:
1014
+ if not isinstance(test, dict):
1015
+ continue
1016
+ relative = test.get("project_path")
1017
+ if isinstance(relative, str):
1018
+ expected = statuses.get(relative, "existing")
1019
+ require(test.get("change_status") == expected, f"test preparation status mismatch: {relative}", errors)
1020
+
1021
+
945
1022
  def validate_plan_and_scope_bindings(
946
1023
  root: Path,
947
1024
  project_root: Path,
@@ -1003,6 +1080,8 @@ def validate_plan_and_scope_bindings(
1003
1080
  for test_id in receipt.get("test_ids", []):
1004
1081
  if isinstance(test_id, str):
1005
1082
  selected_by_test.setdefault(test_id, []).append(receipt)
1083
+ changed_tests = changed_test_statuses(root)
1084
+ planned_test_paths: set[str] = set()
1006
1085
  known_behavior_ids = {item.get("id") for item in matrix.get("behaviors", []) if isinstance(item, dict)}
1007
1086
  for test in tests:
1008
1087
  if not isinstance(test, dict):
@@ -1018,9 +1097,13 @@ def validate_plan_and_scope_bindings(
1018
1097
  for behavior_id in behavior_ids if isinstance(behavior_ids, list) else []:
1019
1098
  require(behavior_id in known_behavior_ids, f"planned test maps unknown behavior: {test_id}/{behavior_id}", errors)
1020
1099
  project_relative = test.get("project_path")
1100
+ planned_test_paths.add(project_relative)
1021
1101
  inventory_relative = test.get("inventory_path")
1022
1102
  snapshot_relative = test.get("snapshot_path")
1103
+ inferred_status = changed_tests.get(project_relative, "existing")
1023
1104
  change_status = test.get("change_status", "modified" if isinstance(snapshot_relative, str) else "existing")
1105
+ if changed_tests:
1106
+ require(change_status == inferred_status, f"planned test change_status does not match canonical capture: {test_id}", errors)
1024
1107
  require(change_status in {"existing", "added", "modified"}, f"planned test has invalid change_status: {test_id}", errors)
1025
1108
  project_path = safe_path(project_root, project_relative, errors, f"planned test {test_id} project_path")
1026
1109
  require(project_path is not None and project_path.is_file(), f"planned test live path is missing: {test_id}", errors)
@@ -1043,6 +1126,19 @@ def validate_plan_and_scope_bindings(
1043
1126
  for receipt in matching_receipts:
1044
1127
  require(receipt.get("layer") in test_layers, f"planned test receipt uses an unplanned layer: {test_id}", errors)
1045
1128
 
1129
+ for relative, status in changed_tests.items():
1130
+ if relative in inventory_by_category.get("tests", {}) and relative not in planned_test_paths:
1131
+ errors.append(f"changed native test is not bound by test plan: {relative}")
1132
+ generated_dir = root / "generated-tests"
1133
+ referenced_snapshots = {
1134
+ test.get("snapshot_path") for test in tests if isinstance(test, dict) and isinstance(test.get("snapshot_path"), str)
1135
+ }
1136
+ if generated_dir.is_dir():
1137
+ actual_snapshots = {
1138
+ path.relative_to(root).as_posix() for path in generated_dir.rglob("*") if path.is_file()
1139
+ }
1140
+ for relative in sorted(actual_snapshots - referenced_snapshots):
1141
+ errors.append(f"generated test snapshot is not bound by test plan: {relative}")
1046
1142
  for ring in scope.get("regression_ring", []):
1047
1143
  if not isinstance(ring, dict):
1048
1144
  continue
@@ -1129,29 +1225,6 @@ def validate_matrix_risks(
1129
1225
  errors.append(f"TEST_PASS has unresolved behavior risk: {behavior_id}/{risk_name}")
1130
1226
 
1131
1227
 
1132
- def is_volatile_snapshot_path(root: Path, candidate: Path, evidence_root: Path) -> bool:
1133
- try:
1134
- relative = candidate.relative_to(root)
1135
- except ValueError:
1136
- return True
1137
- if any(part in SNAPSHOT_VOLATILE_NAMES for part in relative.parts):
1138
- return True
1139
- if any(relative == Path(prefix) or Path(prefix) in relative.parents for prefix in SNAPSHOT_VOLATILE_ROOTS):
1140
- return True
1141
- resolved = candidate.resolve()
1142
- excluded = evidence_root.resolve()
1143
- return resolved == excluded or excluded in resolved.parents
1144
-
1145
-
1146
- def tree_sha256(root: Path, evidence_root: Path) -> str:
1147
- entries: list[dict[str, str]] = []
1148
- for candidate in sorted(root.rglob("*")):
1149
- if not candidate.is_file() or is_volatile_snapshot_path(root, candidate, evidence_root):
1150
- continue
1151
- entries.append({"path": candidate.relative_to(root).as_posix(), "sha256": file_sha256(candidate)})
1152
- return canonical_sha256(entries)
1153
-
1154
-
1155
1228
  def require_sha_or_null(value: Any, location: str, errors: list[str]) -> None:
1156
1229
  require(value is None or is_sha256(value), f"{location} must be a sha256 or null", errors)
1157
1230
 
@@ -1172,7 +1245,6 @@ def validate_differential_proofs(
1172
1245
  receipts = {item.get("execution_id"): item for item in executions if isinstance(item.get("execution_id"), str)}
1173
1246
  behavior_ids = {item.get("id") for item in matrix.get("behaviors", []) if isinstance(item, dict)}
1174
1247
  proof_by_behavior: dict[str, dict[str, Any]] = {}
1175
- live_tree_hash = tree_sha256(project_root, root)
1176
1248
  proofs = proof.get("proofs")
1177
1249
  if not isinstance(proofs, list):
1178
1250
  return
@@ -1189,9 +1261,8 @@ def validate_differential_proofs(
1189
1261
  validate_differential_na(item.get("not_applicable"), f"differential proof {behavior_id} not_applicable", errors)
1190
1262
  for key in (
1191
1263
  "method", "differential_request_path", "differential_request_sha256", "baseline_commit",
1192
- "current_tree_sha256", "baseline_execution_id", "mutation_execution_id", "current_execution_id",
1193
- "project_tree_before_sha256", "project_tree_after_sha256", "isolation_tree_sha256",
1194
- "mutation_apply_sha256", "mutation_restore_sha256",
1264
+ "baseline_execution_id", "mutation_execution_id", "current_execution_id",
1265
+ "isolation_tree_sha256", "mutation_apply_sha256", "mutation_restore_sha256",
1195
1266
  ):
1196
1267
  require(item.get(key) is None, f"N/A differential proof must clear {key}: {behavior_id}", errors)
1197
1268
  require(item.get("failure_reason_matched") is False, f"N/A differential proof cannot claim a failure match: {behavior_id}", errors)
@@ -1209,14 +1280,10 @@ def validate_differential_proofs(
1209
1280
  method = item.get("method")
1210
1281
  require(method == request.get("method") and method in {"baseline", "controlled-mutation"}, f"differential method mismatch: {behavior_id}", errors)
1211
1282
  require(item.get("baseline_commit") == request.get("baseline_commit") == manifest.get("baseline_commit"), f"differential baseline commit mismatch: {behavior_id}", errors)
1212
- require(item.get("current_tree_sha256") == request.get("current_tree_sha256") == live_tree_hash, f"differential current tree hash mismatch: {behavior_id}", errors)
1213
1283
  for key in (
1214
- "differential_request_sha256", "current_tree_sha256", "project_tree_before_sha256",
1215
- "project_tree_after_sha256", "isolation_tree_sha256", "mutation_apply_sha256", "mutation_restore_sha256",
1284
+ "differential_request_sha256", "isolation_tree_sha256", "mutation_apply_sha256", "mutation_restore_sha256",
1216
1285
  ):
1217
1286
  require_sha_or_null(item.get(key), f"differential proof {behavior_id}.{key}", errors)
1218
- require(item.get("project_tree_before_sha256") == live_tree_hash, f"differential project tree before mismatch: {behavior_id}", errors)
1219
- require(item.get("project_tree_after_sha256") == live_tree_hash, f"differential project tree after mismatch: {behavior_id}", errors)
1220
1287
  require(item.get("cleanup_succeeded") is True, f"differential cleanup failed: {behavior_id}", errors)
1221
1288
 
1222
1289
  current = receipts.get(item.get("current_execution_id"))
@@ -1237,7 +1304,7 @@ def validate_differential_proofs(
1237
1304
  require(isinstance(current_isolation, dict) and current_isolation.get("kind") == "current", f"differential current receipt lacks current isolation: {behavior_id}", errors)
1238
1305
  require(isinstance(failing_isolation, dict) and failing_isolation.get("kind") == method, f"differential failing receipt isolation mismatch: {behavior_id}", errors)
1239
1306
  if isinstance(current_isolation, dict):
1240
- require(current_isolation.get("tree_sha256") == item.get("isolation_tree_sha256") == live_tree_hash, f"differential current isolation hash mismatch: {behavior_id}", errors)
1307
+ require(current_isolation.get("tree_sha256") == item.get("isolation_tree_sha256"), f"differential current isolation hash mismatch: {behavior_id}", errors)
1241
1308
  if method == "baseline" and isinstance(failing_isolation, dict):
1242
1309
  require(failing_isolation.get("baseline_commit") == request.get("baseline_commit"), f"baseline receipt commit mismatch: {behavior_id}", errors)
1243
1310
  if method == "controlled-mutation" and isinstance(failing_isolation, dict):
@@ -1415,11 +1482,25 @@ def validate_evidence(
1415
1482
  entry_map = validate_manifest(root, manifest, manifest_schema, records_schema, errors, ignored_hash_paths)
1416
1483
  change_class = manifest.get("change_class")
1417
1484
  final_verdict = manifest.get("final_verdict")
1485
+ canonical_lifecycle = (root / "lifecycle.json").is_file()
1418
1486
  required = COMMON_RECORDS
1487
+ if canonical_lifecycle:
1488
+ required = required | {"change-capture.json"}
1419
1489
  if final_verdict != "TEST_BLOCKED":
1420
1490
  required = required | (BEHAVIOR_RECORDS if change_class == "behavior" else LIGHTWEIGHT_RECORDS)
1491
+ if canonical_lifecycle and change_class == "behavior":
1492
+ required = required | {"test-preparation.json"}
1421
1493
  for relative in required:
1422
1494
  require(relative in entry_map, f"required evidence record not hashed: {relative}", errors)
1495
+ if canonical_lifecycle:
1496
+ capture_path = root / "change-capture.json"
1497
+ if capture_path.is_file():
1498
+ try:
1499
+ capture = load_json(capture_path)
1500
+ validate_definition(capture, "changeCapture", records_schema, "change-capture", errors)
1501
+ validate_canonical_capture(root, project_root, capture, inventory, scope, errors)
1502
+ except EvidenceError as exc:
1503
+ errors.append(str(exc))
1423
1504
  validate_definition(classification, "changeClassification", records_schema, "change-classification", errors)
1424
1505
  validate_definition(scope, "scope", records_schema, "scope", errors)
1425
1506
  validate_definition(inventory, "targetInventory", records_schema, "target-inventory", errors)
@@ -1427,18 +1508,32 @@ def validate_evidence(
1427
1508
  require(classification.get("full_protocol_required") is (change_class == "behavior"), "change classification protocol decision is inconsistent", errors)
1428
1509
 
1429
1510
  plan: dict[str, Any] | None = None
1511
+ preparation: dict[str, Any] | None = None
1430
1512
  if change_class == "behavior":
1431
1513
  try:
1432
1514
  plan = load_json(root / "test-plan.json")
1515
+ if canonical_lifecycle:
1516
+ preparation = load_json(root / "test-preparation.json")
1433
1517
  except EvidenceError as exc:
1434
1518
  errors.append(str(exc))
1519
+ plan = preparation = None
1435
1520
  if isinstance(plan, dict):
1436
1521
  validate_definition(plan, "testPlan", records_schema, "test-plan", errors)
1437
1522
  else:
1438
1523
  errors.append("test-plan.json must be an object")
1439
1524
  plan = None
1525
+ if canonical_lifecycle:
1526
+ if isinstance(preparation, dict):
1527
+ pass
1528
+ else:
1529
+ errors.append("test-preparation.json must be an object")
1440
1530
 
1441
1531
  inventory_by_category, _ = validate_inventory(root, project_root, manifest, inventory, scope, plan, entry_map, errors)
1532
+ if canonical_lifecycle and change_class == "behavior" and isinstance(preparation, dict):
1533
+ validate_test_preparation(
1534
+ root, project_root, preparation, plan or {}, inventory_by_category,
1535
+ entry_map, records_schema, errors,
1536
+ )
1442
1537
  require(manifest.get("evidence_id_inputs", {}).get("scope_sha256") == canonical_sha256(scope), "scope identity hash mismatch", errors)
1443
1538
  validate_scope(
1444
1539
  scope, inventory, inventory_by_category, project_root, root,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "prizmkit",
3
- "version": "1.1.134",
3
+ "version": "1.1.136",
4
4
  "description": "Create a new PrizmKit-powered project with clean initialization — no framework dev files, just what you need.",
5
5
  "type": "module",
6
6
  "bin": {