@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.
- package/LICENSE +21 -0
- package/README.md +206 -0
- package/index.js +335 -0
- package/lib/archive.js +208 -0
- package/lib/assets.js +145 -0
- package/lib/brownfield.js +446 -0
- package/lib/config.js +293 -0
- package/lib/constants.js +262 -0
- package/lib/cursorrules.js +92 -0
- package/lib/delta-merge.js +248 -0
- package/lib/doctor.js +343 -0
- package/lib/download.js +133 -0
- package/lib/feature.js +272 -0
- package/lib/fs-utils.js +114 -0
- package/lib/gates.js +138 -0
- package/lib/install.js +140 -0
- package/lib/memory.js +34 -0
- package/lib/next-steps.js +50 -0
- package/lib/presets.js +176 -0
- package/lib/project-rules.js +210 -0
- package/lib/specs-utils.js +117 -0
- package/lib/token-cost.js +124 -0
- package/package.json +46 -0
- package/rules/engineering-baseline.mdc +56 -0
- package/scripts/_common.py +356 -0
- package/scripts/analyze_artifacts.py +187 -0
- package/scripts/check_commit.py +140 -0
- package/scripts/lessons.py +447 -0
- package/scripts/loop_plan.py +217 -0
- package/scripts/validate_spec.py +345 -0
- package/scripts/validate_state.py +385 -0
- package/scripts/validate_tasks.py +379 -0
- package/skills/agent-architecture.md +221 -0
- package/skills/appsec.md +83 -0
- package/skills/code-simplify.md +49 -0
- package/skills/engineering-standards.md +98 -0
- package/skills/git-handoff.md +213 -0
- package/skills/qa-strategy.md +83 -0
- package/skills/references/analyze.md +56 -0
- package/skills/references/archive.md +60 -0
- package/skills/references/constitution.md +66 -0
- package/skills/references/context-limits.md +73 -0
- package/skills/references/converge.md +47 -0
- package/skills/references/design.md +88 -0
- package/skills/references/discuss.md +68 -0
- package/skills/references/explore.md +61 -0
- package/skills/references/implement.md +175 -0
- package/skills/references/lessons.md +71 -0
- package/skills/references/memory.md +98 -0
- package/skills/references/project-init.md +62 -0
- package/skills/references/quick-mode.md +84 -0
- package/skills/references/specify.md +144 -0
- package/skills/references/sub-agents.md +117 -0
- package/skills/references/tasks.md +178 -0
- package/skills/references/validate.md +210 -0
- package/skills/security-review.md +120 -0
- package/skills/ship-ready.md +50 -0
- package/skills/task-graph-engineering.md +180 -0
- package/templates/GETTING_STARTED.md +61 -0
- package/templates/config.yaml.example +28 -0
- package/templates/presets/default.yaml +16 -0
- package/templates/presets/node-ts.yaml +22 -0
- 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())
|