@descent-vtt/spec-guard 0.1.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/LICENSE +21 -0
- package/README.md +393 -0
- package/bin/spec-guard.js +24 -0
- package/dist/cli.d.ts +43 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +243 -0
- package/dist/cli.js.map +1 -0
- package/dist/engine.d.ts +80 -0
- package/dist/engine.d.ts.map +1 -0
- package/dist/engine.js +539 -0
- package/dist/engine.js.map +1 -0
- package/dist/glob.d.ts +60 -0
- package/dist/glob.d.ts.map +1 -0
- package/dist/glob.js +238 -0
- package/dist/glob.js.map +1 -0
- package/dist/index.d.ts +27 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +20 -0
- package/dist/index.js.map +1 -0
- package/dist/parser.d.ts +33 -0
- package/dist/parser.d.ts.map +1 -0
- package/dist/parser.js +180 -0
- package/dist/parser.js.map +1 -0
- package/dist/reporter.d.ts +45 -0
- package/dist/reporter.d.ts.map +1 -0
- package/dist/reporter.js +198 -0
- package/dist/reporter.js.map +1 -0
- package/dist/runner.d.ts +54 -0
- package/dist/runner.d.ts.map +1 -0
- package/dist/runner.js +387 -0
- package/dist/runner.js.map +1 -0
- package/dist/types.d.ts +136 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +8 -0
- package/dist/types.js.map +1 -0
- package/package.json +72 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 spec-guard contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
# spec-guard
|
|
2
|
+
|
|
3
|
+
**Executable architecture assertions for Markdown specs & ADRs.**
|
|
4
|
+
|
|
5
|
+
Turn the claims your design documents make about your codebase into assertions
|
|
6
|
+
that run in CI, in milliseconds, with no runtime dependencies.
|
|
7
|
+
|
|
8
|
+
```md
|
|
9
|
+
The retired payment gateway is gone from the service layer.
|
|
10
|
+
|
|
11
|
+
<!-- @assert-absence target="src/services" symbol="LegacyPaymentGateway" -->
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
If someone reintroduces `LegacyPaymentGateway`, CI fails and points at the line
|
|
15
|
+
of the ADR that promised it was gone.
|
|
16
|
+
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
## Why this exists
|
|
20
|
+
|
|
21
|
+
Architecture Decision Records, RFCs and technical briefs are full of hard claims
|
|
22
|
+
about a repository:
|
|
23
|
+
|
|
24
|
+
- *"`LegacyPaymentGateway` no longer exists anywhere in the service layer."*
|
|
25
|
+
- *"There is exactly one `UserSessionManager`."*
|
|
26
|
+
- *"No secret is ever read from `process.env` outside `src/config`."*
|
|
27
|
+
|
|
28
|
+
Every one of those claims is true on the day it is written and unverified from
|
|
29
|
+
the day after. Prose does not fail a build. Six months later the document is
|
|
30
|
+
still confidently asserting something the code stopped doing in March.
|
|
31
|
+
|
|
32
|
+
That has always been an annoyance. It became a defect multiplier the moment
|
|
33
|
+
coding agents started reading these documents as ground truth. An agent that
|
|
34
|
+
reads *"module X does not exist yet"* will happily build a second X. A reviewer
|
|
35
|
+
who trusts *"this symbol occurs zero times"* skips the grep. Stale documentation
|
|
36
|
+
is now executable in the worst sense: it executes inside someone's head, or
|
|
37
|
+
inside a model's context window, and produces work that has to be thrown away.
|
|
38
|
+
|
|
39
|
+
spec-guard makes the claims executable in the good sense. It reads directives
|
|
40
|
+
written as ordinary HTML comments - invisible in every Markdown renderer - and
|
|
41
|
+
checks them against the real tree with ripgrep. A stale claim becomes a failing
|
|
42
|
+
build with a line number, not a landmine.
|
|
43
|
+
|
|
44
|
+
## Quickstart
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
npm install --save-dev @descent-vtt/spec-guard
|
|
48
|
+
npx spec-guard "docs/**/*.md"
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
**The package is scoped; the command is not.** Once installed, the binary is
|
|
52
|
+
plain `spec-guard`, so `npx spec-guard`, `npm scripts` and a global install all
|
|
53
|
+
use that name:
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
npm install -g @descent-vtt/spec-guard # then: spec-guard "docs/**/*.md"
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Without a local install, `npx` needs the full package name - `npx spec-guard`
|
|
60
|
+
on its own would resolve to a different package on the registry:
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
npx @descent-vtt/spec-guard "docs/**/*.md"
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Add a directive to any Markdown file, directly under the sentence it makes
|
|
67
|
+
executable:
|
|
68
|
+
|
|
69
|
+
```md
|
|
70
|
+
## Decision
|
|
71
|
+
|
|
72
|
+
Session state has exactly one owner.
|
|
73
|
+
|
|
74
|
+
<!-- @assert-count target="src/" symbol="UserSessionManager" expected="1" -->
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Run it:
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
npx spec-guard "docs/**/*.md" --verbose
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
```text
|
|
84
|
+
spec-guard 1 spec · 1 assertion · ripgrep
|
|
85
|
+
|
|
86
|
+
✔ docs/adr/0007-sessions.md:5 @assert-count "UserSessionManager" (1 match) in src
|
|
87
|
+
|
|
88
|
+
1 passed · 7ms
|
|
89
|
+
✔ every spec assertion holds
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
And when it drifts:
|
|
93
|
+
|
|
94
|
+
```text
|
|
95
|
+
✖ docs/adr/0007-sessions.md:5 @assert-count
|
|
96
|
+
"UserSessionManager" must appear exactly 1 time in src
|
|
97
|
+
expected exactly 1 match, found 3
|
|
98
|
+
src/auth/UserSessionManager.ts:12:14 export class UserSessionManager {
|
|
99
|
+
src/legacy/sessions.ts:4:22 import { UserSessionManager } from '../auth';
|
|
100
|
+
src/workers/refresh.ts:9:10 const manager = new UserSessionManager();
|
|
101
|
+
|
|
102
|
+
0 passed · 1 failed · 9ms
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Exit code 1. The ADR is now a test.
|
|
106
|
+
|
|
107
|
+
## Directives
|
|
108
|
+
|
|
109
|
+
Every directive is an HTML comment. It may span multiple lines. Attribute values
|
|
110
|
+
may use double or single quotes, and a bare attribute means `="true"`.
|
|
111
|
+
|
|
112
|
+
### `@assert-absence` - this symbol is gone
|
|
113
|
+
|
|
114
|
+
```md
|
|
115
|
+
<!-- @assert-absence target="src/controllers,src/services" symbol="LegacyPaymentGateway" -->
|
|
116
|
+
<!-- @assert-absence target="src/" symbol="STRIPE_SECRET_KEY" expected="0" -->
|
|
117
|
+
<!-- @assert-absence target="src/" symbol="TODO" expected="5" reason="burn down the backlog" -->
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
Fails when the symbol occurs more than `expected` times (default `0`).
|
|
121
|
+
`max="..."` is accepted as a synonym for `expected`.
|
|
122
|
+
|
|
123
|
+
### `@assert-count` - this symbol occurs exactly / at least / at most N times
|
|
124
|
+
|
|
125
|
+
```md
|
|
126
|
+
<!-- @assert-count target="src/" symbol="UserSessionManager" expected="1" -->
|
|
127
|
+
<!-- @assert-count target="src/ui/" symbol="PrimaryButton" min="1" -->
|
|
128
|
+
<!-- @assert-count target="src/core/" symbol="DeprecatedHelper" max="3" -->
|
|
129
|
+
<!-- @assert-count target="src/" symbol="Repository" min="2" max="10" -->
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
Requires `expected`, or `min` and/or `max`. `expected` cannot be combined with
|
|
133
|
+
`min`/`max`.
|
|
134
|
+
|
|
135
|
+
### `@assert-present` - this file exists
|
|
136
|
+
|
|
137
|
+
```md
|
|
138
|
+
<!-- @assert-present file="SECURITY.md" -->
|
|
139
|
+
<!-- @assert-present file="config/production.json,config/staging.json" -->
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
Passes when every listed path exists relative to `--root`. Directories count.
|
|
143
|
+
|
|
144
|
+
### Attributes
|
|
145
|
+
|
|
146
|
+
| Attribute | Applies to | Meaning |
|
|
147
|
+
| --- | --- | --- |
|
|
148
|
+
| `target` | absence, count | Comma-separated paths to search, relative to `--root`. Default `.` |
|
|
149
|
+
| `symbol` | absence, count | The literal string to search for (or a regex with `regex="true"`) |
|
|
150
|
+
| `file` | present | Comma-separated paths that must exist |
|
|
151
|
+
| `expected` | absence, count | Upper bound for absence; exact count for count |
|
|
152
|
+
| `min` / `max` | count (`max` also on absence) | Inclusive bounds |
|
|
153
|
+
| `glob` | absence, count | Comma-separated file filters, e.g. `*.ts,*.tsx` (ripgrep `-g` semantics) |
|
|
154
|
+
| `regex` | absence, count | Treat `symbol` as a regular expression |
|
|
155
|
+
| `word` | absence, count | Require word boundaries, so `Primary` does not match `PrimaryButton` |
|
|
156
|
+
| `ignore-case` | absence, count | Case-insensitive matching |
|
|
157
|
+
| `reason` | all | Human-readable justification, printed on failure |
|
|
158
|
+
|
|
159
|
+
Unknown attributes are an error, not a shrug: `expct="1"` fails the run instead
|
|
160
|
+
of silently asserting nothing.
|
|
161
|
+
|
|
162
|
+
## CLI
|
|
163
|
+
|
|
164
|
+
```bash
|
|
165
|
+
spec-guard [patterns...] [options]
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
| Option | Description |
|
|
169
|
+
| --- | --- |
|
|
170
|
+
| `-r, --root <path>` | Codebase root that assertions resolve against (default: cwd) |
|
|
171
|
+
| `-v, --verbose` | Print passing assertions too |
|
|
172
|
+
| `--fail-fast` | Stop at the first failing assertion |
|
|
173
|
+
| `--json` | Machine-readable report on stdout |
|
|
174
|
+
| `--engine <auto\|rg\|js>` | Search engine (default `auto`: ripgrep when available) |
|
|
175
|
+
| `--strict` | Treat a `target` that does not exist as a failure, not a warning |
|
|
176
|
+
| `--include-specs` | Also count matches inside the spec files themselves |
|
|
177
|
+
| `--max-snippets <n>` | Failure snippets per assertion (default 5) |
|
|
178
|
+
| `--concurrency <n>` | Search passes in flight at once (default 8) |
|
|
179
|
+
| `--allow-empty` | Exit 0 when no spec file matched the patterns |
|
|
180
|
+
| `--color` / `--no-color` | Force colour on or off (`NO_COLOR` honoured) |
|
|
181
|
+
|
|
182
|
+
Patterns are expanded by spec-guard itself, so quoted globs behave identically
|
|
183
|
+
on Windows, macOS and Linux. A directory expands to the Markdown files in it.
|
|
184
|
+
|
|
185
|
+
### Exit codes
|
|
186
|
+
|
|
187
|
+
| Code | Meaning |
|
|
188
|
+
| --- | --- |
|
|
189
|
+
| `0` | Every assertion held |
|
|
190
|
+
| `1` | An assertion failed, or a directive was malformed |
|
|
191
|
+
| `2` | spec-guard could not run: bad usage, no spec files matched, `--engine rg` with no ripgrep |
|
|
192
|
+
|
|
193
|
+
## CI integration
|
|
194
|
+
|
|
195
|
+
```yaml
|
|
196
|
+
# .github/workflows/specs.yml
|
|
197
|
+
name: Specs
|
|
198
|
+
on: [push, pull_request]
|
|
199
|
+
|
|
200
|
+
jobs:
|
|
201
|
+
spec-guard:
|
|
202
|
+
runs-on: ubuntu-latest
|
|
203
|
+
steps:
|
|
204
|
+
- uses: actions/checkout@v5
|
|
205
|
+
- uses: actions/setup-node@v5
|
|
206
|
+
with:
|
|
207
|
+
node-version: '22'
|
|
208
|
+
- run: npx @descent-vtt/spec-guard "docs/**/*.md" "README.md" --verbose
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
That job needs nothing else installed. GitHub-hosted runners do **not** ship
|
|
212
|
+
ripgrep on `PATH` - spec-guard's own CI reports `engine: javascript` there - so
|
|
213
|
+
the fallback is what actually runs, and it produces identical results. If your
|
|
214
|
+
repository is large enough that you want ripgrep's speed, install it first:
|
|
215
|
+
|
|
216
|
+
```yaml
|
|
217
|
+
- run: sudo apt-get update && sudo apt-get install -y ripgrep
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
Or point spec-guard at a binary you already have with `SPEC_GUARD_RG=/path/to/rg`.
|
|
221
|
+
|
|
222
|
+
As a pre-commit hook (assuming a local install, so the bare command resolves):
|
|
223
|
+
|
|
224
|
+
```bash
|
|
225
|
+
npx spec-guard "docs/**/*.md" --fail-fast
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
## How it works
|
|
229
|
+
|
|
230
|
+
```text
|
|
231
|
+
parser ──▶ Directive[] ──▶ runner ──▶ Assertion[] ──▶ engine ──▶ AssertionResult[] ──▶ reporter
|
|
232
|
+
(pure) (I/O) (rg | js) (pure)
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
1. **Parse.** Markdown is scanned for `<!-- @assert-* -->` comments. Fenced code
|
|
236
|
+
blocks and inline code spans are masked first, so documentation that shows
|
|
237
|
+
the syntax (like this README) never executes it.
|
|
238
|
+
2. **Resolve.** Each directive becomes a typed assertion. Bad numbers, unknown
|
|
239
|
+
booleans, unknown attributes and paths escaping `--root` are rejected here,
|
|
240
|
+
before any I/O.
|
|
241
|
+
3. **Search.** Assertions that share a target list and flags are answered by a
|
|
242
|
+
*single* pass over the tree.
|
|
243
|
+
4. **Report.** ANSI output with the spec location, the expectation, the observed
|
|
244
|
+
count, and up to five real snippets; or JSON for tooling.
|
|
245
|
+
|
|
246
|
+
### Performance
|
|
247
|
+
|
|
248
|
+
Scanning a tree costs about the same whether you look for one symbol or twenty,
|
|
249
|
+
so spec-guard groups assertions by target set and flags and answers each group
|
|
250
|
+
in one pass. Measured on Windows 11 / Node 24 / ripgrep 15 against a synthetic
|
|
251
|
+
2,000-file, 5.1 MB tree with 8 assertions:
|
|
252
|
+
|
|
253
|
+
| | one pass per assertion | batched (current) |
|
|
254
|
+
| --- | --- | --- |
|
|
255
|
+
| ripgrep engine | ~280 ms | **~72 ms** |
|
|
256
|
+
| JavaScript fallback | ~780 ms | **~283 ms** |
|
|
257
|
+
|
|
258
|
+
Two honest caveats:
|
|
259
|
+
|
|
260
|
+
- **Process spawning is expensive on Windows** (~27 ms each). On a small tree
|
|
261
|
+
the JavaScript engine can beat ripgrep outright - spec-guard checks its own
|
|
262
|
+
repository in ~15 ms with `--engine js` versus ~170 ms with ripgrep. On Linux
|
|
263
|
+
and macOS, where spawning costs a few milliseconds, ripgrep wins at every
|
|
264
|
+
size. If your repository is small and you care about the last millisecond,
|
|
265
|
+
`--engine js` is a legitimate choice.
|
|
266
|
+
- **ripgrep is the reference implementation.** The fallback matches it on
|
|
267
|
+
everything the test suite covers - counts, snippets, word boundaries, globs,
|
|
268
|
+
binary skipping, file-size limits - but ripgrep also honours `.gitignore`,
|
|
269
|
+
while the fallback uses a fixed ignore list (`node_modules`, `dist`, `build`,
|
|
270
|
+
`coverage`, `.git`, dotfiles, and friends).
|
|
271
|
+
|
|
272
|
+
## Programmatic API
|
|
273
|
+
|
|
274
|
+
```ts
|
|
275
|
+
import { runSpecGuard, formatReport } from '@descent-vtt/spec-guard';
|
|
276
|
+
|
|
277
|
+
const report = await runSpecGuard({
|
|
278
|
+
patterns: ['docs/**/*.md'],
|
|
279
|
+
root: process.cwd(),
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
if (!report.ok) {
|
|
283
|
+
console.error(formatReport(report, { color: true, verbose: false }));
|
|
284
|
+
process.exitCode = 1;
|
|
285
|
+
}
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
`runSpecGuard` returns the full report: every assertion, its bounds, the
|
|
289
|
+
observed count, match locations, warnings and timings. The parser
|
|
290
|
+
(`parseDirectives`) and reporter (`formatReport`, `formatJson`) are pure
|
|
291
|
+
functions you can use on their own.
|
|
292
|
+
|
|
293
|
+
## Design decisions
|
|
294
|
+
|
|
295
|
+
This tool was specified loosely and built opinionatedly. Where the
|
|
296
|
+
implementation departs from the obvious reading of the brief, here is why.
|
|
297
|
+
|
|
298
|
+
**Spec files are excluded from their own searches.** An ADR that says
|
|
299
|
+
"`LegacyGateway` must not appear" contains the string `LegacyGateway`. Without
|
|
300
|
+
this rule, absence assertions would fail on the document asserting them - the
|
|
301
|
+
single most confusing possible first-run experience. `--include-specs` restores
|
|
302
|
+
the naive behaviour.
|
|
303
|
+
|
|
304
|
+
**Fenced code and inline code are masked before parsing.** A README documenting
|
|
305
|
+
the syntax must not execute it. Masking preserves byte offsets, so reported line
|
|
306
|
+
numbers stay exact. (This is subtle: pairing backtick runs the naive way
|
|
307
|
+
desynchronises after a stray unmatched run and un-masks real prose. spec-guard
|
|
308
|
+
uses CommonMark's equal-length pairing rule, and there is a regression test.)
|
|
309
|
+
|
|
310
|
+
**Malformed directives fail the run.** A typo like `expct="1"` could be ignored
|
|
311
|
+
as "not a directive". It is instead an error, because a spec tool whose typos
|
|
312
|
+
silently assert nothing is worse than no spec tool.
|
|
313
|
+
|
|
314
|
+
**Four attributes were added beyond the original brief** - `word`, `regex`,
|
|
315
|
+
`glob` and `ignore-case` - because `symbol="Primary"` matching `PrimaryButton`
|
|
316
|
+
is the first thing every user hits, and `reason` because a failure message
|
|
317
|
+
should say *why* the rule exists.
|
|
318
|
+
|
|
319
|
+
**`min`/`max` are accepted on `@assert-absence` too.** "At most 5 TODOs" is an
|
|
320
|
+
absence claim with a budget, and burning a budget down is a real workflow.
|
|
321
|
+
|
|
322
|
+
**The engine is detected lazily.** Probing with `rg --version` up front costs a
|
|
323
|
+
process spawn (~27 ms on Windows) on the critical path of every run, including
|
|
324
|
+
runs where ripgrep is missing. Discovering its absence from the first real
|
|
325
|
+
search is free.
|
|
326
|
+
|
|
327
|
+
**Assertions are batched, but only when provably safe.** Merging literals into
|
|
328
|
+
one ripgrep alternation can lose matches two ways: containment (`Primary` /
|
|
329
|
+
`PrimaryButton`) and dovetailing (`abc` / `cd` in `abcd`). spec-guard checks for
|
|
330
|
+
both and falls back to separate passes when either is possible, and never
|
|
331
|
+
batches regexes or case-insensitive searches. Speed is never traded for a wrong
|
|
332
|
+
count.
|
|
333
|
+
|
|
334
|
+
**Exit code 2 exists.** "Your specs failed" and "spec-guard could not run" are
|
|
335
|
+
different facts, and CI should be able to tell them apart.
|
|
336
|
+
|
|
337
|
+
## spec-guard checks itself
|
|
338
|
+
|
|
339
|
+
The invariants in [`docs/adr/0001-invariants.md`](docs/adr/0001-invariants.md)
|
|
340
|
+
and [`docs/adr/0002-directive-format.md`](docs/adr/0002-directive-format.md) are
|
|
341
|
+
executed against this repository on every CI run. The CLI never calls
|
|
342
|
+
`console.log`, nothing outside the engine spawns a process, and the parser and
|
|
343
|
+
reporter never touch the filesystem - because those documents say so, and the
|
|
344
|
+
build fails if they stop being true.
|
|
345
|
+
|
|
346
|
+
This README is executable too:
|
|
347
|
+
|
|
348
|
+
<!-- @assert-present file="src/parser.ts,src/engine.ts,src/reporter.ts,src/runner.ts,src/cli.ts" -->
|
|
349
|
+
|
|
350
|
+
## Development
|
|
351
|
+
|
|
352
|
+
```bash
|
|
353
|
+
npm install
|
|
354
|
+
npm run build # tsc -> dist/
|
|
355
|
+
npm test # vitest
|
|
356
|
+
npm run test:coverage
|
|
357
|
+
npm run test:mutation # stryker (~20 minutes)
|
|
358
|
+
npm run lint # tsc --noEmit
|
|
359
|
+
npm run selfcheck # run spec-guard on its own docs
|
|
360
|
+
```
|
|
361
|
+
|
|
362
|
+
The test suite runs every assertion case against **both** engines and asserts
|
|
363
|
+
they agree, so the fallback cannot quietly drift from ripgrep. `@vscode/ripgrep`
|
|
364
|
+
is a devDependency purely so that the ripgrep path is exercised on every
|
|
365
|
+
platform in CI, including machines that have no `rg` on PATH; it ships a
|
|
366
|
+
prebuilt binary and is never a runtime dependency.
|
|
367
|
+
|
|
368
|
+
### Mutation testing
|
|
369
|
+
|
|
370
|
+
Coverage says a line ran. It does not say an assertion would notice if the line
|
|
371
|
+
behaved differently. This repository measures the difference: **83.16%** of
|
|
372
|
+
2,060 mutants are killed, against 98.98% line coverage.
|
|
373
|
+
|
|
374
|
+
That gap is the point. The first run scored 77.23%, and the weakest file was
|
|
375
|
+
the reporter at 65.48% - not because it lacked tests, but because its tests were
|
|
376
|
+
almost all `toContain` against colourless output. A mutant could prepend a junk
|
|
377
|
+
line to the output, drop a colour, or turn `remaining > 0` into `remaining >= 0`
|
|
378
|
+
and every test still passed. Seventy-five tests later - exact whole-output
|
|
379
|
+
comparison instead of substring matching - the reporter is at 87.30%.
|
|
380
|
+
|
|
381
|
+
`npm run test:mutation` runs it. CI runs it weekly, on demand, and on pull
|
|
382
|
+
requests that touch `src/` or `tests/`, with the score gated at 80%. The full
|
|
383
|
+
story, including a run whose score turned out to be fiction, is in
|
|
384
|
+
[ADR-0003](docs/adr/0003-mutation-testing.md).
|
|
385
|
+
|
|
386
|
+
## Requirements
|
|
387
|
+
|
|
388
|
+
- Node.js 22 or newer (native ESM)
|
|
389
|
+
- ripgrep optional - used when present, replaced by a built-in scanner when not
|
|
390
|
+
|
|
391
|
+
## License
|
|
392
|
+
|
|
393
|
+
MIT
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Thin launcher. All logic lives in dist/cli.js so the published binary stays
|
|
4
|
+
* a two-line shim that is trivially auditable.
|
|
5
|
+
*/
|
|
6
|
+
import { pathToFileURL } from 'node:url';
|
|
7
|
+
|
|
8
|
+
const entry = new URL('../dist/cli.js', import.meta.url);
|
|
9
|
+
|
|
10
|
+
let cli;
|
|
11
|
+
try {
|
|
12
|
+
cli = await import(entry.href);
|
|
13
|
+
} catch (error) {
|
|
14
|
+
if (error?.code === 'ERR_MODULE_NOT_FOUND') {
|
|
15
|
+
process.stderr.write(
|
|
16
|
+
`spec-guard: build output missing at ${pathToFileURL(entry.pathname).pathname}\n` +
|
|
17
|
+
'Run "npm run build" first (or install the published package).\n',
|
|
18
|
+
);
|
|
19
|
+
process.exit(2);
|
|
20
|
+
}
|
|
21
|
+
throw error;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
process.exitCode = await cli.main();
|
package/dist/cli.d.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Command line entrypoint.
|
|
3
|
+
*
|
|
4
|
+
* Exit codes are the contract CI depends on:
|
|
5
|
+
* 0 - every assertion held
|
|
6
|
+
* 1 - an assertion failed, or a directive was invalid
|
|
7
|
+
* 2 - spec-guard could not run (bad usage, no spec files, missing engine)
|
|
8
|
+
*/
|
|
9
|
+
import type { EnginePreference } from './engine.js';
|
|
10
|
+
export declare const EXIT_OK = 0;
|
|
11
|
+
export declare const EXIT_FAILED = 1;
|
|
12
|
+
export declare const EXIT_ERROR = 2;
|
|
13
|
+
export interface CliIO {
|
|
14
|
+
stdout: (text: string) => void;
|
|
15
|
+
stderr: (text: string) => void;
|
|
16
|
+
env: NodeJS.ProcessEnv;
|
|
17
|
+
cwd: string;
|
|
18
|
+
isTTY: boolean;
|
|
19
|
+
}
|
|
20
|
+
export interface CliOptions {
|
|
21
|
+
patterns: string[];
|
|
22
|
+
root: string;
|
|
23
|
+
verbose: boolean;
|
|
24
|
+
failFast: boolean;
|
|
25
|
+
json: boolean;
|
|
26
|
+
engine: EnginePreference;
|
|
27
|
+
strictTargets: boolean;
|
|
28
|
+
includeSpecs: boolean;
|
|
29
|
+
allowEmpty: boolean;
|
|
30
|
+
maxSnippets: number;
|
|
31
|
+
concurrency: number;
|
|
32
|
+
color?: boolean;
|
|
33
|
+
help: boolean;
|
|
34
|
+
version: boolean;
|
|
35
|
+
}
|
|
36
|
+
export declare class UsageError extends Error {
|
|
37
|
+
}
|
|
38
|
+
export declare const HELP = "spec-guard - Executable architecture assertions for Markdown specs & ADRs\n\nUsage\n spec-guard [patterns...] [options]\n\nPatterns\n Globs or paths to the Markdown specs to execute. A directory expands to the\n Markdown files inside it. Defaults to \"docs/**/*.md\" when omitted.\n\nOptions\n -r, --root <path> Codebase root that assertions are resolved against (default: cwd)\n -v, --verbose Print passing assertions too\n --fail-fast Stop at the first failing assertion\n --json Emit a machine-readable JSON report\n --engine <name> auto | rg | js (default: auto - ripgrep when available)\n --strict Treat a target path that does not exist as a failure\n --include-specs Also count matches inside the spec files themselves\n --max-snippets <n> Failure snippets per assertion (default: 5)\n --concurrency <n> Assertions executed in parallel (default: 8)\n --allow-empty Exit 0 when no spec files matched\n --color/--no-color Force colour on or off (NO_COLOR is honoured)\n -h, --help Show this help\n --version Print the version\n\nDirectives\n <!-- @assert-absence target=\"src/\" symbol=\"LegacyGateway\" -->\n <!-- @assert-count target=\"src/\" symbol=\"SessionManager\" expected=\"1\" -->\n <!-- @assert-present file=\"SECURITY.md\" -->\n\nExit codes\n 0 all assertions passed 1 an assertion failed 2 spec-guard could not run";
|
|
39
|
+
/** Minimal, dependency-free argv parser. Supports `--flag value` and `--flag=value`. */
|
|
40
|
+
export declare function parseArgs(argv: readonly string[], cwd: string): CliOptions;
|
|
41
|
+
/** Runs the CLI and resolves to the process exit code. */
|
|
42
|
+
export declare function main(argv?: readonly string[], io?: CliIO): Promise<number>;
|
|
43
|
+
//# sourceMappingURL=cli.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAOH,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,aAAa,CAAC;AAEpD,eAAO,MAAM,OAAO,IAAI,CAAC;AACzB,eAAO,MAAM,WAAW,IAAI,CAAC;AAC7B,eAAO,MAAM,UAAU,IAAI,CAAC;AAE5B,MAAM,WAAW,KAAK;IACpB,MAAM,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IAC/B,MAAM,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IAC/B,GAAG,EAAE,MAAM,CAAC,UAAU,CAAC;IACvB,GAAG,EAAE,MAAM,CAAC;IACZ,KAAK,EAAE,OAAO,CAAC;CAChB;AAED,MAAM,WAAW,UAAU;IACzB,QAAQ,EAAE,MAAM,EAAE,CAAC;IACnB,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,OAAO,CAAC;IACjB,QAAQ,EAAE,OAAO,CAAC;IAClB,IAAI,EAAE,OAAO,CAAC;IACd,MAAM,EAAE,gBAAgB,CAAC;IACzB,aAAa,EAAE,OAAO,CAAC;IACvB,YAAY,EAAE,OAAO,CAAC;IACtB,UAAU,EAAE,OAAO,CAAC;IACpB,WAAW,EAAE,MAAM,CAAC;IACpB,WAAW,EAAE,MAAM,CAAC;IACpB,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,IAAI,EAAE,OAAO,CAAC;IACd,OAAO,EAAE,OAAO,CAAC;CAClB;AAED,qBAAa,UAAW,SAAQ,KAAK;CAAG;AAaxC,eAAO,MAAM,IAAI,o9CA8B8D,CAAC;AAuBhF,wFAAwF;AACxF,wBAAgB,SAAS,CAAC,IAAI,EAAE,SAAS,MAAM,EAAE,EAAE,GAAG,EAAE,MAAM,GAAG,UAAU,CAmG1E;AAYD,0DAA0D;AAC1D,wBAAsB,IAAI,CAAC,IAAI,GAAE,SAAS,MAAM,EAA0B,EAAE,EAAE,GAAE,KAAmB,GAAG,OAAO,CAAC,MAAM,CAAC,CA+DpH"}
|