@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 CHANGED
@@ -1,13 +1,17 @@
1
1
  # Spec Guardrails
2
2
 
3
3
  [![npm version](https://img.shields.io/npm/v/@luizsantiago/spec-guardrails.svg)](https://www.npmjs.com/package/@luizsantiago/spec-guardrails)
4
+ [![npm downloads](https://img.shields.io/npm/dm/@luizsantiago/spec-guardrails.svg)](https://www.npmjs.com/package/@luizsantiago/spec-guardrails)
5
+ [![CI](https://github.com/luizssantiago92/spec-guardrails/actions/workflows/ci.yml/badge.svg)](https://github.com/luizssantiago92/spec-guardrails/actions/workflows/ci.yml)
4
6
  [![license: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
5
7
 
8
+ ![Spec Guardrails — governed spec-driven development for AI coding agents](https://raw.githubusercontent.com/luizssantiago92/spec-guardrails/main/.assets/banner.svg)
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.4.x**
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
+ ![Spec Guardrails phase flow — classify, optional elicitation, three human approvals, build loop, verify, archive](https://raw.githubusercontent.com/luizssantiago92/spec-guardrails/main/.assets/flow.svg)
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
+ ![Complexity tiers — Quick, Simple, Medium, Complex, Parallel](https://raw.githubusercontent.com/luizssantiago92/spec-guardrails/main/.assets/tiers.svg)
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 (9)
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/` (+ Claude, Copilot, Codex trees) | Phase instructions for the agent |
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({ pythonAvailable, preset: options.preset });
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 {{ pythonAvailable?: boolean, preset?: string }} [options]
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
- " 1. Open your AI coding agent in this project (Cursor, Claude, Copilot, Codex, or AGENTS.md adapters install automatically).",
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 {{ pythonAvailable?: boolean, preset?: string }} [options]
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.4.0",
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
@@ -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
- source = parser.add_mutually_exclusive_group(required=True)
112
- source.add_argument("--message", help="commit message text")
113
- source.add_argument("--file", help="path to a file holding the commit message")
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
- path = Path(args.file).expanduser()
123
- if not path.exists():
124
- print(f"[{GATE}] FAIL - {path}")
125
- print(f" error file not found: {path}")
126
- return EXIT_USAGE
127
- message = path.read_text(encoding="utf-8")
128
- else:
129
- message = args.message or ""
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
- comment_free = "\n".join(
132
- line for line in message.splitlines() if not line.startswith("#")
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
- report = build_report(comment_free)
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: