@luizsantiago/spec-guardrails 4.4.0 → 4.5.0
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/README.md +29 -59
- package/index.js +3 -0
- package/lib/constants.js +3 -0
- package/lib/doctor.js +3 -0
- package/lib/gates.js +2 -0
- package/lib/install.js +8 -2
- package/lib/next-steps.js +58 -4
- package/package.json +1 -1
- package/scripts/_project_config.py +126 -0
- package/scripts/check_commit.py +103 -19
- package/scripts/check_suppressions.py +114 -0
- package/scripts/run_quality_checks.py +102 -0
- package/skills/references/implement.md +2 -0
- package/skills/references/validate.md +2 -1
- package/templates/config.yaml.example +18 -0
package/README.md
CHANGED
|
@@ -1,13 +1,17 @@
|
|
|
1
1
|
# Spec Guardrails
|
|
2
2
|
|
|
3
3
|
[](https://www.npmjs.com/package/@luizsantiago/spec-guardrails)
|
|
4
|
+
[](https://www.npmjs.com/package/@luizsantiago/spec-guardrails)
|
|
5
|
+
[](https://github.com/luizssantiago92/spec-guardrails/actions/workflows/ci.yml)
|
|
4
6
|
[](LICENSE)
|
|
5
7
|
|
|
8
|
+

|
|
9
|
+
|
|
6
10
|
**Governed spec-driven development for AI coding agents.**
|
|
7
11
|
|
|
8
12
|
Spec Guardrails installs a working method into your repository: the agent writes down what it is going to build, gets your approval, implements in small waves, and proves the result before calling it done. Nothing about your stack changes — you get written requirements, a task plan, and verification evidence stored as files in the project.
|
|
9
13
|
|
|
10
|
-
npm: [`@luizsantiago/spec-guardrails`](https://www.npmjs.com/package/@luizsantiago/spec-guardrails) **4.
|
|
14
|
+
npm: [`@luizsantiago/spec-guardrails`](https://www.npmjs.com/package/@luizsantiago/spec-guardrails) **4.5.x**
|
|
11
15
|
|
|
12
16
|
---
|
|
13
17
|
|
|
@@ -63,64 +67,11 @@ Read more: [Quick start](docs/guide/Quick-start.md) · [Platform parity](docs/gu
|
|
|
63
67
|
|
|
64
68
|
You describe your project or the feature you want — in chat, or by pointing the agent at a file such as `prd.md` or `docs/brief.md`. With Spec Guardrails already installed, the agent reads that material plus what is already in the repo and picks up from there if work is in progress (`STATE.md` tells it where you left off).
|
|
65
69
|
|
|
66
|
-
|
|
67
|
-
YOU describe the project or feature
|
|
68
|
-
(chat, prd.md, docs/brief.md, kickoff paste)
|
|
69
|
-
│
|
|
70
|
-
▼
|
|
71
|
-
┌──────────────────────┐
|
|
72
|
-
│ READ & CLASSIFY │ Agent reads your inputs and the repo,
|
|
73
|
-
│ │ then sizes the change (see table below).
|
|
74
|
-
└──────────┬───────────┘
|
|
75
|
-
│
|
|
76
|
-
Still vague? ──► Requirements analysis (optional)
|
|
77
|
-
│ Up to 5 questions per round, one topic at a time,
|
|
78
|
-
│ with suggested options — never repeats what your
|
|
79
|
-
│ document already answered.
|
|
80
|
-
▼
|
|
81
|
-
┌──────────────────────┐
|
|
82
|
-
│ REQUIREMENTS BRIEF │ Captured gaps and decisions in writing.
|
|
83
|
-
└──────────┬───────────┘
|
|
84
|
-
│
|
|
85
|
-
◆ YOU APPROVE ◆ (1 of 3 — when elicitation ran)
|
|
86
|
-
│
|
|
87
|
-
▼
|
|
88
|
-
┌──────────────────────┐
|
|
89
|
-
│ SPECIFY │ What must happen, what "done" means,
|
|
90
|
-
│ │ what is out of scope → spec.md
|
|
91
|
-
└──────────┬───────────┘
|
|
92
|
-
│
|
|
93
|
-
◆ YOU APPROVE ◆ (2 of 3)
|
|
94
|
-
│
|
|
95
|
-
▼
|
|
96
|
-
┌──────────────────────┐
|
|
97
|
-
│ TASKS │ Small checkable jobs → tasks.md
|
|
98
|
-
│ │ (+ task-graph.md when work can split)
|
|
99
|
-
└──────────┬───────────┘
|
|
100
|
-
│
|
|
101
|
-
◆ YOU APPROVE ◆ (3 of 3)
|
|
102
|
-
│
|
|
103
|
-
▼
|
|
104
|
-
┌──────────────────────┐
|
|
105
|
-
│ BUILD (loop) │ One wave at a time: test, implement,
|
|
106
|
-
│ ↺ │ check, commit — repeat until done.
|
|
107
|
-
└──────────┬───────────┘
|
|
108
|
-
▼
|
|
109
|
-
┌──────────────────────┐
|
|
110
|
-
│ VERIFY │ Independent review with proof → validation.md
|
|
111
|
-
└──────────┬───────────┘
|
|
112
|
-
▼
|
|
113
|
-
┌──────────────────────┐
|
|
114
|
-
│ ARCHIVE │ Fold outcome into project memory.
|
|
115
|
-
└──────────────────────┘
|
|
116
|
-
|
|
117
|
-
Tiny fix (≤3 files, no new dependency)?
|
|
118
|
-
Express lane: build → verify → commit (no spec/tasks ceremony).
|
|
119
|
-
```
|
|
70
|
+

|
|
120
71
|
|
|
121
72
|
The agent never skips your approvals on the full path. Requirements analysis is **suggested**, not forced — if the request is already clear, it goes straight to Specify.
|
|
122
73
|
|
|
123
|
-
Read more: [How it works](docs/guide/How-it-works.md) · [Agent commands](docs/guide/agent-commands.md)
|
|
74
|
+
Read more: [How it works](docs/guide/How-it-works.md) · [Agent commands](docs/guide/agent-commands.md) · [Glossary](docs/guide/Glossary.md)
|
|
124
75
|
|
|
125
76
|
---
|
|
126
77
|
|
|
@@ -128,6 +79,8 @@ Read more: [How it works](docs/guide/How-it-works.md) · [Agent commands](docs/g
|
|
|
128
79
|
|
|
129
80
|
Before starting, the agent classifies the change and loads only what that change needs. A typo does not get a task graph; a payments integration does not skip review.
|
|
130
81
|
|
|
82
|
+

|
|
83
|
+
|
|
131
84
|
| Complexity | Typical scope | What gets created | Your approvals |
|
|
132
85
|
| --- | --- | --- | --- |
|
|
133
86
|
| **Quick** | ≤3 files, no new dependency, no auth/payments | Code + quick evidence | None (express lane) |
|
|
@@ -173,7 +126,7 @@ Instructions the agent loads **one at a time** — hub `agent-architecture.md` (
|
|
|
173
126
|
|
|
174
127
|
→ [Skills and hub](docs/guide/skills-and-hub.md)
|
|
175
128
|
|
|
176
|
-
### Gates (
|
|
129
|
+
### Gates (11)
|
|
177
130
|
|
|
178
131
|
Automatic checks at step boundaries — each one blocks a specific kind of shortcut:
|
|
179
132
|
|
|
@@ -187,6 +140,8 @@ Automatic checks at step boundaries — each one blocks a specific kind of short
|
|
|
187
140
|
| `validate-state` | Feature is declared done without evidence |
|
|
188
141
|
| `validate-quick` | Quick-mode fix broke its size or shape rules |
|
|
189
142
|
| `check-commit` | Commit message does not follow the agreed format |
|
|
143
|
+
| `check-suppressions` | Staged diff adds `# noqa`, `eslint-disable`, `@ts-ignore`, skipped tests, or `--no-verify` |
|
|
144
|
+
| `quality-checks` | Configured project commands (`npm test`, …) fail during `/verify` |
|
|
190
145
|
| `lessons` | A failed verify tries to skip the lesson step |
|
|
191
146
|
|
|
192
147
|
→ [Gates](docs/guide/gates.md) · [Garantees matrix](docs/guide/Guarantees-matrix.md)
|
|
@@ -223,7 +178,7 @@ Implementation happens in **small waves**: pick the next runnable jobs, test, im
|
|
|
223
178
|
|
|
224
179
|
| Path | Role |
|
|
225
180
|
| --- | --- |
|
|
226
|
-
| `.cursor/skills/` (
|
|
181
|
+
| `.cursor/skills/` (or detected platform tree) | Phase instructions for your agent |
|
|
227
182
|
| `.specs/STATE.md` | Active feature and next step |
|
|
228
183
|
| `.specs/features/NNN-slug/` | Spec, tasks, and validation per feature |
|
|
229
184
|
| `.specs/guardrails/scripts/` | Python checks (when Brakes mode is on) |
|
|
@@ -231,6 +186,21 @@ Implementation happens in **small waves**: pick the next runnable jobs, test, im
|
|
|
231
186
|
|
|
232
187
|
---
|
|
233
188
|
|
|
189
|
+
## Honest limits
|
|
190
|
+
|
|
191
|
+
Gates enforce **structure and evidence in `.specs/`** — not product taste, not whether your tests are clever, and not a full AST review of implementation code. A green gate means the artifact shape and cited proof look complete; you still approve specs and tasks.
|
|
192
|
+
|
|
193
|
+
| Gates check | Gates do **not** check |
|
|
194
|
+
| --- | --- |
|
|
195
|
+
| Spec sections and `SHALL`/`MUST` criteria | Semantic alignment between tests and requirements |
|
|
196
|
+
| REQ → task → validation traceability | Stub or broken source outside cited paths |
|
|
197
|
+
| Commit message shape and suppression patterns | Whether coverage % equals test quality |
|
|
198
|
+
| Commands you list under `quality.checks` | Commands you never configured |
|
|
199
|
+
|
|
200
|
+
See [Guarantees matrix](docs/guide/Guarantees-matrix.md) for the full product view.
|
|
201
|
+
|
|
202
|
+
---
|
|
203
|
+
|
|
234
204
|
## Documentation
|
|
235
205
|
|
|
236
206
|
| Topic | Start here | Go deeper |
|
|
@@ -241,7 +211,7 @@ Implementation happens in **small waves**: pick the next runnable jobs, test, im
|
|
|
241
211
|
| Enforcement | [Gates](docs/guide/gates.md) | [Gates and guarantees](docs/guide/Gates-and-guarantees.md) |
|
|
242
212
|
| Requirements | [Requirements analysis](docs/guide/requirements-analysis.md) | [Agent commands → /elicit](docs/guide/agent-commands.md) |
|
|
243
213
|
| Long-running projects | [Memory](docs/guide/Memory.md) | [Brownfield context](docs/guide/brownfield-context.md) |
|
|
244
|
-
| Questions | [FAQ](docs/guide/FAQ.md) | [Stability policy](docs/guide/Stability-policy.md) |
|
|
214
|
+
| Questions | [FAQ](docs/guide/FAQ.md) | [Glossary](docs/guide/Glossary.md) · [Stability policy](docs/guide/Stability-policy.md) |
|
|
245
215
|
|
|
246
216
|
Full index: [docs/guide/README.md](docs/guide/README.md)
|
|
247
217
|
|
package/index.js
CHANGED
|
@@ -180,6 +180,9 @@ Commands:
|
|
|
180
180
|
validate-req-analysis [brief.md] Requirements brief gate before /specify (/elicit)
|
|
181
181
|
validate-state [feature] Completion gate before declaring a feature done
|
|
182
182
|
check-commit --message "<msg>" Conventional Commits gate
|
|
183
|
+
[--staged] Also reject empty commits and oversized staged diffs
|
|
184
|
+
check-suppressions Block noqa/eslint-disable/ts-ignore/skip in staged diff
|
|
185
|
+
quality-checks [--json] Run quality.checks from .specs/config.yaml (/verify)
|
|
183
186
|
lessons <add|list|penalize|prune|promote|graduate|status> Lessons engine
|
|
184
187
|
--help Show this message
|
|
185
188
|
--version Print the package version
|
package/lib/constants.js
CHANGED
|
@@ -120,6 +120,9 @@ export const SCRIPT_ASSETS = [
|
|
|
120
120
|
{ file: "code_index.py", remotePath: "scripts/code_index.py" },
|
|
121
121
|
{ file: "validate_req_analysis.py", remotePath: "scripts/validate_req_analysis.py" },
|
|
122
122
|
{ file: "req_context.py", remotePath: "scripts/req_context.py" },
|
|
123
|
+
{ file: "check_suppressions.py", remotePath: "scripts/check_suppressions.py" },
|
|
124
|
+
{ file: "run_quality_checks.py", remotePath: "scripts/run_quality_checks.py" },
|
|
125
|
+
{ file: "_project_config.py", remotePath: "scripts/_project_config.py" },
|
|
123
126
|
];
|
|
124
127
|
|
|
125
128
|
/** @type {{ file: string, remotePath: string }[]} */
|
package/lib/doctor.js
CHANGED
|
@@ -47,6 +47,9 @@ async function hasPlatformAdapterContract(cwd, adapterIds) {
|
|
|
47
47
|
}
|
|
48
48
|
|
|
49
49
|
for (const relativePath of adapter.entryFiles) {
|
|
50
|
+
if (relativePath.endsWith(".mdc")) {
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
50
53
|
try {
|
|
51
54
|
const content = await readFileSafe(path.join(cwd, relativePath));
|
|
52
55
|
if (!content.includes(CURSORRULES_MARKER_BEGIN)) {
|
package/lib/gates.js
CHANGED
|
@@ -37,6 +37,8 @@ const GATE_SCRIPTS = {
|
|
|
37
37
|
"validate-req-analysis": "validate_req_analysis.py",
|
|
38
38
|
"analyze-artifacts": "analyze_artifacts.py",
|
|
39
39
|
"check-commit": "check_commit.py",
|
|
40
|
+
"check-suppressions": "check_suppressions.py",
|
|
41
|
+
"quality-checks": "run_quality_checks.py",
|
|
40
42
|
lessons: "lessons.py",
|
|
41
43
|
};
|
|
42
44
|
|
package/lib/install.js
CHANGED
|
@@ -173,8 +173,14 @@ export async function install(options = {}) {
|
|
|
173
173
|
}
|
|
174
174
|
|
|
175
175
|
if (!options.silent) {
|
|
176
|
-
printInstallNextSteps({
|
|
176
|
+
printInstallNextSteps({
|
|
177
|
+
pythonAvailable,
|
|
178
|
+
preset: options.preset,
|
|
179
|
+
detected,
|
|
180
|
+
skillDirs,
|
|
181
|
+
allPlatforms: options.allPlatforms,
|
|
182
|
+
});
|
|
177
183
|
}
|
|
178
184
|
|
|
179
|
-
return { pythonAvailable };
|
|
185
|
+
return { pythonAvailable, detected, skillDirs };
|
|
180
186
|
}
|
package/lib/next-steps.js
CHANGED
|
@@ -5,8 +5,33 @@
|
|
|
5
5
|
const DOCS_BASE =
|
|
6
6
|
"https://github.com/luizssantiago92/spec-guardrails/blob/main/docs/guide";
|
|
7
7
|
|
|
8
|
+
/** @type {Record<string, string>} */
|
|
9
|
+
const PLATFORM_LABELS = {
|
|
10
|
+
cursor: "Cursor",
|
|
11
|
+
claude: "Claude Code",
|
|
12
|
+
copilot: "GitHub Copilot",
|
|
13
|
+
codex: "OpenAI Codex",
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* @param {string | null | undefined} platformId
|
|
18
|
+
* @returns {string}
|
|
19
|
+
*/
|
|
20
|
+
function formatPlatformLabel(platformId) {
|
|
21
|
+
if (!platformId) {
|
|
22
|
+
return "Cursor (default)";
|
|
23
|
+
}
|
|
24
|
+
return PLATFORM_LABELS[platformId] ?? platformId;
|
|
25
|
+
}
|
|
26
|
+
|
|
8
27
|
/**
|
|
9
|
-
* @param {{
|
|
28
|
+
* @param {{
|
|
29
|
+
* pythonAvailable?: boolean,
|
|
30
|
+
* preset?: string,
|
|
31
|
+
* detected?: string | null,
|
|
32
|
+
* skillDirs?: string[],
|
|
33
|
+
* allPlatforms?: boolean,
|
|
34
|
+
* }} [options]
|
|
10
35
|
* @returns {string[]}
|
|
11
36
|
*/
|
|
12
37
|
export function formatInstallNextSteps(options = {}) {
|
|
@@ -15,12 +40,35 @@ export function formatInstallNextSteps(options = {}) {
|
|
|
15
40
|
"✨ Setup complete.",
|
|
16
41
|
"",
|
|
17
42
|
"Next:",
|
|
18
|
-
|
|
43
|
+
];
|
|
44
|
+
|
|
45
|
+
if (options.allPlatforms) {
|
|
46
|
+
lines.push(
|
|
47
|
+
" 1. Open your AI coding agent — skills installed for Cursor, Claude, Copilot, and Codex.",
|
|
48
|
+
);
|
|
49
|
+
} else if (options.skillDirs?.length === 1) {
|
|
50
|
+
lines.push(
|
|
51
|
+
` 1. Open ${formatPlatformLabel(options.detected)} — skills installed under ${options.skillDirs[0]}/.`,
|
|
52
|
+
);
|
|
53
|
+
lines.push(
|
|
54
|
+
" Need more agents? Re-run with `--all-platforms` or `--platform <cursor|claude|copilot|codex>`.",
|
|
55
|
+
);
|
|
56
|
+
} else {
|
|
57
|
+
const trees = options.skillDirs?.join(", ") ?? ".cursor/skills";
|
|
58
|
+
lines.push(
|
|
59
|
+
` 1. Open your AI coding agent — skills refreshed under: ${trees}.`,
|
|
60
|
+
);
|
|
61
|
+
lines.push(
|
|
62
|
+
" Existing platform trees were preserved. Use `--all-platforms` to install every tree.",
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
lines.push(
|
|
19
67
|
" 2. Run **Specify** (`/specify` or “Specify a feature: …”).",
|
|
20
68
|
"",
|
|
21
69
|
` Architecture: ${DOCS_BASE}/Architecture.md`,
|
|
22
70
|
` Quick start: ${DOCS_BASE}/Quick-start.md · .specs/GETTING_STARTED.md (this project)`,
|
|
23
|
-
|
|
71
|
+
);
|
|
24
72
|
|
|
25
73
|
if (options.preset) {
|
|
26
74
|
lines.push(` Config: .specs/config.yaml (preset: ${options.preset})`);
|
|
@@ -46,7 +94,13 @@ export function formatInstallNextSteps(options = {}) {
|
|
|
46
94
|
}
|
|
47
95
|
|
|
48
96
|
/**
|
|
49
|
-
* @param {{
|
|
97
|
+
* @param {{
|
|
98
|
+
* pythonAvailable?: boolean,
|
|
99
|
+
* preset?: string,
|
|
100
|
+
* detected?: string | null,
|
|
101
|
+
* skillDirs?: string[],
|
|
102
|
+
* allPlatforms?: boolean,
|
|
103
|
+
* }} [options]
|
|
50
104
|
*/
|
|
51
105
|
export function printInstallNextSteps(options = {}) {
|
|
52
106
|
for (const line of formatInstallNextSteps(options)) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@luizsantiago/spec-guardrails",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.5.0",
|
|
4
4
|
"description": "Governed spec-driven development for AI coding agents. Your agent writes the spec, gets your approval, builds in small waves, and proves the result — plans and project memory stored as files in your repo.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""Project config helpers for quality checks and suppression patterns."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
CONFIG_PATH = Path(".specs/config.yaml")
|
|
9
|
+
|
|
10
|
+
DEFAULT_SUPPRESSION_PATTERNS = [
|
|
11
|
+
r"#\s*noqa\b",
|
|
12
|
+
r"nosemgrep\b",
|
|
13
|
+
r"eslint-disable(?:-next-line|-line)?",
|
|
14
|
+
r"@ts-ignore\b",
|
|
15
|
+
r"@ts-expect-error\b",
|
|
16
|
+
r"\bxit\s*\(",
|
|
17
|
+
r"\bxdescribe\s*\(",
|
|
18
|
+
r"\bpytest\.mark\.skip\b",
|
|
19
|
+
r"--no-verify\b",
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
DEFAULT_MAX_STAGED_LINES = 500
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _parse_scalar(raw: str):
|
|
26
|
+
value = raw.strip().strip("'\"")
|
|
27
|
+
lower = value.lower()
|
|
28
|
+
if lower in {"true", "yes", "on"}:
|
|
29
|
+
return True
|
|
30
|
+
if lower in {"false", "no", "off"}:
|
|
31
|
+
return False
|
|
32
|
+
if re.fullmatch(r"-?\d+", value):
|
|
33
|
+
return int(value)
|
|
34
|
+
return value
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _parse_list_block(lines: list[str], start_index: int, parent_indent: int) -> tuple[list[str], int]:
|
|
38
|
+
items: list[str] = []
|
|
39
|
+
index = start_index
|
|
40
|
+
while index < len(lines):
|
|
41
|
+
line = lines[index]
|
|
42
|
+
if not line.strip() or line.lstrip().startswith("#"):
|
|
43
|
+
index += 1
|
|
44
|
+
continue
|
|
45
|
+
indent = len(line) - len(line.lstrip())
|
|
46
|
+
if indent <= parent_indent:
|
|
47
|
+
break
|
|
48
|
+
match = re.match(r"^\s*-\s+(.+)$", line)
|
|
49
|
+
if match:
|
|
50
|
+
items.append(_parse_scalar(match.group(1)))
|
|
51
|
+
index += 1
|
|
52
|
+
return items, index
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def load_project_config() -> dict:
|
|
56
|
+
"""Return quality, suppressions, and commit policy blocks with defaults."""
|
|
57
|
+
|
|
58
|
+
config = {
|
|
59
|
+
"quality": {"checks": []},
|
|
60
|
+
"suppressions": {"patterns": list(DEFAULT_SUPPRESSION_PATTERNS)},
|
|
61
|
+
"commit": {"max_staged_lines": DEFAULT_MAX_STAGED_LINES},
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if not CONFIG_PATH.is_file():
|
|
65
|
+
return config
|
|
66
|
+
|
|
67
|
+
lines = CONFIG_PATH.read_text(encoding="utf-8").splitlines()
|
|
68
|
+
section = None
|
|
69
|
+
section_indent = 0
|
|
70
|
+
subsection = None
|
|
71
|
+
subsection_indent = 0
|
|
72
|
+
|
|
73
|
+
index = 0
|
|
74
|
+
while index < len(lines):
|
|
75
|
+
line = lines[index]
|
|
76
|
+
stripped = line.strip()
|
|
77
|
+
if not stripped or stripped.startswith("#"):
|
|
78
|
+
index += 1
|
|
79
|
+
continue
|
|
80
|
+
|
|
81
|
+
indent = len(line) - len(line.lstrip())
|
|
82
|
+
|
|
83
|
+
if stripped == "quality:":
|
|
84
|
+
section = "quality"
|
|
85
|
+
section_indent = indent
|
|
86
|
+
subsection = None
|
|
87
|
+
index += 1
|
|
88
|
+
continue
|
|
89
|
+
|
|
90
|
+
if stripped == "suppressions:":
|
|
91
|
+
section = "suppressions"
|
|
92
|
+
section_indent = indent
|
|
93
|
+
subsection = None
|
|
94
|
+
index += 1
|
|
95
|
+
continue
|
|
96
|
+
|
|
97
|
+
if stripped == "commit:":
|
|
98
|
+
section = "commit"
|
|
99
|
+
section_indent = indent
|
|
100
|
+
subsection = None
|
|
101
|
+
index += 1
|
|
102
|
+
continue
|
|
103
|
+
|
|
104
|
+
if section and indent <= section_indent and not stripped.endswith(":"):
|
|
105
|
+
section = None
|
|
106
|
+
subsection = None
|
|
107
|
+
|
|
108
|
+
if section == "quality" and stripped == "checks:":
|
|
109
|
+
items, index = _parse_list_block(lines, index + 1, indent)
|
|
110
|
+
config["quality"]["checks"] = items
|
|
111
|
+
continue
|
|
112
|
+
|
|
113
|
+
if section == "suppressions" and stripped == "patterns:":
|
|
114
|
+
items, index = _parse_list_block(lines, index + 1, indent)
|
|
115
|
+
if items:
|
|
116
|
+
config["suppressions"]["patterns"] = items
|
|
117
|
+
continue
|
|
118
|
+
|
|
119
|
+
if section == "commit":
|
|
120
|
+
match = re.match(r"^max_staged_lines:\s*(.+)$", stripped)
|
|
121
|
+
if match:
|
|
122
|
+
config["commit"]["max_staged_lines"] = int(_parse_scalar(match.group(1)))
|
|
123
|
+
|
|
124
|
+
index += 1
|
|
125
|
+
|
|
126
|
+
return config
|
package/scripts/check_commit.py
CHANGED
|
@@ -24,10 +24,12 @@ from __future__ import annotations
|
|
|
24
24
|
|
|
25
25
|
import argparse
|
|
26
26
|
import re
|
|
27
|
+
import subprocess
|
|
27
28
|
import sys
|
|
28
29
|
from pathlib import Path
|
|
29
30
|
|
|
30
|
-
from _common import EXIT_USAGE, Report
|
|
31
|
+
from _common import EXIT_OK, EXIT_USAGE, Report
|
|
32
|
+
from _project_config import load_project_config
|
|
31
33
|
|
|
32
34
|
GATE = "check-commit"
|
|
33
35
|
|
|
@@ -51,6 +53,61 @@ HEADER = re.compile(
|
|
|
51
53
|
MAX_SUBJECT_LENGTH = 72
|
|
52
54
|
|
|
53
55
|
|
|
56
|
+
def read_staged_diff(cwd: Path) -> str:
|
|
57
|
+
result = subprocess.run(
|
|
58
|
+
["git", "diff", "--cached", "--numstat"],
|
|
59
|
+
cwd=cwd,
|
|
60
|
+
capture_output=True,
|
|
61
|
+
text=True,
|
|
62
|
+
check=False,
|
|
63
|
+
)
|
|
64
|
+
if result.returncode not in (0, 1):
|
|
65
|
+
raise RuntimeError(result.stderr.strip() or "git diff --cached --numstat failed")
|
|
66
|
+
return result.stdout
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def count_staged_lines(numstat_text: str) -> int:
|
|
70
|
+
total = 0
|
|
71
|
+
for line in numstat_text.splitlines():
|
|
72
|
+
parts = line.split("\t")
|
|
73
|
+
if len(parts) < 3:
|
|
74
|
+
continue
|
|
75
|
+
added, deleted = parts[0], parts[1]
|
|
76
|
+
if added == "-" or deleted == "-":
|
|
77
|
+
continue
|
|
78
|
+
total += int(added) + int(deleted)
|
|
79
|
+
return total
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def build_staged_report(cwd: Path) -> Report:
|
|
83
|
+
report = Report(gate=GATE, target="staged changes")
|
|
84
|
+
config = load_project_config()
|
|
85
|
+
max_lines = int(config.get("commit", {}).get("max_staged_lines") or 500)
|
|
86
|
+
|
|
87
|
+
if not (cwd / ".git").exists():
|
|
88
|
+
report.error("not a git repository")
|
|
89
|
+
return report
|
|
90
|
+
|
|
91
|
+
try:
|
|
92
|
+
numstat = read_staged_diff(cwd)
|
|
93
|
+
except RuntimeError as err:
|
|
94
|
+
report.error(str(err))
|
|
95
|
+
return report
|
|
96
|
+
|
|
97
|
+
if not numstat.strip():
|
|
98
|
+
report.error("empty commit blocked — no staged changes")
|
|
99
|
+
return report
|
|
100
|
+
|
|
101
|
+
lines = count_staged_lines(numstat)
|
|
102
|
+
report.ok(f"staged diff spans {lines} line(s)")
|
|
103
|
+
if lines > max_lines:
|
|
104
|
+
report.error(
|
|
105
|
+
f"staged diff is {lines} lines — limit is {max_lines} "
|
|
106
|
+
"(split the commit or raise commit.max_staged_lines in config)"
|
|
107
|
+
)
|
|
108
|
+
return report
|
|
109
|
+
|
|
110
|
+
|
|
54
111
|
def build_report(message: str) -> Report:
|
|
55
112
|
lines = message.rstrip().splitlines()
|
|
56
113
|
header = lines[0].strip() if lines else ""
|
|
@@ -107,33 +164,60 @@ def build_report(message: str) -> Report:
|
|
|
107
164
|
|
|
108
165
|
|
|
109
166
|
def main(argv: list[str] | None = None) -> int:
|
|
110
|
-
parser = argparse.ArgumentParser(description="Validate a commit message")
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
167
|
+
parser = argparse.ArgumentParser(description="Validate a commit message and/or staged diff")
|
|
168
|
+
parser.add_argument("--message", help="commit message text")
|
|
169
|
+
parser.add_argument("--file", help="path to a file holding the commit message")
|
|
170
|
+
parser.add_argument(
|
|
171
|
+
"--staged",
|
|
172
|
+
action="store_true",
|
|
173
|
+
help="validate staged diff size and reject empty commits",
|
|
174
|
+
)
|
|
114
175
|
parser.add_argument(
|
|
115
176
|
"--strict",
|
|
116
177
|
action="store_true",
|
|
117
178
|
help="treat warnings as blocking failures",
|
|
118
179
|
)
|
|
180
|
+
parser.add_argument(
|
|
181
|
+
"--cwd",
|
|
182
|
+
default=".",
|
|
183
|
+
help="repository root for --staged (default: current directory)",
|
|
184
|
+
)
|
|
119
185
|
args = parser.parse_args(argv)
|
|
120
186
|
|
|
121
|
-
if args.file:
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
187
|
+
if args.message and args.file:
|
|
188
|
+
print(f"[{GATE}] FAIL - arguments")
|
|
189
|
+
print(" error use either --message or --file, not both")
|
|
190
|
+
return EXIT_USAGE
|
|
191
|
+
|
|
192
|
+
if not args.staged and not args.message and not args.file:
|
|
193
|
+
print(f"[{GATE}] FAIL - arguments")
|
|
194
|
+
print(" error provide --message, --file, or --staged")
|
|
195
|
+
return EXIT_USAGE
|
|
196
|
+
|
|
197
|
+
exit_code = EXIT_OK
|
|
198
|
+
|
|
199
|
+
if args.staged:
|
|
200
|
+
staged_report = build_staged_report(Path(args.cwd).resolve())
|
|
201
|
+
exit_code = max(exit_code, staged_report.emit(strict=args.strict))
|
|
202
|
+
|
|
203
|
+
if args.message or args.file:
|
|
204
|
+
if args.file:
|
|
205
|
+
path = Path(args.file).expanduser()
|
|
206
|
+
if not path.exists():
|
|
207
|
+
print(f"[{GATE}] FAIL - {path}")
|
|
208
|
+
print(f" error file not found: {path}")
|
|
209
|
+
return EXIT_USAGE
|
|
210
|
+
message = path.read_text(encoding="utf-8")
|
|
211
|
+
else:
|
|
212
|
+
message = args.message or ""
|
|
130
213
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
214
|
+
comment_free = "\n".join(
|
|
215
|
+
line for line in message.splitlines() if not line.startswith("#")
|
|
216
|
+
)
|
|
217
|
+
message_report = build_report(comment_free)
|
|
218
|
+
exit_code = max(exit_code, message_report.emit(strict=args.strict))
|
|
134
219
|
|
|
135
|
-
|
|
136
|
-
return report.emit(strict=args.strict)
|
|
220
|
+
return exit_code
|
|
137
221
|
|
|
138
222
|
|
|
139
223
|
if __name__ == "__main__":
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Block verification suppressions in staged diffs.
|
|
3
|
+
|
|
4
|
+
Run before commit when agents might silence linters or skip hooks:
|
|
5
|
+
|
|
6
|
+
python3 check_suppressions.py
|
|
7
|
+
python3 check_suppressions.py --strict
|
|
8
|
+
|
|
9
|
+
Scans `git diff --cached` for patterns such as `# noqa`, `eslint-disable`,
|
|
10
|
+
`@ts-ignore`, skipped tests, or `--no-verify`. Patterns are configurable under
|
|
11
|
+
`.specs/config.yaml` → `suppressions.patterns`.
|
|
12
|
+
|
|
13
|
+
Exit codes: 0 pass, 1 blocking issues, 2 usage error.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import argparse
|
|
19
|
+
import re
|
|
20
|
+
import subprocess
|
|
21
|
+
import sys
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
from _common import EXIT_USAGE, Report
|
|
25
|
+
from _project_config import DEFAULT_SUPPRESSION_PATTERNS, load_project_config
|
|
26
|
+
|
|
27
|
+
GATE = "check-suppressions"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def read_staged_diff(cwd: Path) -> str:
|
|
31
|
+
result = subprocess.run(
|
|
32
|
+
["git", "diff", "--cached", "--unified=0", "--no-color"],
|
|
33
|
+
cwd=cwd,
|
|
34
|
+
capture_output=True,
|
|
35
|
+
text=True,
|
|
36
|
+
check=False,
|
|
37
|
+
)
|
|
38
|
+
if result.returncode not in (0, 1):
|
|
39
|
+
raise RuntimeError(result.stderr.strip() or "git diff --cached failed")
|
|
40
|
+
return result.stdout
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def added_lines(diff_text: str) -> list[tuple[str, str]]:
|
|
44
|
+
"""Return (file, line) pairs for added lines in a unified diff."""
|
|
45
|
+
|
|
46
|
+
entries: list[tuple[str, str]] = []
|
|
47
|
+
current_file = "(unknown)"
|
|
48
|
+
for line in diff_text.splitlines():
|
|
49
|
+
if line.startswith("+++ b/"):
|
|
50
|
+
current_file = line[6:].strip()
|
|
51
|
+
continue
|
|
52
|
+
if line.startswith("+") and not line.startswith("+++"):
|
|
53
|
+
entries.append((current_file, line[1:]))
|
|
54
|
+
return entries
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def build_report(diff_text: str, patterns: list[str]) -> Report:
|
|
58
|
+
report = Report(gate=GATE, target="staged diff")
|
|
59
|
+
compiled = [(pattern, re.compile(pattern, re.IGNORECASE)) for pattern in patterns]
|
|
60
|
+
|
|
61
|
+
if not diff_text.strip():
|
|
62
|
+
report.ok("no staged changes — suppression scan skipped")
|
|
63
|
+
return report
|
|
64
|
+
|
|
65
|
+
additions = added_lines(diff_text)
|
|
66
|
+
hits = 0
|
|
67
|
+
for file_path, content in additions:
|
|
68
|
+
for pattern, regex in compiled:
|
|
69
|
+
if regex.search(content):
|
|
70
|
+
hits += 1
|
|
71
|
+
report.error(f"{file_path}: added line matches forbidden pattern /{pattern}/")
|
|
72
|
+
|
|
73
|
+
if hits == 0:
|
|
74
|
+
report.ok(f"no forbidden suppressions in {len(additions)} added line(s)")
|
|
75
|
+
|
|
76
|
+
return report
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def main(argv: list[str] | None = None) -> int:
|
|
80
|
+
parser = argparse.ArgumentParser(description="Block linter/test suppressions in staged diff")
|
|
81
|
+
parser.add_argument(
|
|
82
|
+
"--cwd",
|
|
83
|
+
default=".",
|
|
84
|
+
help="repository root (default: current directory)",
|
|
85
|
+
)
|
|
86
|
+
parser.add_argument(
|
|
87
|
+
"--strict",
|
|
88
|
+
action="store_true",
|
|
89
|
+
help="treat warnings as blocking failures",
|
|
90
|
+
)
|
|
91
|
+
args = parser.parse_args(argv)
|
|
92
|
+
|
|
93
|
+
cwd = Path(args.cwd).resolve()
|
|
94
|
+
if not (cwd / ".git").exists():
|
|
95
|
+
print(f"[{GATE}] FAIL - {cwd}")
|
|
96
|
+
print(" error not a git repository")
|
|
97
|
+
return EXIT_USAGE
|
|
98
|
+
|
|
99
|
+
config = load_project_config()
|
|
100
|
+
patterns = config.get("suppressions", {}).get("patterns") or DEFAULT_SUPPRESSION_PATTERNS
|
|
101
|
+
|
|
102
|
+
try:
|
|
103
|
+
diff_text = read_staged_diff(cwd)
|
|
104
|
+
except RuntimeError as err:
|
|
105
|
+
print(f"[{GATE}] FAIL - staged diff")
|
|
106
|
+
print(f" error {err}")
|
|
107
|
+
return EXIT_USAGE
|
|
108
|
+
|
|
109
|
+
report = build_report(diff_text, patterns)
|
|
110
|
+
return report.emit(strict=args.strict)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
if __name__ == "__main__":
|
|
114
|
+
sys.exit(main())
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Run project quality commands declared in `.specs/config.yaml`.
|
|
3
|
+
|
|
4
|
+
Used during /verify to execute the owner's test/lint commands and attach
|
|
5
|
+
evidence that commands actually ran:
|
|
6
|
+
|
|
7
|
+
python3 run_quality_checks.py
|
|
8
|
+
python3 run_quality_checks.py --json
|
|
9
|
+
|
|
10
|
+
Configure under:
|
|
11
|
+
|
|
12
|
+
quality:
|
|
13
|
+
checks:
|
|
14
|
+
- npm test
|
|
15
|
+
- npm run lint
|
|
16
|
+
|
|
17
|
+
Exit codes: 0 all passed, 1 one or more failed, 2 usage/config error.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import argparse
|
|
23
|
+
import json
|
|
24
|
+
import subprocess
|
|
25
|
+
import sys
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
|
|
28
|
+
from _common import EXIT_FAILED, EXIT_OK
|
|
29
|
+
from _project_config import load_project_config
|
|
30
|
+
|
|
31
|
+
GATE = "quality-checks"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def run_command(command: str, cwd: Path) -> dict:
|
|
35
|
+
completed = subprocess.run(
|
|
36
|
+
command,
|
|
37
|
+
cwd=cwd,
|
|
38
|
+
shell=True,
|
|
39
|
+
capture_output=True,
|
|
40
|
+
text=True,
|
|
41
|
+
check=False,
|
|
42
|
+
)
|
|
43
|
+
output = (completed.stdout or "") + (completed.stderr or "")
|
|
44
|
+
if len(output) > 8000:
|
|
45
|
+
output = output[:8000] + "\n… (truncated)"
|
|
46
|
+
return {
|
|
47
|
+
"command": command,
|
|
48
|
+
"exit_code": completed.returncode,
|
|
49
|
+
"output": output,
|
|
50
|
+
"passed": completed.returncode == 0,
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def main(argv: list[str] | None = None) -> int:
|
|
55
|
+
parser = argparse.ArgumentParser(description="Run configured project quality checks")
|
|
56
|
+
parser.add_argument("--cwd", default=".", help="repository root")
|
|
57
|
+
parser.add_argument("--json", action="store_true", help="machine-readable output")
|
|
58
|
+
args = parser.parse_args(argv)
|
|
59
|
+
|
|
60
|
+
cwd = Path(args.cwd).resolve()
|
|
61
|
+
checks = load_project_config().get("quality", {}).get("checks") or []
|
|
62
|
+
|
|
63
|
+
if not checks:
|
|
64
|
+
message = {
|
|
65
|
+
"gate": GATE,
|
|
66
|
+
"status": "skipped",
|
|
67
|
+
"reason": "no quality.checks configured in .specs/config.yaml",
|
|
68
|
+
"results": [],
|
|
69
|
+
}
|
|
70
|
+
if args.json:
|
|
71
|
+
print(json.dumps(message, indent=2))
|
|
72
|
+
else:
|
|
73
|
+
print(f"[{GATE}] SKIP - no quality.checks configured")
|
|
74
|
+
print(" info add commands under quality.checks in .specs/config.yaml")
|
|
75
|
+
return EXIT_OK
|
|
76
|
+
|
|
77
|
+
results = [run_command(command, cwd) for command in checks]
|
|
78
|
+
failed = [item for item in results if not item["passed"]]
|
|
79
|
+
|
|
80
|
+
payload = {
|
|
81
|
+
"gate": GATE,
|
|
82
|
+
"status": "pass" if not failed else "fail",
|
|
83
|
+
"results": results,
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if args.json:
|
|
87
|
+
print(json.dumps(payload, indent=2))
|
|
88
|
+
else:
|
|
89
|
+
status = "PASS" if not failed else "FAIL"
|
|
90
|
+
print(f"[{GATE}] {status} - {len(checks)} command(s)")
|
|
91
|
+
for item in results:
|
|
92
|
+
label = "ok" if item["passed"] else "blocking"
|
|
93
|
+
print(f" {label:<9} {item['command']} (exit {item['exit_code']})")
|
|
94
|
+
if not item["passed"] and item["output"].strip():
|
|
95
|
+
for line in item["output"].strip().splitlines()[-8:]:
|
|
96
|
+
print(f" {line}")
|
|
97
|
+
|
|
98
|
+
return EXIT_OK if not failed else EXIT_FAILED
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
if __name__ == "__main__":
|
|
102
|
+
sys.exit(main())
|
|
@@ -84,6 +84,8 @@ Plan → Test → Implement → Gate → Commit → Next
|
|
|
84
84
|
|
|
85
85
|
```bash
|
|
86
86
|
python3 .specs/guardrails/scripts/check_commit.py --message "feat(auth): add token refresh"
|
|
87
|
+
python3 .specs/guardrails/scripts/check_commit.py --staged
|
|
88
|
+
python3 .specs/guardrails/scripts/check_suppressions.py
|
|
87
89
|
git add [files] .specs/features/[feature]/tasks.md
|
|
88
90
|
git commit -m "feat(auth): add token refresh"
|
|
89
91
|
```
|
|
@@ -113,11 +113,12 @@ The completion gate searches for `file:line` (for example `test/routes/login.tes
|
|
|
113
113
|
python3 .specs/guardrails/scripts/validate_state.py .specs/features/[feature]
|
|
114
114
|
python3 .specs/guardrails/scripts/validate_state.py [feature]
|
|
115
115
|
python3 .specs/guardrails/scripts/validate_state.py # single-feature projects
|
|
116
|
+
npx @luizsantiago/spec-guardrails quality-checks # when quality.checks is configured
|
|
116
117
|
```
|
|
117
118
|
|
|
118
119
|
Checks that the report exists, the verdict is exactly PASS in the **preamble** (before the first `##` section) or under a dedicated `## Verdict` / `## Result` / `## Status` heading, every spec requirement ID shares a line with test `file:line` evidence, and no task remains open. A `- Verdict: PASS` buried under Discrimination Sensor or Coverage does not count. Preamble and `## Verdict` must not disagree. Evidence inside fenced samples or HTML comments does not count. `PASS` with any surviving mutant on a sensor/mutant line fails. `PASS` with open `Gaps` bullets or Security Review `Result: fail` fails. On Medium+ features (`design.md` with content, 4+ tasks, or 2+ phases) a discrimination-sensor **outcome** is **blocking** — the section heading alone is not enough — and a Medium+ `PASS` requires at least one `killed` mutant in the sensor focus (`injected` alone, or `killed` only under Gaps, is not enough). Below Medium+ a missing outcome is a warning (`--strict` still promotes warnings). Non-zero exit means the feature is not done.
|
|
119
120
|
|
|
120
|
-
The gate cannot judge whether a cited test actually asserts the criterion. That judgment is the verifier's; a green gate with a weak assertion is still a FAIL in the report.
|
|
121
|
+
The gate cannot judge whether a cited test actually asserts the criterion. Run `quality-checks` when `.specs/config.yaml` lists project commands (`npm test`, …) and cite the passing output in the validation report. That judgment is still the verifier's; a green gate with a weak assertion is still a FAIL in the report.
|
|
121
122
|
|
|
122
123
|
**Gate-enforced vs verifier judgment.** `validate_state.py` enforces form: verdict scope, test-path evidence, REQ↔evidence lines, sensor outcomes on Medium+, open Gaps, Security `Result: fail`, open tasks. The following stay **verifier judgment** (not structural gates): whether each coverage row's test truly asserts the outcome, whether a lightweight Security path is justified, Interactive UAT / walkthrough success, and optional `## AppSec` / `## QA` sections. See the [gate stability contract](https://github.com/luizssantiago92/spec-guardrails/blob/main/prd/gate-stability.md).
|
|
123
124
|
|
|
@@ -82,6 +82,24 @@ sandbox:
|
|
|
82
82
|
- "git\\s+push\\b[^\\n]*(--force|--force-with-lease)[^\\n]*\\b(main|master)\\b"
|
|
83
83
|
- "\\b(DROP\\s+DATABASE|DROP\\s+SCHEMA|TRUNCATE\\s+TABLE)\\b"
|
|
84
84
|
|
|
85
|
+
# Verification suppressions (optional — block agents from silencing linters/tests)
|
|
86
|
+
suppressions:
|
|
87
|
+
patterns:
|
|
88
|
+
- "#\\s*noqa\\b"
|
|
89
|
+
- "eslint-disable"
|
|
90
|
+
- "@ts-ignore\\b"
|
|
91
|
+
- "\\bxit\\s*\\("
|
|
92
|
+
- "--no-verify\\b"
|
|
93
|
+
|
|
94
|
+
# Project quality commands (optional — run during /verify)
|
|
95
|
+
quality:
|
|
96
|
+
checks:
|
|
97
|
+
- npm test
|
|
98
|
+
|
|
99
|
+
# Commit policy (optional — used with check-commit --staged)
|
|
100
|
+
commit:
|
|
101
|
+
max_staged_lines: 500
|
|
102
|
+
|
|
85
103
|
# Project-specific overrides (appended on top of preset + rules above):
|
|
86
104
|
# overrides:
|
|
87
105
|
# rules:
|