@descent-vtt/spec-guard 0.2.0 → 0.4.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/CHANGELOG.md ADDED
@@ -0,0 +1,150 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented here. Versions follow
4
+ [semantic versioning](https://semver.org), with the 0.x caveat that behaviour
5
+ may change in a minor release — each such change is listed under **Changed**
6
+ with the flag that restores the previous behaviour.
7
+
8
+ ## 0.4.0
9
+
10
+ An audit found spec-guard reporting a clean pass on a repository that contained
11
+ the forbidden symbol. On a tree with eight copies of one token, the scanner
12
+ found two and ripgrep found four, and neither said anything about the rest.
13
+
14
+ ### Changed
15
+
16
+ - **Hidden directories are searched.** `.github`, `.husky`, `.claude-rules` and
17
+ `.agents` hold CI, hooks and agent rules, and both engines skipped them
18
+ entirely - so an absence assertion passed while the forbidden thing sat in a
19
+ workflow file.
20
+ - **The skip list is now four names**: `.git`, `.hg`, `.svn`, `node_modules`.
21
+ `dist`, `build`, `out`, `coverage`, `.next` and eight others are searched,
22
+ because spec-guard cannot tell build output from a directory of build scripts,
23
+ and guessing wrong means a rule silently covers nothing. **If a run starts
24
+ failing on your build output, that is this change, and the fix is to say so:**
25
+
26
+ ```md
27
+ <!-- @assert-absence target="." symbol="TODO" exclude="dist coverage" -->
28
+ ```
29
+
30
+ - **`.gitignore` is no longer consulted.** It describes what git should carry,
31
+ not what a rule covers - and ripgrep applies it only inside a git repository,
32
+ so the same tree gave different answers depending on whether a `.git`
33
+ directory existed above it.
34
+ - **Binary files are searched, and a match in one is reported** rather than
35
+ dropped. It is not counted as a violation, but it is no longer invisible.
36
+ "Binary" now means "contains a NUL byte" for both engines; the scanner used to
37
+ look only at the first 8KB, which disagreed with ripgrep on files whose first
38
+ NUL came later.
39
+ - **`--strict` fails when a file could not be inspected**, which now has a
40
+ precise meaning: unreadable, or binary and containing the symbol.
41
+ - **The engines are one implementation.** ripgrep now answers only *which files
42
+ contain this text*; the scanner does all counting, comment classification,
43
+ binary handling and reporting for both. `--engine` changes how long a run
44
+ takes, not what it concludes. See [ADR-0007](docs/adr/0007-search-scope.md).
45
+
46
+ ### Added
47
+
48
+ - **`--no-default-skips`** to search even those four directories.
49
+ - **`skipped`** on each result in `--json`: what was not inspected, and why.
50
+
51
+ ### Removed
52
+
53
+ - `DEFAULT_IGNORED_DIRECTORIES`, `canBatchLiterals`, `shouldBatchPatterns`,
54
+ `createRipgrepSink` and `byteColumnToCharacter` from the public API. All of
55
+ them existed to make ripgrep's own counting trustworthy; as a pre-filter it
56
+ does not count, so pattern batching no longer has to be proved safe and
57
+ ripgrep's byte columns no longer need converting.
58
+
59
+ ### Fixed
60
+
61
+ - ripgrep's per-file errors are no longer discarded. `--no-messages` meant a
62
+ file that could not be opened produced no match, no error, and no way to tell
63
+ it apart from one that was read and found clean.
64
+ - `comments="include"` no longer changes whether binary files are searched. An
65
+ attribute about comments decided that, because it selected a different code
66
+ path through the engine.
67
+
68
+ ## 0.3.0
69
+
70
+ ### Added
71
+
72
+ - **`exclude` attribute.** Most real rules are "nowhere except one place", not
73
+ "not here": `exclude="src/config/**"` expresses that in one assertion instead
74
+ of a hand-maintained list of every directory that is not `src/config`. Follows
75
+ gitignore semantics — deliberately different from `glob`'s basename matching —
76
+ and a parity matrix asserts both engines agree.
77
+ - **Import assertions:** `@assert-import-absence` and `@assert-import-count`.
78
+ "The UI layer must not depend on the database layer" is a dependency claim,
79
+ not a text claim, and approximating it with a string search fails in both
80
+ directions. A zero-dependency tokenizer resolves `import`, `export … from`,
81
+ `import type`, dynamic `import()` with a literal, and `require()`. Module
82
+ references it cannot resolve statically are reported, never counted as clean.
83
+ See [ADR-0005](docs/adr/0005-import-assertions.md).
84
+ - **`comments` attribute.** See Changed, below.
85
+ - **`--allow-missing-targets`** to restore the previous handling of a `target`
86
+ path that does not exist.
87
+ - **`commentMatches` and `unclassifiedFiles`** on every result in `--json`.
88
+
89
+ ### Changed
90
+
91
+ - **Matches inside comments no longer count**, so the note recording that a
92
+ symbol was deleted is no longer read as an occurrence of that symbol. This is
93
+ the defect where documenting a decision defeated the assertion enforcing it.
94
+ Comment syntax is known for 59 extensions across 9 families; strings are
95
+ tracked too, since `//` inside a URL is not a comment. Where classification is
96
+ uncertain the text counts as code, and every run reports how many matches it
97
+ excluded — a pass caused by comment exclusion is never silent.
98
+ `comments="include"` restores counting per assertion.
99
+ See [ADR-0006](docs/adr/0006-comment-classification.md).
100
+ - **A `target` path that does not exist now fails the run** instead of warning
101
+ and searching what remained. An assertion pointed at a renamed directory used
102
+ to search nothing, find nothing, and report success. `--allow-missing-targets`
103
+ restores the old behaviour.
104
+ - **`--strict`** now means "treat analysis that could not be completed as a
105
+ failure" — it no longer carries the missing-target meaning, which is now the
106
+ default.
107
+
108
+ ### Fixed
109
+
110
+ - `search()` on the ripgrep engine was not comment-aware while `searchBatch()`
111
+ was, so a spec containing exactly one assertion kept its comment matches.
112
+ - The two engines reported different columns for the same match on a line
113
+ containing non-ASCII text: ripgrep counts bytes, the scanner counts
114
+ characters, and editors count characters. ripgrep's column is now converted,
115
+ so a reported location points at the match in both engines.
116
+ - Leftover `*.test.ts` files in scratch fixture directories were collected by
117
+ Vitest, breaking a run with "no test suite found" because of debris from a
118
+ previous one.
119
+
120
+ ### Notes
121
+
122
+ The version in `package.json` was bumped to 0.3.0 during development but never
123
+ published; npm went 0.1.0 -> 0.2.0. Everything above ships together as the
124
+ first published 0.3.0.
125
+
126
+ ## 0.2.0
127
+
128
+ ### Added
129
+
130
+ - **Adaptive engine selection.** `--engine auto` now measures rather than
131
+ guesses: a budgeted, stat-only enumeration decides whether a tree is small
132
+ enough for the JavaScript scanner to beat a ripgrep process spawn. The probe
133
+ is the work — the enumeration it performs is reused by the scanner, so
134
+ choosing costs nothing when the scanner wins.
135
+ See [ADR-0004](docs/adr/0004-adaptive-engine.md).
136
+ - **Mutation testing** with Stryker, wired into CI on every push, plus the tests
137
+ that closed the gaps it exposed. See [ADR-0003](docs/adr/0003-mutation-testing.md).
138
+
139
+ ### Changed
140
+
141
+ - Published as `@descent-vtt/spec-guard`. The binary is still plain
142
+ `spec-guard`.
143
+
144
+ ## 0.1.0
145
+
146
+ Initial release: `@assert-absence`, `@assert-count` and `@assert-present`,
147
+ written as HTML comments that are invisible in every Markdown renderer. ripgrep
148
+ primary with a pure-JavaScript fallback of identical semantics, safe assertion
149
+ batching, `--json`, and exit code 2 for "spec-guard could not run" as distinct
150
+ from "your specs failed".
package/README.md CHANGED
@@ -120,6 +120,132 @@ may use double or single quotes, and a bare attribute means `="true"`.
120
120
  Fails when the symbol occurs more than `expected` times (default `0`).
121
121
  `max="..."` is accepted as a synonym for `expected`.
122
122
 
123
+ ### `exclude` - everywhere except
124
+
125
+ Most real rules are "nowhere except one place", not "not here":
126
+
127
+ ```md
128
+ <!-- @assert-absence target="src" symbol="process.env" exclude="src/config/**" -->
129
+ ```
130
+
131
+ That is the rule this README opens with - *no secret is ever read from
132
+ `process.env` outside `src/config`* - written as one assertion instead of a
133
+ hand-maintained list of every directory that is not `src/config`.
134
+
135
+ `exclude` follows gitignore rules, which are **not** the same as `glob`'s:
136
+
137
+ | Pattern | Excludes |
138
+ | --- | --- |
139
+ | `tests` | any directory or file named `tests`, at any depth, and everything under it |
140
+ | `src/config` | that directory and everything under it |
141
+ | `src/config/**` | the same, written explicitly |
142
+ | `*.test.ts` | any file with that name shape, at any depth |
143
+
144
+ The difference is deliberate. `glob="*.ts"` filters files, so basename matching
145
+ is what you want; `exclude="tests"` means the directory, because that is what
146
+ people mean when they write it - and it is what `rg -g '!tests'` does. Both
147
+ engines implement the same rule, and a parity matrix asserts they agree on it.
148
+
149
+ List attributes accept commas or whitespace, so both of these work:
150
+
151
+ ```md
152
+ <!-- @assert-absence target="src" symbol="TODO" exclude="src/legacy/** tests" -->
153
+ <!-- @assert-absence target="src" symbol="TODO" exclude="src/legacy/**,tests" -->
154
+ ```
155
+
156
+ (A path containing a space therefore cannot be written; there is no quoting
157
+ inside an attribute value.)
158
+
159
+ ### `comments` - the note about a deletion is not the deletion
160
+
161
+ You delete a symbol and leave the explanation where the next person will look:
162
+
163
+ ```ts
164
+ // LegacyThing was removed in ADR-398; do not reintroduce it.
165
+ ```
166
+
167
+ A plain text search reads that comment as an occurrence, so the assertion that
168
+ keeps the symbol deleted fails on the sentence proving it was deleted. Both
169
+ fixes are bad: delete the note and lose the reason, or delete the rule.
170
+
171
+ So matches inside comments do not count:
172
+
173
+ ```md
174
+ <!-- comments are ignored by default -->
175
+ <!-- @assert-absence target="src" symbol="LegacyThing" -->
176
+
177
+ <!-- ...unless you ask for them -->
178
+ <!-- @assert-absence target="src" symbol="Copyright" comments="include" -->
179
+ ```
180
+
181
+ `comments="include"` is right for assertions that really are about text — a
182
+ licence header, or a name that must appear nowhere in the repository at all.
183
+
184
+ Because this is the one thing that can turn a failing run green without anyone
185
+ touching code, a run that passed this way says so:
186
+
187
+ ```
188
+ ⚠ 1 match inside comments was not counted; add comments="include" to count it
189
+
190
+ 1 passed · 7ms
191
+ ✔ every spec assertion holds
192
+ ```
193
+
194
+ Comment syntax is known for around 59 extensions across 9 families (JS/TS, C,
195
+ C#, Rust, Go, Python-style `#`, SQL-style `--`, markup, and formats with no
196
+ comments at all). Strings are tracked too, because `//` inside a URL is not a
197
+ comment and reading it as one would hide real code. Where spec-guard is unsure —
198
+ an unknown extension, an unterminated literal — the text counts as code, and the
199
+ report says which files it could not classify. A match wrongly kept is a visible
200
+ failure you can argue with; a match wrongly dropped is a lie.
201
+
202
+ ## What gets searched
203
+
204
+ An assertion is worth exactly as much as the set of files behind it, so
205
+ spec-guard is explicit about that set and never quietly narrows it.
206
+
207
+ **Four directory names are skipped**, and nothing else:
208
+
209
+ | Skipped | Why |
210
+ | --- | --- |
211
+ | `.git`, `.hg`, `.svn` | version-control stores hold compressed copies of code you deleted on purpose |
212
+ | `node_modules` | code you did not write, which your architecture rules are not about |
213
+
214
+ Everything else is searched. That includes **hidden directories** - `.github`,
215
+ `.husky`, `.claude-rules`, `.agents` - because that is where CI, hooks and agent
216
+ rules live, and a rule that cannot see your workflow files is not enforcing much.
217
+ It also includes `dist`, `build`, `out` and `coverage`, because spec-guard
218
+ cannot tell build output from a directory of build scripts, and guessing wrong
219
+ means a rule silently stops covering anything.
220
+
221
+ `.gitignore` is not consulted. It describes what git should carry, not what a
222
+ rule covers - and ripgrep applies it only inside a git repository, so honouring
223
+ it made the same tree answer differently depending on whether a `.git` directory
224
+ happened to exist above it.
225
+
226
+ To narrow scope, say so in the assertion:
227
+
228
+ ```md
229
+ <!-- @assert-absence target="src" symbol="TODO" exclude="dist coverage" -->
230
+ ```
231
+
232
+ `--no-default-skips` removes even those four, for a run that has to be certain.
233
+
234
+ ### Nothing is skipped quietly
235
+
236
+ A file spec-guard could not read, or one whose bytes are not text but which
237
+ contained the symbol anyway, is a gap in the answer rather than a detail of it.
238
+ Those are reported, and `--strict` fails on them:
239
+
240
+ ```text
241
+ ✖ docs/adr.md:3 @assert-absence
242
+ "ApiKey" must not appear in .
243
+ expected no matches, found 0, and 1 file could not be inspected
244
+ ⚠ 1 match in 1 binary file not counted: build/app.bin
245
+ ```
246
+
247
+ The counts are in `--json` too, as `skipped` on each result.
248
+
123
249
  ### `@assert-count` - this symbol occurs exactly / at least / at most N times
124
250
 
125
251
  ```md
@@ -132,6 +258,67 @@ Fails when the symbol occurs more than `expected` times (default `0`).
132
258
  Requires `expected`, or `min` and/or `max`. `expected` cannot be combined with
133
259
  `min`/`max`.
134
260
 
261
+ An exact count is the most brittle assertion in the set: a test that merely
262
+ mentions the symbol will break it. spec-guard does not silently skip test files
263
+ for you - an assertion that quietly ignores part of the tree is the failure mode
264
+ this tool exists to prevent - so say what you mean:
265
+
266
+ ```md
267
+ <!-- @assert-count target="src" symbol="UserSessionManager" expected="1" exclude="*.test.ts *.spec.ts" -->
268
+ ```
269
+
270
+ `min="1"` is often the better rule anyway: it says "this exists" without
271
+ breaking every time someone writes a second test.
272
+
273
+ ### `@assert-import-absence` / `@assert-import-count` - dependencies, not text
274
+
275
+ The rules architecture documents actually contain are usually about
276
+ dependencies, and a text search answers those badly in both directions - it
277
+ matches a mention in a comment, and it misses `export * from '../db'`.
278
+
279
+ ```md
280
+ <!-- @assert-import-absence target="src/ui" module="src/db" reason="the UI talks to services, not storage" -->
281
+ <!-- @assert-import-count target="src" module="axios" max="1" -->
282
+ ```
283
+
284
+ These read the dependency rather than the text. A JavaScript/TypeScript
285
+ tokenizer - comments, strings, template substitutions and regular expressions,
286
+ no parser and no new dependency - extracts module references and counts the
287
+ **files** that depend on the module. It understands:
288
+
289
+ | Form | Counted |
290
+ | --- | --- |
291
+ | `import { A } from 'x'`, `import 'x'`, `import * as ns from 'x'` | yes |
292
+ | `import type { A } from 'x'` | yes, unless `types="ignore"` |
293
+ | `export * from 'x'`, `export { A } from 'x'` | yes - a re-export is a dependency, and a barrel file is how layering rules usually get broken |
294
+ | `require('x')` | yes, with a literal argument |
295
+ | `import('x')` | yes, with a literal argument |
296
+ | `import(name)`, `require(expr)` | **no - reported, see below** |
297
+
298
+ Relative specifiers are resolved by path arithmetic, so `module="src/db"`
299
+ matches `../db/client.js` seen from `src/ui/`. There is deliberately no
300
+ filesystem resolution: tsconfig `paths` aliases and package `exports` maps are
301
+ not followed, so an alias is written out as itself (`module="@app/db"`).
302
+
303
+ **It tells you what it could not see.** Roughly 1% of module references in real
304
+ code are dynamic, and about 0.09% of files defeat the tokenizer outright. Both
305
+ are reported rather than counted as clean:
306
+
307
+ ```text
308
+ ✔ docs/adr.md:1 @assert-import-absence "src/db" (0 matches) in src/core
309
+ ⚠ 1 module reference could not be resolved statically
310
+ ⚠ src/core/plugin.ts:1 import(name)
311
+ ```
312
+
313
+ The count is still true of everything that could be seen; the warning is what
314
+ stops it being mistaken for a complete answer. `--strict` turns those warnings
315
+ into failures. Files in scope that are not JavaScript or TypeScript are counted
316
+ and reported too, so a rule pointed at the wrong tree says "analysed 1 of 3
317
+ files" rather than quietly passing.
318
+
319
+ [ADR-0005](docs/adr/0005-import-assertions.md) has the measurements and the
320
+ reasoning behind each boundary.
321
+
135
322
  ### `@assert-present` - this file exists
136
323
 
137
324
  ```md
@@ -150,10 +337,14 @@ Passes when every listed path exists relative to `--root`. Directories count.
150
337
  | `file` | present | Comma-separated paths that must exist |
151
338
  | `expected` | absence, count | Upper bound for absence; exact count for count |
152
339
  | `min` / `max` | count (`max` also on absence) | Inclusive bounds |
153
- | `glob` | absence, count | Comma-separated file filters, e.g. `*.ts,*.tsx` (ripgrep `-g` semantics) |
340
+ | `glob` | absence, count | Include-only file filters, e.g. `*.ts,*.tsx` (ripgrep `-g` semantics) |
341
+ | `exclude` | absence, count | Paths to leave out, gitignore-style: `src/config/**`, `tests`, `*.test.ts` |
154
342
  | `regex` | absence, count | Treat `symbol` as a regular expression |
155
343
  | `word` | absence, count | Require word boundaries, so `Primary` does not match `PrimaryButton` |
156
344
  | `ignore-case` | absence, count | Case-insensitive matching |
345
+ | `comments` | absence, count | `ignore` (default) or `include` for matches inside comments |
346
+ | `module` | import assertions | Which dependency, matched like `exclude` |
347
+ | `types` | import assertions | `include` (default) or `ignore` for `import type` |
157
348
  | `reason` | all | Human-readable justification, printed on failure |
158
349
 
159
350
  Unknown attributes are an error, not a shrug: `expct="1"` fails the run instead
@@ -172,7 +363,9 @@ spec-guard [patterns...] [options]
172
363
  | `--fail-fast` | Stop at the first failing assertion |
173
364
  | `--json` | Machine-readable report on stdout |
174
365
  | `--engine <auto\|rg\|js>` | Search engine (default `auto`: scanner for small trees, ripgrep for big ones) |
175
- | `--strict` | Treat a `target` that does not exist as a failure, not a warning |
366
+ | `--strict` | Treat analysis that could not be completed as a failure |
367
+ | `--allow-missing-targets` | Warn instead of failing when a `target` path does not exist |
368
+ | `--no-default-skips` | Search `.git`, `.hg`, `.svn` and `node_modules` too |
176
369
  | `--include-specs` | Also count matches inside the spec files themselves |
177
370
  | `--max-snippets <n>` | Failure snippets per assertion (default 5) |
178
371
  | `--concurrency <n>` | Search passes in flight at once (default 8) |
@@ -247,45 +440,54 @@ parser ──▶ Directive[] ──▶ runner ──▶ Assertion[] ──▶ e
247
440
 
248
441
  Scanning a tree costs about the same whether you look for one symbol or twenty,
249
442
  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** |
443
+ in one pass.
257
444
 
258
445
  `auto` then picks between the two engines **per search group**, because they
259
446
  have different shapes of cost: ripgrep is dominated by process startup and
260
447
  barely notices tree size, while the built-in scanner has no startup cost and
261
448
  grows linearly. Starting a process to search a handful of files is a bad trade.
449
+ Re-measured for 0.4.0 on Windows 11 / Node 24, median of three, one symbol over
450
+ a synthetic tree:
451
+
452
+ | files | scanner | ripgrep | |
453
+ | --- | --- | --- | --- |
454
+ | 100 | **17 ms** | 214 ms | scanner, 12.9x |
455
+ | 500 | **70 ms** | 263 ms | scanner, 3.8x |
456
+ | 1,000 | 216 ms | **181 ms** | ripgrep, 1.2x |
457
+ | 3,000 | 735 ms | **222 ms** | ripgrep, 3.3x |
458
+ | 5,000 | 1,046 ms | **241 ms** | ripgrep, 4.3x |
262
459
 
263
460
  The decision uses a bounded enumeration as its probe: spec-guard walks the
264
461
  target set until it either finishes - in which case the file list is already in
265
462
  hand and the scanner runs against it, with no process and no second walk - or
266
- exceeds a budget, in which case ripgrep takes over. Measured end to end:
267
-
268
- | | this repo's own specs | 2,000 files |
269
- | --- | --- | --- |
270
- | `--engine rg` | 793.7 ms | 195.8 ms |
271
- | `--engine js` | 19.5 ms | 330.6 ms |
272
- | `--engine auto` | **19.1 ms** | **171.9 ms** |
273
-
274
- The crossover is ~575 files on Windows and ~25 on Linux, because what is really
275
- being measured is process spawn cost. `scripts/bench-engines.mjs` reproduces
276
- both; [ADR-0004](docs/adr/0004-adaptive-engine.md) has the full tables.
463
+ exceeds a budget, in which case ripgrep takes over. The crossover above sits
464
+ between 500 and 1,000 files, which is what the Windows budget of 512 encodes;
465
+ on Linux it is far lower, because what is really being measured is process
466
+ spawn cost. `scripts/bench-engines.mjs` reproduces this, and
467
+ [ADR-0004](docs/adr/0004-adaptive-engine.md) has the full tables.
468
+
469
+ Treat single measurements from one machine with suspicion. On the development
470
+ machine used here, an absence assertion over 2,000 files measures 303 ms with
471
+ the scanner and 399 ms with ripgrep, but ripgrep's own spread across five runs
472
+ was 379-1039 ms - wide enough that the two are not really distinguishable at
473
+ that size. The order-of-magnitude differences at the ends of the table are the
474
+ part worth trusting.
277
475
 
278
476
  Two honest caveats:
279
477
 
280
478
  - **The reported engine is the one that ran, not the one available.** On a small
281
479
  repository `--json` reports `"engine": "javascript"` even with ripgrep
282
480
  installed. That is the optimisation working, not a failure to find `rg`.
283
- - **ripgrep is the reference implementation.** The scanner matches it on
284
- everything the test suite covers - counts, snippets, word boundaries, globs,
285
- binary skipping, file-size limits - but ripgrep also honours `.gitignore`,
286
- while the scanner uses a fixed ignore list (`node_modules`, `dist`, `build`,
287
- `coverage`, `.git`, dotfiles, and friends). On a repository where those differ
288
- materially, pin the engine with `--engine rg`.
481
+ - **The engines are one implementation, not two that agree.** This used to say
482
+ that ripgrep was the reference and the scanner merely matched it "on
483
+ everything the test suite covers", with a note that the two treated
484
+ `.gitignore` and ignored directories differently and that you should pin an
485
+ engine if it mattered. It did matter: on a tree with eight copies of a symbol
486
+ the scanner found two and ripgrep found four. Since 0.4.0 ripgrep answers only
487
+ *which files contain this text*, and the scanner does all the counting,
488
+ classification and reporting for both. `--engine` now changes how long a run
489
+ takes and nothing about its verdict, and the suite asserts that on trees built
490
+ from every case that used to split them.
289
491
 
290
492
  ## Programmatic API
291
493
 
@@ -317,7 +519,10 @@ implementation departs from the obvious reading of the brief, here is why.
317
519
  "`LegacyGateway` must not appear" contains the string `LegacyGateway`. Without
318
520
  this rule, absence assertions would fail on the document asserting them - the
319
521
  single most confusing possible first-run experience. `--include-specs` restores
320
- the naive behaviour.
522
+ the naive behaviour, and counts the prose of your specs; the directives
523
+ themselves stay uncounted, because a directive is an HTML comment and a rule
524
+ whose own text trips it can never be satisfied. `comments="include"` counts even
525
+ those.
321
526
 
322
527
  **Fenced code and inline code are masked before parsing.** A README documenting
323
528
  the syntax must not execute it. Masking preserves byte offsets, so reported line
@@ -352,6 +557,26 @@ count.
352
557
  **Exit code 2 exists.** "Your specs failed" and "spec-guard could not run" are
353
558
  different facts, and CI should be able to tell them apart.
354
559
 
560
+ **A missing `target` fails the run.** It used to warn and search what was left,
561
+ which meant an assertion pointed at a renamed directory searched nothing, found
562
+ nothing, and reported success — the exact shape of a green check that verified
563
+ nothing. `--allow-missing-targets` restores the old behaviour for repositories
564
+ where a path is legitimately optional.
565
+
566
+ **Hidden directories are searched, and `.gitignore` is not consulted.** An
567
+ audit of 0.3.0 found the tool reporting a clean pass on a repository whose
568
+ forbidden symbol sat in `.github/workflows/ci.yml`; the scanner and ripgrep also
569
+ disagreed with each other, finding two matches and four on the same tree. Scope
570
+ is now one policy that both engines are driven by, the skip list is four names
571
+ long, and anything spec-guard could not inspect is reported rather than assumed
572
+ clean ([ADR-0007](docs/adr/0007-search-scope.md)).
573
+
574
+ **Comments are excluded by default, and the exclusion is reported.** Counting
575
+ the note that records a deletion as an occurrence of the thing deleted punishes
576
+ the documentation this tool exists to keep honest ([ADR-0006](docs/adr/0006-comment-classification.md)).
577
+ The reverse risk — a rule that quietly stops checking anything because every
578
+ match now sits in a comment — is why every run says how many matches it dropped.
579
+
355
580
  ## spec-guard checks itself
356
581
 
357
582
  The invariants in [`docs/adr/0001-invariants.md`](docs/adr/0001-invariants.md)
@@ -363,7 +588,7 @@ build fails if they stop being true.
363
588
 
364
589
  This README is executable too:
365
590
 
366
- <!-- @assert-present file="src/parser.ts,src/engine.ts,src/reporter.ts,src/runner.ts,src/cli.ts" -->
591
+ <!-- @assert-present file="src/parser.ts,src/engine.ts,src/reporter.ts,src/runner.ts,src/cli.ts,src/comments.ts" -->
367
592
 
368
593
  ## Development
369
594
 
@@ -372,7 +597,7 @@ npm install
372
597
  npm run build # tsc -> dist/
373
598
  npm test # vitest
374
599
  npm run test:coverage
375
- npm run test:mutation # stryker (~20 minutes)
600
+ npm run test:mutation # stryker (~16 min in CI; much slower locally)
376
601
  npm run lint # tsc --noEmit
377
602
  npm run selfcheck # run spec-guard on its own docs
378
603
  ```
@@ -386,8 +611,8 @@ prebuilt binary and is never a runtime dependency.
386
611
  ### Mutation testing
387
612
 
388
613
  Coverage says a line ran. It does not say an assertion would notice if the line
389
- behaved differently. This repository measures the difference: **86.40%** of
390
- 2,118 mutants are killed in CI, against 98.98% line coverage.
614
+ behaved differently. This repository measures the difference: **84.63%** of
615
+ 2,949 mutants are killed in CI, against high line coverage.
391
616
 
392
617
  That gap is the point. The first run scored 77.23%, and the weakest file was the
393
618
  reporter at 65.48% - not because it lacked tests, but because its tests were
@@ -397,16 +622,17 @@ test still passed. Exact whole-output comparison took it to 87.30%. The engine
397
622
  and the walker were then rewritten for testability rather than papered over with
398
623
  more tests, which is what moved them from 75%/78% to 83%/93%.
399
624
 
400
- `npm run test:mutation` runs it locally and takes about half an hour; CI runs it
401
- on **every push** in under seven minutes, gated at 85%. The CI figure is the one
625
+ `npm run test:mutation` runs it locally and takes about an hour; CI runs it on
626
+ **every push**, gated at 84%. The CI figure is the one
402
627
  quoted above, and it is the lower of the two: the same commit scores 88.76% on
403
628
  Windows, where three times as many mutants hang and Stryker counts a hang as a
404
629
  kill.
405
630
 
406
- Three cautionary tales are in [ADR-0003](docs/adr/0003-mutation-testing.md): a
631
+ Four cautionary tales are in [ADR-0003](docs/adr/0003-mutation-testing.md): a
407
632
  run whose score was pure fiction because the mutants were never activated, a
408
- tuning knob that lifted the score six points without adding a test, and the
409
- platform gap above.
633
+ tuning knob that lifted the score six points without adding a test, the platform
634
+ gap above, and the baseline being re-anchored when a tokenizer arrived - along
635
+ with the real bug that chasing the gate uncovered.
410
636
 
411
637
  ## Requirements
412
638
 
package/dist/cli.d.ts CHANGED
@@ -24,6 +24,8 @@ export interface CliOptions {
24
24
  failFast: boolean;
25
25
  json: boolean;
26
26
  engine: EnginePreference;
27
+ allowMissingTargets: boolean;
28
+ defaultSkips: boolean;
27
29
  strictTargets: boolean;
28
30
  includeSpecs: boolean;
29
31
  allowEmpty: boolean;
@@ -35,7 +37,7 @@ export interface CliOptions {
35
37
  }
36
38
  export declare class UsageError extends Error {
37
39
  }
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 - scanner for small trees, ripgrep for big ones)\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";
40
+ 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 - scanner for small trees, ripgrep for big ones)\n --strict Treat analysis that could not be completed as a failure\n --allow-missing-targets\n Tolerate target paths that do not exist (they fail by default)\n --no-default-skips Search .git, .hg, .svn and node_modules too\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\" exclude=\"src/legacy/**\" -->\n <!-- @assert-count target=\"src/\" symbol=\"SessionManager\" expected=\"1\" -->\n <!-- @assert-present file=\"SECURITY.md\" -->\n\n Matches inside comments do not count; add comments=\"include\" to count them.\n\nExit codes\n 0 all assertions passed 1 an assertion failed 2 spec-guard could not run";
39
41
  /** Minimal, dependency-free argv parser. Supports `--flag value` and `--flag=value`. */
40
42
  export declare function parseArgs(argv: readonly string[], cwd: string): CliOptions;
41
43
  /** Runs the CLI and resolves to the process exit code. */
package/dist/cli.d.ts.map CHANGED
@@ -1 +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,2+CA8B8D,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"}
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,mBAAmB,EAAE,OAAO,CAAC;IAC7B,YAAY,EAAE,OAAO,CAAC;IACtB,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,2xDAmC8D,CAAC;AAgChF,wFAAwF;AACxF,wBAAgB,SAAS,CAAC,IAAI,EAAE,SAAS,MAAM,EAAE,EAAE,GAAG,EAAE,MAAM,GAAG,UAAU,CA2G1E;AAYD,0DAA0D;AAC1D,wBAAsB,IAAI,CAAC,IAAI,GAAE,SAAS,MAAM,EAA0B,EAAE,EAAE,GAAE,KAAmB,GAAG,OAAO,CAAC,MAAM,CAAC,CAiEpH"}
package/dist/cli.js CHANGED
@@ -41,7 +41,10 @@ Options
41
41
  --fail-fast Stop at the first failing assertion
42
42
  --json Emit a machine-readable JSON report
43
43
  --engine <name> auto | rg | js (default: auto - scanner for small trees, ripgrep for big ones)
44
- --strict Treat a target path that does not exist as a failure
44
+ --strict Treat analysis that could not be completed as a failure
45
+ --allow-missing-targets
46
+ Tolerate target paths that do not exist (they fail by default)
47
+ --no-default-skips Search .git, .hg, .svn and node_modules too
45
48
  --include-specs Also count matches inside the spec files themselves
46
49
  --max-snippets <n> Failure snippets per assertion (default: ${DEFAULT_MAX_SNIPPETS})
47
50
  --concurrency <n> Assertions executed in parallel (default: ${DEFAULT_CONCURRENCY})
@@ -51,10 +54,12 @@ Options
51
54
  --version Print the version
52
55
 
53
56
  Directives
54
- <!-- @assert-absence target="src/" symbol="LegacyGateway" -->
57
+ <!-- @assert-absence target="src/" symbol="LegacyGateway" exclude="src/legacy/**" -->
55
58
  <!-- @assert-count target="src/" symbol="SessionManager" expected="1" -->
56
59
  <!-- @assert-present file="SECURITY.md" -->
57
60
 
61
+ Matches inside comments do not count; add comments="include" to count them.
62
+
58
63
  Exit codes
59
64
  0 all assertions passed 1 an assertion failed 2 spec-guard could not run`;
60
65
  const ENGINE_ALIASES = {
@@ -65,6 +70,15 @@ const ENGINE_ALIASES = {
65
70
  javascript: 'javascript',
66
71
  node: 'javascript',
67
72
  };
73
+ /**
74
+ * Takes the next argv element as a value.
75
+ *
76
+ * A leading dash means the value was omitted and the next option got swallowed
77
+ * ("--root --verbose"). That heuristic only applies here: in the `--opt=value`
78
+ * form there is nothing ambiguous about a leading dash, so "--max-snippets=-1"
79
+ * should be told it wants a non-negative integer rather than that it is missing
80
+ * a value.
81
+ */
68
82
  function requireValue(name, value) {
69
83
  if (value === undefined || value.startsWith('-')) {
70
84
  throw new UsageError(`Option ${name} requires a value.`);
@@ -85,6 +99,8 @@ export function parseArgs(argv, cwd) {
85
99
  failFast: false,
86
100
  json: false,
87
101
  engine: 'auto',
102
+ allowMissingTargets: false,
103
+ defaultSkips: true,
88
104
  strictTargets: false,
89
105
  includeSpecs: false,
90
106
  allowEmpty: false,
@@ -109,7 +125,7 @@ export function parseArgs(argv, cwd) {
109
125
  const inlineValue = equals === -1 ? undefined : argument.slice(equals + 1);
110
126
  const nextValue = () => {
111
127
  if (inlineValue !== undefined)
112
- return requireValue(name, inlineValue);
128
+ return inlineValue;
113
129
  index += 1;
114
130
  return requireValue(name, argv[index]);
115
131
  };
@@ -134,6 +150,12 @@ export function parseArgs(argv, cwd) {
134
150
  case '--strict':
135
151
  options.strictTargets = true;
136
152
  break;
153
+ case '--allow-missing-targets':
154
+ options.allowMissingTargets = true;
155
+ break;
156
+ case '--no-default-skips':
157
+ options.defaultSkips = false;
158
+ break;
137
159
  case '--include-specs':
138
160
  options.includeSpecs = true;
139
161
  break;
@@ -209,6 +231,8 @@ export async function main(argv = process.argv.slice(2), io = defaultIO()) {
209
231
  root: options.root,
210
232
  engine: options.engine,
211
233
  failFast: options.failFast,
234
+ allowMissingTargets: options.allowMissingTargets,
235
+ defaultSkips: options.defaultSkips,
212
236
  strictTargets: options.strictTargets,
213
237
  includeSpecs: options.includeSpecs,
214
238
  concurrency: options.concurrency,