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