prizmkit 1.1.139 → 1.1.141
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bundled/VERSION.json +3 -3
- package/bundled/dev-pipeline/prizmkit_runtime/checkpoint_state.py +41 -121
- package/bundled/dev-pipeline/prizmkit_runtime/runner_models.py +0 -6
- package/bundled/dev-pipeline/prizmkit_runtime/runners.py +1 -2
- package/bundled/dev-pipeline/prizmkit_runtime/test_result.py +150 -0
- package/bundled/dev-pipeline/scripts/generate-bootstrap-prompt.py +21 -804
- package/bundled/dev-pipeline/scripts/generate-bugfix-prompt.py +4 -6
- package/bundled/dev-pipeline/scripts/generate-recovery-prompt.py +1 -1
- package/bundled/dev-pipeline/scripts/generate-refactor-prompt.py +4 -6
- package/bundled/dev-pipeline/scripts/prompt_framework.py +56 -55
- package/bundled/dev-pipeline/scripts/update-bug-status.py +1 -2
- package/bundled/dev-pipeline/scripts/update-checkpoint.py +14 -96
- package/bundled/dev-pipeline/scripts/update-feature-status.py +1 -2
- package/bundled/dev-pipeline/scripts/update-refactor-status.py +1 -2
- package/bundled/dev-pipeline/templates/bootstrap-prompt.md +3 -3
- package/bundled/dev-pipeline/templates/bootstrap-tier3.md +7 -18
- package/bundled/dev-pipeline/templates/bugfix-bootstrap-prompt.md +6 -6
- package/bundled/dev-pipeline/templates/refactor-bootstrap-prompt.md +5 -5
- package/bundled/dev-pipeline/templates/sections/bugfix-phase-commit-report.md +1 -1
- package/bundled/dev-pipeline/templates/sections/bugfix-phase-diagnose-plan.md +1 -1
- package/bundled/dev-pipeline/templates/sections/bugfix-phase-manual-verification.md +1 -1
- package/bundled/dev-pipeline/templates/sections/bugfix-phase-test.md +13 -10
- package/bundled/dev-pipeline/templates/sections/bugfix-reminders.md +1 -1
- package/bundled/dev-pipeline/templates/sections/checkpoint-system.md +14 -9
- package/bundled/dev-pipeline/templates/sections/headless-commit-authorization.md +1 -1
- package/bundled/dev-pipeline/templates/sections/phase-commit-full.md +1 -1
- package/bundled/dev-pipeline/templates/sections/phase-commit.md +1 -1
- package/bundled/dev-pipeline/templates/sections/phase-context-snapshot-agent-suffix.md +2 -2
- package/bundled/dev-pipeline/templates/sections/phase-context-snapshot-base.md +1 -1
- package/bundled/dev-pipeline/templates/sections/phase-context-snapshot-lite-suffix.md +1 -1
- package/bundled/dev-pipeline/templates/sections/phase-implement-lite.md +1 -1
- package/bundled/dev-pipeline/templates/sections/phase-prizmkit-test.md +14 -26
- package/bundled/dev-pipeline/templates/sections/phase-specify-plan-full.md +3 -3
- package/bundled/dev-pipeline/templates/sections/refactor-phase-test.md +13 -10
- package/bundled/dev-pipeline/templates/sections/refactor-reminders.md +1 -1
- package/bundled/dev-pipeline/tests/test_checkpoint_state.py +287 -384
- package/bundled/dev-pipeline/tests/test_generate_bootstrap_prompt.py +119 -684
- package/bundled/dev-pipeline/tests/test_generate_bugfix_prompt.py +17 -40
- package/bundled/dev-pipeline/tests/test_generate_refactor_prompt.py +20 -23
- package/bundled/dev-pipeline/tests/test_python_runner_parity.py +29 -32
- package/bundled/skills/_metadata.json +6 -6
- package/bundled/skills/prizmkit/SKILL.md +8 -13
- package/bundled/skills/prizmkit/references/workflow-state-protocol.md +61 -80
- package/bundled/skills/prizmkit-code-review/SKILL.md +3 -3
- package/bundled/skills/prizmkit-code-review/references/workflow-state-protocol.md +61 -80
- package/bundled/skills/prizmkit-committer/SKILL.md +3 -3
- package/bundled/skills/prizmkit-committer/references/workflow-state-protocol.md +61 -80
- package/bundled/skills/prizmkit-implement/references/workflow-state-protocol.md +61 -80
- package/bundled/skills/prizmkit-plan/references/workflow-state-protocol.md +61 -80
- package/bundled/skills/prizmkit-retrospective/SKILL.md +2 -2
- package/bundled/skills/prizmkit-retrospective/references/workflow-state-protocol.md +61 -80
- package/bundled/skills/prizmkit-test/SKILL.md +217 -187
- package/bundled/skills/prizmkit-test/references/external-contract-mock-guidance.md +119 -0
- package/bundled/skills/prizmkit-test/references/independent-test-review.md +269 -0
- package/bundled/skills/prizmkit-test/references/test-coverage-model.md +193 -0
- package/bundled/skills/prizmkit-test/references/test-report-template.md +164 -79
- package/bundled/skills/prizmkit-workflow/SKILL.md +15 -23
- package/bundled/skills/prizmkit-workflow/references/workflow-state-protocol.md +61 -80
- package/package.json +1 -1
- package/bundled/skills/prizmkit-test/assets/authoritative-records.schema.json +0 -476
- package/bundled/skills/prizmkit-test/assets/behavior-risk-matrix.schema.json +0 -116
- package/bundled/skills/prizmkit-test/assets/evidence-manifest.schema.json +0 -111
- package/bundled/skills/prizmkit-test/assets/evidence-package-template.json +0 -66
- package/bundled/skills/prizmkit-test/references/boundary-coverage-protocol.md +0 -104
- package/bundled/skills/prizmkit-test/references/contract-mock-protocol.md +0 -65
- package/bundled/skills/prizmkit-test/references/evidence-protocol.md +0 -208
- package/bundled/skills/prizmkit-test/references/evidence-request-protocol.md +0 -80
- package/bundled/skills/prizmkit-test/references/examples.md +0 -100
- package/bundled/skills/prizmkit-test/references/service-boundary-test-catalog.md +0 -228
- package/bundled/skills/prizmkit-test/references/test-generation-steps.md +0 -118
- package/bundled/skills/prizmkit-test/references/trusted-evidence-execution.md +0 -110
- package/bundled/skills/prizmkit-test/references/workflow-state-protocol.md +0 -181
- package/bundled/skills/prizmkit-test/scripts/build_test_evidence.py +0 -1851
- package/bundled/skills/prizmkit-test/scripts/validate_test_evidence.py +0 -1793
|
@@ -23,24 +23,30 @@ Usage:
|
|
|
23
23
|
"""
|
|
24
24
|
|
|
25
25
|
import argparse
|
|
26
|
-
import glob
|
|
27
|
-
import hashlib
|
|
28
26
|
import json
|
|
29
27
|
import os
|
|
30
28
|
import re
|
|
31
|
-
import subprocess
|
|
32
29
|
import sys
|
|
33
30
|
|
|
34
31
|
from utils import enrich_global_context, helper_replacements, load_json_file, resolve_prompt_platform, setup_logging
|
|
35
32
|
from continuation import add_continuation_args, append_continuation_handoff, is_continuation
|
|
36
|
-
from prompt_framework import
|
|
33
|
+
from prompt_framework import (
|
|
34
|
+
artifact_exists,
|
|
35
|
+
browser_enabled_from_item,
|
|
36
|
+
checkpoint_group,
|
|
37
|
+
checkpoint_skip,
|
|
38
|
+
checkpoint_step,
|
|
39
|
+
completed_test_artifacts_valid,
|
|
40
|
+
detect_dev_server_port,
|
|
41
|
+
generate_checkpoint_from_sections,
|
|
42
|
+
merge_checkpoint_state as merge_shared_checkpoint_state,
|
|
43
|
+
pipeline_checkpoint_metadata,
|
|
44
|
+
route_pipeline_repair,
|
|
45
|
+
)
|
|
37
46
|
|
|
38
47
|
|
|
39
48
|
DEFAULT_MAX_RETRIES = 3
|
|
40
49
|
MAX_OUTER_REPAIR_ROUNDS = 3
|
|
41
|
-
EVIDENCE_FINAL_RECORDS = {"validation.json", "verdict.json", "test-report.md"}
|
|
42
|
-
EVIDENCE_SHA256_RE = re.compile(r"^[a-f0-9]{64}$")
|
|
43
|
-
EVIDENCE_TARGET_HASH_KEYS = ("source", "tests", "contracts", "lockfiles", "environment", "plan")
|
|
44
50
|
|
|
45
51
|
LOGGER = setup_logging("generate-bootstrap-prompt")
|
|
46
52
|
|
|
@@ -684,10 +690,8 @@ SECTION_TO_SKILL = {
|
|
|
684
690
|
"phase-implement": checkpoint_step("prizmkit-implement", "Implement", []),
|
|
685
691
|
"phase-prizmkit-test": checkpoint_step(
|
|
686
692
|
"prizmkit-test", "Scoped Feature Test Gate",
|
|
687
|
-
[".prizmkit/specs/{slug}/test-report
|
|
688
|
-
".prizmkit/test
|
|
689
|
-
".prizmkit/test/evidence/*/verdict.json",
|
|
690
|
-
".prizmkit/test/evidence/*/validation.json"],
|
|
693
|
+
[".prizmkit/specs/{slug}/test-report.md",
|
|
694
|
+
".prizmkit/specs/{slug}/test-result.json"],
|
|
691
695
|
),
|
|
692
696
|
"phase-review": checkpoint_step("prizmkit-code-review", "Code Review", []),
|
|
693
697
|
"phase-browser": checkpoint_step("browser-verification", "Browser Verification", []),
|
|
@@ -707,817 +711,30 @@ def _resolve_artifacts(artifact_templates, slug):
|
|
|
707
711
|
return [a.replace("{slug}", slug) for a in artifact_templates]
|
|
708
712
|
|
|
709
713
|
|
|
710
|
-
def _artifact_exists(project_root, artifact):
|
|
711
|
-
"""Return whether an artifact path or glob exists under project_root."""
|
|
712
|
-
full_path = os.path.join(project_root, artifact)
|
|
713
|
-
if glob.has_magic(full_path):
|
|
714
|
-
return bool(glob.glob(full_path))
|
|
715
|
-
return os.path.exists(full_path)
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
def _read_text(path):
|
|
719
|
-
"""Read a UTF-8 text file, returning an empty string on IO errors."""
|
|
720
|
-
try:
|
|
721
|
-
with open(path, "r", encoding="utf-8") as f:
|
|
722
|
-
return f.read()
|
|
723
|
-
except IOError:
|
|
724
|
-
return ""
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
def _canonical_sha256(value):
|
|
728
|
-
"""Return the protocol's compact, key-sorted JSON SHA-256."""
|
|
729
|
-
payload = json.dumps(
|
|
730
|
-
value, ensure_ascii=False, sort_keys=True, separators=(",", ":"),
|
|
731
|
-
)
|
|
732
|
-
return hashlib.sha256(payload.encode("utf-8")).hexdigest()
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
def _file_sha256(path):
|
|
736
|
-
"""Hash a file without loading large evidence records into memory."""
|
|
737
|
-
digest = hashlib.sha256()
|
|
738
|
-
try:
|
|
739
|
-
with open(path, "rb") as f:
|
|
740
|
-
for chunk in iter(lambda: f.read(1024 * 1024), b""):
|
|
741
|
-
digest.update(chunk)
|
|
742
|
-
except OSError:
|
|
743
|
-
return ""
|
|
744
|
-
return digest.hexdigest()
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
def _load_strict_json(path):
|
|
748
|
-
"""Load JSON while rejecting duplicate keys and non-JSON constants."""
|
|
749
|
-
def reject_duplicates(pairs):
|
|
750
|
-
result = {}
|
|
751
|
-
for key, value in pairs:
|
|
752
|
-
if key in result:
|
|
753
|
-
raise ValueError("duplicate object key: {}".format(key))
|
|
754
|
-
result[key] = value
|
|
755
|
-
return result
|
|
756
|
-
|
|
757
|
-
def reject_constant(value):
|
|
758
|
-
raise ValueError("non-JSON numeric constant: {}".format(value))
|
|
759
|
-
|
|
760
|
-
try:
|
|
761
|
-
with open(path, "r", encoding="utf-8") as f:
|
|
762
|
-
return json.load(
|
|
763
|
-
f, object_pairs_hook=reject_duplicates,
|
|
764
|
-
parse_constant=reject_constant,
|
|
765
|
-
)
|
|
766
|
-
except (OSError, ValueError, TypeError):
|
|
767
|
-
return None
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
def _is_sha256(value):
|
|
771
|
-
return isinstance(value, str) and bool(EVIDENCE_SHA256_RE.fullmatch(value))
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
def _path_is_within(root, candidate):
|
|
775
|
-
"""Return whether candidate resolves below root without escaping it."""
|
|
776
|
-
try:
|
|
777
|
-
return os.path.commonpath([
|
|
778
|
-
os.path.realpath(root), os.path.realpath(candidate),
|
|
779
|
-
]) == os.path.realpath(root)
|
|
780
|
-
except ValueError:
|
|
781
|
-
return False
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
def _resolve_test_evidence_validator(project_root):
|
|
785
|
-
"""Return the installed validator path used by the current project."""
|
|
786
|
-
candidates = [
|
|
787
|
-
os.path.join(
|
|
788
|
-
project_root, ".claude", "command-assets", "prizmkit-test",
|
|
789
|
-
"scripts", "validate_test_evidence.py",
|
|
790
|
-
),
|
|
791
|
-
os.path.join(
|
|
792
|
-
project_root, ".agents", "skills", "prizmkit-test", "scripts",
|
|
793
|
-
"validate_test_evidence.py",
|
|
794
|
-
),
|
|
795
|
-
os.path.join(
|
|
796
|
-
project_root, ".codebuddy", "skills", "prizmkit-test", "scripts",
|
|
797
|
-
"validate_test_evidence.py",
|
|
798
|
-
),
|
|
799
|
-
]
|
|
800
|
-
return next((path for path in candidates if os.path.isfile(path)), "")
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
def _evidence_validator_passes(project_root, evidence_root, validation):
|
|
804
|
-
"""Re-run the authoritative L1 validator before accepting any verdict."""
|
|
805
|
-
validator_path = _resolve_test_evidence_validator(project_root)
|
|
806
|
-
if (not validator_path or
|
|
807
|
-
validation.get("validator_sha256") != _file_sha256(validator_path)):
|
|
808
|
-
return False
|
|
809
|
-
try:
|
|
810
|
-
result = subprocess.run(
|
|
811
|
-
[sys.executable, validator_path,
|
|
812
|
-
"--evidence-dir", evidence_root,
|
|
813
|
-
"--project-root", project_root],
|
|
814
|
-
stdout=subprocess.DEVNULL,
|
|
815
|
-
stderr=subprocess.DEVNULL,
|
|
816
|
-
timeout=120,
|
|
817
|
-
check=False,
|
|
818
|
-
)
|
|
819
|
-
except (OSError, subprocess.SubprocessError):
|
|
820
|
-
return False
|
|
821
|
-
return result.returncode == 0
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
def _evidence_package_from_pointer(project_root, pointer_artifact):
|
|
825
|
-
"""Resolve a Feature report pointer to the structured evidence package."""
|
|
826
|
-
pointer_path = os.path.join(project_root, pointer_artifact)
|
|
827
|
-
pointed = _read_text(pointer_path).strip().splitlines()
|
|
828
|
-
if not pointed:
|
|
829
|
-
return None, None
|
|
830
|
-
|
|
831
|
-
report_path = pointed[0].strip()
|
|
832
|
-
if not os.path.isabs(report_path):
|
|
833
|
-
report_path = os.path.join(project_root, report_path)
|
|
834
|
-
evidence_root = os.path.dirname(report_path)
|
|
835
|
-
expected_root = os.path.join(project_root, ".prizmkit", "test", "evidence")
|
|
836
|
-
if not _path_is_within(expected_root, evidence_root):
|
|
837
|
-
return None, None
|
|
838
|
-
relative_root = os.path.relpath(evidence_root, expected_root)
|
|
839
|
-
if (not relative_root or relative_root.startswith("..") or
|
|
840
|
-
os.sep in relative_root):
|
|
841
|
-
return None, None
|
|
842
|
-
if os.path.basename(report_path) != "test-report.md":
|
|
843
|
-
return None, None
|
|
844
|
-
if not os.path.isfile(report_path):
|
|
845
|
-
return None, None
|
|
846
|
-
return os.path.realpath(evidence_root), os.path.realpath(report_path)
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
def _manifest_entries(evidence_root, manifest):
|
|
850
|
-
"""Validate the manifest file list and return path -> entry."""
|
|
851
|
-
entries = manifest.get("files")
|
|
852
|
-
if not isinstance(entries, list):
|
|
853
|
-
return None
|
|
854
|
-
result = {}
|
|
855
|
-
for entry in entries:
|
|
856
|
-
if not isinstance(entry, dict):
|
|
857
|
-
return None
|
|
858
|
-
relative = entry.get("path")
|
|
859
|
-
if (not isinstance(relative, str) or not relative or
|
|
860
|
-
os.path.isabs(relative) or relative in result or
|
|
861
|
-
".." in relative.split("/") or "\\" in relative):
|
|
862
|
-
return None
|
|
863
|
-
path = os.path.join(evidence_root, relative)
|
|
864
|
-
if not _path_is_within(evidence_root, path) or not os.path.isfile(path):
|
|
865
|
-
return None
|
|
866
|
-
if not _is_sha256(entry.get("sha256")):
|
|
867
|
-
return None
|
|
868
|
-
if not isinstance(entry.get("produced_by"), str) or not entry["produced_by"].strip():
|
|
869
|
-
return None
|
|
870
|
-
if _file_sha256(path) != entry["sha256"]:
|
|
871
|
-
return None
|
|
872
|
-
result[relative] = entry
|
|
873
|
-
|
|
874
|
-
actual = set()
|
|
875
|
-
for root, _dirs, files in os.walk(evidence_root):
|
|
876
|
-
for filename in files:
|
|
877
|
-
relative = os.path.relpath(os.path.join(root, filename), evidence_root)
|
|
878
|
-
if relative != "manifest.json":
|
|
879
|
-
actual.add(relative)
|
|
880
|
-
return result if set(result) == actual else None
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
def _extract_report_scope(report_text):
|
|
884
|
-
"""Read only the derived report's artifact identity, never its verdict."""
|
|
885
|
-
in_scope = False
|
|
886
|
-
for raw_line in report_text.splitlines():
|
|
887
|
-
line = raw_line.strip()
|
|
888
|
-
if line == "## Scope":
|
|
889
|
-
in_scope = True
|
|
890
|
-
continue
|
|
891
|
-
if in_scope and line.startswith("## "):
|
|
892
|
-
break
|
|
893
|
-
if in_scope and line.startswith("- Artifact Dir:"):
|
|
894
|
-
return line.split(":", 1)[1].strip()
|
|
895
|
-
return ""
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
def _validate_target_hashes(evidence_root, project_root, manifest, entry_map):
|
|
899
|
-
"""Validate live inventoried files against the manifest target hashes."""
|
|
900
|
-
inventory_path = os.path.join(evidence_root, "target-inventory.json")
|
|
901
|
-
environment_path = os.path.join(evidence_root, "environment.json")
|
|
902
|
-
inventory = _load_strict_json(inventory_path)
|
|
903
|
-
environment = _load_strict_json(environment_path)
|
|
904
|
-
if not isinstance(inventory, dict) or not isinstance(environment, dict):
|
|
905
|
-
return False
|
|
906
|
-
if "target-inventory.json" not in entry_map or "environment.json" not in entry_map:
|
|
907
|
-
return False
|
|
908
|
-
|
|
909
|
-
categories = inventory.get("categories")
|
|
910
|
-
expected = set(EVIDENCE_TARGET_HASH_KEYS[:4])
|
|
911
|
-
if not isinstance(categories, dict) or set(categories) != expected:
|
|
912
|
-
return False
|
|
913
|
-
aggregates = {}
|
|
914
|
-
for category in sorted(expected):
|
|
915
|
-
records = categories.get(category)
|
|
916
|
-
if not isinstance(records, list):
|
|
917
|
-
return False
|
|
918
|
-
normalized = []
|
|
919
|
-
for record in records:
|
|
920
|
-
if not isinstance(record, dict):
|
|
921
|
-
return False
|
|
922
|
-
relative = record.get("path")
|
|
923
|
-
expected_hash = record.get("sha256")
|
|
924
|
-
if (not isinstance(relative, str) or os.path.isabs(relative) or
|
|
925
|
-
not _is_sha256(expected_hash)):
|
|
926
|
-
return False
|
|
927
|
-
live_path = os.path.join(project_root, relative)
|
|
928
|
-
if (not _path_is_within(project_root, live_path) or
|
|
929
|
-
not os.path.isfile(live_path) or
|
|
930
|
-
_file_sha256(live_path) != expected_hash):
|
|
931
|
-
return False
|
|
932
|
-
normalized.append({"path": relative, "sha256": expected_hash})
|
|
933
|
-
aggregates[category] = _canonical_sha256(
|
|
934
|
-
sorted(normalized, key=lambda item: item["path"]),
|
|
935
|
-
)
|
|
936
|
-
|
|
937
|
-
aggregates["environment"] = _canonical_sha256(environment)
|
|
938
|
-
plan_inputs = inventory.get("plan_inputs")
|
|
939
|
-
if not isinstance(plan_inputs, dict) or not plan_inputs:
|
|
940
|
-
return False
|
|
941
|
-
aggregates["plan"] = _canonical_sha256(plan_inputs)
|
|
942
|
-
target_hashes = manifest.get("target_hashes")
|
|
943
|
-
return target_hashes == aggregates
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
def validate_feature_test_evidence(project_root, artifact_dir):
|
|
947
|
-
"""Return structured Feature test evidence, or None when it is unsafe."""
|
|
948
|
-
pointer_artifact = os.path.join(artifact_dir, "test-report-path.txt")
|
|
949
|
-
evidence_root, report_path = _evidence_package_from_pointer(
|
|
950
|
-
project_root, pointer_artifact,
|
|
951
|
-
)
|
|
952
|
-
if not evidence_root or not report_path:
|
|
953
|
-
return None
|
|
954
|
-
|
|
955
|
-
marker_path = os.path.join(project_root, artifact_dir, ".prizmkit-test-started")
|
|
956
|
-
if not os.path.isfile(marker_path):
|
|
957
|
-
return None
|
|
958
|
-
try:
|
|
959
|
-
if os.path.getmtime(report_path) < os.path.getmtime(marker_path):
|
|
960
|
-
return None
|
|
961
|
-
except OSError:
|
|
962
|
-
return None
|
|
963
|
-
|
|
964
|
-
manifest = _load_strict_json(os.path.join(evidence_root, "manifest.json"))
|
|
965
|
-
verdict = _load_strict_json(os.path.join(evidence_root, "verdict.json"))
|
|
966
|
-
validation = _load_strict_json(os.path.join(evidence_root, "validation.json"))
|
|
967
|
-
if not all(isinstance(record, dict) for record in (manifest, verdict, validation)):
|
|
968
|
-
return None
|
|
969
|
-
entry_map = _manifest_entries(evidence_root, manifest)
|
|
970
|
-
if entry_map is None or not {"verdict.json", "validation.json"}.issubset(entry_map):
|
|
971
|
-
return None
|
|
972
|
-
|
|
973
|
-
identity = manifest.get("evidence_id_inputs")
|
|
974
|
-
evidence_id = manifest.get("evidence_id")
|
|
975
|
-
if (not isinstance(identity, dict) or not _is_sha256(identity.get("working_diff_sha256")) or
|
|
976
|
-
not _is_sha256(identity.get("scope_sha256")) or
|
|
977
|
-
not isinstance(identity.get("baseline_commit"), str) or
|
|
978
|
-
evidence_id != _canonical_sha256(identity) or
|
|
979
|
-
os.path.basename(evidence_root) != evidence_id):
|
|
980
|
-
return None
|
|
981
|
-
if (manifest.get("protocol_version") != "1.0" or
|
|
982
|
-
manifest.get("sensitivity") != "project-controlled" or
|
|
983
|
-
manifest.get("environment_claim") != "mocked-code-level-only" or
|
|
984
|
-
manifest.get("compatibility") != "legacy-test-report-interface-not-supported" or
|
|
985
|
-
manifest.get("final_verdict") not in {"TEST_PASS", "TEST_FAIL", "TEST_BLOCKED"}):
|
|
986
|
-
return None
|
|
987
|
-
stages = manifest.get("stages")
|
|
988
|
-
stage_names = [stage.get("name") for stage in stages] if isinstance(stages, list) else []
|
|
989
|
-
if stage_names != [
|
|
990
|
-
"CHANGE_CLASSIFY", "SCOPE_DISCOVER", "CONTRACT_MODEL", "TEST_PLAN",
|
|
991
|
-
"INFRA_READY", "TEST_BUILD", "EXECUTE_PROVE", "EVIDENCE_PACKAGE",
|
|
992
|
-
"EVIDENCE_VALIDATE"]:
|
|
993
|
-
return None
|
|
994
|
-
for stage in stages:
|
|
995
|
-
if (not isinstance(stage, dict) or stage.get("status") not in {"complete", "not_applicable"} or
|
|
996
|
-
not isinstance(stage.get("outputs"), list)):
|
|
997
|
-
return None
|
|
998
|
-
if (not _is_sha256(manifest.get("working_diff_sha256")) or
|
|
999
|
-
manifest.get("working_diff_sha256") != identity.get("working_diff_sha256")):
|
|
1000
|
-
return None
|
|
1001
|
-
|
|
1002
|
-
final_verdict = manifest["final_verdict"]
|
|
1003
|
-
if (verdict.get("verdict") != final_verdict or
|
|
1004
|
-
verdict.get("testing_domain_only") is not True or
|
|
1005
|
-
verdict.get("authorizes_commit_or_release") is not False or
|
|
1006
|
-
verdict.get("real_environment_validated") is not False or
|
|
1007
|
-
verdict.get("repairs_business_defects") is not False or
|
|
1008
|
-
not isinstance(verdict.get("blockers"), list) or
|
|
1009
|
-
not isinstance(verdict.get("reproduced_failures"), list)):
|
|
1010
|
-
return None
|
|
1011
|
-
if final_verdict == "TEST_PASS" and (
|
|
1012
|
-
verdict.get("code_evidence_replayable") is not True or
|
|
1013
|
-
verdict.get("blockers") or verdict.get("reproduced_failures")):
|
|
1014
|
-
return None
|
|
1015
|
-
if final_verdict == "TEST_FAIL" and not verdict.get("reproduced_failures"):
|
|
1016
|
-
return None
|
|
1017
|
-
if final_verdict == "TEST_BLOCKED" and not verdict.get("blockers"):
|
|
1018
|
-
return None
|
|
1019
|
-
|
|
1020
|
-
payload = sorted(
|
|
1021
|
-
({"path": path, "sha256": entry["sha256"]}
|
|
1022
|
-
for path, entry in entry_map.items()
|
|
1023
|
-
if path not in EVIDENCE_FINAL_RECORDS),
|
|
1024
|
-
key=lambda item: item["path"],
|
|
1025
|
-
)
|
|
1026
|
-
if (validation.get("validator") != "validate_test_evidence.py" or
|
|
1027
|
-
not _is_sha256(validation.get("validator_sha256")) or
|
|
1028
|
-
validation.get("payload_sha256") != _canonical_sha256(payload) or
|
|
1029
|
-
validation.get("result") != "passed" or
|
|
1030
|
-
validation.get("verdict") != final_verdict or
|
|
1031
|
-
validation.get("semantics") != "integrity-and-protocol-validation-not-hostile-producer-proof" or
|
|
1032
|
-
not isinstance(validation.get("replay_receipt_ids"), list)):
|
|
1033
|
-
return None
|
|
1034
|
-
if not _evidence_validator_passes(project_root, evidence_root, validation):
|
|
1035
|
-
return None
|
|
1036
|
-
|
|
1037
|
-
report_scope = _normalize_artifact_dir(_extract_report_scope(_read_text(report_path)))
|
|
1038
|
-
expected_scope = _normalize_artifact_dir(artifact_dir)
|
|
1039
|
-
if report_scope != expected_scope:
|
|
1040
|
-
return None
|
|
1041
|
-
if not _validate_target_hashes(evidence_root, project_root, manifest, entry_map):
|
|
1042
|
-
return None
|
|
1043
|
-
scope = _load_strict_json(os.path.join(evidence_root, "scope.json"))
|
|
1044
|
-
if not isinstance(scope, dict) or identity.get("scope_sha256") != _canonical_sha256(scope):
|
|
1045
|
-
return None
|
|
1046
|
-
|
|
1047
|
-
state_path = os.path.join(
|
|
1048
|
-
project_root, ".prizmkit", "state", "workflows",
|
|
1049
|
-
os.path.basename(artifact_dir) + ".json",
|
|
1050
|
-
)
|
|
1051
|
-
state = _load_strict_json(state_path) if os.path.isfile(state_path) else {}
|
|
1052
|
-
if state and (not isinstance(state, dict) or state.get("stage_result") not in {None, final_verdict, "TEST_PASS", "TEST_FAIL", "TEST_BLOCKED"}):
|
|
1053
|
-
return None
|
|
1054
|
-
return {
|
|
1055
|
-
"evidence_id": evidence_id,
|
|
1056
|
-
"evidence_dir": os.path.relpath(evidence_root, project_root),
|
|
1057
|
-
"manifest_path": os.path.relpath(os.path.join(evidence_root, "manifest.json"), project_root),
|
|
1058
|
-
"verdict_path": os.path.relpath(os.path.join(evidence_root, "verdict.json"), project_root),
|
|
1059
|
-
"validation_path": os.path.relpath(os.path.join(evidence_root, "validation.json"), project_root),
|
|
1060
|
-
"verdict": final_verdict,
|
|
1061
|
-
"repair_scope": state.get("repair_scope") if isinstance(state, dict) else None,
|
|
1062
|
-
"repair_round": state.get("repair_round", 0) if isinstance(state, dict) else 0,
|
|
1063
|
-
"target_hashes": manifest.get("target_hashes"),
|
|
1064
|
-
}
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
714
|
def route_feature_repair(stage_result, repair_scope=None, repair_round=0):
|
|
1068
715
|
"""Map Feature review/test outcomes to the shared safe route contract."""
|
|
1069
716
|
return route_pipeline_repair(stage_result, repair_scope, repair_round)
|
|
1070
717
|
|
|
1071
718
|
|
|
1072
|
-
def feature_checkpoint_metadata(artifact_dir,
|
|
719
|
+
def feature_checkpoint_metadata(artifact_dir, stage="plan",
|
|
1073
720
|
status="PLAN_READY", stage_result=None,
|
|
1074
721
|
repair_scope=None, repair_round=0,
|
|
1075
722
|
next_stage="prizmkit-implement",
|
|
1076
723
|
resume_from="prizmkit-implement"):
|
|
1077
|
-
"""Build Feature
|
|
724
|
+
"""Build Feature caller handoff metadata through the shared semantic contract."""
|
|
1078
725
|
return pipeline_checkpoint_metadata(
|
|
1079
|
-
artifact_dir,
|
|
726
|
+
artifact_dir, stage=stage, status=status,
|
|
1080
727
|
stage_result=stage_result, repair_scope=repair_scope,
|
|
1081
728
|
repair_round=repair_round, next_stage=next_stage,
|
|
1082
729
|
resume_from=resume_from,
|
|
1083
730
|
)
|
|
1084
731
|
|
|
1085
732
|
|
|
1086
|
-
def _normalize_artifact_dir(path):
|
|
1087
|
-
"""Normalize report artifact_dir values for comparison."""
|
|
1088
|
-
return path.strip().replace("\\", "/").rstrip("/")
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
def _report_path_matches_artifacts(project_root, report_path, artifacts):
|
|
1092
|
-
"""Return whether report_path is an in-project test report artifact."""
|
|
1093
|
-
test_root = os.path.realpath(
|
|
1094
|
-
os.path.join(project_root, ".prizmkit", "test")
|
|
1095
|
-
)
|
|
1096
|
-
resolved_report = os.path.realpath(report_path)
|
|
1097
|
-
|
|
1098
|
-
try:
|
|
1099
|
-
if os.path.commonpath([test_root, resolved_report]) != test_root:
|
|
1100
|
-
return False
|
|
1101
|
-
except ValueError:
|
|
1102
|
-
return False
|
|
1103
|
-
|
|
1104
|
-
report_artifacts = [a for a in artifacts if a.endswith("test-report.md")]
|
|
1105
|
-
for artifact in report_artifacts:
|
|
1106
|
-
artifact_path = os.path.join(project_root, artifact)
|
|
1107
|
-
if glob.has_magic(artifact_path):
|
|
1108
|
-
candidates = glob.glob(artifact_path)
|
|
1109
|
-
else:
|
|
1110
|
-
candidates = [artifact_path]
|
|
1111
|
-
for candidate in candidates:
|
|
1112
|
-
if os.path.realpath(candidate) == resolved_report:
|
|
1113
|
-
return True
|
|
1114
|
-
return False
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
def _extract_scope_field(report_text, field_name):
|
|
1118
|
-
"""Extract a '- Field: value' entry from the report Scope section."""
|
|
1119
|
-
in_scope = False
|
|
1120
|
-
prefix = "- {}:".format(field_name)
|
|
1121
|
-
for raw_line in report_text.splitlines():
|
|
1122
|
-
line = raw_line.strip()
|
|
1123
|
-
if line == "## Scope":
|
|
1124
|
-
in_scope = True
|
|
1125
|
-
continue
|
|
1126
|
-
if in_scope and line.startswith("## "):
|
|
1127
|
-
break
|
|
1128
|
-
if in_scope and line.startswith(prefix):
|
|
1129
|
-
return line[len(prefix):].strip()
|
|
1130
|
-
return ""
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
def _extract_report_verdict(report_text):
|
|
1134
|
-
"""Extract the first non-empty line after the report Verdict heading."""
|
|
1135
|
-
in_verdict = False
|
|
1136
|
-
for raw_line in report_text.splitlines():
|
|
1137
|
-
line = raw_line.strip()
|
|
1138
|
-
if line == "## Verdict":
|
|
1139
|
-
in_verdict = True
|
|
1140
|
-
continue
|
|
1141
|
-
if in_verdict:
|
|
1142
|
-
if line.startswith("## "):
|
|
1143
|
-
break
|
|
1144
|
-
if line:
|
|
1145
|
-
return line
|
|
1146
|
-
return ""
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
def _extract_report_section(report_text, heading):
|
|
1150
|
-
"""Extract text under a level-2 markdown heading."""
|
|
1151
|
-
in_section = False
|
|
1152
|
-
lines = []
|
|
1153
|
-
for raw_line in report_text.splitlines():
|
|
1154
|
-
line = raw_line.strip()
|
|
1155
|
-
if line == heading:
|
|
1156
|
-
in_section = True
|
|
1157
|
-
continue
|
|
1158
|
-
if in_section and line.startswith("## "):
|
|
1159
|
-
break
|
|
1160
|
-
if in_section:
|
|
1161
|
-
lines.append(raw_line)
|
|
1162
|
-
return "\n".join(lines)
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
def _discover_openapi_file(project_root):
|
|
1166
|
-
"""Return the first common OpenAPI/Swagger file under project_root."""
|
|
1167
|
-
candidates = [
|
|
1168
|
-
"openapi.yaml", "openapi.yml", "swagger.yaml", "swagger.yml",
|
|
1169
|
-
"docs/openapi.yaml", "docs/openapi.yml",
|
|
1170
|
-
"docs/swagger.yaml", "docs/swagger.yml",
|
|
1171
|
-
"api/openapi.yaml", "api/openapi.yml",
|
|
1172
|
-
"api/swagger.yaml", "api/swagger.yml",
|
|
1173
|
-
]
|
|
1174
|
-
for candidate in candidates:
|
|
1175
|
-
full_path = os.path.join(project_root, candidate)
|
|
1176
|
-
if os.path.isfile(full_path):
|
|
1177
|
-
return full_path
|
|
1178
|
-
return ""
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
def _normalize_api_path(path):
|
|
1182
|
-
"""Normalize API path tokens for exact comparison."""
|
|
1183
|
-
normalized = path.strip().strip("'\"`")
|
|
1184
|
-
if len(normalized) > 1:
|
|
1185
|
-
normalized = normalized.rstrip("/")
|
|
1186
|
-
return normalized
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
def _extract_openapi_operations(openapi_text):
|
|
1190
|
-
"""Extract OpenAPI method+path operations without requiring PyYAML."""
|
|
1191
|
-
methods = {"GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS", "TRACE"}
|
|
1192
|
-
operations = []
|
|
1193
|
-
in_paths = False
|
|
1194
|
-
paths_indent = 0
|
|
1195
|
-
current_path = ""
|
|
1196
|
-
current_path_indent = 0
|
|
1197
|
-
path_key_re = re.compile(
|
|
1198
|
-
r"^\s*(?:(['\"])(/.*?)\1|(/[^:\s]+))\s*:\s*(?:#.*)?$"
|
|
1199
|
-
)
|
|
1200
|
-
method_re = re.compile(r"^\s*([A-Za-z]+)\s*:\s*(?:#.*)?$")
|
|
1201
|
-
|
|
1202
|
-
for raw_line in openapi_text.splitlines():
|
|
1203
|
-
stripped = raw_line.strip()
|
|
1204
|
-
if not stripped or stripped.startswith("#"):
|
|
1205
|
-
continue
|
|
1206
|
-
|
|
1207
|
-
indent = len(raw_line) - len(raw_line.lstrip())
|
|
1208
|
-
if not in_paths:
|
|
1209
|
-
if re.match(r"^\s*paths\s*:\s*(?:#.*)?$", raw_line):
|
|
1210
|
-
in_paths = True
|
|
1211
|
-
paths_indent = indent
|
|
1212
|
-
continue
|
|
1213
|
-
|
|
1214
|
-
if indent <= paths_indent:
|
|
1215
|
-
break
|
|
1216
|
-
|
|
1217
|
-
path_match = path_key_re.match(raw_line)
|
|
1218
|
-
if path_match:
|
|
1219
|
-
current_path = path_match.group(2) or path_match.group(3) or ""
|
|
1220
|
-
current_path_indent = indent
|
|
1221
|
-
continue
|
|
1222
|
-
|
|
1223
|
-
method_match = method_re.match(raw_line)
|
|
1224
|
-
if current_path and method_match and indent > current_path_indent:
|
|
1225
|
-
method = method_match.group(1).upper()
|
|
1226
|
-
if method in methods:
|
|
1227
|
-
operations.append((method, _normalize_api_path(current_path)))
|
|
1228
|
-
|
|
1229
|
-
return operations
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
def _extract_matrix_operations(rows):
|
|
1233
|
-
"""Extract exact method+path tokens from Boundary Matrix rows."""
|
|
1234
|
-
operations = set()
|
|
1235
|
-
path_only = set()
|
|
1236
|
-
token_re = re.compile(
|
|
1237
|
-
r"(?:(\b(?:GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS|TRACE)\b)\s+)?(/[^\s,`|)]+)",
|
|
1238
|
-
flags=re.IGNORECASE,
|
|
1239
|
-
)
|
|
1240
|
-
for row in rows:
|
|
1241
|
-
interface = row.get("Interface", "")
|
|
1242
|
-
for match in token_re.finditer(interface):
|
|
1243
|
-
method = match.group(1).upper() if match.group(1) else ""
|
|
1244
|
-
path = _normalize_api_path(match.group(2))
|
|
1245
|
-
if not path:
|
|
1246
|
-
continue
|
|
1247
|
-
if method:
|
|
1248
|
-
operations.add((method, path))
|
|
1249
|
-
else:
|
|
1250
|
-
path_only.add(path)
|
|
1251
|
-
return operations, path_only
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
def _boundary_report_sections_valid(project_root, report_text):
|
|
1255
|
-
"""Return whether required boundary sections do not report failure."""
|
|
1256
|
-
required_columns = [
|
|
1257
|
-
"Module",
|
|
1258
|
-
"Interface",
|
|
1259
|
-
"Service Type",
|
|
1260
|
-
"Happy Path",
|
|
1261
|
-
"Request Validation",
|
|
1262
|
-
"Auth / Permission / Ownership",
|
|
1263
|
-
"Domain Invariants",
|
|
1264
|
-
"Collection / Pagination",
|
|
1265
|
-
"Date / Time",
|
|
1266
|
-
"State Transitions",
|
|
1267
|
-
"Dependency Failure",
|
|
1268
|
-
"Response Contract",
|
|
1269
|
-
"N/A Reasons",
|
|
1270
|
-
"Final Status",
|
|
1271
|
-
]
|
|
1272
|
-
category_columns = [
|
|
1273
|
-
"Happy Path",
|
|
1274
|
-
"Request Validation",
|
|
1275
|
-
"Auth / Permission / Ownership",
|
|
1276
|
-
"Domain Invariants",
|
|
1277
|
-
"Collection / Pagination",
|
|
1278
|
-
"Date / Time",
|
|
1279
|
-
"State Transitions",
|
|
1280
|
-
"Dependency Failure",
|
|
1281
|
-
"Response Contract",
|
|
1282
|
-
]
|
|
1283
|
-
allowed_final_statuses = {
|
|
1284
|
-
"boundary-covered",
|
|
1285
|
-
"generated-needs-review",
|
|
1286
|
-
"skipped-with-reason",
|
|
1287
|
-
}
|
|
1288
|
-
incomplete_markers = {
|
|
1289
|
-
"boundary-missing",
|
|
1290
|
-
"happy-path-only",
|
|
1291
|
-
"unresolved",
|
|
1292
|
-
}
|
|
1293
|
-
|
|
1294
|
-
matrix_section = _extract_report_section(report_text, "## Boundary Matrix")
|
|
1295
|
-
if not matrix_section:
|
|
1296
|
-
return False
|
|
1297
|
-
matrix_lines = [line.strip() for line in matrix_section.splitlines()
|
|
1298
|
-
if line.strip().startswith("|")]
|
|
1299
|
-
headers = []
|
|
1300
|
-
rows = []
|
|
1301
|
-
for line in matrix_lines:
|
|
1302
|
-
cells = [cell.strip() for cell in line.strip().strip("|").split("|")]
|
|
1303
|
-
if not headers:
|
|
1304
|
-
if "Interface" in cells and "Final Status" in cells:
|
|
1305
|
-
headers = cells
|
|
1306
|
-
continue
|
|
1307
|
-
if all(re.fullmatch(r":?-{3,}:?", cell) for cell in cells):
|
|
1308
|
-
continue
|
|
1309
|
-
if len(cells) < len(headers):
|
|
1310
|
-
cells.extend([""] * (len(headers) - len(cells)))
|
|
1311
|
-
row = {header: cells[i] if i < len(cells) else ""
|
|
1312
|
-
for i, header in enumerate(headers)}
|
|
1313
|
-
if row.get("Interface", "").strip(".` ") in {"", "..."}:
|
|
1314
|
-
continue
|
|
1315
|
-
rows.append(row)
|
|
1316
|
-
|
|
1317
|
-
if not headers or not rows:
|
|
1318
|
-
return False
|
|
1319
|
-
if any(column not in headers for column in required_columns):
|
|
1320
|
-
return False
|
|
1321
|
-
|
|
1322
|
-
for row in rows:
|
|
1323
|
-
final_status = row.get("Final Status", "").strip().lower()
|
|
1324
|
-
if final_status not in allowed_final_statuses:
|
|
1325
|
-
return False
|
|
1326
|
-
for column in category_columns:
|
|
1327
|
-
if not row.get(column, "").strip():
|
|
1328
|
-
return False
|
|
1329
|
-
for value in row.values():
|
|
1330
|
-
normalized = value.strip().lower()
|
|
1331
|
-
if any(marker in normalized for marker in incomplete_markers):
|
|
1332
|
-
return False
|
|
1333
|
-
|
|
1334
|
-
openapi_file = _discover_openapi_file(project_root)
|
|
1335
|
-
if openapi_file:
|
|
1336
|
-
operations = _extract_openapi_operations(_read_text(openapi_file))
|
|
1337
|
-
matrix_operations, _matrix_path_only = _extract_matrix_operations(rows)
|
|
1338
|
-
for method, path in operations:
|
|
1339
|
-
if (method, path) not in matrix_operations:
|
|
1340
|
-
return False
|
|
1341
|
-
|
|
1342
|
-
gate_section = _extract_report_section(report_text,
|
|
1343
|
-
"## Boundary Completion Gate")
|
|
1344
|
-
if not gate_section:
|
|
1345
|
-
return False
|
|
1346
|
-
completion_gate_passed = False
|
|
1347
|
-
boundary_missing_seen = False
|
|
1348
|
-
happy_path_only_seen = False
|
|
1349
|
-
for raw_line in gate_section.splitlines():
|
|
1350
|
-
line = raw_line.strip()
|
|
1351
|
-
match = re.match(r"-?\s*(Boundary-missing|Happy-path-only):\s*(\d+)\s*$",
|
|
1352
|
-
line, re.IGNORECASE)
|
|
1353
|
-
if match:
|
|
1354
|
-
if match.group(1).lower() == "boundary-missing":
|
|
1355
|
-
boundary_missing_seen = True
|
|
1356
|
-
if match.group(1).lower() == "happy-path-only":
|
|
1357
|
-
happy_path_only_seen = True
|
|
1358
|
-
if int(match.group(2)) > 0:
|
|
1359
|
-
return False
|
|
1360
|
-
if re.match(r"-?\s*Completion gate passed:\s*yes\s*$",
|
|
1361
|
-
line, re.IGNORECASE):
|
|
1362
|
-
completion_gate_passed = True
|
|
1363
|
-
if re.match(r"-?\s*Completion gate passed:\s*no\b",
|
|
1364
|
-
line, re.IGNORECASE):
|
|
1365
|
-
return False
|
|
1366
|
-
if not (completion_gate_passed and boundary_missing_seen and
|
|
1367
|
-
happy_path_only_seen):
|
|
1368
|
-
return False
|
|
1369
|
-
|
|
1370
|
-
validation_section = _extract_report_section(report_text,
|
|
1371
|
-
"## Boundary Validation")
|
|
1372
|
-
if not validation_section:
|
|
1373
|
-
return False
|
|
1374
|
-
validator_result = ""
|
|
1375
|
-
for raw_line in validation_section.splitlines():
|
|
1376
|
-
line = raw_line.strip()
|
|
1377
|
-
match = re.match(r"-?\s*Validator result:\s*(\S+)",
|
|
1378
|
-
line, re.IGNORECASE)
|
|
1379
|
-
if match:
|
|
1380
|
-
validator_result = match.group(1).strip().lower()
|
|
1381
|
-
break
|
|
1382
|
-
|
|
1383
|
-
return validator_result == "passed"
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
def _extract_scope_list(report_text, list_name):
|
|
1387
|
-
"""Extract a nested file list from the report Scope section."""
|
|
1388
|
-
in_scope = False
|
|
1389
|
-
in_list = False
|
|
1390
|
-
list_marker = "- {}:".format(list_name)
|
|
1391
|
-
values = []
|
|
1392
|
-
for raw_line in report_text.splitlines():
|
|
1393
|
-
line = raw_line.strip()
|
|
1394
|
-
if line == "## Scope":
|
|
1395
|
-
in_scope = True
|
|
1396
|
-
continue
|
|
1397
|
-
if in_scope and line.startswith("## "):
|
|
1398
|
-
break
|
|
1399
|
-
if not in_scope:
|
|
1400
|
-
continue
|
|
1401
|
-
if line == list_marker:
|
|
1402
|
-
in_list = True
|
|
1403
|
-
continue
|
|
1404
|
-
if in_list:
|
|
1405
|
-
if raw_line.startswith(" -") or raw_line.startswith("\t-"):
|
|
1406
|
-
value = line[2:].strip().strip("`")
|
|
1407
|
-
if value and value not in {"{...}", "..."}:
|
|
1408
|
-
values.append(value)
|
|
1409
|
-
continue
|
|
1410
|
-
if line.startswith("- "):
|
|
1411
|
-
break
|
|
1412
|
-
return values
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
def _scoped_report_fresh(project_root, pointer_artifact, report_path, report_text):
|
|
1416
|
-
"""Return whether the report is newer than the gate marker and in-scope files."""
|
|
1417
|
-
marker_path = os.path.join(
|
|
1418
|
-
project_root,
|
|
1419
|
-
os.path.dirname(pointer_artifact),
|
|
1420
|
-
".prizmkit-test-started",
|
|
1421
|
-
)
|
|
1422
|
-
if not os.path.exists(marker_path):
|
|
1423
|
-
return False
|
|
1424
|
-
|
|
1425
|
-
try:
|
|
1426
|
-
report_mtime = os.path.getmtime(report_path)
|
|
1427
|
-
marker_mtime = os.path.getmtime(marker_path)
|
|
1428
|
-
except OSError:
|
|
1429
|
-
return False
|
|
1430
|
-
|
|
1431
|
-
if report_mtime < marker_mtime:
|
|
1432
|
-
return False
|
|
1433
|
-
|
|
1434
|
-
project_root_real = os.path.realpath(project_root)
|
|
1435
|
-
source_files = _extract_scope_list(report_text, "In-Scope Source Files")
|
|
1436
|
-
test_files = _extract_scope_list(report_text, "In-Scope Test Files")
|
|
1437
|
-
if not source_files or not test_files:
|
|
1438
|
-
return False
|
|
1439
|
-
|
|
1440
|
-
ignored = {"none", "n/a", "not applicable", "(none)", "{...}", "..."}
|
|
1441
|
-
for listed_file in source_files + test_files:
|
|
1442
|
-
normalized = listed_file.strip().strip("`")
|
|
1443
|
-
if not normalized or normalized.lower() in ignored:
|
|
1444
|
-
return False
|
|
1445
|
-
candidate = (normalized if os.path.isabs(normalized)
|
|
1446
|
-
else os.path.join(project_root, normalized))
|
|
1447
|
-
candidate_real = os.path.realpath(candidate)
|
|
1448
|
-
try:
|
|
1449
|
-
if os.path.commonpath([project_root_real, candidate_real]) != project_root_real:
|
|
1450
|
-
return False
|
|
1451
|
-
except ValueError:
|
|
1452
|
-
return False
|
|
1453
|
-
if not os.path.isfile(candidate_real):
|
|
1454
|
-
return False
|
|
1455
|
-
if os.path.getmtime(candidate_real) > report_mtime:
|
|
1456
|
-
return False
|
|
1457
|
-
|
|
1458
|
-
return True
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
def _validate_legacy_scoped_test_report(project_root, artifacts):
|
|
1462
|
-
"""Validate the legacy Markdown report for old callers only.
|
|
1463
|
-
|
|
1464
|
-
Feature checkpoints must use ``_validate_scoped_test_report`` below; this
|
|
1465
|
-
retained helper is intentionally not used by Feature prompt generation.
|
|
1466
|
-
"""
|
|
1467
|
-
pointer_artifacts = [a for a in artifacts
|
|
1468
|
-
if a.endswith("/test-report-path.txt")]
|
|
1469
|
-
if not pointer_artifacts:
|
|
1470
|
-
return False
|
|
1471
|
-
|
|
1472
|
-
pointer_artifact = pointer_artifacts[0]
|
|
1473
|
-
pointer_path = os.path.join(project_root, pointer_artifact)
|
|
1474
|
-
pointed_report = _read_text(pointer_path).strip().splitlines()
|
|
1475
|
-
if not pointed_report:
|
|
1476
|
-
return False
|
|
1477
|
-
|
|
1478
|
-
report_path = pointed_report[0].strip()
|
|
1479
|
-
if not os.path.isabs(report_path):
|
|
1480
|
-
report_path = os.path.join(project_root, report_path)
|
|
1481
|
-
if not os.path.exists(report_path):
|
|
1482
|
-
return False
|
|
1483
|
-
if not _report_path_matches_artifacts(project_root, report_path, artifacts):
|
|
1484
|
-
return False
|
|
1485
|
-
|
|
1486
|
-
report_text = _read_text(report_path)
|
|
1487
|
-
mode = _extract_scope_field(report_text, "Mode")
|
|
1488
|
-
artifact_dir = _extract_scope_field(report_text, "Artifact Dir")
|
|
1489
|
-
verdict = _extract_report_verdict(report_text)
|
|
1490
|
-
|
|
1491
|
-
expected_artifact_dir = _normalize_artifact_dir(
|
|
1492
|
-
os.path.dirname(pointer_artifact)
|
|
1493
|
-
)
|
|
1494
|
-
actual_artifact_dir = _normalize_artifact_dir(artifact_dir)
|
|
1495
|
-
|
|
1496
|
-
return (mode == "this-change" and
|
|
1497
|
-
actual_artifact_dir == expected_artifact_dir and
|
|
1498
|
-
verdict == "PASS" and
|
|
1499
|
-
_boundary_report_sections_valid(project_root, report_text) and
|
|
1500
|
-
_scoped_report_fresh(project_root, pointer_artifact,
|
|
1501
|
-
report_path, report_text))
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
def _validate_scoped_test_report(project_root, artifacts):
|
|
1505
|
-
"""Validate current Feature structured evidence, never Markdown authority."""
|
|
1506
|
-
pointer_artifacts = [a for a in artifacts if a.endswith("/test-report-path.txt")]
|
|
1507
|
-
if not pointer_artifacts:
|
|
1508
|
-
return False
|
|
1509
|
-
return validate_feature_test_evidence(
|
|
1510
|
-
project_root, os.path.dirname(pointer_artifacts[0]),
|
|
1511
|
-
) is not None
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
733
|
def _completed_step_artifacts_valid(project_root, skill_key, artifacts):
|
|
1515
734
|
"""Return whether a completed checkpoint step can be preserved."""
|
|
1516
|
-
if not all(_artifact_exists(project_root, a) for a in artifacts):
|
|
1517
|
-
return False
|
|
1518
735
|
if skill_key == "prizmkit-test":
|
|
1519
|
-
return
|
|
1520
|
-
return
|
|
736
|
+
return completed_test_artifacts_valid(project_root, artifacts)
|
|
737
|
+
return all(artifact_exists(project_root, artifact) for artifact in artifacts)
|
|
1521
738
|
|
|
1522
739
|
|
|
1523
740
|
def generate_checkpoint_definition(sections, pipeline_mode, workflow_type,
|
|
@@ -2257,7 +1474,7 @@ def main():
|
|
|
2257
1474
|
checkpoint_dir, "workflow-checkpoint.json",
|
|
2258
1475
|
)
|
|
2259
1476
|
|
|
2260
|
-
#
|
|
1477
|
+
# Pipeline metadata remains separate from the skill workflow-state contract.
|
|
2261
1478
|
checkpoint["feature_state"] = feature_checkpoint_metadata(
|
|
2262
1479
|
os.path.join(".prizmkit", "specs", feature_slug),
|
|
2263
1480
|
stage="plan",
|