@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
package/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "@luizsantiago/spec-guardrails",
3
+ "version": "3.0.1",
4
+ "description": "Guardrails for AI coding agents — spec-driven phases, automatic gates, progressive skill loading (~70% fewer tokens per turn). Works in Cursor and Claude.",
5
+ "type": "module",
6
+ "bin": {
7
+ "spec-guardrails": "./index.js"
8
+ },
9
+ "engines": {
10
+ "node": ">=18"
11
+ },
12
+ "scripts": {
13
+ "guardrails": "node index.js",
14
+ "test": "npm run test:node && npm run test:gates",
15
+ "test:node": "node --test test/install.test.js test/test_feature_init.test.js test/test_config.test.js test/test_archive.test.js test/test_delta_merge.test.js test/test_presets.test.js test/test_brownfield.test.js test/test_doctor.test.js test/test_token_cost.test.js test/test_next_steps.test.js",
16
+ "test:gates": "node test/run-gate-tests.mjs",
17
+ "prepublishOnly": "npm test"
18
+ },
19
+ "files": [
20
+ "index.js",
21
+ "lib/",
22
+ "skills/",
23
+ "rules/",
24
+ "scripts/*.py",
25
+ "templates/",
26
+ "LICENSE"
27
+ ],
28
+ "repository": {
29
+ "type": "git",
30
+ "url": "git+https://github.com/luizssantiago92/spec-guardrails.git"
31
+ },
32
+ "keywords": [
33
+ "ai",
34
+ "agent",
35
+ "cursor",
36
+ "claude",
37
+ "spec-driven",
38
+ "guardrails",
39
+ "cli"
40
+ ],
41
+ "author": "luizsantiago",
42
+ "license": "MIT",
43
+ "publishConfig": {
44
+ "access": "public"
45
+ }
46
+ }
@@ -0,0 +1,56 @@
1
+ ---
2
+ description: Engineering baseline and guardrails map for this repository
3
+ alwaysApply: true
4
+ ---
5
+
6
+ # Engineering Baseline
7
+
8
+ - Follow existing codebase conventions before introducing new patterns.
9
+ - Run tests and linters before considering work done.
10
+ - Never commit secrets, API keys, or credentials.
11
+ - Prefer small, focused diffs over large refactors.
12
+ - Touch only the files the current task requires; park out-of-scope ideas in `.specs/STATE.md`.
13
+ - Apply secure coding practices from `.cursor/skills/engineering-standards.md`.
14
+ - Run the security checklist from `.cursor/skills/security-review.md` during `/verify`.
15
+
16
+ # Artifact Language
17
+
18
+ All project artifacts are written in English: source code, tests, comments, commit messages, PR titles and descriptions, `.specs/` documents, and identifier names.
19
+
20
+ Chat language is a personal preference. Set it in your own agent settings if you want replies in another language.
21
+
22
+ # Guardrails Skills
23
+
24
+ <!-- guardrails-managed:skills-map:start -->
25
+ | Skill | Purpose |
26
+ | --- | --- |
27
+ | `.cursor/skills/agent-architecture.md` | SDD hub — contract, phases, gates, complexity router |
28
+ | `.cursor/skills/references/` | Phase procedures (explore, project-init, constitution, specify, discuss, design, tasks, analyze, implement, validate, converge, archive, memory, quick-mode, context-limits, lessons, sub-agents) |
29
+ | `.cursor/skills/task-graph-engineering.md` | Task DAG, parallelism, verify topology |
30
+ | `.cursor/skills/engineering-standards.md` | Code quality, secure coding, git hygiene |
31
+ | `.cursor/skills/security-review.md` | Security checklist for verification |
32
+ | `.cursor/skills/appsec.md` | Application security sister (load on demand) |
33
+ | `.cursor/skills/qa-strategy.md` | QA strategy sister (load on demand) |
34
+ | `.cursor/skills/code-simplify.md` | Simplification sister (load on demand) |
35
+ | `.cursor/skills/ship-ready.md` | Ship-ready sister (load on demand) |
36
+ | `.cursor/skills/git-handoff.md` | Git sync and session handoff |
37
+ <!-- guardrails-managed:skills-map:end -->
38
+
39
+ # Deterministic Gates
40
+
41
+ <!-- guardrails-managed:gates-map:start -->
42
+ Gates live in `.specs/guardrails/scripts/` (Python 3). **The agent runs them** at phase boundaries — see `agent-architecture.md`.
43
+
44
+ | You run (human) | When |
45
+ | --- | --- |
46
+ | `install` | Once (or after upgrading the package) |
47
+ | `feature-init "…"` | Optional — or ask the agent to `/specify` |
48
+ | `project-init` | Optional — brownfield repo with existing code |
49
+ | `doctor` | Install looks broken |
50
+
51
+ Everything else (`validate-spec`, `validate-tasks`, `check-commit`, …) is normally run **by the agent**, not memorized from this file. Full list: `npx @luizsantiago/spec-guardrails --help` or `.specs/GETTING_STARTED.md`.
52
+
53
+ Non-zero gate exit = STOP. Without Python, the agent performs the same checks manually.
54
+
55
+ **Git tiers:** Tier 0 (branch, local commits, `.specs/`) is automatic after you approve spec/tasks. Push, PR, merge, and deploy need owner go-ahead. See `git-handoff.md`.
56
+ <!-- guardrails-managed:gates-map:end -->
@@ -0,0 +1,356 @@
1
+ """Shared helpers for the Spec Guardrails structural gates.
2
+
3
+ Gates are deterministic: they read an artifact, apply structural checks, and exit
4
+ non-zero when the artifact is not ready for the next phase. They never mutate
5
+ project files.
6
+
7
+ Exit codes:
8
+ 0 - gate passed (warnings may still be printed)
9
+ 1 - gate failed; fix the artifact before proceeding
10
+ 2 - usage error (missing file, bad arguments)
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import re
16
+ import sys
17
+ from dataclasses import dataclass, field
18
+ from pathlib import Path
19
+
20
+ EXIT_OK = 0
21
+ EXIT_FAILED = 1
22
+ EXIT_USAGE = 2
23
+
24
+ # Angle brackets are ambiguous: `<fill me>` is an unfilled template, but
25
+ # `Promise<void>` and `List<User>` are types a real acceptance criterion may
26
+ # name. Only flag bracketed text that reads like prose (contains a space) or
27
+ # matches a known template token, so typed specs are not rejected.
28
+ ANGLE_TEMPLATE_TOKEN = (
29
+ r"tbd|todo|fixme|xxx|placeholder|fill[ -]?(?:me|in|this)?|"
30
+ r"your[ -][a-z0-9 _-]+|insert[ -][a-z0-9 _-]+"
31
+ )
32
+
33
+ PLACEHOLDER_PATTERNS = (
34
+ re.compile(r"\bTBD\b", re.IGNORECASE),
35
+ re.compile(r"\bTODO\b"),
36
+ re.compile(r"\bFIXME\b"),
37
+ re.compile(r"\bXXX\b"),
38
+ re.compile(rf"<\s*(?:{ANGLE_TEMPLATE_TOKEN})\s*>", re.IGNORECASE),
39
+ re.compile(r"<[a-z][a-z0-9_-]*(?:[ ][a-z0-9_-]+)+>", re.IGNORECASE),
40
+ # `[name]` style templates, but never a markdown link such as `[label](url)`
41
+ # and never a task checkbox such as `- [x]`.
42
+ re.compile(
43
+ r"\[(?:feature|name|description|fill me|placeholder)\](?!\()",
44
+ re.IGNORECASE,
45
+ ),
46
+ )
47
+
48
+
49
+ @dataclass
50
+ class Report:
51
+ """Collects gate findings and renders a deterministic summary."""
52
+
53
+ gate: str
54
+ target: str
55
+ errors: list[str] = field(default_factory=list)
56
+ warnings: list[str] = field(default_factory=list)
57
+ checks: list[str] = field(default_factory=list)
58
+
59
+ def error(self, message: str) -> None:
60
+ self.errors.append(message)
61
+
62
+ def warn(self, message: str) -> None:
63
+ self.warnings.append(message)
64
+
65
+ def ok(self, message: str) -> None:
66
+ self.checks.append(message)
67
+
68
+ @property
69
+ def passed(self) -> bool:
70
+ return not self.errors
71
+
72
+ def emit(self, strict: bool = False) -> int:
73
+ status = "PASS" if self.passed else "FAIL"
74
+ print(f"[{self.gate}] {status} - {self.target}")
75
+
76
+ for check in self.checks:
77
+ print(f" ok {check}")
78
+ for warning in self.warnings:
79
+ print(f" warn {warning}")
80
+ for error in self.errors:
81
+ print(f" error {error}")
82
+
83
+ if strict and self.warnings and self.passed:
84
+ print(" error strict mode: warnings are treated as failures")
85
+ return EXIT_FAILED
86
+
87
+ if not self.passed:
88
+ print(
89
+ f"\n{len(self.errors)} blocking issue(s). "
90
+ "Fix the artifact and re-run this gate before proceeding."
91
+ )
92
+ return EXIT_FAILED
93
+
94
+ return EXIT_OK
95
+
96
+
97
+ FEATURES_DIR = Path(".specs/features")
98
+
99
+ REQUIREMENT_ID = re.compile(
100
+ r"^(?P<level>#{2,6})\s*(?P<id>[A-Z][A-Z0-9]{1,9}-\d{2,4})\b",
101
+ re.MULTILINE,
102
+ )
103
+ REQUIREMENTS_HEADING = re.compile(
104
+ r"^(?P<level>#{2,6})\s*Requirements\b",
105
+ re.MULTILINE | re.IGNORECASE,
106
+ )
107
+ ANY_HEADING = re.compile(r"^(?P<level>#{1,6})\s", re.MULTILINE)
108
+ HTML_COMMENT = re.compile(r"<!--.*?-->", re.DOTALL)
109
+
110
+
111
+ def section_body(text: str, heading: re.Pattern[str]) -> str | None:
112
+ """Return the body of the first matching section, or None if absent."""
113
+
114
+ match = heading.search(text)
115
+ if not match:
116
+ return None
117
+
118
+ level = len(match.group("level"))
119
+ start = match.end()
120
+ end = len(text)
121
+ for next_heading in ANY_HEADING.finditer(text, start):
122
+ if len(next_heading.group("level")) <= level:
123
+ end = next_heading.start()
124
+ break
125
+ return text[start:end]
126
+
127
+
128
+ def requirement_ids(text: str) -> list[str]:
129
+ """Return requirement IDs from markdown headings under `## Requirements`.
130
+
131
+ Headings under Assumptions, Out of Scope, or other sections are ignored so
132
+ NOTE-001-style notes never become coverage obligations.
133
+ """
134
+
135
+ body = section_body(text, REQUIREMENTS_HEADING)
136
+ search_text = body if body is not None else text
137
+ seen: set[str] = set()
138
+ ordered: list[str] = []
139
+ for match in REQUIREMENT_ID.finditer(search_text):
140
+ requirement_id = match.group("id")
141
+ if requirement_id not in seen:
142
+ seen.add(requirement_id)
143
+ ordered.append(requirement_id)
144
+ return ordered
145
+
146
+
147
+ def mask_fenced_blocks(text: str) -> str:
148
+ """Blank out fenced code so structural regexes ignore sample snippets.
149
+
150
+ Fence marker lines and their interiors become empty lines, so line numbers
151
+ stay aligned with the original document.
152
+ """
153
+
154
+ masked: list[str] = []
155
+ in_fence = False
156
+
157
+ for line in text.splitlines(keepends=True):
158
+ stripped = line.lstrip()
159
+ if stripped.startswith("```"):
160
+ in_fence = not in_fence
161
+ masked.append("\n" if line.endswith("\n") else "")
162
+ continue
163
+ if in_fence:
164
+ masked.append("\n" if line.endswith("\n") else "")
165
+ continue
166
+ masked.append(line)
167
+
168
+ return "".join(masked)
169
+
170
+
171
+ def strip_html_comments(text: str) -> str:
172
+ """Blank HTML comments so evidence and verdicts inside them do not count."""
173
+
174
+ return HTML_COMMENT.sub(lambda match: "\n" * match.group(0).count("\n"), text)
175
+
176
+
177
+ def visible_markdown(text: str) -> str:
178
+ """Markdown visible to structural gates: fences and HTML comments removed."""
179
+
180
+ return mask_fenced_blocks(strip_html_comments(text))
181
+
182
+
183
+ def normalize_file_path(raw: str) -> str:
184
+ """Strip markdown/noise and collapse `./`, `/`, quotes, links, and `..`.
185
+
186
+ Result is case-folded so Auth/Token.ts and auth/token.ts collide on overlap
187
+ checks (macOS/Windows volumes; also stops casing dodges).
188
+ """
189
+
190
+ cleaned = raw.strip().strip("`\"'").replace("\\", "/")
191
+ link = re.fullmatch(r"\[([^\]]*)\]\(([^)]+)\)", cleaned)
192
+ if link:
193
+ cleaned = link.group(2).strip().strip("`\"'")
194
+ while cleaned.startswith("./"):
195
+ cleaned = cleaned[2:]
196
+ cleaned = cleaned.lstrip("/")
197
+ cleaned = re.sub(r"^[A-Za-z]:/", "", cleaned)
198
+ cleaned = cleaned.rstrip("/")
199
+
200
+ parts: list[str] = []
201
+ for part in cleaned.split("/"):
202
+ if part in ("", "."):
203
+ continue
204
+ if part == "..":
205
+ if parts:
206
+ parts.pop()
207
+ continue
208
+ parts.append(part)
209
+ return "/".join(parts).casefold()
210
+
211
+
212
+ def _fail_usage(gate: str, target: str, message: str) -> None:
213
+ print(f"[{gate}] FAIL - {target}")
214
+ print(f" error {message}")
215
+ sys.exit(EXIT_USAGE)
216
+
217
+
218
+ def list_features(root: Path = Path(".")) -> list[Path]:
219
+ """Return every feature directory under `.specs/features`, sorted by name."""
220
+
221
+ base = root / FEATURES_DIR
222
+
223
+ if not base.is_dir():
224
+ return []
225
+
226
+ return sorted(path for path in base.iterdir() if path.is_dir())
227
+
228
+
229
+ def resolve_feature_dir(
230
+ raw: str | None, gate: str, root: Path = Path(".")
231
+ ) -> Path:
232
+ """Resolve a feature directory from a path, a bare feature name, or context.
233
+
234
+ Accepts `.specs/features/auth/spec.md`, `.specs/features/auth`, `auth`, or
235
+ nothing at all when the project has exactly one feature.
236
+ """
237
+
238
+ if raw:
239
+ candidate = Path(raw).expanduser()
240
+
241
+ if candidate.is_file():
242
+ return candidate.parent
243
+
244
+ if candidate.is_dir():
245
+ return candidate
246
+
247
+ named = root / FEATURES_DIR / raw
248
+ if named.is_dir():
249
+ return named
250
+
251
+ _fail_usage(gate, raw, f"no such feature or path: {raw}")
252
+
253
+ features = list_features(root)
254
+
255
+ if len(features) == 1:
256
+ return features[0]
257
+
258
+ if not features:
259
+ _fail_usage(
260
+ gate,
261
+ str(root / FEATURES_DIR),
262
+ "no features found - create .specs/features/[feature]/ first",
263
+ )
264
+
265
+ listed = "\n".join(f" {path.name}" for path in features)
266
+ _fail_usage(
267
+ gate,
268
+ str(root / FEATURES_DIR),
269
+ f"{len(features)} features found - name the one to check:\n{listed}",
270
+ )
271
+
272
+ raise AssertionError("unreachable")
273
+
274
+
275
+ def resolve_artifact(
276
+ raw: str | None, filename: str, gate: str, root: Path = Path(".")
277
+ ) -> tuple[Path, str]:
278
+ """Read `filename` from a feature resolved by path, name, or auto-detection."""
279
+
280
+ if raw:
281
+ candidate = Path(raw).expanduser()
282
+ if candidate.is_file():
283
+ if candidate.name != filename:
284
+ _fail_usage(
285
+ gate,
286
+ raw,
287
+ f"expected {filename}, got {candidate.name}",
288
+ )
289
+ return read_artifact(str(candidate), gate)
290
+
291
+ feature_dir = resolve_feature_dir(raw, gate, root)
292
+ return read_artifact(str(feature_dir / filename), gate)
293
+
294
+
295
+ def read_artifact(raw_path: str, report_gate: str) -> tuple[Path, str]:
296
+ """Resolve and read a required artifact.
297
+
298
+ Missing paths and directories exit with EXIT_USAGE. An empty file exits
299
+ with EXIT_FAILED — the path is valid, the artifact is not ready.
300
+ """
301
+
302
+ path = Path(raw_path).expanduser()
303
+
304
+ if not path.exists():
305
+ print(f"[{report_gate}] FAIL - {path}")
306
+ print(f" error file not found: {path}")
307
+ sys.exit(EXIT_USAGE)
308
+
309
+ if path.is_dir():
310
+ print(f"[{report_gate}] FAIL - {path}")
311
+ print(f" error expected a file, got a directory: {path}")
312
+ sys.exit(EXIT_USAGE)
313
+
314
+ text = path.read_text(encoding="utf-8")
315
+
316
+ if not text.strip():
317
+ print(f"[{report_gate}] FAIL - {path}")
318
+ print(" error file is empty")
319
+ sys.exit(EXIT_FAILED)
320
+
321
+ return path, text
322
+
323
+
324
+ def find_placeholders(text: str) -> list[str]:
325
+ """Return unresolved placeholder tokens found in visible markdown.
326
+
327
+ Fenced samples and HTML comments are ignored, matching the other gates.
328
+ """
329
+
330
+ found: list[str] = []
331
+
332
+ for line_number, line in enumerate(visible_markdown(text).splitlines(), start=1):
333
+ stripped = line.strip()
334
+ if not stripped:
335
+ continue
336
+ is_heading = stripped.startswith("#")
337
+ for pattern in PLACEHOLDER_PATTERNS:
338
+ match = pattern.search(line)
339
+ if not match:
340
+ continue
341
+ token = match.group(0)
342
+ # Task titles such as "Fix TODO later" describe the work; TBD and
343
+ # template holes in a heading are still unfilled and must block.
344
+ if is_heading and token.upper() in {"TODO", "FIXME"}:
345
+ continue
346
+ found.append(f"line {line_number}: {token}")
347
+ break
348
+
349
+ return found
350
+
351
+
352
+ def has_section(text: str, heading: str) -> bool:
353
+ """Case-insensitive check for a markdown heading anywhere in the document."""
354
+
355
+ pattern = re.compile(rf"^#{{1,6}}\s+{re.escape(heading)}\s*$", re.IGNORECASE | re.MULTILINE)
356
+ return bool(pattern.search(text))
@@ -0,0 +1,187 @@
1
+ #!/usr/bin/env python3
2
+ """Cross-artifact consistency gate for a feature.
3
+
4
+ Run after Tasks (and optionally before Implement):
5
+
6
+ python3 analyze_artifacts.py auth
7
+ python3 analyze_artifacts.py .specs/features/003-chat-system
8
+
9
+ Checks structural alignment across spec.md, tasks.md, design.md, and STATE.md:
10
+ * every spec requirement ID appears in at least one task Requirement field
11
+ * every task Requirement references a spec requirement ID (when spec exists)
12
+ * open [NEEDS CLARIFICATION] markers (warning; blocking with --strict)
13
+ * STATE Active Feature branch matches current git branch when git is available
14
+ * design.md with only whitespace when tasks imply architecture (warning)
15
+
16
+ Exit codes: 0 pass, 1 blocking issues, 2 usage error.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import argparse
22
+ import re
23
+ import subprocess
24
+ import sys
25
+ from pathlib import Path
26
+
27
+ from _common import (
28
+ Report,
29
+ requirement_ids,
30
+ resolve_feature_dir,
31
+ visible_markdown,
32
+ )
33
+
34
+ GATE = "analyze-artifacts"
35
+ CLARIFICATION = re.compile(r"\[NEEDS CLARIFICATION(?:\s*:\s*[^\]]+)?\]", re.IGNORECASE)
36
+ TASK_FIELD = re.compile(
37
+ r"^\s*[-*]?\s*\*{0,2}(?P<key>[A-Za-z][A-Za-z ]+?)\*{0,2}\s*:\s*(?P<value>.+?)\s*$",
38
+ re.MULTILINE,
39
+ )
40
+ REQUIREMENT_REF = re.compile(r"\b[A-Z][A-Z0-9]{1,9}-\d{2,4}\b")
41
+ STATE_BRANCH = re.compile(
42
+ r"^\s*-\s*Branch:\s*(.+)$",
43
+ re.IGNORECASE | re.MULTILINE,
44
+ )
45
+ STATE_FEATURE = re.compile(
46
+ r"^\s*-\s*Feature:\s*(.+)$",
47
+ re.IGNORECASE | re.MULTILINE,
48
+ )
49
+
50
+
51
+ def git_branch(root: Path) -> str | None:
52
+ try:
53
+ result = subprocess.run(
54
+ ["git", "branch", "--show-current"],
55
+ cwd=root,
56
+ capture_output=True,
57
+ text=True,
58
+ check=False,
59
+ )
60
+ except OSError:
61
+ return None
62
+
63
+ if result.returncode != 0:
64
+ return None
65
+
66
+ branch = result.stdout.strip()
67
+ return branch or None
68
+
69
+
70
+ def task_requirement_ids(tasks_text: str) -> set[str]:
71
+ ids: set[str] = set()
72
+ for match in TASK_FIELD.finditer(tasks_text):
73
+ if match.group("key").strip().lower() != "requirement":
74
+ continue
75
+ ids.update(REQUIREMENT_REF.findall(match.group("value")))
76
+ return ids
77
+
78
+
79
+ def read_optional(feature_dir: Path, filename: str) -> str | None:
80
+ path = feature_dir / filename
81
+ if not path.is_file():
82
+ return None
83
+ text = path.read_text(encoding="utf-8")
84
+ return text if text.strip() else None
85
+
86
+
87
+ def build_report(feature_dir: Path, root: Path) -> Report:
88
+ report = Report(gate=GATE, target=str(feature_dir))
89
+ spec_text = read_optional(feature_dir, "spec.md")
90
+ tasks_text = read_optional(feature_dir, "tasks.md")
91
+ design_text = read_optional(feature_dir, "design.md")
92
+ state_path = root / ".specs/STATE.md"
93
+
94
+ if spec_text:
95
+ spec_ids = requirement_ids(spec_text)
96
+ report.ok(f"{len(spec_ids)} requirement ID(s) in spec.md")
97
+ else:
98
+ spec_ids = []
99
+ report.warn("spec.md missing or empty — REQ coverage checks skipped")
100
+
101
+ if tasks_text:
102
+ covered = task_requirement_ids(tasks_text)
103
+ report.ok(f"{len(covered)} requirement ID(s) referenced in tasks.md")
104
+
105
+ if spec_ids:
106
+ missing = [req for req in spec_ids if req not in covered]
107
+ if missing:
108
+ report.error(
109
+ "requirements without task coverage: "
110
+ + ", ".join(missing)
111
+ )
112
+ else:
113
+ report.ok("every spec requirement is referenced by a task")
114
+
115
+ orphan_tasks = sorted(covered - set(spec_ids))
116
+ if orphan_tasks:
117
+ report.error(
118
+ "tasks reference unknown requirement IDs: "
119
+ + ", ".join(orphan_tasks)
120
+ )
121
+ else:
122
+ report.warn("tasks.md missing or empty — task coverage checks skipped")
123
+
124
+ if design_text is not None and tasks_text:
125
+ visible_design = visible_markdown(design_text).strip()
126
+ if len(visible_design.splitlines()) < 5:
127
+ report.warn(
128
+ "design.md exists but looks empty — Complex work should document architecture"
129
+ )
130
+
131
+ searchable = "\n".join(filter(None, [spec_text, tasks_text, design_text]))
132
+ if searchable:
133
+ markers = CLARIFICATION.findall(searchable)
134
+ if markers:
135
+ report.warn(
136
+ f"{len(markers)} open [NEEDS CLARIFICATION] marker(s) — resolve before approval"
137
+ )
138
+ else:
139
+ report.ok("no open [NEEDS CLARIFICATION] markers")
140
+
141
+ if state_path.is_file():
142
+ state = state_path.read_text(encoding="utf-8")
143
+ feature_match = STATE_FEATURE.search(state)
144
+ branch_match = STATE_BRANCH.search(state)
145
+
146
+ if feature_match:
147
+ state_feature = feature_match.group(1).strip()
148
+ if state_feature not in {"—", "-", "none"} and state_feature != feature_dir.name:
149
+ report.warn(
150
+ f"STATE.md Active Feature ({state_feature}) differs from "
151
+ f"analyzed feature ({feature_dir.name})"
152
+ )
153
+
154
+ current = git_branch(root)
155
+ if branch_match and current:
156
+ state_branch = branch_match.group(1).strip()
157
+ if state_branch not in {"—", "-", "none"} and state_branch != current:
158
+ report.warn(
159
+ f"STATE.md branch ({state_branch}) differs from git ({current}) — reconcile"
160
+ )
161
+ elif state_branch == current:
162
+ report.ok("STATE branch matches current git branch")
163
+
164
+ return report
165
+
166
+
167
+ def main(argv: list[str] | None = None) -> int:
168
+ parser = argparse.ArgumentParser(description="Analyze cross-artifact consistency")
169
+ parser.add_argument(
170
+ "feature",
171
+ nargs="?",
172
+ help="feature name, feature directory, or path to spec.md",
173
+ )
174
+ parser.add_argument(
175
+ "--strict",
176
+ action="store_true",
177
+ help="treat warnings as blocking failures",
178
+ )
179
+ args = parser.parse_args(argv)
180
+
181
+ feature_dir = resolve_feature_dir(args.feature, GATE)
182
+ report = build_report(feature_dir, Path("."))
183
+ return report.emit(strict=args.strict)
184
+
185
+
186
+ if __name__ == "__main__":
187
+ sys.exit(main())