prizmkit 1.1.87 → 1.1.89

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.
@@ -0,0 +1,446 @@
1
+ #!/usr/bin/env python3
2
+ """Scoped PrizmKit test gate for feature pipeline prompts.
3
+
4
+ The feature bootstrap prompt calls this script instead of embedding the full
5
+ Bash/PowerShell report validation logic inline. It intentionally prints compact
6
+ `GATE:*` lines so the AI session can decide whether to continue, retry, or stop.
7
+ """
8
+
9
+ import argparse
10
+ import os
11
+ import subprocess
12
+ import sys
13
+ from pathlib import Path
14
+
15
+ REQUIRED_BOUNDARY_COLUMNS = [
16
+ "Module",
17
+ "Interface",
18
+ "Service Type",
19
+ "Happy Path",
20
+ "Request Validation",
21
+ "Auth / Permission / Ownership",
22
+ "Domain Invariants",
23
+ "Collection / Pagination",
24
+ "Date / Time",
25
+ "State Transitions",
26
+ "Dependency Failure",
27
+ "Response Contract",
28
+ "N/A Reasons",
29
+ "Final Status",
30
+ ]
31
+
32
+ BOUNDARY_VALIDATOR_CANDIDATES = [
33
+ ".claude/command-assets/prizmkit-test/scripts/validate_boundary_report.py",
34
+ ".codebuddy/skills/prizmkit-test/scripts/validate_boundary_report.py",
35
+ ".agents/skills/prizmkit-test/scripts/validate_boundary_report.py",
36
+ ]
37
+
38
+ OPENAPI_CANDIDATES = [
39
+ "openapi.yaml",
40
+ "openapi.yml",
41
+ "swagger.yaml",
42
+ "swagger.yml",
43
+ "docs/openapi.yaml",
44
+ "docs/openapi.yml",
45
+ "docs/swagger.yaml",
46
+ "docs/swagger.yml",
47
+ "api/openapi.yaml",
48
+ "api/openapi.yml",
49
+ "api/swagger.yaml",
50
+ "api/swagger.yml",
51
+ ]
52
+
53
+ IGNORED_SCOPE_VALUES = {"none", "n/a", "not applicable", "(none)", "{...}", "..."}
54
+
55
+
56
+ def default_artifact_dir(feature_slug):
57
+ return ".prizmkit/specs/{}/".format(feature_slug)
58
+
59
+
60
+ def marker_path(feature_slug):
61
+ return os.path.join(default_artifact_dir(feature_slug), ".prizmkit-test-started")
62
+
63
+
64
+ def pointer_path(feature_slug):
65
+ return os.path.join(default_artifact_dir(feature_slug), "test-report-path.txt")
66
+
67
+
68
+ def normalize_artifact_dir(path):
69
+ return str(path or "").strip().replace("\\", "/").rstrip("/")
70
+
71
+
72
+ def read_text(path):
73
+ with open(path, "r", encoding="utf-8") as fh:
74
+ return fh.read()
75
+
76
+
77
+ def start_gate(feature_slug):
78
+ marker = marker_path(feature_slug)
79
+ os.makedirs(os.path.dirname(marker), exist_ok=True)
80
+ Path(marker).touch()
81
+ return {"gate": "GATE:START", "marker": marker}
82
+
83
+
84
+ def newest_report_after_marker(marker):
85
+ if not os.path.isfile(marker):
86
+ return ""
87
+ try:
88
+ marker_mtime = os.path.getmtime(marker)
89
+ except OSError:
90
+ return ""
91
+
92
+ matches = []
93
+ for root, _dirs, files in os.walk(os.path.join(".prizmkit", "test")):
94
+ if "test-report.md" not in files:
95
+ continue
96
+ path = os.path.join(root, "test-report.md")
97
+ try:
98
+ report_mtime = os.path.getmtime(path)
99
+ except OSError:
100
+ continue
101
+ if report_mtime >= marker_mtime:
102
+ matches.append((report_mtime, path))
103
+
104
+ if not matches:
105
+ return ""
106
+ return sorted(matches)[-1][1]
107
+
108
+
109
+ def extract_section(report_text, heading):
110
+ in_section = False
111
+ lines = []
112
+ for raw_line in report_text.splitlines():
113
+ line = raw_line.strip()
114
+ if line == heading:
115
+ in_section = True
116
+ continue
117
+ if in_section and line.startswith("## "):
118
+ break
119
+ if in_section:
120
+ lines.append(raw_line)
121
+ return lines
122
+
123
+
124
+ def extract_scope_field(report_text, field_name):
125
+ prefix = "- {}:".format(field_name)
126
+ for raw_line in extract_section(report_text, "## Scope"):
127
+ line = raw_line.strip()
128
+ if line.startswith(prefix):
129
+ return line[len(prefix):].strip()
130
+ return ""
131
+
132
+
133
+ def extract_verdict(report_text):
134
+ for raw_line in extract_section(report_text, "## Verdict"):
135
+ line = raw_line.strip()
136
+ if line:
137
+ return line
138
+ return ""
139
+
140
+
141
+ def extract_key_value(section_lines, field_name):
142
+ prefix = "{}:".format(field_name).lower()
143
+ for raw_line in section_lines:
144
+ line = raw_line.strip()
145
+ if line.startswith("-"):
146
+ line = line[1:].strip()
147
+ if line.lower().startswith(prefix):
148
+ return line[len(prefix):].strip()
149
+ return ""
150
+
151
+
152
+ def parse_markdown_cells(line):
153
+ stripped = line.strip()
154
+ if not stripped.startswith("|"):
155
+ return []
156
+ return [cell.strip() for cell in stripped.strip("|").split("|")]
157
+
158
+
159
+ def boundary_columns_ok(matrix_lines):
160
+ for line in matrix_lines:
161
+ cells = parse_markdown_cells(line)
162
+ if cells and all(column in cells for column in REQUIRED_BOUNDARY_COLUMNS):
163
+ return True
164
+ return False
165
+
166
+
167
+ def boundary_incomplete(matrix_lines):
168
+ text = "\n".join(matrix_lines).lower()
169
+ return any(token in text for token in ["boundary-missing", "happy-path-only", "unresolved"])
170
+
171
+
172
+ def discover_openapi_file():
173
+ for candidate in OPENAPI_CANDIDATES:
174
+ if os.path.isfile(candidate):
175
+ return candidate
176
+ return ""
177
+
178
+
179
+ def boundary_validator_passed(report_path, openapi_file):
180
+ for validator in BOUNDARY_VALIDATOR_CANDIDATES:
181
+ if not os.path.isfile(validator):
182
+ continue
183
+ args = [sys.executable, validator, "--report", report_path]
184
+ if openapi_file:
185
+ args.extend(["--openapi", openapi_file])
186
+ result = subprocess.run(
187
+ args,
188
+ stdout=subprocess.DEVNULL,
189
+ stderr=subprocess.DEVNULL,
190
+ check=False,
191
+ )
192
+ if result.returncode == 0:
193
+ return True
194
+ return False
195
+
196
+
197
+ def scope_list(report_text, name):
198
+ values = []
199
+ in_list = False
200
+ marker = "- {}:".format(name)
201
+ for raw_line in extract_section(report_text, "## Scope"):
202
+ line = raw_line.strip()
203
+ if line == marker:
204
+ in_list = True
205
+ continue
206
+ if not in_list:
207
+ continue
208
+ if raw_line.startswith(" -") or raw_line.startswith("\t-"):
209
+ value = line[2:].strip().strip("`")
210
+ if value and value not in {"{...}", "..."}:
211
+ values.append(value)
212
+ continue
213
+ if line.startswith("- "):
214
+ break
215
+ return values
216
+
217
+
218
+ def path_is_inside_project(project_root, candidate):
219
+ try:
220
+ return os.path.commonpath([project_root, candidate]) == project_root
221
+ except ValueError:
222
+ return False
223
+
224
+
225
+ def scope_fresh_ok(report_path, marker, report_text):
226
+ try:
227
+ report_mtime = os.path.getmtime(report_path)
228
+ marker_mtime = os.path.getmtime(marker)
229
+ except OSError:
230
+ return False
231
+ if report_mtime < marker_mtime:
232
+ return False
233
+
234
+ source_files = scope_list(report_text, "In-Scope Source Files")
235
+ test_files = scope_list(report_text, "In-Scope Test Files")
236
+ if not source_files or not test_files:
237
+ return False
238
+
239
+ project_root = os.path.realpath(os.getcwd())
240
+ for listed_file in source_files + test_files:
241
+ normalized = listed_file.strip().strip("`")
242
+ if not normalized or normalized.lower() in IGNORED_SCOPE_VALUES:
243
+ return False
244
+ candidate = normalized if os.path.isabs(normalized) else os.path.join(os.getcwd(), normalized)
245
+ candidate_real = os.path.realpath(candidate)
246
+ if not path_is_inside_project(project_root, candidate_real):
247
+ return False
248
+ if not os.path.isfile(candidate_real):
249
+ return False
250
+ if os.path.getmtime(candidate_real) > report_mtime:
251
+ return False
252
+ return True
253
+
254
+
255
+ def write_report_pointer(feature_slug, report_path):
256
+ path = pointer_path(feature_slug)
257
+ os.makedirs(os.path.dirname(path), exist_ok=True)
258
+ with open(path, "w", encoding="utf-8") as fh:
259
+ fh.write(report_path + "\n")
260
+ return path
261
+
262
+
263
+ def update_checkpoint(checkpoint_path):
264
+ script_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "update-checkpoint.py")
265
+ if not checkpoint_path:
266
+ return True, ""
267
+ result = subprocess.run(
268
+ [
269
+ sys.executable,
270
+ script_path,
271
+ "--checkpoint-path",
272
+ checkpoint_path,
273
+ "--step",
274
+ "prizmkit-test",
275
+ "--status",
276
+ "completed",
277
+ ],
278
+ stdout=subprocess.PIPE,
279
+ stderr=subprocess.STDOUT,
280
+ text=True,
281
+ check=False,
282
+ )
283
+ if result.returncode == 0:
284
+ return True, ""
285
+ output = " ".join((result.stdout or "").split())
286
+ return False, output[:500]
287
+
288
+
289
+ def validate_report(report_path, marker, feature_slug, expected_artifact_dir):
290
+ try:
291
+ report_text = read_text(report_path)
292
+ except OSError as exc:
293
+ return {
294
+ "status": "FAIL",
295
+ "reason": "unreadable_report",
296
+ "error": str(exc),
297
+ "report": report_path,
298
+ }
299
+
300
+ report_mode = extract_scope_field(report_text, "Mode")
301
+ report_artifact_dir = extract_scope_field(report_text, "Artifact Dir")
302
+ report_verdict = extract_verdict(report_text)
303
+
304
+ boundary_gate = extract_section(report_text, "## Boundary Completion Gate")
305
+ boundary_matrix = extract_section(report_text, "## Boundary Matrix")
306
+ boundary_validation = extract_section(report_text, "## Boundary Validation")
307
+
308
+ gate_passed = extract_key_value(boundary_gate, "Completion gate passed")
309
+ boundary_missing = extract_key_value(boundary_gate, "Boundary-missing")
310
+ happy_path_only = extract_key_value(boundary_gate, "Happy-path-only")
311
+ validation_result = extract_key_value(boundary_validation, "Validator result").lower()
312
+
313
+ columns_ok = boundary_columns_ok(boundary_matrix)
314
+ incomplete = boundary_incomplete(boundary_matrix)
315
+ openapi_file = discover_openapi_file()
316
+ validator_ok = boundary_validator_passed(report_path, openapi_file)
317
+ fresh_ok = scope_fresh_ok(report_path, marker, report_text)
318
+
319
+ artifact_ok = normalize_artifact_dir(report_artifact_dir) == normalize_artifact_dir(expected_artifact_dir)
320
+ boundary_ok = (
321
+ columns_ok
322
+ and not incomplete
323
+ and gate_passed == "yes"
324
+ and boundary_missing == "0"
325
+ and happy_path_only == "0"
326
+ and validation_result == "passed"
327
+ and validator_ok
328
+ and fresh_ok
329
+ )
330
+
331
+ result = {
332
+ "status": "PASS" if report_mode == "this-change" and artifact_ok and report_verdict == "PASS" and boundary_ok else "FAIL",
333
+ "verdict": report_verdict,
334
+ "mode": report_mode,
335
+ "artifact_dir": report_artifact_dir,
336
+ "boundary_gate": gate_passed,
337
+ "boundary_missing": boundary_missing,
338
+ "happy_path_only": happy_path_only,
339
+ "boundary_validation": validation_result,
340
+ "validator_passed": "1" if validator_ok else "0",
341
+ "scope_fresh": "1" if fresh_ok else "0",
342
+ "openapi": openapi_file,
343
+ "report": report_path,
344
+ "feature_slug": feature_slug,
345
+ }
346
+ if not columns_ok:
347
+ result["reason"] = "boundary_columns_missing"
348
+ elif incomplete:
349
+ result["reason"] = "boundary_incomplete"
350
+ elif not fresh_ok:
351
+ result["reason"] = "scope_not_fresh"
352
+ elif not artifact_ok:
353
+ result["reason"] = "wrong_artifact_dir"
354
+ elif report_mode != "this-change":
355
+ result["reason"] = "wrong_mode"
356
+ elif report_verdict != "PASS":
357
+ result["reason"] = "verdict_not_pass"
358
+ elif not validator_ok:
359
+ result["reason"] = "validator_failed"
360
+ elif not boundary_ok:
361
+ result["reason"] = "boundary_gate_failed"
362
+ return result
363
+
364
+
365
+ def check_gate(feature_slug, artifact_dir=None, checkpoint_path=None):
366
+ expected_artifact_dir = artifact_dir or default_artifact_dir(feature_slug)
367
+ marker = marker_path(feature_slug)
368
+ report_path = newest_report_after_marker(marker)
369
+ if not report_path:
370
+ return {"status": "MISSING", "marker": marker}
371
+
372
+ result = validate_report(report_path, marker, feature_slug, expected_artifact_dir)
373
+ if result["status"] != "PASS":
374
+ return result
375
+
376
+ write_report_pointer(feature_slug, report_path)
377
+ checkpoint_ok, checkpoint_error = update_checkpoint(checkpoint_path)
378
+ if not checkpoint_ok:
379
+ result["status"] = "FAIL"
380
+ result["reason"] = "checkpoint_update_failed"
381
+ result["checkpoint_error"] = checkpoint_error
382
+ return result
383
+
384
+
385
+ def format_result(result):
386
+ status = result.get("status") or result.get("gate", "")
387
+ if status == "GATE:START":
388
+ return "GATE:START marker={}".format(result.get("marker", ""))
389
+ if status == "MISSING":
390
+ return "GATE:MISSING marker={}".format(result.get("marker", ""))
391
+ if status == "PASS":
392
+ return "GATE:PASS {}".format(result.get("report", ""))
393
+
394
+ ordered = [
395
+ "reason",
396
+ "verdict",
397
+ "mode",
398
+ "artifact_dir",
399
+ "boundary_gate",
400
+ "boundary_missing",
401
+ "happy_path_only",
402
+ "boundary_validation",
403
+ "validator_passed",
404
+ "scope_fresh",
405
+ "openapi",
406
+ "report",
407
+ "checkpoint_error",
408
+ "error",
409
+ ]
410
+ parts = []
411
+ for key in ordered:
412
+ value = result.get(key)
413
+ if value is not None and value != "":
414
+ parts.append("{}={}".format(key, value))
415
+ return "GATE:FAIL {}".format(" ".join(parts)).rstrip()
416
+
417
+
418
+ def build_parser():
419
+ parser = argparse.ArgumentParser(
420
+ description="Run the scoped PrizmKit feature test gate.",
421
+ )
422
+ subparsers = parser.add_subparsers(dest="command", required=True)
423
+
424
+ start = subparsers.add_parser("start", help="Create the test gate marker")
425
+ start.add_argument("--feature-slug", required=True)
426
+
427
+ check = subparsers.add_parser("check", help="Validate the latest scoped test report")
428
+ check.add_argument("--feature-slug", required=True)
429
+ check.add_argument("--artifact-dir", default="")
430
+ check.add_argument("--checkpoint-path", default="")
431
+ return parser
432
+
433
+
434
+ def main(argv=None):
435
+ parser = build_parser()
436
+ args = parser.parse_args(argv)
437
+ if args.command == "start":
438
+ result = start_gate(args.feature_slug)
439
+ else:
440
+ result = check_gate(args.feature_slug, args.artifact_dir, args.checkpoint_path)
441
+ print(format_result(result))
442
+ return 0
443
+
444
+
445
+ if __name__ == "__main__":
446
+ sys.exit(main())
@@ -1,13 +1,12 @@
1
1
  ### Scoped Feature Test Gate — PrizmKit Test
2
2
 
3
- **Goal**: Generate and verify tests for this feature's changed scope before code review.
3
+ **Goal**: Generate and verify tests for this feature's changed scope before code review without embedding the full gate implementation in this prompt.
4
4
 
5
5
  Create a start marker immediately before invoking the skill so the gate can reject stale reports from older sessions:
6
6
 
7
7
  ```powershell
8
- New-Item -ItemType Directory -Force -Path ".prizmkit/specs/{{FEATURE_SLUG}}" | Out-Null
9
- $testGateMarker = ".prizmkit/specs/{{FEATURE_SLUG}}/.prizmkit-test-started"
10
- New-Item -ItemType File -Force -Path $testGateMarker | Out-Null
8
+ Invoke-PrizmPython {{PIPELINE_DIR}}\scripts\prizmkit-test-gate.py start `
9
+ --feature-slug "{{FEATURE_SLUG}}"
11
10
  ```
12
11
 
13
12
  Run `/prizmkit-test` with the feature artifact directory:
@@ -24,163 +23,17 @@ Rules:
24
23
  - If failures are baseline or unrelated to this feature, document them in the report and continue only when no in-scope failure remains unexplained.
25
24
 
26
25
  **Gate Check — Test Report**:
27
- After `/prizmkit-test` returns, locate the newest test report created after the start marker and validate that it belongs to this feature scope, has a PASS verdict, and has passed the boundary completion/validation gates before writing the checkpoint pointer:
28
- ```powershell
29
- $featureArtifactDir = ".prizmkit/specs/{{FEATURE_SLUG}}/"
30
- $latestTestReport = Get-ChildItem -Path ".prizmkit/test" -Filter "test-report.md" -Recurse -ErrorAction SilentlyContinue |
31
- Where-Object { $_.LastWriteTime -ge (Get-Item -LiteralPath $testGateMarker).LastWriteTime } |
32
- Sort-Object FullName |
33
- Select-Object -Last 1
26
+ After `/prizmkit-test` returns, run the gate script to locate the newest current-run report, validate scope/freshness/boundary coverage, write `test-report-path.txt`, and mark the `prizmkit-test` checkpoint completed only on `GATE:PASS`:
34
27
 
35
- if ($latestTestReport) {
36
- $lines = Get-Content -LiteralPath $latestTestReport.FullName -ErrorAction SilentlyContinue
37
- $inScope = $false
38
- $reportMode = ""
39
- $reportArtifactDir = ""
40
- $reportVerdict = ""
41
- $inBoundaryGate = $false
42
- $inBoundaryValidation = $false
43
- $inBoundaryMatrix = $false
44
- $boundaryGatePassed = ""
45
- $boundaryMissing = ""
46
- $happyPathOnly = ""
47
- $boundaryValidationResult = ""
48
- $boundaryColumnsOk = $false
49
- $boundaryIncomplete = $false
50
- for ($i = 0; $i -lt $lines.Count; $i++) {
51
- $line = $lines[$i]
52
- if ($line -match '^## Scope\s*$') { $inScope = $true; $inBoundaryGate = $false; $inBoundaryValidation = $false; $inBoundaryMatrix = $false; continue }
53
- if ($line -match '^## Boundary Matrix\s*$') { $inBoundaryMatrix = $true; $inScope = $false; $inBoundaryGate = $false; $inBoundaryValidation = $false; continue }
54
- if ($line -match '^## Boundary Completion Gate\s*$') { $inBoundaryGate = $true; $inScope = $false; $inBoundaryValidation = $false; $inBoundaryMatrix = $false; continue }
55
- if ($line -match '^## Boundary Validation\s*$') { $inBoundaryValidation = $true; $inScope = $false; $inBoundaryGate = $false; $inBoundaryMatrix = $false; continue }
56
- if ($line -match '^## ' -and $inScope) { $inScope = $false }
57
- if ($line -match '^## ' -and $inBoundaryMatrix) { $inBoundaryMatrix = $false }
58
- if ($line -match '^## ' -and $inBoundaryGate) { $inBoundaryGate = $false }
59
- if ($line -match '^## ' -and $inBoundaryValidation) { $inBoundaryValidation = $false }
60
- if ($inScope -and $line -match '^- Mode:\s*(.+)$') { $reportMode = $Matches[1].Trim() }
61
- if ($inScope -and $line -match '^- Artifact Dir:\s*(.+)$') { $reportArtifactDir = $Matches[1].Trim() }
62
- if ($inBoundaryMatrix -and $line -match '^\|') {
63
- $hasRequiredColumns =
64
- $line -match '\|\s*Module\s*\|' -and
65
- $line -match '\|\s*Interface\s*\|' -and
66
- $line -match '\|\s*Service Type\s*\|' -and
67
- $line -match '\|\s*Happy Path\s*\|' -and
68
- $line -match '\|\s*Request Validation\s*\|' -and
69
- $line -match '\|\s*Auth / Permission / Ownership\s*\|' -and
70
- $line -match '\|\s*Domain Invariants\s*\|' -and
71
- $line -match '\|\s*Collection / Pagination\s*\|' -and
72
- $line -match '\|\s*Date / Time\s*\|' -and
73
- $line -match '\|\s*State Transitions\s*\|' -and
74
- $line -match '\|\s*Dependency Failure\s*\|' -and
75
- $line -match '\|\s*Response Contract\s*\|' -and
76
- $line -match '\|\s*N/A Reasons\s*\|' -and
77
- $line -match '\|\s*Final Status\s*\|'
78
- if ($hasRequiredColumns) { $boundaryColumnsOk = $true }
79
- if ($line -match 'boundary-missing|happy-path-only|unresolved') { $boundaryIncomplete = $true }
80
- }
81
- if ($inBoundaryGate -and $line -match '^-?\s*Completion gate passed:\s*yes\s*$') { $boundaryGatePassed = "yes" }
82
- if ($inBoundaryGate -and $line -match '^-?\s*Boundary-missing:\s*(\d+)\s*$') { $boundaryMissing = $Matches[1].Trim() }
83
- if ($inBoundaryGate -and $line -match '^-?\s*Happy-path-only:\s*(\d+)\s*$') { $happyPathOnly = $Matches[1].Trim() }
84
- if ($inBoundaryValidation -and $line -match '^-?\s*Validator result:\s*(\S+)') { $boundaryValidationResult = $Matches[1].Trim().ToLowerInvariant() }
85
- if ($line -match '^## Verdict\s*$') {
86
- for ($j = $i + 1; $j -lt $lines.Count; $j++) {
87
- if ($lines[$j].Trim()) { $reportVerdict = $lines[$j].Trim(); break }
88
- }
89
- break
90
- }
91
- }
92
- $openapiFile = ""
93
- $openapiCandidates = @(
94
- "openapi.yaml", "openapi.yml", "swagger.yaml", "swagger.yml",
95
- "docs/openapi.yaml", "docs/openapi.yml", "docs/swagger.yaml", "docs/swagger.yml",
96
- "api/openapi.yaml", "api/openapi.yml", "api/swagger.yaml", "api/swagger.yml"
97
- )
98
- foreach ($openapiCandidate in $openapiCandidates) {
99
- if (Test-Path -LiteralPath $openapiCandidate) { $openapiFile = $openapiCandidate; break }
100
- }
101
- $boundaryValidatorPassed = $false
102
- $boundaryValidators = @(
103
- ".claude/command-assets/prizmkit-test/scripts/validate_boundary_report.py",
104
- ".codebuddy/skills/prizmkit-test/scripts/validate_boundary_report.py",
105
- ".agents/skills/prizmkit-test/scripts/validate_boundary_report.py"
106
- )
107
- foreach ($boundaryValidator in $boundaryValidators) {
108
- if (Test-Path -LiteralPath $boundaryValidator) {
109
- $boundaryValidatorArgs = @($boundaryValidator, "--report", $latestTestReport.FullName)
110
- if ($openapiFile) { $boundaryValidatorArgs += @("--openapi", $openapiFile) }
111
- Invoke-PrizmPython @boundaryValidatorArgs *> $null
112
- if ($LASTEXITCODE -eq 0) { $boundaryValidatorPassed = $true; break }
113
- }
114
- }
115
- $scopeFreshOk = $false
116
- try {
117
- $reportMtime = (Get-Item -LiteralPath $latestTestReport.FullName).LastWriteTimeUtc
118
- $markerMtime = (Get-Item -LiteralPath $testGateMarker).LastWriteTimeUtc
119
- if ($reportMtime -ge $markerMtime) {
120
- function Get-ScopedFileList($sectionName) {
121
- $inScopeSection = $false
122
- $inList = $false
123
- $values = @()
124
- foreach ($rawLine in $lines) {
125
- $line = $rawLine.Trim()
126
- if ($line -match '^## Scope\s*$') { $inScopeSection = $true; continue }
127
- if ($inScopeSection -and $line -match '^## ') { break }
128
- if (-not $inScopeSection) { continue }
129
- if ($line -eq "- ${sectionName}:") { $inList = $true; continue }
130
- if ($inList) {
131
- if ($rawLine -match '^\s{2,}-\s+(.+)$') {
132
- $value = $Matches[1].Trim().Trim('`')
133
- if ($value -and $value -ne '{...}' -and $value -ne '...') { $values += $value }
134
- continue
135
- }
136
- if ($line -match '^- ') { break }
137
- }
138
- }
139
- return $values
140
- }
141
- $sourceFiles = @(Get-ScopedFileList "In-Scope Source Files")
142
- $testFiles = @(Get-ScopedFileList "In-Scope Test Files")
143
- if ($sourceFiles.Count -gt 0 -and $testFiles.Count -gt 0) {
144
- $scopeFreshOk = $true
145
- $projectRoot = (Resolve-Path ".").Path
146
- $ignored = @('none', 'n/a', 'not applicable', '(none)', '{...}', '...')
147
- foreach ($listedFile in $sourceFiles + $testFiles) {
148
- $normalized = $listedFile.Trim().Trim('`')
149
- if (-not $normalized -or $ignored -contains $normalized.ToLowerInvariant()) { $scopeFreshOk = $false; break }
150
- if ([System.IO.Path]::IsPathRooted($normalized)) { $candidate = $normalized } else { $candidate = Join-Path $projectRoot $normalized }
151
- if (-not (Test-Path -LiteralPath $candidate -PathType Leaf)) { $scopeFreshOk = $false; break }
152
- $candidatePath = (Resolve-Path -LiteralPath $candidate).Path
153
- if (-not ($candidatePath -eq $projectRoot -or $candidatePath.StartsWith($projectRoot + [System.IO.Path]::DirectorySeparatorChar))) { $scopeFreshOk = $false; break }
154
- if ((Get-Item -LiteralPath $candidatePath).LastWriteTimeUtc -gt $reportMtime) { $scopeFreshOk = $false; break }
155
- }
156
- }
157
- }
158
- } catch {
159
- $scopeFreshOk = $false
160
- }
161
- $artifactOk = $reportArtifactDir -eq $featureArtifactDir -or $reportArtifactDir -eq $featureArtifactDir.TrimEnd('/')
162
- $boundaryOk = $boundaryColumnsOk -and -not $boundaryIncomplete -and $boundaryGatePassed -eq "yes" -and $boundaryMissing -eq "0" -and $happyPathOnly -eq "0" -and $boundaryValidationResult -eq "passed" -and $boundaryValidatorPassed -and $scopeFreshOk
163
- if ($reportMode -eq "this-change" -and $artifactOk -and $reportVerdict -eq "PASS" -and $boundaryOk) {
164
- Set-Content -LiteralPath ".prizmkit/specs/{{FEATURE_SLUG}}/test-report-path.txt" -Value $latestTestReport.FullName
165
- "GATE:PASS $($latestTestReport.FullName)"
166
- } else {
167
- "GATE:FAIL verdict=$reportVerdict mode=$reportMode artifact_dir=$reportArtifactDir boundary_gate=$boundaryGatePassed boundary_missing=$boundaryMissing happy_path_only=$happyPathOnly boundary_validation=$boundaryValidationResult validator_passed=$boundaryValidatorPassed scope_fresh=$scopeFreshOk openapi=$openapiFile report=$($latestTestReport.FullName)"
168
- }
169
- } else {
170
- "GATE:MISSING"
171
- }
28
+ ```powershell
29
+ Invoke-PrizmPython {{PIPELINE_DIR}}\scripts\prizmkit-test-gate.py check `
30
+ --feature-slug "{{FEATURE_SLUG}}" `
31
+ --artifact-dir ".prizmkit/specs/{{FEATURE_SLUG}}/" `
32
+ --checkpoint-path "{{CHECKPOINT_PATH}}"
172
33
  ```
173
34
 
174
35
  Gate outcomes:
175
- - `GATE:PASS` → append the report path and a 3-5 bullet summary to `context-snapshot.md` under `## PrizmKit Test Gate`, then update the checkpoint.
36
+ - `GATE:PASS` → append the report path and a 3-5 bullet summary to `context-snapshot.md` under `## PrizmKit Test Gate`, then proceed to the next checkpoint step. The gate script already wrote `test-report-path.txt` and marked `prizmkit-test` completed.
176
37
  - `GATE:FAIL` with `NEEDS_FIXES` → read the report's `In-Scope Failures`, fix the feature implementation or generated tests within this feature scope, rerun `/prizmkit-test`, and repeat the gate check. Do not proceed to code review while the verdict is not `PASS`.
177
- - `GATE:FAIL` with `BLOCKED`, wrong mode, wrong artifact dir, or unreadable report → write `.prizmkit/specs/{{FEATURE_SLUG}}/failure-log.md` with the scoped test failure and stop for recovery.
38
+ - `GATE:FAIL` with `BLOCKED`, wrong mode, wrong artifact dir, unreadable report, stale scope, boundary failure, validator failure, or checkpoint update failure → write `.prizmkit/specs/{{FEATURE_SLUG}}/failure-log.md` with the scoped test failure and stop for recovery.
178
39
  - `GATE:MISSING` → perform one bounded status check: inspect `/prizmkit-test` output and `.prizmkit/test/` for a report directory. If no current-run report exists, write `failure-log.md` and stop for recovery.
179
-
180
- Only after `GATE:PASS`, run the update script to set step `prizmkit-test` to `"completed"`:
181
- ```powershell
182
- Invoke-PrizmPython {{PIPELINE_DIR}}\scripts\update-checkpoint.py `
183
- --checkpoint-path {{CHECKPOINT_PATH}} `
184
- --step prizmkit-test `
185
- --status completed
186
- ```
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "1.1.87",
2
+ "version": "1.1.89",
3
3
  "skills": {
4
4
  "prizm-kit": {
5
5
  "description": "Full-lifecycle dev toolkit. Covers spec-driven development, Prizm context docs, code quality, debugging, deployment, and knowledge management.",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "prizmkit",
3
- "version": "1.1.87",
3
+ "version": "1.1.89",
4
4
  "description": "Create a new PrizmKit-powered project with clean initialization — no framework dev files, just what you need.",
5
5
  "type": "module",
6
6
  "bin": {