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.
- package/bundled/VERSION.json +3 -3
- package/bundled/adapters/claude/command-adapter.js +0 -2
- package/bundled/dev-pipeline/assets/skill-subagent-integration.md +11 -6
- package/bundled/dev-pipeline/prizmkit_runtime/gitops.py +2 -9
- package/bundled/dev-pipeline/prizmkit_runtime/processes.py +21 -1
- package/bundled/dev-pipeline/prizmkit_runtime/runners.py +104 -17
- package/bundled/dev-pipeline/prizmkit_runtime/sessions.py +3 -2
- package/bundled/dev-pipeline/scripts/generate-bootstrap-prompt.py +15 -13
- package/bundled/dev-pipeline/scripts/generate-bugfix-prompt.py +3 -13
- package/bundled/dev-pipeline/scripts/generate-recovery-prompt.py +5 -4
- package/bundled/dev-pipeline/scripts/generate-refactor-prompt.py +1 -1
- package/bundled/dev-pipeline/templates/bootstrap-prompt.md +4 -4
- package/bundled/dev-pipeline/templates/bootstrap-tier3.md +9 -9
- package/bundled/dev-pipeline/templates/bugfix-bootstrap-prompt.md +4 -7
- package/bundled/dev-pipeline/templates/refactor-bootstrap-prompt.md +3 -3
- package/bundled/dev-pipeline/templates/sections/bugfix-phase-commit-report.md +2 -2
- package/bundled/dev-pipeline/templates/sections/bugfix-phase-manual-verification.md +5 -10
- package/bundled/dev-pipeline/templates/sections/bugfix-phase-review.md +5 -5
- package/bundled/dev-pipeline/templates/sections/bugfix-session-status.md +1 -1
- package/bundled/dev-pipeline/templates/sections/context-budget-rules.md +1 -1
- package/bundled/dev-pipeline/templates/sections/headless-commit-authorization.md +2 -3
- package/bundled/dev-pipeline/templates/sections/phase-commit-full.md +2 -2
- package/bundled/dev-pipeline/templates/sections/phase-commit.md +2 -2
- package/bundled/dev-pipeline/templates/sections/phase-review-agent.md +5 -5
- package/bundled/dev-pipeline/templates/sections/phase-review-full.md +5 -5
- package/bundled/dev-pipeline/templates/sections/refactor-phase-commit-report.md +1 -1
- package/bundled/dev-pipeline/templates/sections/refactor-phase-review.md +5 -5
- package/bundled/dev-pipeline/templates/sections/refactor-reminders.md +1 -1
- package/bundled/dev-pipeline/templates/sections/subagent-timeout-recovery.md +1 -1
- package/bundled/dev-pipeline/templates/sections/task-contract.md +4 -0
- package/bundled/dev-pipeline/tests/test_generate_bootstrap_prompt.py +39 -26
- package/bundled/dev-pipeline/tests/test_generate_bugfix_prompt.py +7 -10
- package/bundled/dev-pipeline/tests/test_generate_refactor_prompt.py +3 -3
- package/bundled/dev-pipeline/tests/test_python_runner_parity.py +156 -5
- package/bundled/dev-pipeline/tests/test_unified_cli.py +34 -7
- package/bundled/skills/_metadata.json +3 -3
- package/bundled/skills/prizmkit/references/workflow-state-protocol.md +3 -3
- package/bundled/skills/prizmkit-code-review/SKILL.md +22 -6
- package/bundled/skills/prizmkit-code-review/references/independent-code-review.md +243 -0
- package/bundled/skills/prizmkit-code-review/references/review-report-template.md +41 -0
- package/bundled/skills/prizmkit-code-review/references/workflow-state-protocol.md +3 -3
- package/bundled/skills/prizmkit-code-review/scripts/render_review_report.py +93 -1
- package/bundled/skills/prizmkit-committer/SKILL.md +6 -13
- package/bundled/skills/prizmkit-committer/references/workflow-state-protocol.md +3 -3
- package/bundled/skills/prizmkit-implement/references/workflow-state-protocol.md +3 -3
- package/bundled/skills/prizmkit-plan/SKILL.md +17 -3
- package/bundled/skills/prizmkit-plan/assets/plan-template.md +18 -0
- package/bundled/skills/prizmkit-plan/references/independent-plan-review.md +242 -0
- package/bundled/skills/prizmkit-plan/references/review-plan-spec-loop.md +4 -4
- package/bundled/skills/prizmkit-plan/references/workflow-state-protocol.md +3 -3
- package/bundled/skills/prizmkit-retrospective/references/workflow-state-protocol.md +3 -3
- package/bundled/skills/prizmkit-test/SKILL.md +26 -3
- package/bundled/skills/prizmkit-test/assets/authoritative-records.schema.json +147 -14
- package/bundled/skills/prizmkit-test/assets/evidence-manifest.schema.json +1 -1
- package/bundled/skills/prizmkit-test/assets/evidence-package-template.json +15 -0
- package/bundled/skills/prizmkit-test/references/evidence-protocol.md +6 -1
- package/bundled/skills/prizmkit-test/references/evidence-request-protocol.md +8 -1
- package/bundled/skills/prizmkit-test/references/test-generation-steps.md +5 -2
- package/bundled/skills/prizmkit-test/references/trusted-evidence-execution.md +15 -0
- package/bundled/skills/prizmkit-test/references/workflow-state-protocol.md +3 -3
- package/bundled/skills/prizmkit-test/scripts/build_test_evidence.py +1144 -27
- package/bundled/skills/prizmkit-test/scripts/validate_test_evidence.py +336 -18
- package/bundled/skills/prizmkit-workflow/SKILL.md +1 -1
- package/bundled/skills/prizmkit-workflow/references/workflow-state-protocol.md +3 -3
- package/package.json +1 -1
|
@@ -38,14 +38,15 @@ LAYER_ORDER = {
|
|
|
38
38
|
"regression-ring": 4,
|
|
39
39
|
}
|
|
40
40
|
FINAL_RECORDS = {"validation.json", "verdict.json", "test-report.md"}
|
|
41
|
+
ATTESTATION_MUTABLE = {"validation.json"}
|
|
41
42
|
COMMON_RECORDS = {
|
|
42
43
|
"change-classification.json", "scope.json", "target-inventory.json",
|
|
43
44
|
"environment.json", "executions.json", "verdict.json", "validation.json",
|
|
44
45
|
"source-change.patch", "test-report.md",
|
|
45
46
|
}
|
|
46
47
|
BEHAVIOR_RECORDS = {
|
|
47
|
-
"behavior-risk-matrix.json", "test-plan.json",
|
|
48
|
-
"differential-proof.json",
|
|
48
|
+
"behavior-risk-matrix.json", "test-plan.json",
|
|
49
|
+
"infrastructure-changes.json", "differential-proof.json",
|
|
49
50
|
}
|
|
50
51
|
LIGHTWEIGHT_RECORDS = {"lightweight-verification.json"}
|
|
51
52
|
SHA256_RE = re.compile(r"^[a-f0-9]{64}$")
|
|
@@ -54,9 +55,13 @@ BLOCKED_EXTERNAL_CLASSES = {"production", "unknown"}
|
|
|
54
55
|
SNAPSHOT_VOLATILE_ROOTS = (
|
|
55
56
|
".prizmkit/state",
|
|
56
57
|
".prizmkit/test/evidence",
|
|
58
|
+
".prizmkit/" + "dev-" + "pipeline",
|
|
57
59
|
".claude/worktrees",
|
|
60
|
+
".agents/worktrees",
|
|
61
|
+
".codebuddy/worktrees",
|
|
62
|
+
".codex/worktrees",
|
|
58
63
|
)
|
|
59
|
-
SNAPSHOT_VOLATILE_NAMES = {".git", "__pycache__"}
|
|
64
|
+
SNAPSHOT_VOLATILE_NAMES = {".git", "__pycache__", ".pytest_cache", ".mypy_cache"}
|
|
60
65
|
RISK_CONFLICT_PATTERNS = {
|
|
61
66
|
"permission": re.compile(r"auth|tenant|role|permission|access|acl|rbac", re.I),
|
|
62
67
|
"concurrency": re.compile(r"lock|shared[ _-]?state|worker|queue|concurr|parallel|race", re.I),
|
|
@@ -219,6 +224,18 @@ def validate_schema(
|
|
|
219
224
|
"""Validate the strict JSON-Schema subset used by skill-owned schemas."""
|
|
220
225
|
schema = resolve_ref(schema, root_schema)
|
|
221
226
|
|
|
227
|
+
one_of = schema.get("oneOf")
|
|
228
|
+
if isinstance(one_of, list):
|
|
229
|
+
matching = 0
|
|
230
|
+
for branch in one_of:
|
|
231
|
+
if not isinstance(branch, dict):
|
|
232
|
+
continue
|
|
233
|
+
branch_errors: list[str] = []
|
|
234
|
+
validate_schema(value, branch, root_schema, location, branch_errors)
|
|
235
|
+
if not branch_errors:
|
|
236
|
+
matching += 1
|
|
237
|
+
require(matching == 1, f"{location}: value must match exactly one schema branch", errors)
|
|
238
|
+
|
|
222
239
|
for child in schema.get("allOf", []):
|
|
223
240
|
if isinstance(child, dict):
|
|
224
241
|
validate_schema(value, child, root_schema, location, errors)
|
|
@@ -341,6 +358,31 @@ def require_manifest_entry(
|
|
|
341
358
|
return path
|
|
342
359
|
|
|
343
360
|
|
|
361
|
+
def validate_differential_na(
|
|
362
|
+
value: Any,
|
|
363
|
+
location: str,
|
|
364
|
+
errors: list[str],
|
|
365
|
+
) -> None:
|
|
366
|
+
"""Validate the differential-only N/A shape without weakening risk-cell N/A."""
|
|
367
|
+
if not isinstance(value, dict):
|
|
368
|
+
errors.append(f"{location}: differential N/A must be an object")
|
|
369
|
+
return
|
|
370
|
+
required = {"reason", "rationale", "evidence", "considered_signals", "conflicts"}
|
|
371
|
+
require(set(value) == required, f"{location}: differential N/A fields are incomplete", errors)
|
|
372
|
+
require(value.get("reason") in {"new-behavior", "textual-contract"}, f"{location}: invalid differential N/A reason", errors)
|
|
373
|
+
rationale = value.get("rationale")
|
|
374
|
+
require(isinstance(rationale, str) and len(rationale.strip()) >= 16, f"{location}: differential N/A rationale is too short", errors)
|
|
375
|
+
if value.get("reason") == "new-behavior" and isinstance(rationale, str):
|
|
376
|
+
require("no stable pre-change observable" in rationale.lower(), f"{location}: new behavior N/A lacks baseline rationale", errors)
|
|
377
|
+
if value.get("reason") == "textual-contract" and isinstance(rationale, str):
|
|
378
|
+
require("textual" in rationale.lower(), f"{location}: textual contract N/A lacks textual rationale", errors)
|
|
379
|
+
evidence = value.get("evidence")
|
|
380
|
+
require(isinstance(evidence, list) and bool(evidence) and all(isinstance(item, str) and item.strip() for item in evidence), f"{location}: differential N/A needs evidence strings", errors)
|
|
381
|
+
signals = value.get("considered_signals")
|
|
382
|
+
require(isinstance(signals, list) and bool(signals) and all(isinstance(item, str) and item.strip() for item in signals), f"{location}: differential N/A needs considered signals", errors)
|
|
383
|
+
require(isinstance(value.get("conflicts"), list), f"{location}: differential N/A conflicts must be an array", errors)
|
|
384
|
+
|
|
385
|
+
|
|
344
386
|
def structured_na(
|
|
345
387
|
value: Any,
|
|
346
388
|
records_schema: dict[str, Any],
|
|
@@ -503,6 +545,25 @@ def validate_manifest(
|
|
|
503
545
|
return entry_map
|
|
504
546
|
|
|
505
547
|
|
|
548
|
+
def get_captured_deleted_paths(root: Path) -> set[str]:
|
|
549
|
+
capture_path = root / "change-capture.json"
|
|
550
|
+
if not capture_path.is_file():
|
|
551
|
+
return set()
|
|
552
|
+
try:
|
|
553
|
+
capture = load_json(capture_path)
|
|
554
|
+
except EvidenceError:
|
|
555
|
+
return set()
|
|
556
|
+
if not isinstance(capture, dict):
|
|
557
|
+
return set()
|
|
558
|
+
return {
|
|
559
|
+
item["path"]
|
|
560
|
+
for item in capture.get("changed_files", [])
|
|
561
|
+
if isinstance(item, dict)
|
|
562
|
+
and isinstance(item.get("path"), str)
|
|
563
|
+
and (item.get("status", "").strip() == "D" or item.get("role") == "source")
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
|
|
506
567
|
def validate_inventory(
|
|
507
568
|
root: Path,
|
|
508
569
|
project_root: Path,
|
|
@@ -569,6 +630,7 @@ def validate_inventory(
|
|
|
569
630
|
require(plan.get("plan_inputs") == plan_inputs, "test plan inputs do not match target inventory", errors)
|
|
570
631
|
|
|
571
632
|
patch = require_manifest_entry("source-change.patch", entry_map, root, errors, "source-change patch")
|
|
633
|
+
captured_deleted_paths = get_captured_deleted_paths(root)
|
|
572
634
|
if patch is not None:
|
|
573
635
|
require(manifest.get("working_diff_sha256") == file_sha256(patch), "working_diff_sha256 does not match source-change.patch", errors)
|
|
574
636
|
|
|
@@ -579,7 +641,19 @@ def validate_inventory(
|
|
|
579
641
|
request = load_json(request_path)
|
|
580
642
|
if isinstance(request, dict):
|
|
581
643
|
require(request.get("changed_files") == inventory.get("changed_files"), "inventory request changed_files mismatch", errors)
|
|
582
|
-
|
|
644
|
+
request_roots = request.get("module_roots")
|
|
645
|
+
normalized_request_roots = []
|
|
646
|
+
if isinstance(request_roots, list):
|
|
647
|
+
for item in request_roots:
|
|
648
|
+
if isinstance(item, str):
|
|
649
|
+
normalized_request_roots.append(item)
|
|
650
|
+
elif isinstance(item, dict) and isinstance(item.get("path"), str):
|
|
651
|
+
normalized_request_roots.append(item["path"])
|
|
652
|
+
require(
|
|
653
|
+
normalized_request_roots == inventory.get("module_roots"),
|
|
654
|
+
"inventory request module_roots mismatch",
|
|
655
|
+
errors,
|
|
656
|
+
)
|
|
583
657
|
require(request.get("exclusions") == scope.get("exclusions"), "inventory request exclusions mismatch", errors)
|
|
584
658
|
require(request.get("plan_inputs") == plan_inputs, "inventory request plan_inputs mismatch", errors)
|
|
585
659
|
else:
|
|
@@ -594,6 +668,7 @@ def validate_scope(
|
|
|
594
668
|
inventory_by_category: dict[str, dict[str, str]],
|
|
595
669
|
project_root: Path,
|
|
596
670
|
evidence_root: Path,
|
|
671
|
+
captured_deleted_paths: set[str],
|
|
597
672
|
errors: list[str],
|
|
598
673
|
) -> None:
|
|
599
674
|
changed_files = scope.get("changed_files")
|
|
@@ -614,8 +689,14 @@ def validate_scope(
|
|
|
614
689
|
if relative in exclusion_paths:
|
|
615
690
|
require(relative not in inventory_paths, f"excluded changed file is also inventoried: {relative}", errors)
|
|
616
691
|
else:
|
|
617
|
-
|
|
618
|
-
|
|
692
|
+
candidate = safe_path(project_root, relative, errors, "changed file")
|
|
693
|
+
deleted = relative in captured_deleted_paths
|
|
694
|
+
require(
|
|
695
|
+
deleted or (candidate is not None and candidate.is_file()),
|
|
696
|
+
f"changed file is not live: {relative}",
|
|
697
|
+
errors,
|
|
698
|
+
)
|
|
699
|
+
require(relative in inventory_paths or deleted, f"changed file is neither inventoried nor excluded: {relative}", errors)
|
|
619
700
|
|
|
620
701
|
roots = scope.get("module_roots")
|
|
621
702
|
require(isinstance(roots, list) and bool(roots), "scope module_roots is empty", errors)
|
|
@@ -695,7 +776,15 @@ def validate_requests_and_receipts(
|
|
|
695
776
|
if not isinstance(request, dict):
|
|
696
777
|
errors.append(f"request must be an object: {relative}")
|
|
697
778
|
continue
|
|
698
|
-
if "
|
|
779
|
+
if set(request) == {"request_version", "layers", "tests"}:
|
|
780
|
+
validate_definition(request, "testPreparationRequest", records_schema, relative, errors)
|
|
781
|
+
elif request.get("method") == "not-applicable":
|
|
782
|
+
validate_definition(request, "differentialRequest", records_schema, relative, errors)
|
|
783
|
+
not_applicable = request.get("not_applicable")
|
|
784
|
+
if isinstance(not_applicable, dict):
|
|
785
|
+
validate_differential_na(not_applicable, f"{relative}.not_applicable", errors)
|
|
786
|
+
differential_requests[relative] = request
|
|
787
|
+
elif "execution_request" in request:
|
|
699
788
|
validate_definition(request, "differentialRequest", records_schema, relative, errors)
|
|
700
789
|
nested = request.get("execution_request")
|
|
701
790
|
if isinstance(nested, dict):
|
|
@@ -707,6 +796,17 @@ def validate_requests_and_receipts(
|
|
|
707
796
|
validate_execution_request_semantics(request, relative, errors)
|
|
708
797
|
validate_external_targets(request.get("external_targets"), f"{relative}.external_targets", errors)
|
|
709
798
|
execution_requests[relative] = request
|
|
799
|
+
elif set(request) == {
|
|
800
|
+
"request_version", "change_class", "artifact_dir",
|
|
801
|
+
} or (
|
|
802
|
+
"request_version" in request
|
|
803
|
+
and "change_class" in request
|
|
804
|
+
and set(request) <= {
|
|
805
|
+
"request_version", "change_class", "baseline_commit", "scope_path",
|
|
806
|
+
"environment_path", "artifact_dir", "blockers", "repair_scope",
|
|
807
|
+
}
|
|
808
|
+
):
|
|
809
|
+
require(request.get("request_version", "1.0") == "1.0", f"unsupported finalize request version: {relative}", errors)
|
|
710
810
|
elif set(request) == {
|
|
711
811
|
"request_version", "categories", "changed_files", "module_roots",
|
|
712
812
|
"exclusions", "discovery_evidence", "plan_inputs",
|
|
@@ -837,13 +937,103 @@ def validate_execution_request_semantics(
|
|
|
837
937
|
environment = value.get("environment")
|
|
838
938
|
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)
|
|
839
939
|
test_ids = value.get("test_ids")
|
|
840
|
-
|
|
940
|
+
test_paths = value.get("test_paths")
|
|
941
|
+
auto_bind = value.get("auto_bind") is True
|
|
942
|
+
require(
|
|
943
|
+
(isinstance(test_ids, list) and all(isinstance(item, str) and item for item in test_ids))
|
|
944
|
+
or (isinstance(test_paths, list) and all(isinstance(item, str) and item for item in test_paths))
|
|
945
|
+
or auto_bind,
|
|
946
|
+
f"{location}: execution must provide test_ids, test_paths, or auto_bind",
|
|
947
|
+
errors,
|
|
948
|
+
)
|
|
841
949
|
if receipt:
|
|
842
950
|
require(type(value.get("selected_execution")) is bool, f"{location}: selected_execution must be boolean", errors)
|
|
843
951
|
require(type(value.get("reliable")) is bool, f"{location}: reliable must be boolean", errors)
|
|
844
952
|
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)
|
|
845
953
|
|
|
846
954
|
|
|
955
|
+
def changed_test_statuses(root: Path) -> dict[str, str]:
|
|
956
|
+
capture_path = root / "change-capture.json"
|
|
957
|
+
if not capture_path.is_file():
|
|
958
|
+
return {}
|
|
959
|
+
try:
|
|
960
|
+
capture = load_json(capture_path)
|
|
961
|
+
except EvidenceError:
|
|
962
|
+
return {}
|
|
963
|
+
statuses: dict[str, str] = {}
|
|
964
|
+
for item in capture.get("changed_files", []) if isinstance(capture, dict) else []:
|
|
965
|
+
if not isinstance(item, dict) or not isinstance(item.get("path"), str) or item.get("role") == "source":
|
|
966
|
+
continue
|
|
967
|
+
status = str(item.get("status", "")).strip()
|
|
968
|
+
if status == "??" or status.startswith("A"):
|
|
969
|
+
statuses[item["path"]] = "added"
|
|
970
|
+
elif status.startswith("M") or status.endswith("M") or status.startswith(("R", "C")):
|
|
971
|
+
statuses[item["path"]] = "modified"
|
|
972
|
+
return statuses
|
|
973
|
+
|
|
974
|
+
|
|
975
|
+
def validate_canonical_capture(
|
|
976
|
+
root: Path,
|
|
977
|
+
project_root: Path,
|
|
978
|
+
capture: dict[str, Any],
|
|
979
|
+
inventory: dict[str, Any],
|
|
980
|
+
scope: dict[str, Any],
|
|
981
|
+
errors: list[str],
|
|
982
|
+
) -> None:
|
|
983
|
+
patch_relative = capture.get("patch_path")
|
|
984
|
+
patch = safe_path(root, patch_relative, errors, "canonical change patch") if isinstance(patch_relative, str) else None
|
|
985
|
+
require(patch_relative == "source-change.patch", "canonical capture patch_path must be source-change.patch", errors)
|
|
986
|
+
if patch is not None:
|
|
987
|
+
require(file_sha256(patch) == capture.get("patch_sha256"), "canonical capture patch hash mismatch", errors)
|
|
988
|
+
require(
|
|
989
|
+
capture.get("source_tree_sha256") == tree_sha256(project_root, root),
|
|
990
|
+
"canonical change capture source tree is stale",
|
|
991
|
+
errors,
|
|
992
|
+
)
|
|
993
|
+
captured_paths = {
|
|
994
|
+
item.get("path")
|
|
995
|
+
for item in capture.get("changed_files", [])
|
|
996
|
+
if isinstance(item, dict) and isinstance(item.get("path"), str)
|
|
997
|
+
}
|
|
998
|
+
inventory_changed = set(inventory.get("changed_files", [])) if isinstance(inventory.get("changed_files"), list) else set()
|
|
999
|
+
scope_changed = set(scope.get("changed_files", [])) if isinstance(scope.get("changed_files"), list) else set()
|
|
1000
|
+
require(captured_paths == inventory_changed, "canonical capture and inventory changed_files do not match", errors)
|
|
1001
|
+
require(captured_paths == scope_changed, "canonical capture and scope changed_files do not match", errors)
|
|
1002
|
+
|
|
1003
|
+
|
|
1004
|
+
def validate_test_preparation(
|
|
1005
|
+
root: Path,
|
|
1006
|
+
project_root: Path,
|
|
1007
|
+
preparation: dict[str, Any],
|
|
1008
|
+
plan: dict[str, Any],
|
|
1009
|
+
inventory_by_category: dict[str, dict[str, str]],
|
|
1010
|
+
entry_map: dict[str, dict[str, Any]],
|
|
1011
|
+
records_schema: dict[str, Any],
|
|
1012
|
+
errors: list[str],
|
|
1013
|
+
) -> None:
|
|
1014
|
+
validate_definition(preparation, "testPreparation", records_schema, "test-preparation", errors)
|
|
1015
|
+
request_relative = preparation.get("request_path")
|
|
1016
|
+
request_path = require_manifest_entry(request_relative, entry_map, root, errors, "test preparation request") if isinstance(request_relative, str) else None
|
|
1017
|
+
if request_path is not None:
|
|
1018
|
+
require(file_sha256(request_path) == preparation.get("request_sha256"), "test preparation request hash mismatch", errors)
|
|
1019
|
+
prepared_tests = preparation.get("tests")
|
|
1020
|
+
require(prepared_tests == plan.get("tests"), "test preparation tests do not match test plan", errors)
|
|
1021
|
+
statuses = preparation.get("changed_test_statuses")
|
|
1022
|
+
require(isinstance(statuses, dict), "test preparation changed_test_statuses must be an object", errors)
|
|
1023
|
+
if not isinstance(statuses, dict):
|
|
1024
|
+
return
|
|
1025
|
+
for relative, status in statuses.items():
|
|
1026
|
+
require(relative in inventory_by_category.get("tests", {}), f"test preparation status is not in test inventory: {relative}", errors)
|
|
1027
|
+
require(status in {"added", "modified"}, f"test preparation has invalid changed test status: {relative}", errors)
|
|
1028
|
+
for test in prepared_tests if isinstance(prepared_tests, list) else []:
|
|
1029
|
+
if not isinstance(test, dict):
|
|
1030
|
+
continue
|
|
1031
|
+
relative = test.get("project_path")
|
|
1032
|
+
if isinstance(relative, str):
|
|
1033
|
+
expected = statuses.get(relative, "existing")
|
|
1034
|
+
require(test.get("change_status") == expected, f"test preparation status mismatch: {relative}", errors)
|
|
1035
|
+
|
|
1036
|
+
|
|
847
1037
|
def validate_plan_and_scope_bindings(
|
|
848
1038
|
root: Path,
|
|
849
1039
|
project_root: Path,
|
|
@@ -905,6 +1095,8 @@ def validate_plan_and_scope_bindings(
|
|
|
905
1095
|
for test_id in receipt.get("test_ids", []):
|
|
906
1096
|
if isinstance(test_id, str):
|
|
907
1097
|
selected_by_test.setdefault(test_id, []).append(receipt)
|
|
1098
|
+
changed_tests = changed_test_statuses(root)
|
|
1099
|
+
planned_test_paths: set[str] = set()
|
|
908
1100
|
known_behavior_ids = {item.get("id") for item in matrix.get("behaviors", []) if isinstance(item, dict)}
|
|
909
1101
|
for test in tests:
|
|
910
1102
|
if not isinstance(test, dict):
|
|
@@ -920,14 +1112,24 @@ def validate_plan_and_scope_bindings(
|
|
|
920
1112
|
for behavior_id in behavior_ids if isinstance(behavior_ids, list) else []:
|
|
921
1113
|
require(behavior_id in known_behavior_ids, f"planned test maps unknown behavior: {test_id}/{behavior_id}", errors)
|
|
922
1114
|
project_relative = test.get("project_path")
|
|
1115
|
+
planned_test_paths.add(project_relative)
|
|
923
1116
|
inventory_relative = test.get("inventory_path")
|
|
924
1117
|
snapshot_relative = test.get("snapshot_path")
|
|
1118
|
+
inferred_status = changed_tests.get(project_relative, "existing")
|
|
1119
|
+
change_status = test.get("change_status", "modified" if isinstance(snapshot_relative, str) else "existing")
|
|
1120
|
+
if changed_tests:
|
|
1121
|
+
require(change_status == inferred_status, f"planned test change_status does not match canonical capture: {test_id}", errors)
|
|
1122
|
+
require(change_status in {"existing", "added", "modified"}, f"planned test has invalid change_status: {test_id}", errors)
|
|
925
1123
|
project_path = safe_path(project_root, project_relative, errors, f"planned test {test_id} project_path")
|
|
926
1124
|
require(project_path is not None and project_path.is_file(), f"planned test live path is missing: {test_id}", errors)
|
|
927
1125
|
require(inventory_relative == project_relative, f"planned test inventory path differs from live project path: {test_id}", errors)
|
|
928
1126
|
require(inventory_relative in inventory_by_category.get("tests", {}), f"planned test is not in the test inventory: {test_id}", errors)
|
|
929
|
-
|
|
930
|
-
|
|
1127
|
+
if change_status == "existing":
|
|
1128
|
+
require(snapshot_relative is None, f"existing test must not have a generated snapshot: {test_id}", errors)
|
|
1129
|
+
snapshot_path = None
|
|
1130
|
+
else:
|
|
1131
|
+
snapshot_path = require_manifest_entry(snapshot_relative, entry_map, root, errors, f"planned test snapshot {test_id}") if isinstance(snapshot_relative, str) else None
|
|
1132
|
+
require(isinstance(snapshot_relative, str) and path_under(snapshot_relative, "generated-tests"), f"planned test snapshot must be under generated-tests/: {test_id}", errors)
|
|
931
1133
|
if project_path is not None and project_path.is_file() and snapshot_path is not None and snapshot_path.is_file():
|
|
932
1134
|
require(file_sha256(project_path) == file_sha256(snapshot_path), f"generated test snapshot differs from live test: {test_id}", errors)
|
|
933
1135
|
test_layers = test.get("layers")
|
|
@@ -939,6 +1141,19 @@ def validate_plan_and_scope_bindings(
|
|
|
939
1141
|
for receipt in matching_receipts:
|
|
940
1142
|
require(receipt.get("layer") in test_layers, f"planned test receipt uses an unplanned layer: {test_id}", errors)
|
|
941
1143
|
|
|
1144
|
+
for relative, status in changed_tests.items():
|
|
1145
|
+
if relative in inventory_by_category.get("tests", {}) and relative not in planned_test_paths:
|
|
1146
|
+
errors.append(f"changed native test is not bound by test plan: {relative}")
|
|
1147
|
+
generated_dir = root / "generated-tests"
|
|
1148
|
+
referenced_snapshots = {
|
|
1149
|
+
test.get("snapshot_path") for test in tests if isinstance(test, dict) and isinstance(test.get("snapshot_path"), str)
|
|
1150
|
+
}
|
|
1151
|
+
if generated_dir.is_dir():
|
|
1152
|
+
actual_snapshots = {
|
|
1153
|
+
path.relative_to(root).as_posix() for path in generated_dir.rglob("*") if path.is_file()
|
|
1154
|
+
}
|
|
1155
|
+
for relative in sorted(actual_snapshots - referenced_snapshots):
|
|
1156
|
+
errors.append(f"generated test snapshot is not bound by test plan: {relative}")
|
|
942
1157
|
for ring in scope.get("regression_ring", []):
|
|
943
1158
|
if not isinstance(ring, dict):
|
|
944
1159
|
continue
|
|
@@ -1082,7 +1297,7 @@ def validate_differential_proofs(
|
|
|
1082
1297
|
proof_by_behavior[behavior_id] = item
|
|
1083
1298
|
classification = item.get("classification")
|
|
1084
1299
|
if classification == "NOT_APPLICABLE":
|
|
1085
|
-
|
|
1300
|
+
validate_differential_na(item.get("not_applicable"), f"differential proof {behavior_id} not_applicable", errors)
|
|
1086
1301
|
for key in (
|
|
1087
1302
|
"method", "differential_request_path", "differential_request_sha256", "baseline_commit",
|
|
1088
1303
|
"current_tree_sha256", "baseline_execution_id", "mutation_execution_id", "current_execution_id",
|
|
@@ -1245,6 +1460,13 @@ def validate_verdict_and_attestation(
|
|
|
1245
1460
|
require(bool(verdict_record.get("blockers")), "TEST_BLOCKED lacks blocker reasons", errors)
|
|
1246
1461
|
elif verdict == "TEST_FAIL":
|
|
1247
1462
|
require(bool(verdict_record.get("reproduced_failures")), "TEST_FAIL lacks reproduced failures", errors)
|
|
1463
|
+
require(
|
|
1464
|
+
verdict_record.get("repair_scope") in {
|
|
1465
|
+
"test-infrastructure", "production", "runtime", "schema", "dependency", "public-interface",
|
|
1466
|
+
},
|
|
1467
|
+
"TEST_FAIL lacks a valid evidence-backed repair_scope",
|
|
1468
|
+
errors,
|
|
1469
|
+
)
|
|
1248
1470
|
|
|
1249
1471
|
if verify_attestation:
|
|
1250
1472
|
validate_definition(validation, "validation", records_schema, "validation", errors)
|
|
@@ -1264,6 +1486,10 @@ def validate_verdict_and_attestation(
|
|
|
1264
1486
|
require(validation.get("semantics") == "integrity-and-protocol-validation-not-hostile-producer-proof", "validation attestation semantics exceed integrity/protocol validation", errors)
|
|
1265
1487
|
require(validation.get("replay_receipt_ids") == replayed_ids, "validation replay receipt IDs do not match actual replay executions", errors)
|
|
1266
1488
|
|
|
1489
|
+
if root.joinpath("lifecycle.json").is_file():
|
|
1490
|
+
expected_report = render_report_text(root, manifest, verdict_record, validation)
|
|
1491
|
+
require(report == expected_report, "derived report does not match canonical rendering", errors)
|
|
1492
|
+
|
|
1267
1493
|
fragments = [
|
|
1268
1494
|
f"Verdict: {verdict}", f"Evidence ID: {manifest.get('evidence_id')}",
|
|
1269
1495
|
"Derived from structured evidence", "sensitivity=project-controlled",
|
|
@@ -1299,9 +1525,26 @@ def validate_evidence(
|
|
|
1299
1525
|
|
|
1300
1526
|
entry_map = validate_manifest(root, manifest, manifest_schema, records_schema, errors, ignored_hash_paths)
|
|
1301
1527
|
change_class = manifest.get("change_class")
|
|
1302
|
-
|
|
1528
|
+
final_verdict = manifest.get("final_verdict")
|
|
1529
|
+
canonical_lifecycle = (root / "lifecycle.json").is_file()
|
|
1530
|
+
required = COMMON_RECORDS
|
|
1531
|
+
if canonical_lifecycle:
|
|
1532
|
+
required = required | {"change-capture.json"}
|
|
1533
|
+
if final_verdict != "TEST_BLOCKED":
|
|
1534
|
+
required = required | (BEHAVIOR_RECORDS if change_class == "behavior" else LIGHTWEIGHT_RECORDS)
|
|
1535
|
+
if canonical_lifecycle and change_class == "behavior":
|
|
1536
|
+
required = required | {"test-preparation.json"}
|
|
1303
1537
|
for relative in required:
|
|
1304
1538
|
require(relative in entry_map, f"required evidence record not hashed: {relative}", errors)
|
|
1539
|
+
if canonical_lifecycle:
|
|
1540
|
+
capture_path = root / "change-capture.json"
|
|
1541
|
+
if capture_path.is_file():
|
|
1542
|
+
try:
|
|
1543
|
+
capture = load_json(capture_path)
|
|
1544
|
+
validate_definition(capture, "changeCapture", records_schema, "change-capture", errors)
|
|
1545
|
+
validate_canonical_capture(root, project_root, capture, inventory, scope, errors)
|
|
1546
|
+
except EvidenceError as exc:
|
|
1547
|
+
errors.append(str(exc))
|
|
1305
1548
|
validate_definition(classification, "changeClassification", records_schema, "change-classification", errors)
|
|
1306
1549
|
validate_definition(scope, "scope", records_schema, "scope", errors)
|
|
1307
1550
|
validate_definition(inventory, "targetInventory", records_schema, "target-inventory", errors)
|
|
@@ -1309,20 +1552,37 @@ def validate_evidence(
|
|
|
1309
1552
|
require(classification.get("full_protocol_required") is (change_class == "behavior"), "change classification protocol decision is inconsistent", errors)
|
|
1310
1553
|
|
|
1311
1554
|
plan: dict[str, Any] | None = None
|
|
1555
|
+
preparation: dict[str, Any] | None = None
|
|
1312
1556
|
if change_class == "behavior":
|
|
1313
1557
|
try:
|
|
1314
1558
|
plan = load_json(root / "test-plan.json")
|
|
1559
|
+
if canonical_lifecycle:
|
|
1560
|
+
preparation = load_json(root / "test-preparation.json")
|
|
1315
1561
|
except EvidenceError as exc:
|
|
1316
1562
|
errors.append(str(exc))
|
|
1563
|
+
plan = preparation = None
|
|
1317
1564
|
if isinstance(plan, dict):
|
|
1318
1565
|
validate_definition(plan, "testPlan", records_schema, "test-plan", errors)
|
|
1319
1566
|
else:
|
|
1320
1567
|
errors.append("test-plan.json must be an object")
|
|
1321
1568
|
plan = None
|
|
1569
|
+
if canonical_lifecycle:
|
|
1570
|
+
if isinstance(preparation, dict):
|
|
1571
|
+
pass
|
|
1572
|
+
else:
|
|
1573
|
+
errors.append("test-preparation.json must be an object")
|
|
1322
1574
|
|
|
1323
1575
|
inventory_by_category, _ = validate_inventory(root, project_root, manifest, inventory, scope, plan, entry_map, errors)
|
|
1576
|
+
if canonical_lifecycle and change_class == "behavior" and isinstance(preparation, dict):
|
|
1577
|
+
validate_test_preparation(
|
|
1578
|
+
root, project_root, preparation, plan or {}, inventory_by_category,
|
|
1579
|
+
entry_map, records_schema, errors,
|
|
1580
|
+
)
|
|
1324
1581
|
require(manifest.get("evidence_id_inputs", {}).get("scope_sha256") == canonical_sha256(scope), "scope identity hash mismatch", errors)
|
|
1325
|
-
validate_scope(
|
|
1582
|
+
validate_scope(
|
|
1583
|
+
scope, inventory, inventory_by_category, project_root, root,
|
|
1584
|
+
get_captured_deleted_paths(root), errors,
|
|
1585
|
+
)
|
|
1326
1586
|
execution_requests, differential_requests, executions = validate_requests_and_receipts(root, entry_map, records_schema, errors)
|
|
1327
1587
|
|
|
1328
1588
|
if change_class == "behavior":
|
|
@@ -1373,6 +1633,64 @@ def validate_evidence(
|
|
|
1373
1633
|
return errors
|
|
1374
1634
|
|
|
1375
1635
|
|
|
1636
|
+
def atomic_write_text(path: Path, value: str) -> None:
|
|
1637
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
1638
|
+
descriptor, temporary_name = tempfile.mkstemp(prefix=f".{path.name}.", suffix=".tmp", dir=path.parent)
|
|
1639
|
+
try:
|
|
1640
|
+
with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
|
|
1641
|
+
handle.write(value)
|
|
1642
|
+
os.replace(temporary_name, path)
|
|
1643
|
+
except BaseException:
|
|
1644
|
+
try:
|
|
1645
|
+
os.unlink(temporary_name)
|
|
1646
|
+
except FileNotFoundError:
|
|
1647
|
+
pass
|
|
1648
|
+
raise
|
|
1649
|
+
|
|
1650
|
+
|
|
1651
|
+
def render_report_text(
|
|
1652
|
+
root: Path,
|
|
1653
|
+
manifest: dict[str, Any],
|
|
1654
|
+
verdict: dict[str, Any],
|
|
1655
|
+
validation: dict[str, Any],
|
|
1656
|
+
) -> str:
|
|
1657
|
+
lifecycle_path = root / "lifecycle.json"
|
|
1658
|
+
lifecycle = load_json(lifecycle_path) if lifecycle_path.exists() else {}
|
|
1659
|
+
proof_path = root / "differential-proof.json"
|
|
1660
|
+
proof_record = load_json(proof_path) if proof_path.exists() else {}
|
|
1661
|
+
executions_record = load_json(root / "executions.json")
|
|
1662
|
+
proofs = proof_record.get("proofs", []) if isinstance(proof_record, dict) else []
|
|
1663
|
+
executions = executions_record if isinstance(executions_record, list) else []
|
|
1664
|
+
artifact_dir = lifecycle.get("artifact_dir") if isinstance(lifecycle, dict) else None
|
|
1665
|
+
lines = [
|
|
1666
|
+
"# Test Evidence Report",
|
|
1667
|
+
"",
|
|
1668
|
+
"Derived from structured evidence",
|
|
1669
|
+
f"Evidence ID: {manifest.get('evidence_id')}",
|
|
1670
|
+
f"Verdict: {verdict.get('verdict')}",
|
|
1671
|
+
f"Validator result: {validation.get('result', 'pending')}",
|
|
1672
|
+
"sensitivity=project-controlled",
|
|
1673
|
+
"The project owns access control, retention, and upload policy.",
|
|
1674
|
+
"Mocked code-level evidence does not verify a real deployed environment.",
|
|
1675
|
+
"",
|
|
1676
|
+
"## Scope",
|
|
1677
|
+
f"- Artifact Dir: {artifact_dir or 'not provided'}",
|
|
1678
|
+
"",
|
|
1679
|
+
f"Execution receipts: {len(executions)}",
|
|
1680
|
+
f"Differential proofs: {len(proofs) if isinstance(proofs, list) else 0}",
|
|
1681
|
+
"The legacy test-report interface is not supported.",
|
|
1682
|
+
"",
|
|
1683
|
+
]
|
|
1684
|
+
return "\n".join(lines)
|
|
1685
|
+
|
|
1686
|
+
|
|
1687
|
+
def refresh_final_record_hashes(root: Path, manifest: dict[str, Any]) -> None:
|
|
1688
|
+
for entry in manifest.get("files", []):
|
|
1689
|
+
if isinstance(entry, dict) and entry.get("path") in FINAL_RECORDS:
|
|
1690
|
+
entry["sha256"] = file_sha256(root / entry["path"])
|
|
1691
|
+
atomic_write_json(root / "manifest.json", manifest)
|
|
1692
|
+
|
|
1693
|
+
|
|
1376
1694
|
def main() -> int:
|
|
1377
1695
|
parser = argparse.ArgumentParser(description="Validate a PrizmKit test evidence package")
|
|
1378
1696
|
parser.add_argument("--evidence-dir", required=True, help="Path to .prizmkit/test/evidence/<evidence-id>")
|
|
@@ -1388,7 +1706,7 @@ def main() -> int:
|
|
|
1388
1706
|
except IndexError:
|
|
1389
1707
|
print("Test evidence validation: FAIL\n- cannot infer project root from evidence directory")
|
|
1390
1708
|
return 1
|
|
1391
|
-
errors = validate_evidence(root, project_root,
|
|
1709
|
+
errors = validate_evidence(root, project_root, ATTESTATION_MUTABLE if args.attest else None)
|
|
1392
1710
|
if errors:
|
|
1393
1711
|
print("Test evidence validation: FAIL")
|
|
1394
1712
|
for error in errors:
|
|
@@ -1396,6 +1714,7 @@ def main() -> int:
|
|
|
1396
1714
|
return 1
|
|
1397
1715
|
|
|
1398
1716
|
manifest = load_json(root / "manifest.json")
|
|
1717
|
+
verdict_record = load_json(root / "verdict.json")
|
|
1399
1718
|
if args.attest:
|
|
1400
1719
|
executions = load_json(root / "executions.json")
|
|
1401
1720
|
replayed_ids = [
|
|
@@ -1421,10 +1740,9 @@ def main() -> int:
|
|
|
1421
1740
|
"replay_receipt_ids": replayed_ids,
|
|
1422
1741
|
}
|
|
1423
1742
|
atomic_write_json(root / "validation.json", validation)
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
atomic_write_json(root / "manifest.json", manifest)
|
|
1743
|
+
report_text = render_report_text(root, manifest, verdict_record, validation)
|
|
1744
|
+
atomic_write_text(root / "test-report.md", report_text)
|
|
1745
|
+
refresh_final_record_hashes(root, manifest)
|
|
1428
1746
|
errors = validate_evidence(root, project_root)
|
|
1429
1747
|
if errors:
|
|
1430
1748
|
print("Test evidence validation: FAIL")
|
|
@@ -199,7 +199,7 @@ Interactive execution requires the committer to:
|
|
|
199
199
|
4. wait for explicit user confirmation from the current user;
|
|
200
200
|
5. create and verify the local commit only after confirmation.
|
|
201
201
|
|
|
202
|
-
Trusted headless execution is a separate atomic-stage path. It requires a host-defined non-interactive `mode`, a trusted `owner` identifier, `local_commit_authorized=true
|
|
202
|
+
Trusted headless execution is a separate atomic-stage path. It requires a host-defined non-interactive `mode`, a trusted `owner` identifier, and `local_commit_authorized=true`; it does not ask or wait. Unknown headless contexts are blocked. Remote publication is a separate host-runtime operation and is never decided by this composite workflow.
|
|
203
203
|
|
|
204
204
|
## Automatic Handoff and Manual Fallback
|
|
205
205
|
|
|
@@ -157,12 +157,12 @@ Trusted headless execution:
|
|
|
157
157
|
```text
|
|
158
158
|
a trusted host explicitly authorizes the current local task commit
|
|
159
159
|
→ host injects mode=<host-defined-headless-mode>, owner=<trusted-host-identifier>,
|
|
160
|
-
local_commit_authorized=true
|
|
160
|
+
local_commit_authorized=true
|
|
161
161
|
→ committer verifies gates and commits automatically
|
|
162
|
-
→ no question
|
|
162
|
+
→ no question or wait is permitted
|
|
163
163
|
```
|
|
164
164
|
|
|
165
|
-
A self-declared or otherwise untrusted headless context does not authorize a commit.
|
|
165
|
+
A self-declared or otherwise untrusted headless context does not authorize a commit. Remote publication remains a separate host-runtime decision.
|
|
166
166
|
|
|
167
167
|
## Recovery
|
|
168
168
|
|