@descent-vtt/spec-guard 0.3.0 → 0.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.
Files changed (54) hide show
  1. package/CHANGELOG.md +125 -0
  2. package/README.md +292 -49
  3. package/dist/cli.d.ts +9 -1
  4. package/dist/cli.d.ts.map +1 -1
  5. package/dist/cli.js +46 -7
  6. package/dist/cli.js.map +1 -1
  7. package/dist/comments.d.ts +23 -4
  8. package/dist/comments.d.ts.map +1 -1
  9. package/dist/comments.js +40 -14
  10. package/dist/comments.js.map +1 -1
  11. package/dist/engine.d.ts +52 -49
  12. package/dist/engine.d.ts.map +1 -1
  13. package/dist/engine.js +185 -259
  14. package/dist/engine.js.map +1 -1
  15. package/dist/glob.d.ts +19 -6
  16. package/dist/glob.d.ts.map +1 -1
  17. package/dist/glob.js +14 -28
  18. package/dist/glob.js.map +1 -1
  19. package/dist/imports.d.ts +28 -4
  20. package/dist/imports.d.ts.map +1 -1
  21. package/dist/imports.js +36 -4
  22. package/dist/imports.js.map +1 -1
  23. package/dist/index.d.ts +15 -10
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.js +9 -6
  26. package/dist/index.js.map +1 -1
  27. package/dist/parser.d.ts.map +1 -1
  28. package/dist/parser.js +28 -23
  29. package/dist/parser.js.map +1 -1
  30. package/dist/polyglot.d.ts +129 -0
  31. package/dist/polyglot.d.ts.map +1 -0
  32. package/dist/polyglot.js +544 -0
  33. package/dist/polyglot.js.map +1 -0
  34. package/dist/reporter.d.ts +41 -0
  35. package/dist/reporter.d.ts.map +1 -1
  36. package/dist/reporter.js +187 -4
  37. package/dist/reporter.js.map +1 -1
  38. package/dist/runner.d.ts +47 -3
  39. package/dist/runner.d.ts.map +1 -1
  40. package/dist/runner.js +371 -105
  41. package/dist/runner.js.map +1 -1
  42. package/dist/scope.d.ts +108 -0
  43. package/dist/scope.d.ts.map +1 -0
  44. package/dist/scope.js +115 -0
  45. package/dist/scope.js.map +1 -0
  46. package/dist/text.d.ts +26 -0
  47. package/dist/text.d.ts.map +1 -0
  48. package/dist/text.js +69 -0
  49. package/dist/text.js.map +1 -0
  50. package/dist/types.d.ts +87 -0
  51. package/dist/types.d.ts.map +1 -1
  52. package/dist/types.js +0 -6
  53. package/dist/types.js.map +1 -1
  54. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -5,6 +5,131 @@ All notable changes to this project are documented here. Versions follow
5
5
  may change in a minor release — each such change is listed under **Changed**
6
6
  with the flag that restores the previous behaviour.
7
7
 
8
+ ## 0.5.0
9
+
10
+ Import assertions covered one language, so a dependency rule pointed at a
11
+ directory of Go files did not fail - it passed, having analysed nothing. That is
12
+ the same defect 0.4.0 was written to remove, and this release closes the two
13
+ remaining shapes of it: a rule that cannot read the language, and a rule whose
14
+ scope holds no files at all.
15
+
16
+ ### Added
17
+
18
+ - **Import assertions read Python, Go, Rust and C#**, alongside JavaScript and
19
+ TypeScript. `import a.b` / `from .rel import x`, `import ( ... )` groups,
20
+ `use a::{b, c}` with nested expansion, `using static` and `global using`, and
21
+ the dynamic forms (`importlib.import_module`) that can only be reported.
22
+ Not four new tokenizers: the comment and string lexer from ADR-0006 masks the
23
+ source and the readers work on what is left. Tree-sitter measured 94 MB
24
+ unpacked against this package's 0.33 MB. See
25
+ [ADR-0008](docs/adr/0008-polyglot-imports.md).
26
+ - **`baseline` and `ratchet`** on the absence assertions, for adopting a strict
27
+ rule on a codebase that already breaks it. `baseline="src/legacy/a.ts:2"`
28
+ names the debt by file and count. The ratchet is two-sided: new violations
29
+ fail, and so does an entry the code no longer supports, because a baseline
30
+ that only grows is an `exclude` with extra steps. `ratchet="one-way"` relaxes
31
+ the second half. See [ADR-0009](docs/adr/0009-debt-baselines.md).
32
+ - **`--print-baseline`** prints the attribute that would exempt today's
33
+ violations, for a human to paste. It prints; it does not edit. That is the
34
+ whole answer to `--fix`, and ADR-0009 argues it.
35
+ - **`--format sarif`** writes SARIF 2.1.0, which GitHub turns into an annotation
36
+ on the offending line. One alert per broken rule, anchored on the code, with
37
+ the directive as a related location and a fingerprint that survives the code
38
+ moving. `--json` is unchanged and is now also `--format json`.
39
+ - **`allow-empty` and `--allow-empty-scope`**, for the rules where covering
40
+ nothing is the honest state of the world.
41
+ - **`baselinedMatches`, `staleBaseline` and `fileMatches`** on each result in
42
+ `--json`.
43
+
44
+ ### Changed
45
+
46
+ - **An assertion that inspected no files now fails.** A rule whose scope holds
47
+ nothing passes every time and reads in the report exactly like a rule that
48
+ inspected a thousand files and found nothing. The usual causes are a `glob`
49
+ matching no extension in the tree, an `exclude` that swallowed the target, or
50
+ an emptied directory. **If a run starts failing this way, the rule was
51
+ covering nothing before it started failing;** `allow-empty="true"` on the
52
+ directive, or `--allow-empty-scope` for the run, restores the old behaviour.
53
+ - **An import assertion fails when nothing in scope is in a language it can
54
+ read**, rather than reporting "analysed 0 of 12 files" in a warning and
55
+ passing.
56
+ - **`.py`, `.pyi`, `.go`, `.rs`, `.cs` and `.csx` files are now analysed** by import
57
+ assertions rather than counted as skipped, so a rule over a polyglot tree
58
+ starts finding dependencies it previously reported as unanalysable.
59
+ - **Mutation testing in CI runs in two tiers**: incremental on branches,
60
+ a full authoritative sweep on `main` and nightly. The gate stays at 85 in
61
+ both. See [ADR-0003](docs/adr/0003-mutation-testing.md).
62
+
63
+ ### Fixed
64
+
65
+ - `excludeFiles` was applied after enumeration rather than during it, so an
66
+ excluded spec file counted towards a budgeted walk. Only reachable through the
67
+ adaptive engine's probe, where it could make a tree look larger than the file
68
+ set actually being searched.
69
+ - The README described the safe-batching apparatus - containment and
70
+ dovetailing checks before merging literals into a ripgrep alternation - as
71
+ current behaviour. It was deleted in 0.4.0 when ripgrep became a pre-filter.
72
+
73
+ ## 0.4.0
74
+
75
+ An audit found spec-guard reporting a clean pass on a repository that contained
76
+ the forbidden symbol. On a tree with eight copies of one token, the scanner
77
+ found two and ripgrep found four, and neither said anything about the rest.
78
+
79
+ ### Changed
80
+
81
+ - **Hidden directories are searched.** `.github`, `.husky`, `.claude-rules` and
82
+ `.agents` hold CI, hooks and agent rules, and both engines skipped them
83
+ entirely - so an absence assertion passed while the forbidden thing sat in a
84
+ workflow file.
85
+ - **The skip list is now four names**: `.git`, `.hg`, `.svn`, `node_modules`.
86
+ `dist`, `build`, `out`, `coverage`, `.next` and eight others are searched,
87
+ because spec-guard cannot tell build output from a directory of build scripts,
88
+ and guessing wrong means a rule silently covers nothing. **If a run starts
89
+ failing on your build output, that is this change, and the fix is to say so:**
90
+
91
+ ```md
92
+ <!-- @assert-absence target="." symbol="TODO" exclude="dist coverage" -->
93
+ ```
94
+
95
+ - **`.gitignore` is no longer consulted.** It describes what git should carry,
96
+ not what a rule covers - and ripgrep applies it only inside a git repository,
97
+ so the same tree gave different answers depending on whether a `.git`
98
+ directory existed above it.
99
+ - **Binary files are searched, and a match in one is reported** rather than
100
+ dropped. It is not counted as a violation, but it is no longer invisible.
101
+ "Binary" now means "contains a NUL byte" for both engines; the scanner used to
102
+ look only at the first 8KB, which disagreed with ripgrep on files whose first
103
+ NUL came later.
104
+ - **`--strict` fails when a file could not be inspected**, which now has a
105
+ precise meaning: unreadable, or binary and containing the symbol.
106
+ - **The engines are one implementation.** ripgrep now answers only *which files
107
+ contain this text*; the scanner does all counting, comment classification,
108
+ binary handling and reporting for both. `--engine` changes how long a run
109
+ takes, not what it concludes. See [ADR-0007](docs/adr/0007-search-scope.md).
110
+
111
+ ### Added
112
+
113
+ - **`--no-default-skips`** to search even those four directories.
114
+ - **`skipped`** on each result in `--json`: what was not inspected, and why.
115
+
116
+ ### Removed
117
+
118
+ - `DEFAULT_IGNORED_DIRECTORIES`, `canBatchLiterals`, `shouldBatchPatterns`,
119
+ `createRipgrepSink` and `byteColumnToCharacter` from the public API. All of
120
+ them existed to make ripgrep's own counting trustworthy; as a pre-filter it
121
+ does not count, so pattern batching no longer has to be proved safe and
122
+ ripgrep's byte columns no longer need converting.
123
+
124
+ ### Fixed
125
+
126
+ - ripgrep's per-file errors are no longer discarded. `--no-messages` meant a
127
+ file that could not be opened produced no match, no error, and no way to tell
128
+ it apart from one that was read and found clean.
129
+ - `comments="include"` no longer changes whether binary files are searched. An
130
+ attribute about comments decided that, because it selected a different code
131
+ path through the engine.
132
+
8
133
  ## 0.3.0
9
134
 
10
135
  ### Added
package/README.md CHANGED
@@ -199,6 +199,53 @@ an unknown extension, an unterminated literal — the text counts as code, and t
199
199
  report says which files it could not classify. A match wrongly kept is a visible
200
200
  failure you can argue with; a match wrongly dropped is a lie.
201
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
+
202
249
  ### `@assert-count` - this symbol occurs exactly / at least / at most N times
203
250
 
204
251
  ```md
@@ -234,10 +281,33 @@ matches a mention in a comment, and it misses `export * from '../db'`.
234
281
  <!-- @assert-import-count target="src" module="axios" max="1" -->
235
282
  ```
236
283
 
237
- These read the dependency rather than the text. A JavaScript/TypeScript
238
- tokenizer - comments, strings, template substitutions and regular expressions,
239
- no parser and no new dependency - extracts module references and counts the
240
- **files** that depend on the module. It understands:
284
+ These read the dependency rather than the text, in **five languages**:
285
+
286
+ | Language | Extensions | Reads |
287
+ | --- | --- | --- |
288
+ | JavaScript / TypeScript | `.js .mjs .cjs .jsx .ts .mts .cts .tsx` | `import`, `export ... from`, `require()`, `import()` |
289
+ | Python | `.py .pyi` | `import a.b`, `from .rel import x`, `importlib.import_module("x")` |
290
+ | Go | `.go` | `import "x"` and grouped `import ( ... )`, including aliases and `_` |
291
+ | Rust | `.rs` | `use a::{b, c}` with nested groups, `pub use`, `extern crate` |
292
+ | C# | `.cs .csx` | `using`, `using static`, `global using`, `using X = A.B` |
293
+
294
+ No parser and no new dependency. JavaScript gets a full tokenizer because a
295
+ module reference can appear anywhere in an expression; the other four are read
296
+ by masking comments and string literals with the classifier from
297
+ [ADR-0006](docs/adr/0006-comment-classification.md) and then reading statements
298
+ off what is left. Tree-sitter would have cost 94 MB unpacked against this
299
+ package's 0.33 MB - [ADR-0008](docs/adr/0008-polyglot-imports.md) has the
300
+ measurements, and the list of things a real parser would genuinely see that
301
+ this does not.
302
+
303
+ Module patterns are matched against the resolved `/`-separated path *and*
304
+ against the specifier as written, so `module="System.Text.Json"` and
305
+ `module="System/Text/Json"` both work. Matching is case-sensitive, so a rule
306
+ spanning C# and Go needs both conventions:
307
+ `module="app/db/** App/Db/**"`.
308
+
309
+ The unit counted is **files** that depend on the module. For JavaScript and
310
+ TypeScript it understands:
241
311
 
242
312
  | Form | Counted |
243
313
  | --- | --- |
@@ -249,7 +319,8 @@ no parser and no new dependency - extracts module references and counts the
249
319
  | `import(name)`, `require(expr)` | **no - reported, see below** |
250
320
 
251
321
  Relative specifiers are resolved by path arithmetic, so `module="src/db"`
252
- matches `../db/client.js` seen from `src/ui/`. There is deliberately no
322
+ matches `../db/client.js` seen from `src/ui/`, and Python's `from ..core import
323
+ x` resolves against the importing file the same way. There is deliberately no
253
324
  filesystem resolution: tsconfig `paths` aliases and package `exports` maps are
254
325
  not followed, so an alias is written out as itself (`module="@app/db"`).
255
326
 
@@ -266,8 +337,9 @@ are reported rather than counted as clean:
266
337
  The count is still true of everything that could be seen; the warning is what
267
338
  stops it being mistaken for a complete answer. `--strict` turns those warnings
268
339
  into failures. Files in scope that are not JavaScript or TypeScript are counted
269
- and reported too, so a rule pointed at the wrong tree says "analysed 1 of 3
270
- files" rather than quietly passing.
340
+ and reported too, so a rule pointed at the wrong tree says "analysed 2 of 3
341
+ files" rather than quietly passing - and if *none* of them can be read, the
342
+ assertion fails rather than passing on an empty analysis.
271
343
 
272
344
  [ADR-0005](docs/adr/0005-import-assertions.md) has the measurements and the
273
345
  reasoning behind each boundary.
@@ -298,11 +370,77 @@ Passes when every listed path exists relative to `--root`. Directories count.
298
370
  | `comments` | absence, count | `ignore` (default) or `include` for matches inside comments |
299
371
  | `module` | import assertions | Which dependency, matched like `exclude` |
300
372
  | `types` | import assertions | `include` (default) or `ignore` for `import type` |
373
+ | `allow-empty` | absence, count, import assertions | Tolerate a scope that holds no files. Off by default - see below |
374
+ | `baseline` | absence, import-absence | Known violations that do not count: `path` or `path:count` |
375
+ | `ratchet` | absence, import-absence | `two-sided` (default) or `one-way` - see below |
301
376
  | `reason` | all | Human-readable justification, printed on failure |
302
377
 
303
378
  Unknown attributes are an error, not a shrug: `expct="1"` fails the run instead
304
379
  of silently asserting nothing.
305
380
 
381
+ ### `allow-empty` - an assertion that covers nothing is a failure
382
+
383
+ ```md
384
+ <!-- @assert-absence target="services" symbol="Legacy" glob="*.ts" -->
385
+ ```
386
+
387
+ If `services/` holds no `.ts` file, that rule passes every time, forever,
388
+ without inspecting anything - and in the report it is indistinguishable from a
389
+ rule that inspected a thousand files and found nothing. So it fails instead:
390
+
391
+ ```text
392
+ ✖ docs/adr.md:3 @assert-absence
393
+ "Legacy" must not appear in services
394
+ no files were inspected, so this assertion verified nothing
395
+ (add allow-empty="true" if that is expected)
396
+ ```
397
+
398
+ The usual causes are a `glob` matching no extension in the tree, an `exclude`
399
+ that swallowed the target, or a directory somebody emptied. Where covering
400
+ nothing yet is the honest state of the world - a rule written before the code
401
+ it guards - `allow-empty="true"` says so, and `--allow-empty-scope` says it for
402
+ a whole run.
403
+
404
+ ### `baseline` - adopting a rule the codebase already breaks
405
+
406
+ A strict rule introduced into a mature codebase lands on violations that already
407
+ exist. `exclude` turns those into a permanent blind spot, and `expected="5"`
408
+ cannot tell "one fixed, one added" from "nothing happened". A baseline names
409
+ them:
410
+
411
+ ```md
412
+ <!-- @assert-absence target="src" symbol="LegacyGateway"
413
+ baseline="src/legacy/gateway.ts:2
414
+ src/legacy/adapter.ts" -->
415
+ ```
416
+
417
+ The rule now holds when no file outside the list matches and no file inside it
418
+ matches more than it declares. A bare path means one match.
419
+
420
+ **It ratchets in both directions.** New debt fails, obviously. So does debt that
421
+ has been *paid* and left on the list, because an entry the code no longer
422
+ supports is a spec asserting something untrue:
423
+
424
+ ```text
425
+ expected no matches, found 0; the baseline is out of date and must be
426
+ pruned: src/legacy/adapter.ts (no longer matches)
427
+ ```
428
+
429
+ The fix is deleting the line the message names. `ratchet="one-way"` relaxes that
430
+ half to a report-only note. Every run says how many matches the baseline
431
+ excluded, because a pass bought by an exemption is never silent.
432
+
433
+ To adopt a rule on a codebase that already violates it:
434
+
435
+ ```bash
436
+ spec-guard docs/adr.md --print-baseline
437
+ ```
438
+
439
+ prints the attribute that would exempt exactly today's violations, for you to
440
+ paste in. It prints; it does not edit. [ADR-0009](docs/adr/0009-debt-baselines.md)
441
+ explains why that distinction is the whole design, and why there is no `--fix`
442
+ for architecture rules.
443
+
306
444
  ## CLI
307
445
 
308
446
  ```bash
@@ -314,14 +452,18 @@ spec-guard [patterns...] [options]
314
452
  | `-r, --root <path>` | Codebase root that assertions resolve against (default: cwd) |
315
453
  | `-v, --verbose` | Print passing assertions too |
316
454
  | `--fail-fast` | Stop at the first failing assertion |
317
- | `--json` | Machine-readable report on stdout |
455
+ | `--json` | Machine-readable report on stdout (same as `--format json`) |
456
+ | `--format <human\|json\|sarif>` | Output format. `sarif` uploads to GitHub code scanning |
318
457
  | `--engine <auto\|rg\|js>` | Search engine (default `auto`: scanner for small trees, ripgrep for big ones) |
319
458
  | `--strict` | Treat analysis that could not be completed as a failure |
320
459
  | `--allow-missing-targets` | Warn instead of failing when a `target` path does not exist |
460
+ | `--allow-empty-scope` | Warn instead of failing when an assertion inspects no files |
461
+ | `--print-baseline` | Print the `baseline="..."` that would exempt today's violations, and exit |
462
+ | `--no-default-skips` | Search `.git`, `.hg`, `.svn` and `node_modules` too |
321
463
  | `--include-specs` | Also count matches inside the spec files themselves |
322
464
  | `--max-snippets <n>` | Failure snippets per assertion (default 5) |
323
465
  | `--concurrency <n>` | Search passes in flight at once (default 8) |
324
- | `--allow-empty` | Exit 0 when no spec file matched the patterns |
466
+ | `--allow-empty` | Exit 0 when no spec file matched the patterns (about the run, not an assertion) |
325
467
  | `--color` / `--no-color` | Force colour on or off (`NO_COLOR` honoured) |
326
468
 
327
469
  Patterns are expanded by spec-guard itself, so quoted globs behave identically
@@ -370,6 +512,35 @@ As a pre-commit hook (assuming a local install, so the bare command resolves):
370
512
  npx spec-guard "docs/**/*.md" --fail-fast
371
513
  ```
372
514
 
515
+ ### Annotations on the pull request
516
+
517
+ `--format sarif` writes a SARIF 2.1.0 document, which GitHub turns into a
518
+ comment on the offending line:
519
+
520
+ ```yaml
521
+ - run: npx @descent-vtt/spec-guard "docs/**/*.md" --format sarif > spec-guard.sarif || true
522
+ - uses: github/codeql-action/upload-sarif@v3
523
+ with:
524
+ sarif_file: spec-guard.sarif
525
+ category: spec-guard
526
+ ```
527
+
528
+ That job needs `permissions: security-events: write`. One alert per broken rule
529
+ rather than per match - the thing that broke is the rule - anchored on the first
530
+ offending line, with the directive as a related location because that is often
531
+ where the fix goes. Alerts carry a fingerprint derived from what the assertion
532
+ is about rather than where its matches landed, so inserting a line above a
533
+ violation does not close the alert and open a new one.
534
+
535
+ **There is no language server, and that is a decision rather than a gap.**
536
+ spec-guard's claims are about a whole repository - "this symbol appears nowhere
537
+ in `src`" - and a language server is handed one buffer at a time. Answering a
538
+ repository-wide question on every keystroke means rescanning the tree on every
539
+ keystroke; the alternative is answering a smaller question and calling it the
540
+ same one. The CLI does a full run of this repository in about 130 ms, so a
541
+ pre-commit hook or a watch loop already closes the feedback gap without a
542
+ daemon, an extension per editor, or a protocol version matrix.
543
+
373
544
  ## How it works
374
545
 
375
546
  ```text
@@ -392,45 +563,69 @@ parser ──▶ Directive[] ──▶ runner ──▶ Assertion[] ──▶ e
392
563
 
393
564
  Scanning a tree costs about the same whether you look for one symbol or twenty,
394
565
  so spec-guard groups assertions by target set and flags and answers each group
395
- in one pass. Measured on Windows 11 / Node 24 / ripgrep 15 against a synthetic
396
- 2,000-file, 5.1 MB tree with 8 assertions:
397
-
398
- | | one pass per assertion | batched (current) |
399
- | --- | --- | --- |
400
- | ripgrep engine | ~280 ms | **~72 ms** |
401
- | JavaScript fallback | ~780 ms | **~283 ms** |
566
+ in one pass.
402
567
 
403
568
  `auto` then picks between the two engines **per search group**, because they
404
569
  have different shapes of cost: ripgrep is dominated by process startup and
405
570
  barely notices tree size, while the built-in scanner has no startup cost and
406
571
  grows linearly. Starting a process to search a handful of files is a bad trade.
572
+ Re-measured for 0.4.0 on Windows 11 / Node 24, median of three, one symbol over
573
+ a synthetic tree:
574
+
575
+ | files | scanner | ripgrep | |
576
+ | --- | --- | --- | --- |
577
+ | 100 | **17 ms** | 214 ms | scanner, 12.9x |
578
+ | 500 | **70 ms** | 263 ms | scanner, 3.8x |
579
+ | 1,000 | 216 ms | **181 ms** | ripgrep, 1.2x |
580
+ | 3,000 | 735 ms | **222 ms** | ripgrep, 3.3x |
581
+ | 5,000 | 1,046 ms | **241 ms** | ripgrep, 4.3x |
407
582
 
408
583
  The decision uses a bounded enumeration as its probe: spec-guard walks the
409
584
  target set until it either finishes - in which case the file list is already in
410
585
  hand and the scanner runs against it, with no process and no second walk - or
411
- exceeds a budget, in which case ripgrep takes over. Measured end to end:
412
-
413
- | | this repo's own specs | 2,000 files |
586
+ exceeds a budget, in which case ripgrep takes over. The crossover above sits
587
+ between 500 and 1,000 files, which is what the Windows budget of 512 encodes;
588
+ on Linux it is far lower, because what is really being measured is process
589
+ spawn cost. `scripts/bench-engines.mjs` reproduces this, and
590
+ [ADR-0004](docs/adr/0004-adaptive-engine.md) has the full tables.
591
+
592
+ Treat single measurements from one machine with suspicion. On the development
593
+ machine used here, an absence assertion over 2,000 files measures 303 ms with
594
+ the scanner and 399 ms with ripgrep, but ripgrep's own spread across five runs
595
+ was 379-1039 ms - wide enough that the two are not really distinguishable at
596
+ that size. The order-of-magnitude differences at the ends of the table are the
597
+ part worth trusting.
598
+
599
+ 0.5.0 added four language analysers, a scope probe on every assertion and a
600
+ baseline pass, so the obvious question is what that cost. Both versions were run
601
+ against the same synthetic 1,000-file tree, alternating rounds inside one
602
+ process so that this machine's load - which varies by a factor of two over a
603
+ session - falls on both equally:
604
+
605
+ | | median | min |
414
606
  | --- | --- | --- |
415
- | `--engine rg` | 793.7 ms | 195.8 ms |
416
- | `--engine js` | 19.5 ms | 330.6 ms |
417
- | `--engine auto` | **19.1 ms** | **171.9 ms** |
607
+ | 0.4.0 | 908.8 ms | 493.7 ms |
608
+ | 0.5.0 | 913.4 ms | 496.6 ms |
418
609
 
419
- The crossover is ~575 files on Windows and ~25 on Linux, because what is really
420
- being measured is process spawn cost. `scripts/bench-engines.mjs` reproduces
421
- both; [ADR-0004](docs/adr/0004-adaptive-engine.md) has the full tables.
610
+ 0.5% apart, against a run-to-run spread far wider than that. The two versions
611
+ also returned identical counts for all five assertions, which is the more
612
+ useful half of the result.
422
613
 
423
614
  Two honest caveats:
424
615
 
425
616
  - **The reported engine is the one that ran, not the one available.** On a small
426
617
  repository `--json` reports `"engine": "javascript"` even with ripgrep
427
618
  installed. That is the optimisation working, not a failure to find `rg`.
428
- - **ripgrep is the reference implementation.** The scanner matches it on
429
- everything the test suite covers - counts, snippets, word boundaries, globs,
430
- binary skipping, file-size limits - but ripgrep also honours `.gitignore`,
431
- while the scanner uses a fixed ignore list (`node_modules`, `dist`, `build`,
432
- `coverage`, `.git`, dotfiles, and friends). On a repository where those differ
433
- materially, pin the engine with `--engine rg`.
619
+ - **The engines are one implementation, not two that agree.** This used to say
620
+ that ripgrep was the reference and the scanner merely matched it "on
621
+ everything the test suite covers", with a note that the two treated
622
+ `.gitignore` and ignored directories differently and that you should pin an
623
+ engine if it mattered. It did matter: on a tree with eight copies of a symbol
624
+ the scanner found two and ripgrep found four. Since 0.4.0 ripgrep answers only
625
+ *which files contain this text*, and the scanner does all the counting,
626
+ classification and reporting for both. `--engine` now changes how long a run
627
+ takes and nothing about its verdict, and the suite asserts that on trees built
628
+ from every case that used to split them.
434
629
 
435
630
  ## Programmatic API
436
631
 
@@ -490,12 +685,14 @@ process spawn (~27 ms on Windows) on the critical path of every run, including
490
685
  runs where ripgrep is missing. Discovering its absence from the first real
491
686
  search is free.
492
687
 
493
- **Assertions are batched, but only when provably safe.** Merging literals into
494
- one ripgrep alternation can lose matches two ways: containment (`Primary` /
495
- `PrimaryButton`) and dovetailing (`abc` / `cd` in `abcd`). spec-guard checks for
496
- both and falls back to separate passes when either is possible, and never
497
- batches regexes or case-insensitive searches. Speed is never traded for a wrong
498
- count.
688
+ **Assertions that share a target list share one pass over the tree.** This used
689
+ to need a proof: merging literals into a ripgrep alternation can lose matches
690
+ through containment (`Primary` / `PrimaryButton`) and dovetailing (`abc` / `cd`
691
+ in `abcd`), so spec-guard checked for both and fell back to separate passes.
692
+ Since 0.4.0 it does not need the proof, because ripgrep no longer counts
693
+ anything - it answers only *which files contain this text*, and the scanner
694
+ counts each pattern separately over the shared file contents. The batching
695
+ checks were deleted along with the risk they guarded.
499
696
 
500
697
  **Exit code 2 exists.** "Your specs failed" and "spec-guard could not run" are
501
698
  different facts, and CI should be able to tell them apart.
@@ -506,12 +703,42 @@ nothing, and reported success — the exact shape of a green check that verified
506
703
  nothing. `--allow-missing-targets` restores the old behaviour for repositories
507
704
  where a path is legitimately optional.
508
705
 
706
+ **Hidden directories are searched, and `.gitignore` is not consulted.** An
707
+ audit of 0.3.0 found the tool reporting a clean pass on a repository whose
708
+ forbidden symbol sat in `.github/workflows/ci.yml`; the scanner and ripgrep also
709
+ disagreed with each other, finding two matches and four on the same tree. Scope
710
+ is now one policy that both engines are driven by, the skip list is four names
711
+ long, and anything spec-guard could not inspect is reported rather than assumed
712
+ clean ([ADR-0007](docs/adr/0007-search-scope.md)).
713
+
509
714
  **Comments are excluded by default, and the exclusion is reported.** Counting
510
715
  the note that records a deletion as an occurrence of the thing deleted punishes
511
716
  the documentation this tool exists to keep honest ([ADR-0006](docs/adr/0006-comment-classification.md)).
512
717
  The reverse risk — a rule that quietly stops checking anything because every
513
718
  match now sits in a comment — is why every run says how many matches it dropped.
514
719
 
720
+ **An assertion that inspected no files fails.** It is the same defect as a
721
+ missing `target` seen from a different angle: a rule whose scope is empty passes
722
+ forever and reads exactly like a rule that found nothing. Turning this on found
723
+ a vacuous assertion inside this repository's own test suite on the first run.
724
+ `allow-empty="true"` covers the honest case of a rule written before the code it
725
+ guards.
726
+
727
+ **Four more languages, and no parser.** Import assertions read Python, Go, Rust
728
+ and C# as well as JavaScript. Not by adding four tokenizers - by reusing the
729
+ comment and string lexer that already existed, masking the source with it, and
730
+ reading statements off what is left. Tree-sitter would have been the modern
731
+ answer and costs 94 MB unpacked against this package's 0.33 MB; the four things
732
+ a real parser would genuinely see that this cannot are listed in
733
+ [ADR-0008](docs/adr/0008-polyglot-imports.md) rather than glossed over.
734
+
735
+ **There is no `--fix`.** Every edit a machine can make to a failing boundary
736
+ assertion is an edit that records the rule no longer holding: widen the bound,
737
+ add an exclusion, append to the baseline, insert an ignore comment. A one-flag
738
+ path from red to green is a bad button for a person and a much worse one for an
739
+ agent whose loop terminates on a green build. `--print-baseline` prints what you
740
+ could paste; it does not paste it ([ADR-0009](docs/adr/0009-debt-baselines.md)).
741
+
515
742
  ## spec-guard checks itself
516
743
 
517
744
  The invariants in [`docs/adr/0001-invariants.md`](docs/adr/0001-invariants.md)
@@ -532,7 +759,7 @@ npm install
532
759
  npm run build # tsc -> dist/
533
760
  npm test # vitest
534
761
  npm run test:coverage
535
- npm run test:mutation # stryker (~16 min in CI; much slower locally)
762
+ npm run test:mutation # stryker (~16 min in CI; over two hours locally)
536
763
  npm run lint # tsc --noEmit
537
764
  npm run selfcheck # run spec-guard on its own docs
538
765
  ```
@@ -546,8 +773,8 @@ prebuilt binary and is never a runtime dependency.
546
773
  ### Mutation testing
547
774
 
548
775
  Coverage says a line ran. It does not say an assertion would notice if the line
549
- behaved differently. This repository measures the difference: **84.63%** of
550
- 2,949 mutants are killed in CI, against high line coverage.
776
+ behaved differently. This repository measures the difference: **87.21%** of
777
+ 4,512 mutants are killed in CI, against high line coverage.
551
778
 
552
779
  That gap is the point. The first run scored 77.23%, and the weakest file was the
553
780
  reporter at 65.48% - not because it lacked tests, but because its tests were
@@ -557,17 +784,33 @@ test still passed. Exact whole-output comparison took it to 87.30%. The engine
557
784
  and the walker were then rewritten for testability rather than papered over with
558
785
  more tests, which is what moved them from 75%/78% to 83%/93%.
559
786
 
560
- `npm run test:mutation` runs it locally and takes about an hour; CI runs it on
561
- **every push**, gated at 84%. The CI figure is the one
562
- quoted above, and it is the lower of the two: the same commit scores 88.76% on
563
- Windows, where three times as many mutants hang and Stryker counts a hang as a
564
- kill.
565
-
566
- Four cautionary tales are in [ADR-0003](docs/adr/0003-mutation-testing.md): a
787
+ CI runs it in **two tiers**, both gated at 85%. Branches and pull requests run
788
+ Stryker incrementally, reusing the verdict for any mutant whose source and
789
+ covering tests are both unchanged. Pushes to `main`, the weekly schedule and
790
+ manual runs do the full sweep, which is the authoritative number and the one
791
+ quoted above; a full sweep is also what publishes the cache the branches start
792
+ from, so an incremental verdict can never be built on another incremental
793
+ verdict.
794
+
795
+ The full sweep was 6m54s at 2,118 mutants, 15m51s at 3,493, and is 22m11s at
796
+ 4,512. That growth is why the tiers exist: a check that gets quietly more
797
+ expensive every release is a check somebody eventually proposes lowering.
798
+
799
+ Run it locally with `npm run test:mutation` if you like, but do not calibrate
800
+ anything on the result: on the Windows machine this was developed on the same
801
+ suite takes over two hours against 22 minutes hosted, and it scores *higher*,
802
+ because far more mutants hang there and Stryker counts a hang as a kill. Linux
803
+ CI is the measurement.
804
+
805
+ Six cautionary tales are in [ADR-0003](docs/adr/0003-mutation-testing.md): a
567
806
  run whose score was pure fiction because the mutants were never activated, a
568
807
  tuning knob that lifted the score six points without adding a test, the platform
569
- gap above, and the baseline being re-anchored when a tokenizer arrived - along
570
- with the real bug that chasing the gate uncovered.
808
+ gap above, the baseline being re-anchored when a tokenizer arrived, and a score
809
+ that rose partly because code carrying hard-to-kill mutants was deleted rather
810
+ than because tests improved, and the 0.5.0 sweep that failed the build at 83.77%
811
+ because a suite can be thorough about the thing it was written to test and
812
+ silent about the machinery underneath it - along with the real bug that chasing
813
+ the gate uncovered.
571
814
 
572
815
  ## Requirements
573
816
 
package/dist/cli.d.ts CHANGED
@@ -17,15 +17,22 @@ export interface CliIO {
17
17
  cwd: string;
18
18
  isTTY: boolean;
19
19
  }
20
+ /** How a finished run is written out. */
21
+ export type OutputFormat = 'human' | 'json' | 'sarif';
20
22
  export interface CliOptions {
21
23
  patterns: string[];
22
24
  root: string;
23
25
  verbose: boolean;
24
26
  failFast: boolean;
27
+ format: OutputFormat;
28
+ /** Kept as its own field so `--json` remains exactly what it always was. */
25
29
  json: boolean;
26
30
  engine: EnginePreference;
27
31
  allowMissingTargets: boolean;
32
+ defaultSkips: boolean;
28
33
  strictTargets: boolean;
34
+ allowEmptyScope: boolean;
35
+ printBaseline: boolean;
29
36
  includeSpecs: boolean;
30
37
  allowEmpty: boolean;
31
38
  maxSnippets: number;
@@ -36,7 +43,8 @@ export interface CliOptions {
36
43
  }
37
44
  export declare class UsageError extends Error {
38
45
  }
39
- 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 --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";
46
+ export declare function version(): string;
47
+ 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 (same as --format json)\n --format <name> human | json | sarif (sarif uploads to GitHub code scanning)\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 --allow-empty-scope Tolerate assertions whose scope holds no files (they fail by default)\n --print-baseline Print the baseline=\"...\" that would exempt today's violations, and exit\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 (about the run, not an assertion)\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 An assertion whose scope holds no files fails; add allow-empty=\"true\" to allow it.\n\nExit codes\n 0 all assertions passed 1 an assertion failed 2 spec-guard could not run";
40
48
  /** Minimal, dependency-free argv parser. Supports `--flag value` and `--flag=value`. */
41
49
  export declare function parseArgs(argv: readonly string[], cwd: string): CliOptions;
42
50
  /** 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,mBAAmB,EAAE,OAAO,CAAC;IAC7B,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,otDAkC8D,CAAC;AAgChF,wFAAwF;AACxF,wBAAgB,SAAS,CAAC,IAAI,EAAE,SAAS,MAAM,EAAE,EAAE,GAAG,EAAE,MAAM,GAAG,UAAU,CAuG1E;AAYD,0DAA0D;AAC1D,wBAAsB,IAAI,CAAC,IAAI,GAAE,SAAS,MAAM,EAA0B,EAAE,EAAE,GAAE,KAAmB,GAAG,OAAO,CAAC,MAAM,CAAC,CAgEpH"}
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,yCAAyC;AACzC,MAAM,MAAM,YAAY,GAAG,OAAO,GAAG,MAAM,GAAG,OAAO,CAAC;AAEtD,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,MAAM,EAAE,YAAY,CAAC;IACrB,4EAA4E;IAC5E,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,eAAe,EAAE,OAAO,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;AAExC,wBAAgB,OAAO,IAAI,MAAM,CAShC;AAED,eAAO,MAAM,IAAI,4sEAuC8D,CAAC;AAgChF,wFAAwF;AACxF,wBAAgB,SAAS,CAAC,IAAI,EAAE,SAAS,MAAM,EAAE,EAAE,GAAG,EAAE,MAAM,GAAG,UAAU,CA8H1E;AAYD,0DAA0D;AAC1D,wBAAsB,IAAI,CAAC,IAAI,GAAE,SAAS,MAAM,EAA0B,EAAE,EAAE,GAAE,KAAmB,GAAG,OAAO,CAAC,MAAM,CAAC,CA2EpH"}