prizmkit 1.1.86 → 1.1.87
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/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 +260 -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/test_generate_bootstrap_prompt.py +429 -0
- package/bundled/dev-pipeline-windows/scripts/generate-bootstrap-prompt.py +462 -4
- 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 +186 -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
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Validate a prizmkit-test report's boundary coverage gate.
|
|
4
|
+
|
|
5
|
+
Usage:
|
|
6
|
+
python3 scripts/validate_boundary_report.py --report .prizmkit/test/.../test-report.md
|
|
7
|
+
python3 scripts/validate_boundary_report.py --report .prizmkit/test/.../test-report.md --openapi openapi.yaml
|
|
8
|
+
|
|
9
|
+
The script intentionally performs deterministic structural checks only. It cannot prove that every
|
|
10
|
+
business boundary is semantically correct, but it prevents reports from silently claiming completion
|
|
11
|
+
when interfaces are missing from the Boundary Matrix or remain happy-path-only / boundary-missing.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import argparse
|
|
17
|
+
import re
|
|
18
|
+
import sys
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Dict, List, Set, Tuple
|
|
21
|
+
|
|
22
|
+
ALLOWED_FINAL_STATUSES = {
|
|
23
|
+
"boundary-covered",
|
|
24
|
+
"generated-needs-review",
|
|
25
|
+
"skipped-with-reason",
|
|
26
|
+
}
|
|
27
|
+
INCOMPLETE_MARKERS = {
|
|
28
|
+
"boundary-missing",
|
|
29
|
+
"happy-path-only",
|
|
30
|
+
"unresolved",
|
|
31
|
+
}
|
|
32
|
+
REQUIRED_SECTIONS = [
|
|
33
|
+
"## Boundary Matrix",
|
|
34
|
+
"## Boundary Completion Gate",
|
|
35
|
+
"## Final Status",
|
|
36
|
+
]
|
|
37
|
+
REQUIRED_BOUNDARY_COLUMNS = [
|
|
38
|
+
"Module",
|
|
39
|
+
"Interface",
|
|
40
|
+
"Service Type",
|
|
41
|
+
"Happy Path",
|
|
42
|
+
"Request Validation",
|
|
43
|
+
"Auth / Permission / Ownership",
|
|
44
|
+
"Domain Invariants",
|
|
45
|
+
"Collection / Pagination",
|
|
46
|
+
"Date / Time",
|
|
47
|
+
"State Transitions",
|
|
48
|
+
"Dependency Failure",
|
|
49
|
+
"Response Contract",
|
|
50
|
+
"N/A Reasons",
|
|
51
|
+
"Final Status",
|
|
52
|
+
]
|
|
53
|
+
BOUNDARY_CATEGORY_COLUMNS = [
|
|
54
|
+
"Happy Path",
|
|
55
|
+
"Request Validation",
|
|
56
|
+
"Auth / Permission / Ownership",
|
|
57
|
+
"Domain Invariants",
|
|
58
|
+
"Collection / Pagination",
|
|
59
|
+
"Date / Time",
|
|
60
|
+
"State Transitions",
|
|
61
|
+
"Dependency Failure",
|
|
62
|
+
"Response Contract",
|
|
63
|
+
]
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def read_text(path: Path) -> str:
|
|
67
|
+
try:
|
|
68
|
+
return path.read_text(encoding="utf-8")
|
|
69
|
+
except FileNotFoundError:
|
|
70
|
+
raise SystemExit(f"FAIL: file not found: {path}")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
HTTP_METHODS = {"GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS", "TRACE"}
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def extract_openapi_paths(openapi_text: str) -> List[str]:
|
|
77
|
+
"""Extract OpenAPI path keys without requiring PyYAML.
|
|
78
|
+
|
|
79
|
+
Supports common YAML forms inside the `paths:` mapping:
|
|
80
|
+
- /users:
|
|
81
|
+
- "/users/{id}":
|
|
82
|
+
- '/users/{id}':
|
|
83
|
+
"""
|
|
84
|
+
return [path for _method, path in extract_openapi_operations(openapi_text)]
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def extract_openapi_operations(openapi_text: str) -> List[Tuple[str, str]]:
|
|
88
|
+
"""Extract OpenAPI method+path operations without requiring PyYAML."""
|
|
89
|
+
operations: List[Tuple[str, str]] = []
|
|
90
|
+
in_paths = False
|
|
91
|
+
paths_indent = 0
|
|
92
|
+
current_path = ""
|
|
93
|
+
current_path_indent = 0
|
|
94
|
+
path_key_re = re.compile(
|
|
95
|
+
r"^\s*(?:(['\"])(/.*?)\1|(/[^:\s]+))\s*:\s*(?:#.*)?$"
|
|
96
|
+
)
|
|
97
|
+
method_re = re.compile(r"^\s*([A-Za-z]+)\s*:\s*(?:#.*)?$")
|
|
98
|
+
|
|
99
|
+
for raw_line in openapi_text.splitlines():
|
|
100
|
+
stripped = raw_line.strip()
|
|
101
|
+
if not stripped or stripped.startswith("#"):
|
|
102
|
+
continue
|
|
103
|
+
|
|
104
|
+
indent = len(raw_line) - len(raw_line.lstrip())
|
|
105
|
+
if not in_paths:
|
|
106
|
+
if re.match(r"^\s*paths\s*:\s*(?:#.*)?$", raw_line):
|
|
107
|
+
in_paths = True
|
|
108
|
+
paths_indent = indent
|
|
109
|
+
continue
|
|
110
|
+
|
|
111
|
+
if indent <= paths_indent:
|
|
112
|
+
break
|
|
113
|
+
|
|
114
|
+
path_match = path_key_re.match(raw_line)
|
|
115
|
+
if path_match:
|
|
116
|
+
current_path = path_match.group(2) or path_match.group(3) or ""
|
|
117
|
+
current_path_indent = indent
|
|
118
|
+
continue
|
|
119
|
+
|
|
120
|
+
method_match = method_re.match(raw_line)
|
|
121
|
+
if current_path and method_match and indent > current_path_indent:
|
|
122
|
+
method = method_match.group(1).upper()
|
|
123
|
+
if method in HTTP_METHODS:
|
|
124
|
+
operations.append((method, current_path))
|
|
125
|
+
|
|
126
|
+
return operations
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def extract_section(text: str, heading: str) -> str:
|
|
130
|
+
start = text.find(heading)
|
|
131
|
+
if start == -1:
|
|
132
|
+
return ""
|
|
133
|
+
rest = text[start + len(heading) :]
|
|
134
|
+
match = re.search(r"\n## ", rest)
|
|
135
|
+
if not match:
|
|
136
|
+
return rest
|
|
137
|
+
return rest[: match.start()]
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def split_markdown_row(line: str) -> List[str]:
|
|
141
|
+
return [cell.strip() for cell in line.strip().strip("|").split("|")]
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def parse_boundary_matrix(text: str) -> Tuple[List[str], List[Dict[str, str]]]:
|
|
145
|
+
section = extract_section(text, "## Boundary Matrix")
|
|
146
|
+
if not section:
|
|
147
|
+
return [], []
|
|
148
|
+
|
|
149
|
+
lines = [line.strip() for line in section.splitlines() if line.strip().startswith("|")]
|
|
150
|
+
header_index = -1
|
|
151
|
+
for idx, line in enumerate(lines):
|
|
152
|
+
cells = split_markdown_row(line)
|
|
153
|
+
if "Interface" in cells and "Final Status" in cells:
|
|
154
|
+
header_index = idx
|
|
155
|
+
break
|
|
156
|
+
if header_index == -1:
|
|
157
|
+
return [], []
|
|
158
|
+
|
|
159
|
+
headers = split_markdown_row(lines[header_index])
|
|
160
|
+
rows: List[Dict[str, str]] = []
|
|
161
|
+
for line in lines[header_index + 1 :]:
|
|
162
|
+
cells = split_markdown_row(line)
|
|
163
|
+
if all(re.fullmatch(r":?-{3,}:?", cell) for cell in cells):
|
|
164
|
+
continue
|
|
165
|
+
if len(cells) < len(headers):
|
|
166
|
+
cells.extend([""] * (len(headers) - len(cells)))
|
|
167
|
+
row = {header: cells[i] if i < len(cells) else "" for i, header in enumerate(headers)}
|
|
168
|
+
# Ignore example placeholder rows that were accidentally left in templates.
|
|
169
|
+
if row.get("Interface", "").strip(".` ") in {"...", ""}:
|
|
170
|
+
continue
|
|
171
|
+
rows.append(row)
|
|
172
|
+
return headers, rows
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def has_inline_reason(value: str) -> bool:
|
|
176
|
+
normalized = value.strip().lower()
|
|
177
|
+
if not normalized:
|
|
178
|
+
return False
|
|
179
|
+
if "—" in value or " - " in value or ":" in value:
|
|
180
|
+
return True
|
|
181
|
+
# Accept phrases longer than a bare status marker.
|
|
182
|
+
bare = {"n/a", "na", "skipped", "skip"}
|
|
183
|
+
return normalized not in bare and (normalized.startswith("n/a ") or normalized.startswith("skipped "))
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def extract_matrix_operations(interface_values: List[str]) -> Tuple[Set[Tuple[str, str]], Set[str]]:
|
|
187
|
+
"""Extract method+path and path-only tokens from Boundary Matrix Interface cells."""
|
|
188
|
+
operations: Set[Tuple[str, str]] = set()
|
|
189
|
+
path_only: Set[str] = set()
|
|
190
|
+
token_re = re.compile(
|
|
191
|
+
r"(?:(\b(?:GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS|TRACE)\b)\s+)?(/[^\s,`|)]+)",
|
|
192
|
+
flags=re.IGNORECASE,
|
|
193
|
+
)
|
|
194
|
+
for value in interface_values:
|
|
195
|
+
for match in token_re.finditer(value):
|
|
196
|
+
method = match.group(1).upper() if match.group(1) else ""
|
|
197
|
+
path = normalize_path(match.group(2))
|
|
198
|
+
if not path:
|
|
199
|
+
continue
|
|
200
|
+
if method:
|
|
201
|
+
operations.add((method, path))
|
|
202
|
+
else:
|
|
203
|
+
path_only.add(path)
|
|
204
|
+
return operations, path_only
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def normalize_path(path: str) -> str:
|
|
208
|
+
normalized = path.strip().strip("'\"`")
|
|
209
|
+
if len(normalized) > 1:
|
|
210
|
+
normalized = normalized.rstrip("/")
|
|
211
|
+
return normalized
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def validate_report(report_text: str, openapi_paths: List[str] | None,
|
|
215
|
+
openapi_operations: List[Tuple[str, str]] | None = None) -> List[str]:
|
|
216
|
+
errors: List[str] = []
|
|
217
|
+
|
|
218
|
+
for section in REQUIRED_SECTIONS:
|
|
219
|
+
if section not in report_text:
|
|
220
|
+
errors.append(f"missing required section: {section}")
|
|
221
|
+
|
|
222
|
+
headers, rows = parse_boundary_matrix(report_text)
|
|
223
|
+
if not rows:
|
|
224
|
+
errors.append("Boundary Matrix table is missing or empty")
|
|
225
|
+
return errors
|
|
226
|
+
|
|
227
|
+
for column in REQUIRED_BOUNDARY_COLUMNS:
|
|
228
|
+
if column not in headers:
|
|
229
|
+
errors.append(f"Boundary Matrix missing required column: {column}")
|
|
230
|
+
|
|
231
|
+
final_statuses = []
|
|
232
|
+
interface_values = []
|
|
233
|
+
for idx, row in enumerate(rows, start=1):
|
|
234
|
+
interface = row.get("Interface", "").strip()
|
|
235
|
+
interface_values.append(interface)
|
|
236
|
+
|
|
237
|
+
final_status = row.get("Final Status", "").strip().lower()
|
|
238
|
+
final_statuses.append(final_status)
|
|
239
|
+
if not final_status:
|
|
240
|
+
errors.append(f"row {idx} ({interface}) missing Final Status")
|
|
241
|
+
elif final_status not in ALLOWED_FINAL_STATUSES:
|
|
242
|
+
errors.append(
|
|
243
|
+
f"row {idx} ({interface}) has invalid/incomplete Final Status: {row.get('Final Status', '')}"
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
for column in BOUNDARY_CATEGORY_COLUMNS:
|
|
247
|
+
if column in headers and not row.get(column, "").strip():
|
|
248
|
+
errors.append(f"row {idx} ({interface}) missing boundary category value: {column}")
|
|
249
|
+
|
|
250
|
+
for header, value in row.items():
|
|
251
|
+
normalized = value.strip().lower()
|
|
252
|
+
if any(marker in normalized for marker in INCOMPLETE_MARKERS):
|
|
253
|
+
errors.append(f"row {idx} ({interface}) has incomplete marker in {header}: {value}")
|
|
254
|
+
|
|
255
|
+
n_a_reasons = row.get("N/A Reasons", "").strip()
|
|
256
|
+
for header, value in row.items():
|
|
257
|
+
if header in {"Module", "Interface", "Service Type", "Final Status", "N/A Reasons"}:
|
|
258
|
+
continue
|
|
259
|
+
normalized = value.strip().lower()
|
|
260
|
+
if normalized in {"n/a", "na", "skipped", "skip"} and not n_a_reasons:
|
|
261
|
+
errors.append(f"row {idx} ({interface}) marks {header} as {value} without N/A reason")
|
|
262
|
+
if ("skipped" in normalized or normalized.startswith("n/a")) and not (n_a_reasons or has_inline_reason(value)):
|
|
263
|
+
errors.append(f"row {idx} ({interface}) marks {header} as {value} without concrete reason")
|
|
264
|
+
|
|
265
|
+
if openapi_operations is not None:
|
|
266
|
+
matrix_operations, matrix_path_only = extract_matrix_operations(interface_values)
|
|
267
|
+
for method, path in openapi_operations:
|
|
268
|
+
normalized_path = normalize_path(path)
|
|
269
|
+
if (method, normalized_path) not in matrix_operations:
|
|
270
|
+
if normalized_path in matrix_path_only:
|
|
271
|
+
errors.append(
|
|
272
|
+
"OpenAPI operation missing from Boundary Matrix: "
|
|
273
|
+
f"{method} {path} (path-only row is ambiguous)"
|
|
274
|
+
)
|
|
275
|
+
else:
|
|
276
|
+
errors.append(
|
|
277
|
+
f"OpenAPI operation missing from Boundary Matrix: {method} {path}"
|
|
278
|
+
)
|
|
279
|
+
elif openapi_paths is not None:
|
|
280
|
+
_matrix_operations, matrix_path_only = extract_matrix_operations(interface_values)
|
|
281
|
+
missing_paths = [path for path in openapi_paths
|
|
282
|
+
if normalize_path(path) not in matrix_path_only]
|
|
283
|
+
for path in missing_paths:
|
|
284
|
+
errors.append(f"OpenAPI path missing from Boundary Matrix: {path}")
|
|
285
|
+
|
|
286
|
+
gate_section = extract_section(report_text, "## Boundary Completion Gate")
|
|
287
|
+
if gate_section:
|
|
288
|
+
for label in ["Interfaces total", "Boundary-covered", "Boundary-missing", "Happy-path-only", "Completion gate passed"]:
|
|
289
|
+
if label not in gate_section:
|
|
290
|
+
errors.append(f"Boundary Completion Gate missing metric: {label}")
|
|
291
|
+
for label in ["Boundary-missing", "Happy-path-only"]:
|
|
292
|
+
match = re.search(rf"{re.escape(label)}:\s*(\d+)", gate_section)
|
|
293
|
+
if match and int(match.group(1)) > 0:
|
|
294
|
+
errors.append(f"Boundary Completion Gate reports {label}: {match.group(1)}")
|
|
295
|
+
if re.search(r"Completion gate passed:\s*no", gate_section, flags=re.IGNORECASE):
|
|
296
|
+
errors.append("Boundary Completion Gate reports failure")
|
|
297
|
+
|
|
298
|
+
final_section = extract_section(report_text, "## Final Status")
|
|
299
|
+
if final_section:
|
|
300
|
+
if re.search(r"Ready for commit:\s*yes", final_section, flags=re.IGNORECASE):
|
|
301
|
+
if any(status != "boundary-covered" for status in final_statuses):
|
|
302
|
+
errors.append("Final Status says ready for commit but not every interface is boundary-covered")
|
|
303
|
+
review_match = re.search(r"Needs human review:\s*(.+)", final_section, flags=re.IGNORECASE)
|
|
304
|
+
if review_match and review_match.group(1).strip().lower() not in {"none", "no", "n/a", ""}:
|
|
305
|
+
errors.append("Final Status says ready for commit while human review is still needed")
|
|
306
|
+
|
|
307
|
+
return errors
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def main() -> int:
|
|
311
|
+
parser = argparse.ArgumentParser(description="Validate prizmkit-test boundary coverage report")
|
|
312
|
+
parser.add_argument("--report", required=True, help="Path to test-report.md")
|
|
313
|
+
parser.add_argument("--openapi", help="Optional OpenAPI file whose paths must appear in Boundary Matrix")
|
|
314
|
+
args = parser.parse_args()
|
|
315
|
+
|
|
316
|
+
report_path = Path(args.report)
|
|
317
|
+
report_text = read_text(report_path)
|
|
318
|
+
|
|
319
|
+
openapi_paths = None
|
|
320
|
+
openapi_operations = None
|
|
321
|
+
if args.openapi:
|
|
322
|
+
openapi_path = Path(args.openapi)
|
|
323
|
+
if openapi_path.exists():
|
|
324
|
+
openapi_text = read_text(openapi_path)
|
|
325
|
+
openapi_operations = extract_openapi_operations(openapi_text)
|
|
326
|
+
openapi_paths = [path for _method, path in openapi_operations]
|
|
327
|
+
else:
|
|
328
|
+
print(f"WARN: OpenAPI file not found, skipping path coverage check: {openapi_path}")
|
|
329
|
+
|
|
330
|
+
errors = validate_report(report_text, openapi_paths, openapi_operations)
|
|
331
|
+
if errors:
|
|
332
|
+
print("Boundary report validation: FAIL")
|
|
333
|
+
for error in errors:
|
|
334
|
+
print(f"- {error}")
|
|
335
|
+
return 1
|
|
336
|
+
|
|
337
|
+
print("Boundary report validation: PASS")
|
|
338
|
+
print(f"Report: {report_path}")
|
|
339
|
+
if openapi_operations is not None:
|
|
340
|
+
print(f"OpenAPI operations checked: {len(openapi_operations)}")
|
|
341
|
+
elif openapi_paths is not None:
|
|
342
|
+
print(f"OpenAPI paths checked: {len(openapi_paths)}")
|
|
343
|
+
return 0
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
if __name__ == "__main__":
|
|
347
|
+
sys.exit(main())
|