@luizsantiago/spec-guardrails 3.0.1

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.
Files changed (63) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +206 -0
  3. package/index.js +335 -0
  4. package/lib/archive.js +208 -0
  5. package/lib/assets.js +145 -0
  6. package/lib/brownfield.js +446 -0
  7. package/lib/config.js +293 -0
  8. package/lib/constants.js +262 -0
  9. package/lib/cursorrules.js +92 -0
  10. package/lib/delta-merge.js +248 -0
  11. package/lib/doctor.js +343 -0
  12. package/lib/download.js +133 -0
  13. package/lib/feature.js +272 -0
  14. package/lib/fs-utils.js +114 -0
  15. package/lib/gates.js +138 -0
  16. package/lib/install.js +140 -0
  17. package/lib/memory.js +34 -0
  18. package/lib/next-steps.js +50 -0
  19. package/lib/presets.js +176 -0
  20. package/lib/project-rules.js +210 -0
  21. package/lib/specs-utils.js +117 -0
  22. package/lib/token-cost.js +124 -0
  23. package/package.json +46 -0
  24. package/rules/engineering-baseline.mdc +56 -0
  25. package/scripts/_common.py +356 -0
  26. package/scripts/analyze_artifacts.py +187 -0
  27. package/scripts/check_commit.py +140 -0
  28. package/scripts/lessons.py +447 -0
  29. package/scripts/loop_plan.py +217 -0
  30. package/scripts/validate_spec.py +345 -0
  31. package/scripts/validate_state.py +385 -0
  32. package/scripts/validate_tasks.py +379 -0
  33. package/skills/agent-architecture.md +221 -0
  34. package/skills/appsec.md +83 -0
  35. package/skills/code-simplify.md +49 -0
  36. package/skills/engineering-standards.md +98 -0
  37. package/skills/git-handoff.md +213 -0
  38. package/skills/qa-strategy.md +83 -0
  39. package/skills/references/analyze.md +56 -0
  40. package/skills/references/archive.md +60 -0
  41. package/skills/references/constitution.md +66 -0
  42. package/skills/references/context-limits.md +73 -0
  43. package/skills/references/converge.md +47 -0
  44. package/skills/references/design.md +88 -0
  45. package/skills/references/discuss.md +68 -0
  46. package/skills/references/explore.md +61 -0
  47. package/skills/references/implement.md +175 -0
  48. package/skills/references/lessons.md +71 -0
  49. package/skills/references/memory.md +98 -0
  50. package/skills/references/project-init.md +62 -0
  51. package/skills/references/quick-mode.md +84 -0
  52. package/skills/references/specify.md +144 -0
  53. package/skills/references/sub-agents.md +117 -0
  54. package/skills/references/tasks.md +178 -0
  55. package/skills/references/validate.md +210 -0
  56. package/skills/security-review.md +120 -0
  57. package/skills/ship-ready.md +50 -0
  58. package/skills/task-graph-engineering.md +180 -0
  59. package/templates/GETTING_STARTED.md +61 -0
  60. package/templates/config.yaml.example +28 -0
  61. package/templates/presets/default.yaml +16 -0
  62. package/templates/presets/node-ts.yaml +22 -0
  63. package/templates/presets/python.yaml +22 -0
@@ -0,0 +1,385 @@
1
+ #!/usr/bin/env python3
2
+ """Completion gate for a feature directory under `.specs/features/`.
3
+
4
+ Run before declaring a feature done:
5
+
6
+ python3 validate_state.py .specs/features/auth
7
+ python3 validate_state.py auth
8
+ python3 validate_state.py # when the project has a single feature
9
+
10
+ Checks:
11
+ * spec.md exists
12
+ * validation.md exists and was written by the independent verifier
13
+ * the verdict is filled and reads PASS (preamble or ## Verdict heading only)
14
+ * the report cites file:line evidence (evidence-or-zero)
15
+ * every spec requirement ID has test evidence on the same coverage line
16
+ * the discrimination sensor result is recorded (blocking on Medium+ features)
17
+ * PASS with a surviving mutant fails; Medium+ PASS requires at least one kill
18
+ * PASS with open Gaps or a failing Security Review result fails
19
+ * open task checkboxes in tasks.md block completion
20
+
21
+ Exit codes: 0 pass, 1 blocking issues, 2 usage error.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import argparse
27
+ import re
28
+ import sys
29
+ from pathlib import Path
30
+
31
+ from _common import (
32
+ Report,
33
+ find_placeholders,
34
+ mask_fenced_blocks,
35
+ requirement_ids,
36
+ resolve_feature_dir,
37
+ section_body,
38
+ visible_markdown,
39
+ )
40
+
41
+ GATE = "validate-state"
42
+
43
+ VERDICT = re.compile(
44
+ r"^\s*[-*]?\s*\*{0,2}(?:verdict|result|status)\*{0,2}\s*:\s*\*{0,2}(?P<value>[A-Za-z ]+)",
45
+ re.IGNORECASE | re.MULTILINE,
46
+ )
47
+ VERDICT_HEADING = re.compile(
48
+ r"^#{1,6}\s*(?:verdict|result|status)\s*$\s*\n+\s*\*{0,2}(?P<value>[A-Za-z ]+)",
49
+ re.IGNORECASE | re.MULTILINE,
50
+ )
51
+ SECTION_START = re.compile(r"^#{2,6}\s", re.MULTILINE)
52
+ EVIDENCE = re.compile(r"[\w./\\-]+\.[A-Za-z][A-Za-z0-9]{0,9}:\d{1,6}\b")
53
+ URL = re.compile(r"\b[a-z][a-z0-9+.-]*://\S+", re.IGNORECASE)
54
+ SENSOR = re.compile(r"(discrimination sensor|mutant)", re.IGNORECASE)
55
+ # Outcome words only — the section title "Discrimination Sensor" must not count.
56
+ SENSOR_RESULT = re.compile(r"\b(killed|survived|injected)\b", re.IGNORECASE)
57
+ SENSOR_KILLED = re.compile(r"\bkilled\b", re.IGNORECASE)
58
+ SENSOR_SURVIVED = re.compile(r"\bsurvived\b", re.IGNORECASE)
59
+ SENSOR_SECTION = re.compile(
60
+ r"^(?P<level>#{2,6})\s*(?:Discrimination Sensor|Mutants?)\b",
61
+ re.MULTILINE | re.IGNORECASE,
62
+ )
63
+ GAPS_SECTION = re.compile(
64
+ r"^(?P<level>#{2,6})\s*Gaps?\b",
65
+ re.MULTILINE | re.IGNORECASE,
66
+ )
67
+ SECURITY_SECTION = re.compile(
68
+ r"^(?P<level>#{2,6})\s*Security Review\b",
69
+ re.MULTILINE | re.IGNORECASE,
70
+ )
71
+ SECURITY_FAIL = re.compile(
72
+ r"^\s*[-*]?\s*\*{0,2}Result\*{0,2}\s*:\s*\*{0,2}(?P<value>fail|failed|failing)\b",
73
+ re.IGNORECASE | re.MULTILINE,
74
+ )
75
+ SECTION_HEADING = re.compile(r"^(?P<level>#{1,6})\s", re.MULTILINE)
76
+ OPEN_TASK = re.compile(r"^\s*[-*]\s*\[ \]\s+(?P<label>.+)$", re.MULTILINE)
77
+ TASK_HEADING = re.compile(
78
+ r"^#{2,6}\s*T\d{1,6}\b", re.MULTILINE | re.IGNORECASE
79
+ )
80
+ PHASE_HEADING = re.compile(
81
+ r"^#{1,6}\s*Phase\s+\d+\b", re.MULTILINE | re.IGNORECASE
82
+ )
83
+ REQUIREMENT_REF = re.compile(r"\b[A-Z][A-Z0-9]{1,9}-\d{2,4}\b")
84
+ # evidence-or-zero requires a test path, not an arbitrary file:line such as config.yaml:12
85
+ TEST_EVIDENCE = re.compile(
86
+ r"(?:^|/)(?:tests?|__tests__|spec)(?:/|$)|[._-](?:test|spec)\.|test_[^/]+\.",
87
+ re.IGNORECASE,
88
+ )
89
+ PASS_VERDICTS = {"PASS", "PASSED"}
90
+ FAIL_VERDICTS = {"FAIL", "FAILED"}
91
+ MEDIUM_TASK_FLOOR = 4
92
+
93
+
94
+ def validation_preamble(text: str) -> str:
95
+ """Return the text before the first `##` section (fences already ignored)."""
96
+
97
+ visible = visible_markdown(text)
98
+ match = SECTION_START.search(visible)
99
+ if not match:
100
+ return visible
101
+ return visible[: match.start()]
102
+
103
+
104
+ def find_verdict(text: str) -> re.Match[str] | None:
105
+ """Accept Verdict only in the preamble or as a dedicated ## Verdict heading."""
106
+
107
+ preamble = validation_preamble(text)
108
+ return VERDICT.search(preamble) or VERDICT_HEADING.search(visible_markdown(text))
109
+
110
+
111
+ def verdict_conflict(text: str) -> tuple[str, str] | None:
112
+ """Return (preamble, heading) values when both exist and disagree."""
113
+
114
+ preamble = validation_preamble(text)
115
+ preamble_match = VERDICT.search(preamble)
116
+ heading_match = VERDICT_HEADING.search(visible_markdown(text))
117
+ if not preamble_match or not heading_match:
118
+ return None
119
+
120
+ left = re.sub(r"\s+", " ", preamble_match.group("value").strip().upper())
121
+ right = re.sub(r"\s+", " ", heading_match.group("value").strip().upper())
122
+ if left == right:
123
+ return None
124
+ return left, right
125
+
126
+
127
+ def sensor_focus(text: str) -> str:
128
+ """Text where mutant outcomes must appear: sensor sections and mutant lines."""
129
+
130
+ visible = visible_markdown(text)
131
+ chunks: list[str] = []
132
+
133
+ for match in SENSOR_SECTION.finditer(visible):
134
+ level = len(match.group("level"))
135
+ start = match.end()
136
+ end = len(visible)
137
+ for heading in SECTION_HEADING.finditer(visible, start):
138
+ if len(heading.group("level")) <= level:
139
+ end = heading.start()
140
+ break
141
+ chunks.append(visible[match.start() : end])
142
+
143
+ for line in visible.splitlines():
144
+ if SENSOR.search(line):
145
+ chunks.append(line)
146
+
147
+ return "\n".join(chunks)
148
+
149
+
150
+ def find_evidence(text: str) -> list[str]:
151
+ """Return test file:line references, ignoring fences, comments, URLs, and ports."""
152
+
153
+ visible = visible_markdown(text)
154
+ hits = EVIDENCE.findall(URL.sub(" ", visible))
155
+ return [hit for hit in hits if TEST_EVIDENCE.search(hit.replace("\\", "/"))]
156
+
157
+
158
+ def open_gap_lines(text: str) -> list[str]:
159
+ """Return non-empty Gaps bullets that are not an explicit none placeholder."""
160
+
161
+ body = section_body(visible_markdown(text), GAPS_SECTION)
162
+ if body is None:
163
+ return []
164
+ gaps: list[str] = []
165
+ none_values = {
166
+ "none",
167
+ "n/a",
168
+ "na",
169
+ "-",
170
+ "—",
171
+ "–",
172
+ "no gaps",
173
+ }
174
+ for raw in body.splitlines():
175
+ line = raw.strip()
176
+ marker = re.match(r"^[-*]\s+(.*)$", line)
177
+ if not marker:
178
+ continue
179
+ # Do not use lstrip("-* ") — it eats emphasis markers on `**none**`.
180
+ cleaned = marker.group(1).strip().strip("`")
181
+ while len(cleaned) >= 2 and cleaned[0] == cleaned[-1] and cleaned[0] in "*_":
182
+ cleaned = cleaned[1:-1].strip()
183
+ if not cleaned or cleaned.lower() in none_values:
184
+ continue
185
+ gaps.append(cleaned)
186
+ return gaps
187
+
188
+
189
+ def security_blocks_pass(text: str) -> bool:
190
+ """True when Security Review explicitly records a failing result."""
191
+
192
+ body = section_body(visible_markdown(text), SECURITY_SECTION)
193
+ if body is None:
194
+ return False
195
+ return bool(SECURITY_FAIL.search(body))
196
+
197
+
198
+ def is_medium_plus(feature_dir: Path) -> bool:
199
+ """Medium+ when a non-empty design exists, tasks are substantial, or work is phased."""
200
+
201
+ design_path = feature_dir / "design.md"
202
+ if design_path.is_file() and design_path.read_text(encoding="utf-8").strip():
203
+ return True
204
+
205
+ tasks_path = feature_dir / "tasks.md"
206
+ if not tasks_path.is_file():
207
+ return False
208
+
209
+ tasks = mask_fenced_blocks(tasks_path.read_text(encoding="utf-8"))
210
+ task_count = len(TASK_HEADING.findall(tasks))
211
+ phase_count = len(PHASE_HEADING.findall(tasks))
212
+ return task_count >= MEDIUM_TASK_FLOOR or phase_count >= 2
213
+
214
+
215
+ def requirement_evidence_gaps(spec_text: str, validation: str) -> list[str]:
216
+ """Return requirement IDs that lack a same-line test evidence citation."""
217
+
218
+ missing: list[str] = []
219
+ visible = visible_markdown(validation)
220
+ for requirement_id in requirement_ids(visible_markdown(spec_text)):
221
+ covered = False
222
+ for line in visible.splitlines():
223
+ if requirement_id not in line:
224
+ continue
225
+ if find_evidence(line):
226
+ covered = True
227
+ break
228
+ if not covered:
229
+ missing.append(requirement_id)
230
+ return missing
231
+
232
+
233
+ def build_report(feature_dir: Path) -> Report:
234
+ report = Report(gate=GATE, target=str(feature_dir))
235
+
236
+ spec_path = feature_dir / "spec.md"
237
+ spec_text = ""
238
+ if spec_path.exists() and spec_path.read_text(encoding="utf-8").strip():
239
+ report.ok("spec.md present")
240
+ spec_text = spec_path.read_text(encoding="utf-8")
241
+ else:
242
+ report.error("spec.md missing or empty - a feature cannot close without a spec")
243
+
244
+ validation_path = feature_dir / "validation.md"
245
+ if not validation_path.exists():
246
+ report.error(
247
+ "validation.md missing - run /verify with an independent verifier "
248
+ "before closing the feature"
249
+ )
250
+ return report
251
+
252
+ validation = validation_path.read_text(encoding="utf-8")
253
+ if not validation.strip():
254
+ report.error("validation.md is empty")
255
+ return report
256
+
257
+ verdict: str | None = None
258
+ conflict = verdict_conflict(validation)
259
+ if conflict:
260
+ left, right = conflict
261
+ report.error(
262
+ f"conflicting verdicts: preamble says '{left}' but ## Verdict says '{right}'"
263
+ )
264
+
265
+ verdict_match = find_verdict(validation)
266
+ if not verdict_match:
267
+ report.error(
268
+ "validation.md has no verdict - add 'Verdict: PASS' in the preamble "
269
+ "(before ## sections) or a '## Verdict' heading"
270
+ )
271
+ else:
272
+ verdict = re.sub(r"\s+", " ", verdict_match.group("value").strip().upper())
273
+ if verdict in PASS_VERDICTS:
274
+ report.ok("verifier verdict is PASS")
275
+ elif verdict in FAIL_VERDICTS:
276
+ report.error("verifier verdict is FAIL - resolve gaps and re-verify")
277
+ else:
278
+ report.error(
279
+ f"verifier verdict is not PASS: '{verdict}' - "
280
+ "write PASS only with no remaining gaps"
281
+ )
282
+
283
+ evidence = find_evidence(validation)
284
+ if evidence:
285
+ report.ok(f"{len(evidence)} file:line evidence reference(s)")
286
+ else:
287
+ report.error(
288
+ "no file:line evidence found - evidence-or-zero requires test references "
289
+ "such as test/auth/token.test.ts:41 (a URL is not evidence)"
290
+ )
291
+
292
+ if spec_text.strip():
293
+ gaps = requirement_evidence_gaps(spec_text, validation)
294
+ if gaps:
295
+ for requirement_id in gaps:
296
+ report.error(
297
+ f"{requirement_id} has no test file:line on the same coverage line"
298
+ )
299
+ else:
300
+ report.ok("every spec requirement has test evidence")
301
+
302
+ focus = sensor_focus(validation)
303
+ if verdict in PASS_VERDICTS and SENSOR_SURVIVED.search(focus):
304
+ report.error(
305
+ "verdict is PASS but a mutant survived - kill every mutant before closing"
306
+ )
307
+
308
+ medium_plus = is_medium_plus(feature_dir)
309
+ has_sensor = bool(SENSOR.search(validation) and SENSOR_RESULT.search(focus))
310
+ if has_sensor:
311
+ if (
312
+ medium_plus
313
+ and verdict in PASS_VERDICTS
314
+ and not SENSOR_KILLED.search(focus)
315
+ ):
316
+ report.error(
317
+ "Medium+ PASS requires at least one killed mutant "
318
+ "(injected alone is not enough to close)"
319
+ )
320
+ else:
321
+ report.ok("discrimination sensor result recorded")
322
+ elif medium_plus:
323
+ report.error(
324
+ "Medium+ feature requires a discrimination sensor result "
325
+ "(mutant injected/killed/survived) before closing"
326
+ )
327
+ else:
328
+ report.warn(
329
+ "no discrimination sensor section found - confirm mutants were injected"
330
+ )
331
+
332
+ if verdict in PASS_VERDICTS:
333
+ for gap in open_gap_lines(validation)[:10]:
334
+ report.error(
335
+ f"verdict is PASS but Gaps still lists '{gap}' - "
336
+ "resolve gaps or write FAIL"
337
+ )
338
+ if security_blocks_pass(validation):
339
+ report.error(
340
+ "verdict is PASS but Security Review result is fail - "
341
+ "resolve findings or write FAIL"
342
+ )
343
+
344
+ tasks_path = feature_dir / "tasks.md"
345
+ if tasks_path.exists():
346
+ open_tasks = OPEN_TASK.findall(
347
+ mask_fenced_blocks(tasks_path.read_text(encoding="utf-8"))
348
+ )
349
+ if open_tasks:
350
+ for label in open_tasks[:10]:
351
+ report.error(f"open task remains: {label.strip()}")
352
+ else:
353
+ report.ok("all tasks are checked off")
354
+
355
+ placeholders = find_placeholders(validation)
356
+ if placeholders:
357
+ for item in placeholders[:10]:
358
+ report.error(f"unresolved placeholder in validation.md at {item}")
359
+
360
+ return report
361
+
362
+
363
+ def main(argv: list[str] | None = None) -> int:
364
+ parser = argparse.ArgumentParser(
365
+ description="Validate that a feature is ready to be declared done"
366
+ )
367
+ parser.add_argument(
368
+ "feature",
369
+ nargs="?",
370
+ help="feature name or path to .specs/features/[feature]",
371
+ )
372
+ parser.add_argument(
373
+ "--strict",
374
+ action="store_true",
375
+ help="treat warnings as blocking failures",
376
+ )
377
+ args = parser.parse_args(argv)
378
+
379
+ feature_dir = resolve_feature_dir(args.feature, GATE)
380
+ report = build_report(feature_dir)
381
+ return report.emit(strict=args.strict)
382
+
383
+
384
+ if __name__ == "__main__":
385
+ sys.exit(main())