prizmkit 1.1.86 → 1.1.88
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/scripts/generate-bootstrap-prompt.py +462 -4
- package/bundled/dev-pipeline/scripts/prizmkit-test-gate.py +446 -0
- package/bundled/dev-pipeline/templates/bootstrap-prompt.md +1 -1
- package/bundled/dev-pipeline/templates/bootstrap-tier1.md +14 -0
- package/bundled/dev-pipeline/templates/bootstrap-tier2.md +18 -3
- package/bundled/dev-pipeline/templates/bootstrap-tier3.md +18 -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-prizmkit-test.md +39 -0
- package/bundled/dev-pipeline/templates/sections/phase-review-agent.md +5 -1
- package/bundled/dev-pipeline/templates/sections/phase-review-full.md +5 -1
- package/bundled/dev-pipeline/tests/conftest.py +1 -0
- package/bundled/dev-pipeline/tests/test_generate_bootstrap_prompt.py +434 -0
- package/bundled/dev-pipeline/tests/test_prizmkit_test_gate.py +107 -0
- package/bundled/dev-pipeline-windows/scripts/generate-bootstrap-prompt.py +462 -4
- package/bundled/dev-pipeline-windows/scripts/prizmkit-test-gate.py +446 -0
- package/bundled/dev-pipeline-windows/templates/bootstrap-prompt.md +1 -1
- package/bundled/dev-pipeline-windows/templates/bootstrap-tier1.md +14 -0
- package/bundled/dev-pipeline-windows/templates/bootstrap-tier2.md +18 -3
- package/bundled/dev-pipeline-windows/templates/bootstrap-tier3.md +18 -3
- package/bundled/dev-pipeline-windows/templates/sections/phase-commit-full.md +2 -2
- package/bundled/dev-pipeline-windows/templates/sections/phase-commit.md +2 -2
- package/bundled/dev-pipeline-windows/templates/sections/phase-prizmkit-test.md +39 -0
- package/bundled/dev-pipeline-windows/templates/sections/phase-review-agent.md +5 -1
- package/bundled/dev-pipeline-windows/templates/sections/phase-review-full.md +5 -1
- package/bundled/skills/_metadata.json +1 -1
- package/bundled/skills/prizmkit-code-review/SKILL.md +7 -2
- package/bundled/skills/prizmkit-code-review/references/reviewer-agent-prompt.md +6 -2
- package/bundled/skills/prizmkit-test/SKILL.md +106 -14
- package/bundled/skills/prizmkit-test/references/boundary-coverage-protocol.md +220 -0
- package/bundled/skills/prizmkit-test/references/examples.md +82 -9
- package/bundled/skills/prizmkit-test/references/test-generation-steps.md +68 -12
- package/bundled/skills/prizmkit-test/references/test-report-template.md +73 -9
- package/bundled/skills/prizmkit-test/scripts/validate_boundary_report.py +347 -0
- package/bundled/skills-windows/prizmkit-code-review/SKILL.md +7 -2
- package/bundled/skills-windows/prizmkit-code-review/references/reviewer-agent-prompt.md +6 -2
- package/bundled/skills-windows/prizmkit-test/SKILL.md +106 -14
- package/bundled/skills-windows/prizmkit-test/references/boundary-coverage-protocol.md +220 -0
- package/bundled/skills-windows/prizmkit-test/references/examples.md +82 -9
- package/bundled/skills-windows/prizmkit-test/references/service-boundary-test-catalog.md +4 -4
- package/bundled/skills-windows/prizmkit-test/references/test-generation-steps.md +68 -12
- package/bundled/skills-windows/prizmkit-test/references/test-report-template.md +73 -9
- package/bundled/skills-windows/prizmkit-test/scripts/validate_boundary_report.py +347 -0
- package/package.json +1 -1
package/bundled/VERSION.json
CHANGED
|
@@ -21,6 +21,7 @@ Usage:
|
|
|
21
21
|
"""
|
|
22
22
|
|
|
23
23
|
import argparse
|
|
24
|
+
import glob
|
|
24
25
|
import json
|
|
25
26
|
import os
|
|
26
27
|
import re
|
|
@@ -623,6 +624,11 @@ SECTION_TO_SKILL = {
|
|
|
623
624
|
[".prizmkit/specs/{slug}/plan.md"]),
|
|
624
625
|
"phase-critic-plan": ("critic-plan-review", "Critic: Plan Review", []),
|
|
625
626
|
"phase-implement": ("prizmkit-implement", "Implement + Test", []),
|
|
627
|
+
"phase-prizmkit-test": (
|
|
628
|
+
"prizmkit-test", "Scoped Feature Test Gate",
|
|
629
|
+
[".prizmkit/specs/{slug}/test-report-path.txt",
|
|
630
|
+
".prizmkit/test/*/test-report.md"],
|
|
631
|
+
),
|
|
626
632
|
"phase-review": ("prizmkit-code-review", "Code Review", []),
|
|
627
633
|
"phase-browser": ("browser-verification", "Browser Verification", []),
|
|
628
634
|
"phase-commit": None, # special: split into retrospective + committer
|
|
@@ -640,6 +646,446 @@ def _resolve_artifacts(artifact_templates, slug):
|
|
|
640
646
|
return [a.replace("{slug}", slug) for a in artifact_templates]
|
|
641
647
|
|
|
642
648
|
|
|
649
|
+
def _artifact_exists(project_root, artifact):
|
|
650
|
+
"""Return whether an artifact path or glob exists under project_root."""
|
|
651
|
+
full_path = os.path.join(project_root, artifact)
|
|
652
|
+
if glob.has_magic(full_path):
|
|
653
|
+
return bool(glob.glob(full_path))
|
|
654
|
+
return os.path.exists(full_path)
|
|
655
|
+
|
|
656
|
+
|
|
657
|
+
def _read_text(path):
|
|
658
|
+
"""Read a UTF-8 text file, returning an empty string on IO errors."""
|
|
659
|
+
try:
|
|
660
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
661
|
+
return f.read()
|
|
662
|
+
except IOError:
|
|
663
|
+
return ""
|
|
664
|
+
|
|
665
|
+
|
|
666
|
+
def _normalize_artifact_dir(path):
|
|
667
|
+
"""Normalize report artifact_dir values for comparison."""
|
|
668
|
+
return path.strip().replace("\\", "/").rstrip("/")
|
|
669
|
+
|
|
670
|
+
|
|
671
|
+
def _report_path_matches_artifacts(project_root, report_path, artifacts):
|
|
672
|
+
"""Return whether report_path is an in-project test report artifact."""
|
|
673
|
+
test_root = os.path.realpath(
|
|
674
|
+
os.path.join(project_root, ".prizmkit", "test")
|
|
675
|
+
)
|
|
676
|
+
resolved_report = os.path.realpath(report_path)
|
|
677
|
+
|
|
678
|
+
try:
|
|
679
|
+
if os.path.commonpath([test_root, resolved_report]) != test_root:
|
|
680
|
+
return False
|
|
681
|
+
except ValueError:
|
|
682
|
+
return False
|
|
683
|
+
|
|
684
|
+
report_artifacts = [a for a in artifacts if a.endswith("test-report.md")]
|
|
685
|
+
for artifact in report_artifacts:
|
|
686
|
+
artifact_path = os.path.join(project_root, artifact)
|
|
687
|
+
if glob.has_magic(artifact_path):
|
|
688
|
+
candidates = glob.glob(artifact_path)
|
|
689
|
+
else:
|
|
690
|
+
candidates = [artifact_path]
|
|
691
|
+
for candidate in candidates:
|
|
692
|
+
if os.path.realpath(candidate) == resolved_report:
|
|
693
|
+
return True
|
|
694
|
+
return False
|
|
695
|
+
|
|
696
|
+
|
|
697
|
+
def _extract_scope_field(report_text, field_name):
|
|
698
|
+
"""Extract a '- Field: value' entry from the report Scope section."""
|
|
699
|
+
in_scope = False
|
|
700
|
+
prefix = "- {}:".format(field_name)
|
|
701
|
+
for raw_line in report_text.splitlines():
|
|
702
|
+
line = raw_line.strip()
|
|
703
|
+
if line == "## Scope":
|
|
704
|
+
in_scope = True
|
|
705
|
+
continue
|
|
706
|
+
if in_scope and line.startswith("## "):
|
|
707
|
+
break
|
|
708
|
+
if in_scope and line.startswith(prefix):
|
|
709
|
+
return line[len(prefix):].strip()
|
|
710
|
+
return ""
|
|
711
|
+
|
|
712
|
+
|
|
713
|
+
def _extract_report_verdict(report_text):
|
|
714
|
+
"""Extract the first non-empty line after the report Verdict heading."""
|
|
715
|
+
in_verdict = False
|
|
716
|
+
for raw_line in report_text.splitlines():
|
|
717
|
+
line = raw_line.strip()
|
|
718
|
+
if line == "## Verdict":
|
|
719
|
+
in_verdict = True
|
|
720
|
+
continue
|
|
721
|
+
if in_verdict:
|
|
722
|
+
if line.startswith("## "):
|
|
723
|
+
break
|
|
724
|
+
if line:
|
|
725
|
+
return line
|
|
726
|
+
return ""
|
|
727
|
+
|
|
728
|
+
|
|
729
|
+
def _extract_report_section(report_text, heading):
|
|
730
|
+
"""Extract text under a level-2 markdown heading."""
|
|
731
|
+
in_section = False
|
|
732
|
+
lines = []
|
|
733
|
+
for raw_line in report_text.splitlines():
|
|
734
|
+
line = raw_line.strip()
|
|
735
|
+
if line == heading:
|
|
736
|
+
in_section = True
|
|
737
|
+
continue
|
|
738
|
+
if in_section and line.startswith("## "):
|
|
739
|
+
break
|
|
740
|
+
if in_section:
|
|
741
|
+
lines.append(raw_line)
|
|
742
|
+
return "\n".join(lines)
|
|
743
|
+
|
|
744
|
+
|
|
745
|
+
def _discover_openapi_file(project_root):
|
|
746
|
+
"""Return the first common OpenAPI/Swagger file under project_root."""
|
|
747
|
+
candidates = [
|
|
748
|
+
"openapi.yaml", "openapi.yml", "swagger.yaml", "swagger.yml",
|
|
749
|
+
"docs/openapi.yaml", "docs/openapi.yml",
|
|
750
|
+
"docs/swagger.yaml", "docs/swagger.yml",
|
|
751
|
+
"api/openapi.yaml", "api/openapi.yml",
|
|
752
|
+
"api/swagger.yaml", "api/swagger.yml",
|
|
753
|
+
]
|
|
754
|
+
for candidate in candidates:
|
|
755
|
+
full_path = os.path.join(project_root, candidate)
|
|
756
|
+
if os.path.isfile(full_path):
|
|
757
|
+
return full_path
|
|
758
|
+
return ""
|
|
759
|
+
|
|
760
|
+
|
|
761
|
+
def _normalize_api_path(path):
|
|
762
|
+
"""Normalize API path tokens for exact comparison."""
|
|
763
|
+
normalized = path.strip().strip("'\"`")
|
|
764
|
+
if len(normalized) > 1:
|
|
765
|
+
normalized = normalized.rstrip("/")
|
|
766
|
+
return normalized
|
|
767
|
+
|
|
768
|
+
|
|
769
|
+
def _extract_openapi_operations(openapi_text):
|
|
770
|
+
"""Extract OpenAPI method+path operations without requiring PyYAML."""
|
|
771
|
+
methods = {"GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS", "TRACE"}
|
|
772
|
+
operations = []
|
|
773
|
+
in_paths = False
|
|
774
|
+
paths_indent = 0
|
|
775
|
+
current_path = ""
|
|
776
|
+
current_path_indent = 0
|
|
777
|
+
path_key_re = re.compile(
|
|
778
|
+
r"^\s*(?:(['\"])(/.*?)\1|(/[^:\s]+))\s*:\s*(?:#.*)?$"
|
|
779
|
+
)
|
|
780
|
+
method_re = re.compile(r"^\s*([A-Za-z]+)\s*:\s*(?:#.*)?$")
|
|
781
|
+
|
|
782
|
+
for raw_line in openapi_text.splitlines():
|
|
783
|
+
stripped = raw_line.strip()
|
|
784
|
+
if not stripped or stripped.startswith("#"):
|
|
785
|
+
continue
|
|
786
|
+
|
|
787
|
+
indent = len(raw_line) - len(raw_line.lstrip())
|
|
788
|
+
if not in_paths:
|
|
789
|
+
if re.match(r"^\s*paths\s*:\s*(?:#.*)?$", raw_line):
|
|
790
|
+
in_paths = True
|
|
791
|
+
paths_indent = indent
|
|
792
|
+
continue
|
|
793
|
+
|
|
794
|
+
if indent <= paths_indent:
|
|
795
|
+
break
|
|
796
|
+
|
|
797
|
+
path_match = path_key_re.match(raw_line)
|
|
798
|
+
if path_match:
|
|
799
|
+
current_path = path_match.group(2) or path_match.group(3) or ""
|
|
800
|
+
current_path_indent = indent
|
|
801
|
+
continue
|
|
802
|
+
|
|
803
|
+
method_match = method_re.match(raw_line)
|
|
804
|
+
if current_path and method_match and indent > current_path_indent:
|
|
805
|
+
method = method_match.group(1).upper()
|
|
806
|
+
if method in methods:
|
|
807
|
+
operations.append((method, _normalize_api_path(current_path)))
|
|
808
|
+
|
|
809
|
+
return operations
|
|
810
|
+
|
|
811
|
+
|
|
812
|
+
def _extract_matrix_operations(rows):
|
|
813
|
+
"""Extract exact method+path tokens from Boundary Matrix rows."""
|
|
814
|
+
operations = set()
|
|
815
|
+
path_only = set()
|
|
816
|
+
token_re = re.compile(
|
|
817
|
+
r"(?:(\b(?:GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS|TRACE)\b)\s+)?(/[^\s,`|)]+)",
|
|
818
|
+
flags=re.IGNORECASE,
|
|
819
|
+
)
|
|
820
|
+
for row in rows:
|
|
821
|
+
interface = row.get("Interface", "")
|
|
822
|
+
for match in token_re.finditer(interface):
|
|
823
|
+
method = match.group(1).upper() if match.group(1) else ""
|
|
824
|
+
path = _normalize_api_path(match.group(2))
|
|
825
|
+
if not path:
|
|
826
|
+
continue
|
|
827
|
+
if method:
|
|
828
|
+
operations.add((method, path))
|
|
829
|
+
else:
|
|
830
|
+
path_only.add(path)
|
|
831
|
+
return operations, path_only
|
|
832
|
+
|
|
833
|
+
|
|
834
|
+
def _boundary_report_sections_valid(project_root, report_text):
|
|
835
|
+
"""Return whether required boundary sections do not report failure."""
|
|
836
|
+
required_columns = [
|
|
837
|
+
"Module",
|
|
838
|
+
"Interface",
|
|
839
|
+
"Service Type",
|
|
840
|
+
"Happy Path",
|
|
841
|
+
"Request Validation",
|
|
842
|
+
"Auth / Permission / Ownership",
|
|
843
|
+
"Domain Invariants",
|
|
844
|
+
"Collection / Pagination",
|
|
845
|
+
"Date / Time",
|
|
846
|
+
"State Transitions",
|
|
847
|
+
"Dependency Failure",
|
|
848
|
+
"Response Contract",
|
|
849
|
+
"N/A Reasons",
|
|
850
|
+
"Final Status",
|
|
851
|
+
]
|
|
852
|
+
category_columns = [
|
|
853
|
+
"Happy Path",
|
|
854
|
+
"Request Validation",
|
|
855
|
+
"Auth / Permission / Ownership",
|
|
856
|
+
"Domain Invariants",
|
|
857
|
+
"Collection / Pagination",
|
|
858
|
+
"Date / Time",
|
|
859
|
+
"State Transitions",
|
|
860
|
+
"Dependency Failure",
|
|
861
|
+
"Response Contract",
|
|
862
|
+
]
|
|
863
|
+
allowed_final_statuses = {
|
|
864
|
+
"boundary-covered",
|
|
865
|
+
"generated-needs-review",
|
|
866
|
+
"skipped-with-reason",
|
|
867
|
+
}
|
|
868
|
+
incomplete_markers = {
|
|
869
|
+
"boundary-missing",
|
|
870
|
+
"happy-path-only",
|
|
871
|
+
"unresolved",
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
matrix_section = _extract_report_section(report_text, "## Boundary Matrix")
|
|
875
|
+
if not matrix_section:
|
|
876
|
+
return False
|
|
877
|
+
matrix_lines = [line.strip() for line in matrix_section.splitlines()
|
|
878
|
+
if line.strip().startswith("|")]
|
|
879
|
+
headers = []
|
|
880
|
+
rows = []
|
|
881
|
+
for line in matrix_lines:
|
|
882
|
+
cells = [cell.strip() for cell in line.strip().strip("|").split("|")]
|
|
883
|
+
if not headers:
|
|
884
|
+
if "Interface" in cells and "Final Status" in cells:
|
|
885
|
+
headers = cells
|
|
886
|
+
continue
|
|
887
|
+
if all(re.fullmatch(r":?-{3,}:?", cell) for cell in cells):
|
|
888
|
+
continue
|
|
889
|
+
if len(cells) < len(headers):
|
|
890
|
+
cells.extend([""] * (len(headers) - len(cells)))
|
|
891
|
+
row = {header: cells[i] if i < len(cells) else ""
|
|
892
|
+
for i, header in enumerate(headers)}
|
|
893
|
+
if row.get("Interface", "").strip(".` ") in {"", "..."}:
|
|
894
|
+
continue
|
|
895
|
+
rows.append(row)
|
|
896
|
+
|
|
897
|
+
if not headers or not rows:
|
|
898
|
+
return False
|
|
899
|
+
if any(column not in headers for column in required_columns):
|
|
900
|
+
return False
|
|
901
|
+
|
|
902
|
+
for row in rows:
|
|
903
|
+
final_status = row.get("Final Status", "").strip().lower()
|
|
904
|
+
if final_status not in allowed_final_statuses:
|
|
905
|
+
return False
|
|
906
|
+
for column in category_columns:
|
|
907
|
+
if not row.get(column, "").strip():
|
|
908
|
+
return False
|
|
909
|
+
for value in row.values():
|
|
910
|
+
normalized = value.strip().lower()
|
|
911
|
+
if any(marker in normalized for marker in incomplete_markers):
|
|
912
|
+
return False
|
|
913
|
+
|
|
914
|
+
openapi_file = _discover_openapi_file(project_root)
|
|
915
|
+
if openapi_file:
|
|
916
|
+
operations = _extract_openapi_operations(_read_text(openapi_file))
|
|
917
|
+
matrix_operations, _matrix_path_only = _extract_matrix_operations(rows)
|
|
918
|
+
for method, path in operations:
|
|
919
|
+
if (method, path) not in matrix_operations:
|
|
920
|
+
return False
|
|
921
|
+
|
|
922
|
+
gate_section = _extract_report_section(report_text,
|
|
923
|
+
"## Boundary Completion Gate")
|
|
924
|
+
if not gate_section:
|
|
925
|
+
return False
|
|
926
|
+
completion_gate_passed = False
|
|
927
|
+
boundary_missing_seen = False
|
|
928
|
+
happy_path_only_seen = False
|
|
929
|
+
for raw_line in gate_section.splitlines():
|
|
930
|
+
line = raw_line.strip()
|
|
931
|
+
match = re.match(r"-?\s*(Boundary-missing|Happy-path-only):\s*(\d+)\s*$",
|
|
932
|
+
line, re.IGNORECASE)
|
|
933
|
+
if match:
|
|
934
|
+
if match.group(1).lower() == "boundary-missing":
|
|
935
|
+
boundary_missing_seen = True
|
|
936
|
+
if match.group(1).lower() == "happy-path-only":
|
|
937
|
+
happy_path_only_seen = True
|
|
938
|
+
if int(match.group(2)) > 0:
|
|
939
|
+
return False
|
|
940
|
+
if re.match(r"-?\s*Completion gate passed:\s*yes\s*$",
|
|
941
|
+
line, re.IGNORECASE):
|
|
942
|
+
completion_gate_passed = True
|
|
943
|
+
if re.match(r"-?\s*Completion gate passed:\s*no\b",
|
|
944
|
+
line, re.IGNORECASE):
|
|
945
|
+
return False
|
|
946
|
+
if not (completion_gate_passed and boundary_missing_seen and
|
|
947
|
+
happy_path_only_seen):
|
|
948
|
+
return False
|
|
949
|
+
|
|
950
|
+
validation_section = _extract_report_section(report_text,
|
|
951
|
+
"## Boundary Validation")
|
|
952
|
+
if not validation_section:
|
|
953
|
+
return False
|
|
954
|
+
validator_result = ""
|
|
955
|
+
for raw_line in validation_section.splitlines():
|
|
956
|
+
line = raw_line.strip()
|
|
957
|
+
match = re.match(r"-?\s*Validator result:\s*(\S+)",
|
|
958
|
+
line, re.IGNORECASE)
|
|
959
|
+
if match:
|
|
960
|
+
validator_result = match.group(1).strip().lower()
|
|
961
|
+
break
|
|
962
|
+
|
|
963
|
+
return validator_result == "passed"
|
|
964
|
+
|
|
965
|
+
|
|
966
|
+
def _extract_scope_list(report_text, list_name):
|
|
967
|
+
"""Extract a nested file list from the report Scope section."""
|
|
968
|
+
in_scope = False
|
|
969
|
+
in_list = False
|
|
970
|
+
list_marker = "- {}:".format(list_name)
|
|
971
|
+
values = []
|
|
972
|
+
for raw_line in report_text.splitlines():
|
|
973
|
+
line = raw_line.strip()
|
|
974
|
+
if line == "## Scope":
|
|
975
|
+
in_scope = True
|
|
976
|
+
continue
|
|
977
|
+
if in_scope and line.startswith("## "):
|
|
978
|
+
break
|
|
979
|
+
if not in_scope:
|
|
980
|
+
continue
|
|
981
|
+
if line == list_marker:
|
|
982
|
+
in_list = True
|
|
983
|
+
continue
|
|
984
|
+
if in_list:
|
|
985
|
+
if raw_line.startswith(" -") or raw_line.startswith("\t-"):
|
|
986
|
+
value = line[2:].strip().strip("`")
|
|
987
|
+
if value and value not in {"{...}", "..."}:
|
|
988
|
+
values.append(value)
|
|
989
|
+
continue
|
|
990
|
+
if line.startswith("- "):
|
|
991
|
+
break
|
|
992
|
+
return values
|
|
993
|
+
|
|
994
|
+
|
|
995
|
+
def _scoped_report_fresh(project_root, pointer_artifact, report_path, report_text):
|
|
996
|
+
"""Return whether the report is newer than the gate marker and in-scope files."""
|
|
997
|
+
marker_path = os.path.join(
|
|
998
|
+
project_root,
|
|
999
|
+
os.path.dirname(pointer_artifact),
|
|
1000
|
+
".prizmkit-test-started",
|
|
1001
|
+
)
|
|
1002
|
+
if not os.path.exists(marker_path):
|
|
1003
|
+
return False
|
|
1004
|
+
|
|
1005
|
+
try:
|
|
1006
|
+
report_mtime = os.path.getmtime(report_path)
|
|
1007
|
+
marker_mtime = os.path.getmtime(marker_path)
|
|
1008
|
+
except OSError:
|
|
1009
|
+
return False
|
|
1010
|
+
|
|
1011
|
+
if report_mtime < marker_mtime:
|
|
1012
|
+
return False
|
|
1013
|
+
|
|
1014
|
+
project_root_real = os.path.realpath(project_root)
|
|
1015
|
+
source_files = _extract_scope_list(report_text, "In-Scope Source Files")
|
|
1016
|
+
test_files = _extract_scope_list(report_text, "In-Scope Test Files")
|
|
1017
|
+
if not source_files or not test_files:
|
|
1018
|
+
return False
|
|
1019
|
+
|
|
1020
|
+
ignored = {"none", "n/a", "not applicable", "(none)", "{...}", "..."}
|
|
1021
|
+
for listed_file in source_files + test_files:
|
|
1022
|
+
normalized = listed_file.strip().strip("`")
|
|
1023
|
+
if not normalized or normalized.lower() in ignored:
|
|
1024
|
+
return False
|
|
1025
|
+
candidate = (normalized if os.path.isabs(normalized)
|
|
1026
|
+
else os.path.join(project_root, normalized))
|
|
1027
|
+
candidate_real = os.path.realpath(candidate)
|
|
1028
|
+
try:
|
|
1029
|
+
if os.path.commonpath([project_root_real, candidate_real]) != project_root_real:
|
|
1030
|
+
return False
|
|
1031
|
+
except ValueError:
|
|
1032
|
+
return False
|
|
1033
|
+
if not os.path.isfile(candidate_real):
|
|
1034
|
+
return False
|
|
1035
|
+
if os.path.getmtime(candidate_real) > report_mtime:
|
|
1036
|
+
return False
|
|
1037
|
+
|
|
1038
|
+
return True
|
|
1039
|
+
|
|
1040
|
+
|
|
1041
|
+
def _validate_scoped_test_report(project_root, artifacts):
|
|
1042
|
+
"""Validate the feature prizmkit-test pointer and scoped PASS report."""
|
|
1043
|
+
pointer_artifacts = [a for a in artifacts
|
|
1044
|
+
if a.endswith("/test-report-path.txt")]
|
|
1045
|
+
if not pointer_artifacts:
|
|
1046
|
+
return False
|
|
1047
|
+
|
|
1048
|
+
pointer_artifact = pointer_artifacts[0]
|
|
1049
|
+
pointer_path = os.path.join(project_root, pointer_artifact)
|
|
1050
|
+
pointed_report = _read_text(pointer_path).strip().splitlines()
|
|
1051
|
+
if not pointed_report:
|
|
1052
|
+
return False
|
|
1053
|
+
|
|
1054
|
+
report_path = pointed_report[0].strip()
|
|
1055
|
+
if not os.path.isabs(report_path):
|
|
1056
|
+
report_path = os.path.join(project_root, report_path)
|
|
1057
|
+
if not os.path.exists(report_path):
|
|
1058
|
+
return False
|
|
1059
|
+
if not _report_path_matches_artifacts(project_root, report_path, artifacts):
|
|
1060
|
+
return False
|
|
1061
|
+
|
|
1062
|
+
report_text = _read_text(report_path)
|
|
1063
|
+
mode = _extract_scope_field(report_text, "Mode")
|
|
1064
|
+
artifact_dir = _extract_scope_field(report_text, "Artifact Dir")
|
|
1065
|
+
verdict = _extract_report_verdict(report_text)
|
|
1066
|
+
|
|
1067
|
+
expected_artifact_dir = _normalize_artifact_dir(
|
|
1068
|
+
os.path.dirname(pointer_artifact)
|
|
1069
|
+
)
|
|
1070
|
+
actual_artifact_dir = _normalize_artifact_dir(artifact_dir)
|
|
1071
|
+
|
|
1072
|
+
return (mode == "this-change" and
|
|
1073
|
+
actual_artifact_dir == expected_artifact_dir and
|
|
1074
|
+
verdict == "PASS" and
|
|
1075
|
+
_boundary_report_sections_valid(project_root, report_text) and
|
|
1076
|
+
_scoped_report_fresh(project_root, pointer_artifact,
|
|
1077
|
+
report_path, report_text))
|
|
1078
|
+
|
|
1079
|
+
|
|
1080
|
+
def _completed_step_artifacts_valid(project_root, skill_key, artifacts):
|
|
1081
|
+
"""Return whether a completed checkpoint step can be preserved."""
|
|
1082
|
+
if not all(_artifact_exists(project_root, a) for a in artifacts):
|
|
1083
|
+
return False
|
|
1084
|
+
if skill_key == "prizmkit-test":
|
|
1085
|
+
return _validate_scoped_test_report(project_root, artifacts)
|
|
1086
|
+
return True
|
|
1087
|
+
|
|
1088
|
+
|
|
643
1089
|
def generate_checkpoint_definition(sections, pipeline_mode, workflow_type,
|
|
644
1090
|
item_id, item_slug, session_id,
|
|
645
1091
|
init_done=False):
|
|
@@ -727,17 +1173,24 @@ def merge_checkpoint_state(existing, fresh, project_root):
|
|
|
727
1173
|
"""
|
|
728
1174
|
existing_status = {}
|
|
729
1175
|
existing_artifacts = {}
|
|
1176
|
+
fresh_artifacts = {step["skill"]: step.get("required_artifacts", [])
|
|
1177
|
+
for step in fresh.get("steps", [])}
|
|
730
1178
|
for step in existing.get("steps", []):
|
|
731
1179
|
existing_status[step["skill"]] = step["status"]
|
|
732
1180
|
existing_artifacts[step["skill"]] = step.get("required_artifacts", [])
|
|
733
1181
|
|
|
734
|
-
# Determine which completed steps have valid artifacts
|
|
1182
|
+
# Determine which completed steps have valid artifacts. Prefer the freshly
|
|
1183
|
+
# generated artifact contract so newly added gates invalidate older
|
|
1184
|
+
# checkpoint entries that only satisfied a weaker contract.
|
|
735
1185
|
valid_completed = set()
|
|
736
1186
|
for skill_key, status in existing_status.items():
|
|
737
1187
|
if status == "completed":
|
|
738
|
-
artifacts =
|
|
739
|
-
|
|
740
|
-
|
|
1188
|
+
artifacts = fresh_artifacts.get(
|
|
1189
|
+
skill_key, existing_artifacts.get(skill_key, [])
|
|
1190
|
+
)
|
|
1191
|
+
if _completed_step_artifacts_valid(project_root,
|
|
1192
|
+
skill_key,
|
|
1193
|
+
artifacts):
|
|
741
1194
|
valid_completed.add(skill_key)
|
|
742
1195
|
else:
|
|
743
1196
|
LOGGER.warning(
|
|
@@ -1045,6 +1498,11 @@ def assemble_sections(pipeline_mode, sections_dir, init_done, is_resume,
|
|
|
1045
1498
|
load_section(sections_dir,
|
|
1046
1499
|
"test-failure-recovery-agent.md")))
|
|
1047
1500
|
|
|
1501
|
+
# --- Scoped Feature Test Gate ---
|
|
1502
|
+
sections.append(("phase-prizmkit-test",
|
|
1503
|
+
load_section(sections_dir,
|
|
1504
|
+
"phase-prizmkit-test.md")))
|
|
1505
|
+
|
|
1048
1506
|
# Verification Gates are included in Task Contract. Keep AC in one place so
|
|
1049
1507
|
# background context and implementation prompts cannot redefine scope.
|
|
1050
1508
|
# --- Review (only for agent tiers) ---
|