@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
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Plan the next Execute wave from tasks.md — parallel groups and sub-agent hints.
|
|
3
|
+
|
|
4
|
+
Run at the start of each /loop round (and after every batch completes):
|
|
5
|
+
|
|
6
|
+
python3 loop_plan.py auth
|
|
7
|
+
python3 loop_plan.py .specs/features/auth/tasks.md
|
|
8
|
+
python3 loop_plan.py --json auth
|
|
9
|
+
|
|
10
|
+
Reads dependency edges and Files ownership from tasks.md. Tasks marked
|
|
11
|
+
`- [x] complete` are treated as done. The next wave is every incomplete task
|
|
12
|
+
whose dependencies are complete. Within that wave, tasks with disjoint Files
|
|
13
|
+
lists may run in parallel (sub-agents when 2+).
|
|
14
|
+
|
|
15
|
+
Exit codes: 0 plan emitted, 1 nothing ready / blocked, 2 usage error.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import argparse
|
|
21
|
+
import json
|
|
22
|
+
import re
|
|
23
|
+
import sys
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
|
|
26
|
+
from _common import resolve_artifact, visible_markdown
|
|
27
|
+
from validate_tasks import parse_dependencies, parse_fields, parse_files, split_tasks
|
|
28
|
+
|
|
29
|
+
GATE = "loop-plan"
|
|
30
|
+
COMPLETE = re.compile(r"-\s*\[x\]\s*complete\b", re.IGNORECASE)
|
|
31
|
+
PARALLEL_GROUP = re.compile(
|
|
32
|
+
r"^\|\s*(?:T)?(?P<id>\d{1,6})\s*\|.*?\|\s*(?P<group>[^|]+?)\s*\|",
|
|
33
|
+
re.MULTILINE | re.IGNORECASE,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def is_complete(body: str) -> bool:
|
|
38
|
+
return bool(COMPLETE.search(body))
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def parse_parallel_groups(task_graph_text: str | None) -> dict[str, str]:
|
|
42
|
+
if not task_graph_text:
|
|
43
|
+
return {}
|
|
44
|
+
|
|
45
|
+
groups: dict[str, str] = {}
|
|
46
|
+
for match in PARALLEL_GROUP.finditer(task_graph_text):
|
|
47
|
+
task_id = f"T{match.group('id')}"
|
|
48
|
+
group = match.group("group").strip()
|
|
49
|
+
if group and group not in {"—", "-", "n/a", "na", "none"}:
|
|
50
|
+
groups[task_id] = group
|
|
51
|
+
return groups
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def build_plan(text: str, *, task_graph_text: str | None = None) -> dict:
|
|
55
|
+
visible = visible_markdown(text)
|
|
56
|
+
tasks = split_tasks(visible)
|
|
57
|
+
graph: dict[str, list[str]] = {}
|
|
58
|
+
files_by_task: dict[str, list[str]] = {}
|
|
59
|
+
completed: set[str] = set()
|
|
60
|
+
titles: dict[str, str] = {}
|
|
61
|
+
|
|
62
|
+
for task_id, title, body in tasks:
|
|
63
|
+
titles[task_id] = title
|
|
64
|
+
fields = parse_fields(body)
|
|
65
|
+
graph[task_id] = parse_dependencies(fields.get("depends on", ""))
|
|
66
|
+
files_by_task[task_id] = parse_files(fields.get("files", ""))
|
|
67
|
+
if is_complete(body):
|
|
68
|
+
completed.add(task_id)
|
|
69
|
+
|
|
70
|
+
incomplete = [task_id for task_id in graph if task_id not in completed]
|
|
71
|
+
ready = [
|
|
72
|
+
task_id
|
|
73
|
+
for task_id in incomplete
|
|
74
|
+
if all(dep in completed for dep in graph[task_id])
|
|
75
|
+
]
|
|
76
|
+
|
|
77
|
+
parallel_groups_doc = parse_parallel_groups(task_graph_text)
|
|
78
|
+
|
|
79
|
+
def files_disjoint(left: str, right: str) -> bool:
|
|
80
|
+
left_files = set(files_by_task.get(left, []))
|
|
81
|
+
right_files = set(files_by_task.get(right, []))
|
|
82
|
+
return not left_files.intersection(right_files)
|
|
83
|
+
|
|
84
|
+
groups: list[dict] = []
|
|
85
|
+
remaining = set(ready)
|
|
86
|
+
|
|
87
|
+
while remaining:
|
|
88
|
+
batch: list[str] = []
|
|
89
|
+
batch_files: set[str] = set()
|
|
90
|
+
|
|
91
|
+
for task_id in sorted(remaining, key=lambda value: int(value[1:])):
|
|
92
|
+
task_files = set(files_by_task.get(task_id, []))
|
|
93
|
+
if task_files.intersection(batch_files):
|
|
94
|
+
continue
|
|
95
|
+
batch.append(task_id)
|
|
96
|
+
batch_files.update(task_files)
|
|
97
|
+
|
|
98
|
+
for task_id in batch:
|
|
99
|
+
remaining.discard(task_id)
|
|
100
|
+
|
|
101
|
+
mode = "parallel" if len(batch) > 1 else "inline"
|
|
102
|
+
groups.append(
|
|
103
|
+
{
|
|
104
|
+
"mode": mode,
|
|
105
|
+
"tasks": [
|
|
106
|
+
{
|
|
107
|
+
"id": task_id,
|
|
108
|
+
"title": titles.get(task_id, ""),
|
|
109
|
+
"files": files_by_task.get(task_id, []),
|
|
110
|
+
"parallel_group": parallel_groups_doc.get(task_id),
|
|
111
|
+
}
|
|
112
|
+
for task_id in batch
|
|
113
|
+
],
|
|
114
|
+
"sub_agents": mode == "parallel",
|
|
115
|
+
}
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
blocked = [
|
|
119
|
+
{
|
|
120
|
+
"id": task_id,
|
|
121
|
+
"title": titles.get(task_id, ""),
|
|
122
|
+
"waiting_on": [dep for dep in graph[task_id] if dep not in completed],
|
|
123
|
+
}
|
|
124
|
+
for task_id in incomplete
|
|
125
|
+
if task_id not in ready
|
|
126
|
+
]
|
|
127
|
+
|
|
128
|
+
return {
|
|
129
|
+
"completed": sorted(completed, key=lambda value: int(value[1:])),
|
|
130
|
+
"ready": ready,
|
|
131
|
+
"groups": groups,
|
|
132
|
+
"blocked": blocked,
|
|
133
|
+
"all_done": not incomplete,
|
|
134
|
+
"recommend_sub_agents": any(group["sub_agents"] for group in groups),
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def format_plan(plan: dict) -> str:
|
|
139
|
+
lines: list[str] = []
|
|
140
|
+
|
|
141
|
+
if plan["all_done"]:
|
|
142
|
+
lines.append("All tasks complete — run /verify with a fresh context.")
|
|
143
|
+
return "\n".join(lines)
|
|
144
|
+
|
|
145
|
+
if not plan["ready"]:
|
|
146
|
+
lines.append("No tasks ready — resolve blocked dependencies first.")
|
|
147
|
+
for item in plan["blocked"][:5]:
|
|
148
|
+
waiting = ", ".join(item["waiting_on"]) or "unknown"
|
|
149
|
+
lines.append(f" {item['id']}: waiting on {waiting}")
|
|
150
|
+
return "\n".join(lines)
|
|
151
|
+
|
|
152
|
+
lines.append("Next Execute wave:")
|
|
153
|
+
for index, group in enumerate(plan["groups"], start=1):
|
|
154
|
+
if group["mode"] == "parallel":
|
|
155
|
+
lines.append(
|
|
156
|
+
f" Group {index} — PARALLEL ({len(group['tasks'])} tasks, use sub-agents):"
|
|
157
|
+
)
|
|
158
|
+
else:
|
|
159
|
+
lines.append(f" Group {index} — inline (orchestrator):")
|
|
160
|
+
|
|
161
|
+
for task in group["tasks"]:
|
|
162
|
+
files = ", ".join(task["files"]) or "(no files listed)"
|
|
163
|
+
group_hint = ""
|
|
164
|
+
if task.get("parallel_group"):
|
|
165
|
+
group_hint = f" [graph group {task['parallel_group']}]"
|
|
166
|
+
lines.append(f" {task['id']}: {task['title']} — {files}{group_hint}")
|
|
167
|
+
|
|
168
|
+
if plan["recommend_sub_agents"]:
|
|
169
|
+
lines.append("")
|
|
170
|
+
lines.append(
|
|
171
|
+
"Sub-agents: offer parallel dispatch per references/sub-agents.md "
|
|
172
|
+
"(owner must confirm before spawning)."
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
if plan["blocked"]:
|
|
176
|
+
lines.append("")
|
|
177
|
+
lines.append("Blocked (later waves):")
|
|
178
|
+
for item in plan["blocked"][:5]:
|
|
179
|
+
waiting = ", ".join(item["waiting_on"])
|
|
180
|
+
lines.append(f" {item['id']}: after {waiting}")
|
|
181
|
+
|
|
182
|
+
return "\n".join(lines)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def main(argv: list[str] | None = None) -> int:
|
|
186
|
+
parser = argparse.ArgumentParser(
|
|
187
|
+
description="Plan the next Execute wave with parallel groups"
|
|
188
|
+
)
|
|
189
|
+
parser.add_argument(
|
|
190
|
+
"tasks",
|
|
191
|
+
nargs="?",
|
|
192
|
+
help="feature name, feature directory, or path to tasks.md",
|
|
193
|
+
)
|
|
194
|
+
parser.add_argument("--json", action="store_true", help="emit JSON for agents")
|
|
195
|
+
args = parser.parse_args(argv)
|
|
196
|
+
|
|
197
|
+
path, text = resolve_artifact(args.tasks, "tasks.md", GATE)
|
|
198
|
+
graph_path = path.parent / "task-graph.md"
|
|
199
|
+
graph_text = (
|
|
200
|
+
graph_path.read_text(encoding="utf-8") if graph_path.is_file() else None
|
|
201
|
+
)
|
|
202
|
+
plan = build_plan(text, task_graph_text=graph_text)
|
|
203
|
+
|
|
204
|
+
if args.json:
|
|
205
|
+
print(json.dumps(plan, indent=2))
|
|
206
|
+
else:
|
|
207
|
+
print(format_plan(plan))
|
|
208
|
+
|
|
209
|
+
if plan["all_done"]:
|
|
210
|
+
return 0
|
|
211
|
+
if not plan["ready"]:
|
|
212
|
+
return 1
|
|
213
|
+
return 0
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
if __name__ == "__main__":
|
|
217
|
+
sys.exit(main())
|
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Closure gate for `.specs/features/[feature]/spec.md`.
|
|
3
|
+
|
|
4
|
+
Run before confirming a spec with the project owner:
|
|
5
|
+
|
|
6
|
+
python3 validate_spec.py .specs/features/auth/spec.md
|
|
7
|
+
|
|
8
|
+
The feature can be named instead of pathed:
|
|
9
|
+
|
|
10
|
+
python3 validate_spec.py auth
|
|
11
|
+
python3 validate_spec.py # when the project has a single feature
|
|
12
|
+
|
|
13
|
+
Checks (full spec):
|
|
14
|
+
* required sections are present (Requirements, Assumptions, Out of Scope)
|
|
15
|
+
* at least one well-formed requirement ID (REQ-001 style)
|
|
16
|
+
* every requirement carries at least one acceptance criterion
|
|
17
|
+
* every criterion states a required outcome (SHALL or MUST)
|
|
18
|
+
* no unresolved placeholders (TBD, TODO, <fill me>) outside fences and HTML comments
|
|
19
|
+
* EARS shape (WHEN ... THEN ...) is reported as a warning
|
|
20
|
+
* open [NEEDS CLARIFICATION] markers are reported as warnings
|
|
21
|
+
|
|
22
|
+
Checks (delta spec — when ADDED/MODIFIED/REMOVED sections are present):
|
|
23
|
+
* Goal and Assumptions required; Out of Scope recommended
|
|
24
|
+
* ADDED/MODIFIED requirements follow the same SHALL/MUST rules
|
|
25
|
+
* REMOVED lists requirement IDs to retire
|
|
26
|
+
|
|
27
|
+
Exit codes: 0 pass, 1 blocking issues, 2 usage error.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import argparse
|
|
33
|
+
import re
|
|
34
|
+
import sys
|
|
35
|
+
|
|
36
|
+
from _common import (
|
|
37
|
+
REQUIREMENTS_HEADING,
|
|
38
|
+
Report,
|
|
39
|
+
find_placeholders,
|
|
40
|
+
has_section,
|
|
41
|
+
resolve_artifact,
|
|
42
|
+
section_body,
|
|
43
|
+
visible_markdown,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
GATE = "validate-spec"
|
|
47
|
+
|
|
48
|
+
REQUIREMENT_HEADING = re.compile(
|
|
49
|
+
r"^(?P<level>#{2,6})\s*(?P<id>[A-Z][A-Z0-9]{1,9}-\d{2,4})\s*[:\-–]?\s*(?P<title>.*)$",
|
|
50
|
+
re.MULTILINE,
|
|
51
|
+
)
|
|
52
|
+
ANY_HEADING = re.compile(r"^(?P<level>#{1,6})\s+\S", re.MULTILINE)
|
|
53
|
+
MALFORMED_ID = re.compile(r"^#{2,6}\s*(REQ|req)[\s_]*(\d{1,4})\b", re.MULTILINE)
|
|
54
|
+
ACCEPTANCE_LABEL = re.compile(r"(acceptance criteri|\bAC\b)", re.IGNORECASE)
|
|
55
|
+
METADATA_KEY = re.compile(
|
|
56
|
+
r"^\*{0,2}(owner|priority|status|estimate|risk|risks|files|file|notes|note|"
|
|
57
|
+
r"tags|links|link|related|depends on|reuses|source|epic|milestone)\*{0,2}\s*:",
|
|
58
|
+
re.IGNORECASE,
|
|
59
|
+
)
|
|
60
|
+
# A criterion without a normative verb states an intention, not an outcome a test
|
|
61
|
+
# can assert, so it blocks. The EARS lead keyword sharpens it further and is
|
|
62
|
+
# reported as a warning.
|
|
63
|
+
NORMATIVE_VERB = re.compile(r"\b(SHALL|MUST)\b", re.IGNORECASE)
|
|
64
|
+
EARS_LEAD = re.compile(
|
|
65
|
+
r"\b(WHEN|IF|WHILE|WHERE)\b.*\bTHEN\b", re.IGNORECASE | re.DOTALL
|
|
66
|
+
)
|
|
67
|
+
REQUIRED_SECTIONS = ("Requirements", "Assumptions", "Out of Scope")
|
|
68
|
+
DELTA_SECTIONS = (
|
|
69
|
+
("ADDED Requirements", "added"),
|
|
70
|
+
("MODIFIED Requirements", "modified"),
|
|
71
|
+
("REMOVED Requirements", "removed"),
|
|
72
|
+
)
|
|
73
|
+
CLARIFICATION = re.compile(r"\[NEEDS CLARIFICATION(?:\s*:\s*[^\]]+)?\]", re.IGNORECASE)
|
|
74
|
+
REMOVED_ID = re.compile(r"^\s*(?:-\s*)?(?P<id>[A-Z][A-Z0-9]{1,9}-\d{2,4})\b", re.MULTILINE)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def is_delta_spec(text: str) -> bool:
|
|
78
|
+
visible = visible_markdown(text)
|
|
79
|
+
return any(has_section(visible, heading) for heading, _ in DELTA_SECTIONS)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def split_requirements(text: str) -> list[tuple[str, str, str]]:
|
|
83
|
+
"""Return (id, title, body) for each requirement heading under ``## Requirements``.
|
|
84
|
+
|
|
85
|
+
Headings under Assumptions, Out of Scope, or other sections are ignored so
|
|
86
|
+
NOTE-001-style notes never become acceptance-criteria obligations. A
|
|
87
|
+
requirement body ends at the next heading of the same or higher level.
|
|
88
|
+
"""
|
|
89
|
+
|
|
90
|
+
scoped = section_body(text, REQUIREMENTS_HEADING)
|
|
91
|
+
if scoped is None:
|
|
92
|
+
return []
|
|
93
|
+
|
|
94
|
+
requirements: list[tuple[str, str, str]] = []
|
|
95
|
+
|
|
96
|
+
for match in REQUIREMENT_HEADING.finditer(scoped):
|
|
97
|
+
level = len(match.group("level"))
|
|
98
|
+
start = match.end()
|
|
99
|
+
end = len(scoped)
|
|
100
|
+
|
|
101
|
+
for heading in ANY_HEADING.finditer(scoped, start):
|
|
102
|
+
if len(heading.group("level")) <= level:
|
|
103
|
+
end = heading.start()
|
|
104
|
+
break
|
|
105
|
+
|
|
106
|
+
requirements.append(
|
|
107
|
+
(match.group("id"), match.group("title").strip(), scoped[start:end])
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
return requirements
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def split_delta_requirements(text: str, section_heading: str) -> list[tuple[str, str, str]]:
|
|
114
|
+
scoped = section_body(text, re.compile(
|
|
115
|
+
rf"^(?P<level>#{2,6})\s*{re.escape(section_heading)}\b",
|
|
116
|
+
re.MULTILINE | re.IGNORECASE,
|
|
117
|
+
))
|
|
118
|
+
if scoped is None:
|
|
119
|
+
return []
|
|
120
|
+
|
|
121
|
+
requirements: list[tuple[str, str, str]] = []
|
|
122
|
+
for match in REQUIREMENT_HEADING.finditer(scoped):
|
|
123
|
+
level = len(match.group("level"))
|
|
124
|
+
start = match.end()
|
|
125
|
+
end = len(scoped)
|
|
126
|
+
for heading in ANY_HEADING.finditer(scoped, start):
|
|
127
|
+
if len(heading.group("level")) <= level:
|
|
128
|
+
end = heading.start()
|
|
129
|
+
break
|
|
130
|
+
requirements.append(
|
|
131
|
+
(match.group("id"), match.group("title").strip(), scoped[start:end])
|
|
132
|
+
)
|
|
133
|
+
return requirements
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def validate_requirement_block(
|
|
137
|
+
report: Report,
|
|
138
|
+
requirements: list[tuple[str, str, str]],
|
|
139
|
+
label: str,
|
|
140
|
+
) -> None:
|
|
141
|
+
if not requirements:
|
|
142
|
+
report.warn(f"{label}: no requirement headings found")
|
|
143
|
+
return
|
|
144
|
+
|
|
145
|
+
report.ok(f"{label}: {len(requirements)} requirement(s) with well-formed IDs")
|
|
146
|
+
seen: set[str] = set()
|
|
147
|
+
for requirement_id, title, body in requirements:
|
|
148
|
+
if requirement_id in seen:
|
|
149
|
+
report.error(f"{label} {requirement_id}: duplicate requirement ID")
|
|
150
|
+
seen.add(requirement_id)
|
|
151
|
+
|
|
152
|
+
if not title:
|
|
153
|
+
report.error(f"{label} {requirement_id}: heading has no title")
|
|
154
|
+
|
|
155
|
+
criteria = acceptance_lines(body)
|
|
156
|
+
if not criteria:
|
|
157
|
+
report.error(f"{label} {requirement_id}: no acceptance criteria found")
|
|
158
|
+
continue
|
|
159
|
+
|
|
160
|
+
for item in criteria:
|
|
161
|
+
excerpt = item if len(item) <= 70 else f"{item[:67]}..."
|
|
162
|
+
if not NORMATIVE_VERB.search(item):
|
|
163
|
+
report.error(
|
|
164
|
+
f"{label} {requirement_id}: criterion is not testable, it states no "
|
|
165
|
+
f"required outcome (add SHALL or MUST): '{excerpt}'"
|
|
166
|
+
)
|
|
167
|
+
continue
|
|
168
|
+
if not EARS_LEAD.search(item):
|
|
169
|
+
report.warn(
|
|
170
|
+
f"{label} {requirement_id}: criterion has SHALL/MUST but no trigger "
|
|
171
|
+
f"(WHEN/IF ... THEN ...): '{excerpt}'"
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def validate_removed_section(report: Report, text: str) -> None:
|
|
176
|
+
scoped = section_body(text, re.compile(
|
|
177
|
+
r"^(?P<level>#{2,6})\s*REMOVED Requirements\b",
|
|
178
|
+
re.MULTILINE | re.IGNORECASE,
|
|
179
|
+
))
|
|
180
|
+
if scoped is None:
|
|
181
|
+
report.error("delta spec missing ## REMOVED Requirements (use '- none' when nothing removed)")
|
|
182
|
+
return
|
|
183
|
+
|
|
184
|
+
ids = [match.group("id") for match in REMOVED_ID.finditer(scoped)]
|
|
185
|
+
if not ids and re.search(r"\bnone\b", scoped, re.IGNORECASE):
|
|
186
|
+
report.ok("REMOVED Requirements: none")
|
|
187
|
+
return
|
|
188
|
+
|
|
189
|
+
if not ids:
|
|
190
|
+
report.error("REMOVED Requirements: list requirement IDs to retire, or '- none'")
|
|
191
|
+
return
|
|
192
|
+
|
|
193
|
+
report.ok(f"REMOVED Requirements: {len(ids)} requirement ID(s) listed")
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _split_criterion_label(cleaned: str) -> str | None:
|
|
197
|
+
"""Return the criterion text after an Acceptance Criteria / AC label.
|
|
198
|
+
|
|
199
|
+
List markers are already stripped. Split on a colon, en-dash, em-dash, or a
|
|
200
|
+
space-hyphen-space so `- **Acceptance Criteria** - WHEN ...` still works
|
|
201
|
+
without treating the leading `-` of a bullet as a separator.
|
|
202
|
+
"""
|
|
203
|
+
|
|
204
|
+
remainder = re.split(r"[:\u2013\u2014]|\s+-\s+", cleaned, maxsplit=1)
|
|
205
|
+
if len(remainder) == 2 and remainder[1].strip():
|
|
206
|
+
return remainder[1].strip()
|
|
207
|
+
return None
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def acceptance_lines(body: str) -> list[str]:
|
|
211
|
+
"""Collect candidate acceptance-criteria lines from a requirement body."""
|
|
212
|
+
|
|
213
|
+
lines: list[str] = []
|
|
214
|
+
in_labeled_block = False
|
|
215
|
+
in_fence = False
|
|
216
|
+
|
|
217
|
+
for raw_line in body.splitlines():
|
|
218
|
+
line = raw_line.strip()
|
|
219
|
+
if line.startswith("```"):
|
|
220
|
+
in_fence = not in_fence
|
|
221
|
+
continue
|
|
222
|
+
if in_fence or not line:
|
|
223
|
+
continue
|
|
224
|
+
|
|
225
|
+
# Markdown tables are documentation, not criteria.
|
|
226
|
+
if line.startswith("|"):
|
|
227
|
+
continue
|
|
228
|
+
|
|
229
|
+
if ACCEPTANCE_LABEL.search(line):
|
|
230
|
+
in_labeled_block = True
|
|
231
|
+
cleaned = line.lstrip("-* ").strip()
|
|
232
|
+
remainder = _split_criterion_label(cleaned)
|
|
233
|
+
if remainder:
|
|
234
|
+
lines.append(remainder)
|
|
235
|
+
continue
|
|
236
|
+
|
|
237
|
+
if line.startswith(("-", "*")) or re.match(r"^\d+\.", line):
|
|
238
|
+
cleaned = line.lstrip("-* ").strip()
|
|
239
|
+
if cleaned and not cleaned.startswith("---"):
|
|
240
|
+
if METADATA_KEY.match(cleaned):
|
|
241
|
+
continue
|
|
242
|
+
lines.append(cleaned)
|
|
243
|
+
continue
|
|
244
|
+
|
|
245
|
+
if in_labeled_block and not line.startswith("#"):
|
|
246
|
+
lines.append(line)
|
|
247
|
+
|
|
248
|
+
return lines
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def build_report(target: str, text: str) -> Report:
|
|
252
|
+
report = Report(gate=GATE, target=target)
|
|
253
|
+
visible = visible_markdown(text)
|
|
254
|
+
delta = is_delta_spec(text)
|
|
255
|
+
|
|
256
|
+
if not has_section(visible, "Goal"):
|
|
257
|
+
report.error("missing required section: ## Goal")
|
|
258
|
+
|
|
259
|
+
if has_section(visible, "Assumptions"):
|
|
260
|
+
report.ok("section present: Assumptions")
|
|
261
|
+
else:
|
|
262
|
+
report.error("missing required section: ## Assumptions")
|
|
263
|
+
|
|
264
|
+
if delta:
|
|
265
|
+
report.ok("delta spec detected (ADDED/MODIFIED/REMOVED)")
|
|
266
|
+
if not has_section(visible, "Out of Scope"):
|
|
267
|
+
report.warn("delta spec: ## Out of Scope recommended")
|
|
268
|
+
|
|
269
|
+
for heading, label in DELTA_SECTIONS:
|
|
270
|
+
if has_section(visible, heading):
|
|
271
|
+
report.ok(f"section present: {heading}")
|
|
272
|
+
elif label != "removed":
|
|
273
|
+
report.warn(f"delta spec: ## {heading} not present")
|
|
274
|
+
|
|
275
|
+
validate_requirement_block(
|
|
276
|
+
report,
|
|
277
|
+
split_delta_requirements(visible, "ADDED Requirements"),
|
|
278
|
+
"ADDED",
|
|
279
|
+
)
|
|
280
|
+
validate_requirement_block(
|
|
281
|
+
report,
|
|
282
|
+
split_delta_requirements(visible, "MODIFIED Requirements"),
|
|
283
|
+
"MODIFIED",
|
|
284
|
+
)
|
|
285
|
+
validate_removed_section(report, visible)
|
|
286
|
+
else:
|
|
287
|
+
for section in REQUIRED_SECTIONS:
|
|
288
|
+
if has_section(visible, section):
|
|
289
|
+
report.ok(f"section present: {section}")
|
|
290
|
+
else:
|
|
291
|
+
report.error(f"missing required section: ## {section}")
|
|
292
|
+
|
|
293
|
+
requirements = split_requirements(visible)
|
|
294
|
+
|
|
295
|
+
if not requirements:
|
|
296
|
+
report.error(
|
|
297
|
+
"no requirement headings found - use '### REQ-001: Title' (prefix-NNN)"
|
|
298
|
+
)
|
|
299
|
+
else:
|
|
300
|
+
validate_requirement_block(report, requirements, "Requirements")
|
|
301
|
+
|
|
302
|
+
for malformed in MALFORMED_ID.finditer(visible):
|
|
303
|
+
raw = malformed.group(0).lstrip("# ").strip()
|
|
304
|
+
if not REQUIREMENT_HEADING.match(f"### {raw}"):
|
|
305
|
+
report.error(f"malformed requirement ID: '{raw}' - expected REQ-001 style")
|
|
306
|
+
|
|
307
|
+
placeholders = find_placeholders(text)
|
|
308
|
+
if placeholders:
|
|
309
|
+
for item in placeholders[:10]:
|
|
310
|
+
report.error(f"unresolved placeholder at {item}")
|
|
311
|
+
else:
|
|
312
|
+
report.ok("no unresolved placeholders")
|
|
313
|
+
|
|
314
|
+
clarifications = CLARIFICATION.findall(visible)
|
|
315
|
+
if clarifications:
|
|
316
|
+
report.warn(
|
|
317
|
+
f"{len(clarifications)} open [NEEDS CLARIFICATION] marker(s) — resolve before approval"
|
|
318
|
+
)
|
|
319
|
+
else:
|
|
320
|
+
report.ok("no open [NEEDS CLARIFICATION] markers")
|
|
321
|
+
|
|
322
|
+
return report
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def main(argv: list[str] | None = None) -> int:
|
|
326
|
+
parser = argparse.ArgumentParser(description="Validate a feature spec.md")
|
|
327
|
+
parser.add_argument(
|
|
328
|
+
"spec",
|
|
329
|
+
nargs="?",
|
|
330
|
+
help="feature name, feature directory, or path to spec.md",
|
|
331
|
+
)
|
|
332
|
+
parser.add_argument(
|
|
333
|
+
"--strict",
|
|
334
|
+
action="store_true",
|
|
335
|
+
help="treat warnings as blocking failures",
|
|
336
|
+
)
|
|
337
|
+
args = parser.parse_args(argv)
|
|
338
|
+
|
|
339
|
+
path, text = resolve_artifact(args.spec, "spec.md", GATE)
|
|
340
|
+
report = build_report(str(path), text)
|
|
341
|
+
return report.emit(strict=args.strict)
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
if __name__ == "__main__":
|
|
345
|
+
sys.exit(main())
|