@descent-vtt/spec-guard 0.11.0 → 0.12.1

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 (157) hide show
  1. package/CHANGELOG.md +443 -31
  2. package/README.md +403 -41
  3. package/dist/cites.d.ts +192 -0
  4. package/dist/cites.d.ts.map +1 -0
  5. package/dist/cites.js +599 -0
  6. package/dist/cites.js.map +1 -0
  7. package/dist/cli.d.ts +14 -6
  8. package/dist/cli.d.ts.map +1 -1
  9. package/dist/cli.js +315 -22
  10. package/dist/cli.js.map +1 -1
  11. package/dist/comments.d.ts +1 -1
  12. package/dist/comments.js +1 -1
  13. package/dist/config.d.ts +3 -0
  14. package/dist/config.d.ts.map +1 -1
  15. package/dist/config.js +46 -5
  16. package/dist/config.js.map +1 -1
  17. package/dist/engine.d.ts.map +1 -1
  18. package/dist/engine.js +38 -14
  19. package/dist/engine.js.map +1 -1
  20. package/dist/glob.d.ts +128 -24
  21. package/dist/glob.d.ts.map +1 -1
  22. package/dist/glob.js +304 -61
  23. package/dist/glob.js.map +1 -1
  24. package/dist/impact.d.ts +179 -0
  25. package/dist/impact.d.ts.map +1 -0
  26. package/dist/impact.js +360 -0
  27. package/dist/impact.js.map +1 -0
  28. package/dist/index.d.ts +15 -7
  29. package/dist/index.d.ts.map +1 -1
  30. package/dist/index.js +8 -4
  31. package/dist/index.js.map +1 -1
  32. package/dist/mcp.d.ts +22 -75
  33. package/dist/mcp.d.ts.map +1 -1
  34. package/dist/mcp.js +206 -370
  35. package/dist/mcp.js.map +1 -1
  36. package/dist/overlay.d.ts +39 -0
  37. package/dist/overlay.d.ts.map +1 -0
  38. package/dist/overlay.js +133 -0
  39. package/dist/overlay.js.map +1 -0
  40. package/dist/parser.d.ts +41 -24
  41. package/dist/parser.d.ts.map +1 -1
  42. package/dist/parser.js +217 -160
  43. package/dist/parser.js.map +1 -1
  44. package/dist/prove.d.ts +46 -0
  45. package/dist/prove.d.ts.map +1 -0
  46. package/dist/prove.js +549 -0
  47. package/dist/prove.js.map +1 -0
  48. package/dist/query.d.ts +18 -1
  49. package/dist/query.d.ts.map +1 -1
  50. package/dist/query.js +9 -3
  51. package/dist/query.js.map +1 -1
  52. package/dist/reporter.d.ts +139 -1
  53. package/dist/reporter.d.ts.map +1 -1
  54. package/dist/reporter.js +593 -21
  55. package/dist/reporter.js.map +1 -1
  56. package/dist/rules.js +3 -3
  57. package/dist/rules.js.map +1 -1
  58. package/dist/runner.d.ts +31 -2
  59. package/dist/runner.d.ts.map +1 -1
  60. package/dist/runner.js +85 -21
  61. package/dist/runner.js.map +1 -1
  62. package/dist/specs.d.ts +27 -1
  63. package/dist/specs.d.ts.map +1 -1
  64. package/dist/specs.js +45 -10
  65. package/dist/specs.js.map +1 -1
  66. package/dist/structure.d.ts.map +1 -1
  67. package/dist/structure.js +7 -6
  68. package/dist/structure.js.map +1 -1
  69. package/dist/types.d.ts +193 -0
  70. package/dist/types.d.ts.map +1 -1
  71. package/dist/vendor/spec-core/jsonrpc/index.d.ts +3 -0
  72. package/dist/vendor/spec-core/jsonrpc/index.d.ts.map +1 -0
  73. package/dist/vendor/spec-core/jsonrpc/index.js +2 -0
  74. package/dist/vendor/spec-core/jsonrpc/index.js.map +1 -0
  75. package/dist/vendor/spec-core/jsonrpc/mcp.d.ts +158 -0
  76. package/dist/vendor/spec-core/jsonrpc/mcp.d.ts.map +1 -0
  77. package/dist/vendor/spec-core/jsonrpc/mcp.js +353 -0
  78. package/dist/vendor/spec-core/jsonrpc/mcp.js.map +1 -0
  79. package/dist/vendor/spec-core/markdown/frontmatter.d.ts +104 -0
  80. package/dist/vendor/spec-core/markdown/frontmatter.d.ts.map +1 -0
  81. package/dist/vendor/spec-core/markdown/frontmatter.js +459 -0
  82. package/dist/vendor/spec-core/markdown/frontmatter.js.map +1 -0
  83. package/dist/vendor/spec-core/markdown/headings.d.ts +46 -0
  84. package/dist/vendor/spec-core/markdown/headings.d.ts.map +1 -0
  85. package/dist/vendor/spec-core/markdown/headings.js +199 -0
  86. package/dist/vendor/spec-core/markdown/headings.js.map +1 -0
  87. package/dist/vendor/spec-core/markdown/index.d.ts +6 -0
  88. package/dist/vendor/spec-core/markdown/index.d.ts.map +1 -0
  89. package/dist/vendor/spec-core/markdown/index.js +4 -0
  90. package/dist/vendor/spec-core/markdown/index.js.map +1 -0
  91. package/dist/vendor/spec-core/markdown/layout.d.ts +40 -0
  92. package/dist/vendor/spec-core/markdown/layout.d.ts.map +1 -0
  93. package/dist/vendor/spec-core/markdown/layout.js +28 -0
  94. package/dist/vendor/spec-core/markdown/layout.js.map +1 -0
  95. package/dist/vendor/spec-core/markdown/links.d.ts +26 -0
  96. package/dist/vendor/spec-core/markdown/links.d.ts.map +1 -0
  97. package/dist/vendor/spec-core/markdown/links.js +394 -0
  98. package/dist/vendor/spec-core/markdown/links.js.map +1 -0
  99. package/dist/vendor/spec-core/markdown/lists.d.ts +23 -0
  100. package/dist/vendor/spec-core/markdown/lists.d.ts.map +1 -0
  101. package/dist/vendor/spec-core/markdown/lists.js +127 -0
  102. package/dist/vendor/spec-core/markdown/lists.js.map +1 -0
  103. package/dist/vendor/spec-core/markdown/scan.d.ts +37 -0
  104. package/dist/vendor/spec-core/markdown/scan.d.ts.map +1 -0
  105. package/dist/vendor/spec-core/markdown/scan.js +463 -0
  106. package/dist/vendor/spec-core/markdown/scan.js.map +1 -0
  107. package/dist/vendor/spec-core/markdown/syntax.d.ts +76 -0
  108. package/dist/vendor/spec-core/markdown/syntax.d.ts.map +1 -0
  109. package/dist/vendor/spec-core/markdown/syntax.js +181 -0
  110. package/dist/vendor/spec-core/markdown/syntax.js.map +1 -0
  111. package/dist/vendor/spec-core/markdown/tables.d.ts +19 -0
  112. package/dist/vendor/spec-core/markdown/tables.d.ts.map +1 -0
  113. package/dist/vendor/spec-core/markdown/tables.js +86 -0
  114. package/dist/vendor/spec-core/markdown/tables.js.map +1 -0
  115. package/dist/vendor/spec-core/markdown/types.d.ts +291 -0
  116. package/dist/vendor/spec-core/markdown/types.d.ts.map +1 -0
  117. package/dist/vendor/spec-core/markdown/types.js +9 -0
  118. package/dist/vendor/spec-core/markdown/types.js.map +1 -0
  119. package/dist/vendor/spec-core/path/index.d.ts +2 -0
  120. package/dist/vendor/spec-core/path/index.d.ts.map +1 -0
  121. package/dist/vendor/spec-core/path/index.js +2 -0
  122. package/dist/vendor/spec-core/path/index.js.map +1 -0
  123. package/dist/vendor/spec-core/path/posix.d.ts +84 -0
  124. package/dist/vendor/spec-core/path/posix.d.ts.map +1 -0
  125. package/dist/vendor/spec-core/path/posix.js +150 -0
  126. package/dist/vendor/spec-core/path/posix.js.map +1 -0
  127. package/dist/vendor/spec-core/pattern/automaton.d.ts +130 -0
  128. package/dist/vendor/spec-core/pattern/automaton.d.ts.map +1 -0
  129. package/dist/vendor/spec-core/pattern/automaton.js +340 -0
  130. package/dist/vendor/spec-core/pattern/automaton.js.map +1 -0
  131. package/dist/vendor/spec-core/pattern/charset.d.ts +59 -0
  132. package/dist/vendor/spec-core/pattern/charset.d.ts.map +1 -0
  133. package/dist/vendor/spec-core/pattern/charset.js +116 -0
  134. package/dist/vendor/spec-core/pattern/charset.js.map +1 -0
  135. package/dist/vendor/spec-core/pattern/glob.d.ts +122 -0
  136. package/dist/vendor/spec-core/pattern/glob.d.ts.map +1 -0
  137. package/dist/vendor/spec-core/pattern/glob.js +481 -0
  138. package/dist/vendor/spec-core/pattern/glob.js.map +1 -0
  139. package/dist/vendor/spec-core/pattern/index.d.ts +7 -0
  140. package/dist/vendor/spec-core/pattern/index.d.ts.map +1 -0
  141. package/dist/vendor/spec-core/pattern/index.js +4 -0
  142. package/dist/vendor/spec-core/pattern/index.js.map +1 -0
  143. package/dist/vendor/spec-core/pattern/regex.d.ts +68 -0
  144. package/dist/vendor/spec-core/pattern/regex.d.ts.map +1 -0
  145. package/dist/vendor/spec-core/pattern/regex.js +813 -0
  146. package/dist/vendor/spec-core/pattern/regex.js.map +1 -0
  147. package/dist/vendor/spec-core/text/index.d.ts +3 -0
  148. package/dist/vendor/spec-core/text/index.d.ts.map +1 -0
  149. package/dist/vendor/spec-core/text/index.js +2 -0
  150. package/dist/vendor/spec-core/text/index.js.map +1 -0
  151. package/dist/vendor/spec-core/text/lines.d.ts +73 -0
  152. package/dist/vendor/spec-core/text/lines.d.ts.map +1 -0
  153. package/dist/vendor/spec-core/text/lines.js +156 -0
  154. package/dist/vendor/spec-core/text/lines.js.map +1 -0
  155. package/package.json +9 -2
  156. package/src/vendor/spec-core/LICENSE +21 -0
  157. package/src/vendor/spec-core/README.md +8 -0
package/README.md CHANGED
@@ -158,8 +158,21 @@ can never exclude anything:
158
158
  - **a drive path** such as `C:/repo/dist`.
159
159
  - **`.` or `/`**, the root itself.
160
160
 
161
- In a directive a refused pattern is an invalid directive; in the configuration
162
- or `--exclude` it is exit 2.
161
+ So is any pattern the glob engine cannot read - an unclosed `[` or `{`, an
162
+ extended glob such as `+(a|b)`, a range that runs backwards, `**` inside a
163
+ name - rather than being read as a literal that excludes nothing. The same goes
164
+ for `glob`, `module`, `order` and the structure rules' patterns, and for a spec
165
+ pattern.
166
+
167
+ `**` means any number of directories only as a whole segment. `docs/**.md` is
168
+ refused, with the two ways to say what was meant: `docs/**/*.md` for any depth,
169
+ `*.md` for one level. The tools it could have come from read it three ways,
170
+ and a spec pattern read the narrow way drops every nested document without a
171
+ word.
172
+
173
+ In a directive a refused pattern is an invalid directive, whether or not its
174
+ document is in force; in the configuration's `specs` or `exclude`, a spec
175
+ pattern on the command line or `--spec`, or `--exclude`, it is exit 2.
163
176
 
164
177
  List attributes accept commas or whitespace, so both of these work:
165
178
 
@@ -169,7 +182,10 @@ List attributes accept commas or whitespace, so both of these work:
169
182
  ```
170
183
 
171
184
  (A path containing a space therefore cannot be written; there is no quoting
172
- inside an attribute value.)
185
+ inside an attribute value. Nor can a brace group with a comma in it:
186
+ `glob="*.{ts,tsx}"` is the two patterns `*.{ts` and `tsx}`, and is refused with a
187
+ message that says so. Write `glob="*.ts, *.tsx"`. A configuration's `exclude` is
188
+ a JSON array, where a brace group keeps its commas.)
173
189
 
174
190
  ### `comments` - the note about a deletion is not the deletion
175
191
 
@@ -315,9 +331,9 @@ These read the dependency rather than the text, in **five languages**:
315
331
  No parser and no new dependency. JavaScript gets a full tokenizer because a
316
332
  module reference can appear anywhere in an expression; the other four are read
317
333
  by masking comments and string literals with the classifier from
318
- [ADR-0006](docs/adr/0006-comment-classification.md) and then reading statements
334
+ [ADR-0006](https://github.com/DescentVTT/spec-guard/blob/main/docs/adr/0006-comment-classification.md) and then reading statements
319
335
  off what is left. Tree-sitter would have cost 94 MB unpacked against this
320
- package's 0.33 MB - [ADR-0008](docs/adr/0008-polyglot-imports.md) has the
336
+ package's 0.33 MB - [ADR-0008](https://github.com/DescentVTT/spec-guard/blob/main/docs/adr/0008-polyglot-imports.md) has the
321
337
  measurements, and the list of things a real parser would genuinely see that
322
338
  this does not.
323
339
 
@@ -368,7 +384,7 @@ and reported too, so a rule pointed at the wrong tree says "analysed 2 of 3
368
384
  files; 1 is in a language whose imports spec-guard cannot read (.razor)" rather than quietly passing - and if *none* of them can be read, the
369
385
  assertion fails rather than passing on an empty analysis.
370
386
 
371
- [ADR-0005](docs/adr/0005-import-assertions.md) has the measurements and the
387
+ [ADR-0005](https://github.com/DescentVTT/spec-guard/blob/main/docs/adr/0005-import-assertions.md) has the measurements and the
372
388
  reasoning behind each boundary.
373
389
 
374
390
  ### `@assert-layers` - dependencies point one way
@@ -530,7 +546,7 @@ did not - a relative path matching no file, or an alias like `@/db` or
530
546
  `--strict`. An alias spelled like a real package, `@app/db` through `tsconfig`
531
547
  paths, is the one thing it cannot see and cannot report: that would mean
532
548
  reading `tsconfig.json`, which is the resolver
533
- [ADR-0011](docs/adr/0011-layers-and-cycles.md) declines to become, for the
549
+ [ADR-0011](https://github.com/DescentVTT/spec-guard/blob/main/docs/adr/0011-layers-and-cycles.md) declines to become, for the
534
550
  reasons it gives.
535
551
 
536
552
  ### `@assert-structure` - what files are called, and what comes with them
@@ -623,7 +639,7 @@ src/handlers/invoice.ts (does not exist yet)
623
639
  partner: src/handlers/invoice.test.ts
624
640
  ```
625
641
 
626
- [ADR-0013](docs/adr/0013-structure-assertions.md) has the design and what it
642
+ [ADR-0013](https://github.com/DescentVTT/spec-guard/blob/main/docs/adr/0013-structure-assertions.md) has the design and what it
627
643
  costs.
628
644
 
629
645
  ### `@assert-present` - this file exists
@@ -725,7 +741,7 @@ spec-guard docs/adr.md --print-baseline
725
741
  ```
726
742
 
727
743
  prints the attribute that would exempt exactly today's violations, for you to
728
- paste in. It prints; it does not edit. [ADR-0009](docs/adr/0009-debt-baselines.md)
744
+ paste in. It prints; it does not edit. [ADR-0009](https://github.com/DescentVTT/spec-guard/blob/main/docs/adr/0009-debt-baselines.md)
729
745
  explains why that distinction is the whole design, and why there is no `--fix`
730
746
  for architecture rules.
731
747
 
@@ -736,8 +752,11 @@ long before anyone deletes it - because deleting it deletes the reason a
736
752
  decision was made.
737
753
 
738
754
  spec-guard reads that status from the document and honours it. A document whose
739
- status is `draft`, `proposed`, `rejected`, `deprecated` or `superseded` is
740
- parsed, validated, reported - and not executed.
755
+ status is `draft`, `proposed`, `rejected`, `deprecated`, `superseded` or
756
+ `archived` is parsed, validated, reported - and not executed. `archived` is what
757
+ `spec-brief` writes when it closes a task brief's round: the brief is kept as
758
+ the record, and its directives, which stated the round's premises and goals,
759
+ are history.
741
760
 
742
761
  Three spellings are recognised, because three are in use:
743
762
 
@@ -760,7 +779,13 @@ Accepted (0.3.0).
760
779
  ```
761
780
 
762
781
  Front-matter wins, then the `## Status` section, then the bold label (which is
763
- only read above the first `##`). Anything else - `Provisional`, `In review`, a
782
+ only read above the first section heading). A front-matter `status` wins even
783
+ when its value cannot be read - `status: "accepted" (2024-05-01)`, text after
784
+ a closing quote - and then the document stays in force, the section and the
785
+ label are not read in its place, and the report warns on the key's line with
786
+ the reason. Headings are read as CommonMark reads them, so an underlined
787
+ `Status` is the section and a `## Status` kept in a comment is not. Anything
788
+ else - `Provisional`, `In review`, a
764
789
  misspelled `Supersedded`, or no status at all - keeps enforcing. That asymmetry
765
790
  is deliberate: an unanticipated word that keeps enforcing is a visible failure
766
791
  with an obvious fix, while one that stops enforcing is a green build over a rule
@@ -781,7 +806,7 @@ found on the day it is written rather than on the day it is accepted.
781
806
  `--ignore-status` runs everything, which is how you ask whether a draft would
782
807
  pass if you accepted it today.
783
808
 
784
- [ADR-0010](docs/adr/0010-spec-status.md) has the full reasoning, including why
809
+ [ADR-0010](https://github.com/DescentVTT/spec-guard/blob/main/docs/adr/0010-spec-status.md) has the full reasoning, including why
785
810
  there is no per-directive `if-status` attribute.
786
811
 
787
812
  ## Asking before writing - `query` and the MCP server
@@ -838,7 +863,7 @@ project's does not.
838
863
 
839
864
  The arithmetic that decides whether a rule governs a path is tested against the
840
865
  walk a real run makes, file for file, under both engines and on randomly
841
- generated trees. [ADR-0012](docs/adr/0012-query-and-mcp.md) lists the three
866
+ generated trees. [ADR-0012](https://github.com/DescentVTT/spec-guard/blob/main/docs/adr/0012-query-and-mcp.md) lists the three
842
867
  things it cannot see: file content (binary, oversized, unreadable), symbolic
843
868
  links, and letter case on case-insensitive filesystems.
844
869
 
@@ -872,6 +897,12 @@ to its checks.
872
897
  | --- | --- |
873
898
  | `get_architectural_rules(path, include_inactive?)` | The query above, as text and as structured content |
874
899
  | `check_architecture(paths?)` | A run. Given paths, only the rules that govern them, each over its whole scope, with every violation marked as in those paths or not |
900
+ | `get_dependents(paths, depth?, include_inactive?)` | [`impact`](#who-depends-on-this---impact): the files that import each path, however far away, with the import that leads there, the imports that could not be followed, and the rules in play. The structured content is the document `impact --json` writes |
901
+
902
+ All three are marked read-only, idempotent and closed-world. The server's
903
+ instructions tell an agent when to call each: the rules before creating or
904
+ changing a file, the dependents before changing a file others import, and the
905
+ check after changing files.
875
906
 
876
907
  | Resource | What it holds |
877
908
  | --- | --- |
@@ -884,6 +915,174 @@ version on every request and probe with `server/discover`. Nothing is cached, so
884
915
  an ADR edited mid-session is read as edited. ADR-0012 has the shapes, the
885
916
  sources they follow, and what is deliberately not implemented.
886
917
 
918
+ ## Can each rule fail? - `prove`
919
+
920
+ A passing rule says one of two things, and the report cannot tell which: the
921
+ code holds, or the rule cannot see the code. A `glob="*.js"` over a codebase
922
+ written in TypeScript passes, and would pass whatever the code did.
923
+
924
+ ```bash
925
+ spec-guard prove
926
+ ```
927
+
928
+ `prove` shows each rule in force a violation of itself and runs the rule over
929
+ it: a file holding the forbidden text beside the files the rule reads, a file
930
+ importing the forbidden module, two files importing each other, a file in a
931
+ lower layer importing a higher one, a misnamed file, a required entry or a
932
+ partner taken away, a file the rule requires removed. The violation is made **in
933
+ memory**, through the same door every read goes through. Nothing is written to
934
+ disk, and nothing touches git.
935
+
936
+ ```text
937
+ spec-guard prove 3 specs · 12 rules
938
+
939
+ ✖ docs/adr/0004-legacy.md:12 @assert-absence passed with a violation in place
940
+ "LegacyClient" must not appear in src
941
+ maximum: added src/spec-guard-prove.ts holding "LegacyClient", beside the code under src, none of which the rule reads, and it still passed: expected no matches, found 0
942
+
943
+ ○ docs/adr/0009-queue.md:8 @assert-absence no violation could be made
944
+ "enqueueRaw" must not appear in src/queue
945
+ it fails on the tree as it stands (expected no matches, found 1), so no change can be shown to be what fails it
946
+
947
+ 10 seen to fail · 1 survived · 1 unprovable · 1.10s
948
+ ✖ 1 rule passed with a violation of itself in place
949
+ ```
950
+
951
+ Each rule is **killed** (it failed on the violation), **survived** (it still
952
+ passed, and the violation is shown), or **unprovable** (no violation could be
953
+ made, and the reason is given). Both bounds of a count are probed. `prove`
954
+ exits 1 when a rule survived, and under `--strict` when one is unprovable. It
955
+ takes the run's `--root`, `--spec`, `--exclude`, `--ignore-status`, `--json`
956
+ and `--format sarif|github|gitlab`, and the configuration's specs, exclusions
957
+ and strictness.
958
+
959
+ A kill means the rule can fail, not that it catches the way the code is
960
+ written: `module="src/db.ts"` is killed by `import 'src/db.ts'` though every
961
+ file imports `./db.js`. [ADR-0016](https://github.com/DescentVTT/spec-guard/blob/main/docs/adr/0016-rules-seen-to-fail.md) has the
962
+ violations for each kind of rule, and what `unprovable` means.
963
+
964
+ ## Do the comments cite decisions in force? - `cites`
965
+
966
+ Code comments cite decisions - `// ADR-0011: the domain imports no
967
+ infrastructure`, `# see ADR-7`, `/* Q-172 */` - and the claim goes stale the
968
+ way code does: the document was never written, or it has since been superseded.
969
+ `spec-guard cites` reads the comment text of every source file for the ids a
970
+ project names, and reports each citation of a document that does not exist or
971
+ is no longer in force:
972
+
973
+ ```bash
974
+ spec-guard cites
975
+ ```
976
+
977
+ ```text
978
+ spec-guard cites ADR-{n} (10 documents matching docs/adr/{n}-*.md); RFC-{n} (2 documents matching docs/rfcs/rfc-{n}.md)
979
+
980
+ ⚠ crates/ledger/src/journal.rs:1 cites ADR-0007, which is superseded - cite ADR-0009 instead stale-citation
981
+ hint: docs/adr/0007-async-journal.md says "superseded by ADR-0009"
982
+ ✖ crates/ledger/src/lib.rs:28 cites ADR-0011, which no document defines ghost-citation
983
+ hint: no document matching docs/adr/{n}-*.md has the number 11; the nearest is ADR-0010
984
+
985
+ 3 citations in 2 files · 1 ghost · 1 stale · 22ms
986
+ ✖ 1 citation names a document that does not exist
987
+ ```
988
+
989
+ The families of documents are named in the configuration, one entry each - an
990
+ id with `{n}` for the number, and a path glob naming the documents with `{n}`
991
+ in the file name:
992
+
993
+ ```json
994
+ {
995
+ "cites": [
996
+ { "id": "ADR-{n}", "files": "docs/adr/{n}-*.md" },
997
+ { "id": "RFC-{n}", "files": "docs/rfcs/rfc-{n}.md" }
998
+ ]
999
+ }
1000
+ ```
1001
+
1002
+ - **The number is a number**: `ADR-7`, `ADR-007` and `docs/adr/0007-x.md` are
1003
+ one document. An id has a word boundary on each side, so `XADR-1` and
1004
+ `ADR-12a` are not citations, and one id on one line is one finding however
1005
+ often it is written there.
1006
+ - **Without `cites`**, a directory of specs whose names begin with a number, and
1007
+ whose titles all begin with one id and that number (`# ADR-0007: ...` in
1008
+ `0007-x.md`), is read as a family. Anything less is not guessed at: the
1009
+ command exits 0 with a note saying to name the documents.
1010
+ - **Comment text only**, as the comment classifier reads it: an id in a string,
1011
+ a raw string or a YAML value is not a citation. Markdown is spec-graph's and
1012
+ is not read, nor are the spec files, the project's `exclude`, and the default
1013
+ skips. Files in a language whose comments spec-guard does not know are
1014
+ counted by extension, and a file read in part - binary, unreadable, or a scan
1015
+ that lost its place - is named.
1016
+ - **Another project's ids are not checked**: `spec-core's ADR-0005`,
1017
+ `spec-graph ADR-0017` or `its ADR-12` name a document in another repository,
1018
+ and are counted instead.
1019
+ - **Stale** is `superseded`, `deprecated`, `rejected` or `archived`; a draft or
1020
+ proposal cited from the code implementing it is not. The successor is read
1021
+ from the document's status line - `Superseded by ADR-0014` - and followed to
1022
+ the one in force.
1023
+
1024
+ A ghost exits 1. A stale citation is a warning, and exits 1 under `--strict`,
1025
+ as does a file read in part or a check that looked for nothing. A family whose
1026
+ files match no document exits 2. `--json` (versioned by `formatVersion`),
1027
+ `--format sarif`, `--format github` and `--format gitlab` carry the same
1028
+ findings; `spec-guard cites src lib/a.ts` reads only those paths.
1029
+ [ADR-0017](https://github.com/DescentVTT/spec-guard/blob/main/docs/adr/0017-citations-in-comments.md) has the design, and what is
1030
+ out of scope: links to symbols, which need a parser the family does not ship.
1031
+
1032
+ ## Who depends on this? - `impact`
1033
+
1034
+ Before changing a file, the question after "which rules govern it" is "what
1035
+ else does it reach". `spec-guard impact` reads the import graph backwards: for
1036
+ each path, the files that import it, and the files that import those, with how
1037
+ far each is and the import that takes it one step closer; then the rules in
1038
+ force that govern the path or any of them:
1039
+
1040
+ ```bash
1041
+ spec-guard impact src/db/client.ts --depth 3
1042
+ ```
1043
+
1044
+ ```text
1045
+ src/db/client.ts
1046
+ 4 files depend on it, 2 directly, up to 3 imports away
1047
+ 1 src/app/service.ts imports src/db/client.ts (line 2)
1048
+ 1 src/db/index.ts imports src/db/client.ts (line 1)
1049
+ 2 src/app/cache.ts imports src/app/service.ts (line 1)
1050
+ 3 src/ui/view.tsx imports src/app/cache.ts (line 1)
1051
+
1052
+ 3 rules govern these files, from 1 document
1053
+
1054
+ ADR-0001: Layers (docs/adr/0001-layers.md)
1055
+ :3 @assert-layers src must keep its layers in order, src/db < src/app < src/ui
1056
+ governs: src/db/client.ts, src/app/service.ts, src/db/index.ts, src/app/cache.ts, src/ui/view.tsx
1057
+ ...
1058
+
1059
+ 3 imports could not be resolved, and may depend on these paths:
1060
+ src/ui/view.tsx:3 ./gone.js (names no file)
1061
+ src/ui/view.tsx:4 @/db (names no file)
1062
+ src/ui/view.tsx:7 import(name)
1063
+ ```
1064
+
1065
+ - **Followed**: relative JavaScript and TypeScript imports, by the table the
1066
+ cycle rule uses ([ADR-0011](https://github.com/DescentVTT/spec-guard/blob/main/docs/adr/0011-layers-and-cycles.md)), and relative
1067
+ Python imports, which name a module beside the importing file.
1068
+ - **Counted, not followed**: Go imports, Rust `use`s, C# `using`s and absolute
1069
+ Python imports name modules, and which file a module is depends on `go.mod`,
1070
+ the crate's module tree, the compiler or `sys.path`. A path in one of those
1071
+ languages says its dependents are not computed, rather than showing an empty
1072
+ list.
1073
+ - **Listed, never guessed**: an import naming no file, an alias such as `@/db`,
1074
+ a dynamic `import(name)`. Any of them may depend on the path.
1075
+
1076
+ A directory asks about every file under it. `--depth <n>` stops `n` imports
1077
+ away; cycles are safe. `--ignore-status` lists rules from documents not in
1078
+ force, which are otherwise counted, as `query` does. `--json` is versioned by
1079
+ `formatVersion`. A path that does not exist exits 2, and so does a run of
1080
+ `impact` in which no spec matched, as `query` does: the dependents are still
1081
+ written, but "no rules" over no specs is nothing measured. `--allow-empty`
1082
+ asks for the dependents alone, and exits 0. The MCP server answers the same
1083
+ question as `get_dependents`, with the same document.
1084
+ [ADR-0018](https://github.com/DescentVTT/spec-guard/blob/main/docs/adr/0018-impact.md) has the table and what it leaves out.
1085
+
887
1086
  ## CLI
888
1087
 
889
1088
  ```bash
@@ -891,6 +1090,9 @@ spec-guard [patterns...] [options] # execute the directives
891
1090
  spec-guard --watch [patterns...] # execute them again whenever the tree changes
892
1091
  spec-guard query <paths...> [options] # the rules in force for files or directories
893
1092
  spec-guard mcp [options] # serve the rules over MCP on stdio
1093
+ spec-guard prove [patterns...] # show each rule a violation of itself, in memory
1094
+ spec-guard cites [paths...] # check each spec a code comment cites exists and is in force
1095
+ spec-guard impact <paths...> # the files that depend on each path, and the rules in play there
894
1096
  ```
895
1097
 
896
1098
  | Option | Description |
@@ -900,8 +1102,8 @@ spec-guard mcp [options] # serve the rules over MCP on stdio
900
1102
  | `-v, --verbose` | Print passing assertions too |
901
1103
  | `--watch` | Report, then report again as the tree changes, until Ctrl+C |
902
1104
  | `--fail-fast` | Stop at the first failing assertion |
903
- | `--json` | Machine-readable report on stdout (same as `--format json`) |
904
- | `--format <human\|json\|sarif>` | Output format. `sarif` uploads to GitHub code scanning |
1105
+ | `--json` | Machine-readable report on stdout (same as `--format json`), versioned by `formatVersion` |
1106
+ | `--format <human\|json\|sarif\|github\|gitlab>` | Output format. `sarif` uploads to GitHub code scanning, `github` annotates a pull request from the job's log, `gitlab` is a GitLab Code Quality report |
905
1107
  | `--engine <auto\|rg\|js>` | Search engine (default `auto`: scanner for small trees, ripgrep for big ones) |
906
1108
  | `--strict` / `--no-strict` | Treat analysis that could not be completed as a failure |
907
1109
  | `--allow-missing-targets` / `--no-allow-missing-targets` | Warn instead of failing when a `target` path does not exist |
@@ -909,11 +1111,12 @@ spec-guard mcp [options] # serve the rules over MCP on stdio
909
1111
  | `--print-baseline` | Print the `baseline="..."` that would exempt today's violations, and exit |
910
1112
  | `--no-default-skips` / `--default-skips` | Search `.git`, `.hg`, `.svn` and `node_modules` too |
911
1113
  | `--exclude <globs>` | Paths no assertion looks at, beside each directive's own `exclude`. Repeatable; replaces the configuration's list, and `--exclude=` clears it |
912
- | `--ignore-status` / `--no-ignore-status` | Execute directives in draft, proposed and superseded documents too |
1114
+ | `--ignore-status` / `--no-ignore-status` | Execute directives in documents not in force too: draft, proposed, rejected, deprecated, superseded or archived |
913
1115
  | `--include-specs` / `--no-include-specs` | Also count matches inside the spec files themselves |
914
1116
  | `--max-snippets <n>` | Failure snippets per assertion (default 5) |
915
1117
  | `--concurrency <n>` | Search passes in flight at once (default 8) |
916
- | `--allow-empty` | Exit 0 when no spec file matched the patterns (about the run, not an assertion) |
1118
+ | `--depth <n>` | `impact` only: follow dependents at most `n` imports away (default: all) |
1119
+ | `--allow-empty` | Exit 0 when no spec file matched the patterns, for a run, `prove` or `impact` (about the run, not an assertion) |
917
1120
  | `--color` / `--no-color` | Force colour on or off (`NO_COLOR` honoured) |
918
1121
 
919
1122
  Patterns are expanded by spec-guard itself, so quoted globs behave identically
@@ -958,6 +1161,7 @@ same options, at the top level, in `.spec-guard.json`:
958
1161
  | `strict`, `allowMissingTargets`, `allowEmptyScope`, `ignoreStatus`, `includeSpecs`, `defaultSkips` | the flag of that name | `true` or `false` |
959
1162
  | `maxSnippets` | `--max-snippets` | an integer, 0 or more |
960
1163
  | `concurrency` | `--concurrency` | an integer, 1 or more |
1164
+ | `cites` | none; `spec-guard cites` reads it | a non-empty array of `{ "id": "ADR-{n}", "files": "docs/adr/{n}-*.md" }`, and no other key |
961
1165
 
962
1166
  - **Only the root's files** are read: the `--root` directory, or the working
963
1167
  directory. Nothing is inherited from a parent directory.
@@ -1016,7 +1220,7 @@ pure function of a file's bytes - tokenizing, comment masking, scanning - is
1016
1220
  remembered by the bytes' hash. Each of those is a way to report a tree that no
1017
1221
  longer exists, so a test changes trees at random and requires every report a
1018
1222
  session gives to equal a fresh run's, with six deliberately broken sessions
1019
- that it has to catch. [ADR-0014](docs/adr/0014-configuration-and-watch.md) has
1223
+ that it has to catch. [ADR-0014](https://github.com/DescentVTT/spec-guard/blob/main/docs/adr/0014-configuration-and-watch.md) has
1020
1224
  the design, the measurements and the limits.
1021
1225
 
1022
1226
  On this repository, from a change to the report, in the quietest of four runs
@@ -1045,8 +1249,8 @@ symbolic link, and anything outside the root. CI stays the authority.
1045
1249
  | Code | Meaning |
1046
1250
  | --- | --- |
1047
1251
  | `0` | Every assertion held |
1048
- | `1` | An assertion failed, or a directive was malformed |
1049
- | `2` | spec-guard could not run: bad usage, a malformed configuration, no spec files matched, `--engine rg` with no ripgrep, a watch that could not start |
1252
+ | `1` | An assertion failed, or a directive was malformed; for `prove`, a rule survived; for `cites`, a comment cites a document that does not exist |
1253
+ | `2` | spec-guard could not run: bad usage, a malformed configuration, no spec files matched, `--engine rg` with no ripgrep, a watch that could not start, a `cites` family whose files match no document |
1050
1254
  | `130` | A `--watch` session was stopped |
1051
1255
 
1052
1256
  ## CI integration
@@ -1104,6 +1308,43 @@ where the fix goes. Alerts carry a fingerprint derived from what the assertion
1104
1308
  is about rather than where its matches landed, so inserting a line above a
1105
1309
  violation does not close the alert and open a new one.
1106
1310
 
1311
+ Two formats need no upload step. `--format github` writes one GitHub Actions
1312
+ workflow command per finding, which the job's log turns into an annotation on
1313
+ the diff:
1314
+
1315
+ ```text
1316
+ ::error file=src/a.ts,line=2,title=assert-absence::"Legacy" must not appear in src: expected no matches, found 2 (docs/adr/0004-legacy.md:12)
1317
+ ```
1318
+
1319
+ `--format gitlab` writes a
1320
+ [GitLab Code Quality](https://docs.gitlab.com/ci/testing/code_quality/) report,
1321
+ which a merge request shows beside its changes:
1322
+
1323
+ ```yaml
1324
+ spec-guard:
1325
+ script:
1326
+ - npx @descent-vtt/spec-guard --format gitlab > gl-code-quality-report.json || true
1327
+ artifacts:
1328
+ reports:
1329
+ codequality: gl-code-quality-report.json
1330
+ ```
1331
+
1332
+ Every format places the same findings, and `prove` and `cites` write all three:
1333
+
1334
+ | Finding | GitHub | GitLab severity |
1335
+ | --- | --- | --- |
1336
+ | a failing assertion, on its first offending line or its directive | `error` | `critical` |
1337
+ | a rule that survived `prove`, on its directive | `error` | `critical` |
1338
+ | a ghost citation (`cites`), on its comment | `error` | `critical` |
1339
+ | a directive that could not be read | `error` | `major` |
1340
+ | a stale citation (`cites`) | `warning`; `error` under `--strict` | `minor`; `critical` under `--strict` |
1341
+ | a rule `prove` could make no violation for | `notice` | `minor` |
1342
+ | a file `cites` could read only in part | `notice` | `info` |
1343
+ | a document not in force, on its first line | `notice` | `info` |
1344
+
1345
+ A GitLab issue's `fingerprint` is the SHA-256 of its rule, file and message,
1346
+ which GitLab compares across pipelines to tell a new finding from an old one.
1347
+
1107
1348
  **There is no language server, and that is a decision rather than a gap.**
1108
1349
  spec-guard's claims are about a whole repository - "this symbol appears nowhere
1109
1350
  in `src`" - and a language server is handed one buffer at a time. Answering a
@@ -1121,9 +1362,11 @@ parser ──▶ Directive[] ──▶ runner ──▶ Assertion[] ──▶ e
1121
1362
  (pure) (I/O) (rg | js) (pure)
1122
1363
  ```
1123
1364
 
1124
- 1. **Parse.** Markdown is scanned for `<!-- @assert-* -->` comments. Fenced code
1125
- blocks and inline code spans are masked first, so documentation that shows
1126
- the syntax (like this README) never executes it.
1365
+ 1. **Parse.** Markdown is scanned for `<!-- @assert-* -->` comments. Code -
1366
+ fenced and indented blocks, code spans, and the HTML elements whose content
1367
+ is not Markdown - and front matter are masked first, by the Markdown scanner
1368
+ every spec-* tool shares, so documentation that shows the syntax (like this
1369
+ README) never executes it.
1127
1370
  2. **Resolve.** Each directive becomes a typed assertion. Bad numbers, unknown
1128
1371
  booleans, unknown attributes and paths escaping `--root` are rejected here,
1129
1372
  before any I/O.
@@ -1160,7 +1403,7 @@ exceeds a budget, in which case ripgrep takes over. The crossover above sits
1160
1403
  between 500 and 1,000 files, which is what the Windows budget of 512 encodes;
1161
1404
  on Linux it is far lower, because what is really being measured is process
1162
1405
  spawn cost. `scripts/bench-engines.mjs` reproduces this, and
1163
- [ADR-0004](docs/adr/0004-adaptive-engine.md) has the full tables.
1406
+ [ADR-0004](https://github.com/DescentVTT/spec-guard/blob/main/docs/adr/0004-adaptive-engine.md) has the full tables.
1164
1407
 
1165
1408
  Treat single measurements from one machine with suspicion. On the development
1166
1409
  machine used here, an absence assertion over 2,000 files measures 303 ms with
@@ -1238,6 +1481,46 @@ observed count, match locations, warnings and timings. The parser
1238
1481
  (`parseDirectives`) and reporter (`formatReport`, `formatJson`) are pure
1239
1482
  functions you can use on their own.
1240
1483
 
1484
+ A run reads the tree through one interface, `Io` - list a directory, `stat` a
1485
+ path, read a file, resolve a real path - and `nodeIo` is the filesystem. Pass
1486
+ your own as `io` and every read the run makes goes through it: finding and
1487
+ reading the specs, checking targets exist, walking them, scanning files, reading
1488
+ imports and listing directories. That is how to run the rules over a tree that
1489
+ is not on disk, or not as the disk has it:
1490
+
1491
+ ```ts
1492
+ import { nodeIo, runSpecGuard, type Io } from '@descent-vtt/spec-guard';
1493
+
1494
+ // The disk, except for one file edited in memory.
1495
+ const edited: Io = {
1496
+ ...nodeIo,
1497
+ readFile: async (file) =>
1498
+ file === '/repo/src/pay.ts' ? Buffer.from('new LegacyGateway();\n') : nodeIo.readFile(file),
1499
+ };
1500
+
1501
+ const report = await runSpecGuard({ patterns: ['docs/**/*.md'], root: '/repo', io: edited });
1502
+ ```
1503
+
1504
+ ripgrep reads the disk itself, in a process of its own, so no `io` can reach
1505
+ it: `engine: 'ripgrep'` with an `io` is an error, and `auto` with one searches
1506
+ with the built-in scanner.
1507
+
1508
+ `proveSpecGuard` is `spec-guard prove` for a caller: it takes the options that
1509
+ decide whether a rule passes, and an `io` too, and returns each rule's outcome
1510
+ with the violations it was shown. `overlayIo` is the door it makes them behind,
1511
+ and is there for a caller who wants to try a change of its own.
1512
+
1513
+ `findCitations` and `impactOf` are `spec-guard cites` and `spec-guard impact`,
1514
+ and take an `io` as well; so do `loadRuleSet` and `queryRules`. Each command's
1515
+ JSON is the report its function returns, with a `formatVersion` in front.
1516
+
1517
+ A process that reads the specs again for every request, as the MCP server
1518
+ does, can keep what it parsed: pass one `createDocumentMemo()` as `documents`
1519
+ to `runSpecGuard`, `loadRuleSet`, `queryRules` or `impactOf`, every time. Every
1520
+ spec is still found and read, so an edit is seen by the next call; a document
1521
+ whose bytes have not changed is not parsed again, and the memo holds only the
1522
+ documents of the spec set read last. A single run gains nothing from one.
1523
+
1241
1524
  ## Design decisions
1242
1525
 
1243
1526
  This tool was specified loosely and built opinionatedly. Where the
@@ -1252,11 +1535,27 @@ themselves stay uncounted, because a directive is an HTML comment and a rule
1252
1535
  whose own text trips it can never be satisfied. `comments="include"` counts even
1253
1536
  those.
1254
1537
 
1255
- **Fenced code and inline code are masked before parsing.** A README documenting
1256
- the syntax must not execute it. Masking preserves byte offsets, so reported line
1257
- numbers stay exact. (This is subtle: pairing backtick runs the naive way
1258
- desynchronises after a stray unmatched run and un-masks real prose. spec-guard
1259
- uses CommonMark's equal-length pairing rule, and there is a regression test.)
1538
+ **Code is masked before parsing.** A README documenting the syntax must not
1539
+ execute it. What is code is what CommonMark says it is, decided by
1540
+ [spec-core](https://github.com/DescentVTT/spec-core)'s Markdown scanner, which
1541
+ every spec-* tool reads documents with and which is copied into
1542
+ `src/vendor/spec-core` and verified by hash: fenced and indented code, code
1543
+ spans, `<script>`, `<pre>`, `<style>` and `<textarea>` blocks, and front matter.
1544
+ Comments and code spans are resolved left to right in one pass, so a backtick
1545
+ inside a comment is a character and `<!--` inside a code span is code, and a
1546
+ code span ends with its paragraph, so a stray backtick cannot hide the
1547
+ directives after it. A fence may be indented with the list item it sits in.
1548
+ Masking preserves every offset and line terminator, so reported line numbers
1549
+ stay exact. [ADR-0002](https://github.com/DescentVTT/spec-guard/blob/main/docs/adr/0002-directive-format.md)'s amendment lists
1550
+ where this differs from 0.11.0.
1551
+
1552
+ What is masked is still counted. A comment shaped like a directive -
1553
+ `<!-- @assert-...` - in code, raw HTML or front matter is not run, and the
1554
+ report says how many there were and where, on one line (every one under
1555
+ `--verbose`, and in `maskedDirectives` in JSON), so text read as code by
1556
+ accident is not a rule gone quiet without a word. A code fence or raw-text
1557
+ block that is never closed, which makes the rest of its document code, is a
1558
+ warning on its opening line.
1260
1559
 
1261
1560
  **A document's lifecycle status is read; a directive's is not.** Status is
1262
1561
  document-level and visible in every rendered Markdown view. A per-directive
@@ -1290,6 +1589,13 @@ anything - it answers only *which files contain this text*, and the scanner
1290
1589
  counts each pattern separately over the shared file contents. The batching
1291
1590
  checks were deleted along with the risk they guarded.
1292
1591
 
1592
+ **Globs are matched by an automaton, not a regular expression.** Every
1593
+ pattern - `glob`, `exclude`, `module`, a layer, a spec pattern - is read by the
1594
+ glob engine the spec-* tools share, copied from spec-core and checked by hash.
1595
+ A `RegExp` backtracks: `*-*-*-*-*-*x` took 55 seconds to fail one long file
1596
+ name. The automaton cannot, and ripgrep is handed each pattern spelled as the
1597
+ automaton reads it ([ADR-0015](https://github.com/DescentVTT/spec-guard/blob/main/docs/adr/0015-globs-from-spec-core.md)).
1598
+
1293
1599
  **Exit code 2 exists.** "Your specs failed" and "spec-guard could not run" are
1294
1600
  different facts, and CI should be able to tell them apart.
1295
1601
 
@@ -1305,11 +1611,11 @@ forbidden symbol sat in `.github/workflows/ci.yml`; the scanner and ripgrep also
1305
1611
  disagreed with each other, finding two matches and four on the same tree. Scope
1306
1612
  is now one policy that both engines are driven by, the skip list is four names
1307
1613
  long, and anything spec-guard could not inspect is reported rather than assumed
1308
- clean ([ADR-0007](docs/adr/0007-search-scope.md)).
1614
+ clean ([ADR-0007](https://github.com/DescentVTT/spec-guard/blob/main/docs/adr/0007-search-scope.md)).
1309
1615
 
1310
1616
  **Comments are excluded by default, and the exclusion is reported.** Counting
1311
1617
  the note that records a deletion as an occurrence of the thing deleted punishes
1312
- the documentation this tool exists to keep honest ([ADR-0006](docs/adr/0006-comment-classification.md)).
1618
+ the documentation this tool exists to keep honest ([ADR-0006](https://github.com/DescentVTT/spec-guard/blob/main/docs/adr/0006-comment-classification.md)).
1313
1619
  The reverse risk — a rule that quietly stops checking anything because every
1314
1620
  match now sits in a comment — is why every run says how many matches it dropped.
1315
1621
 
@@ -1326,23 +1632,25 @@ comment and string lexer that already existed, masking the source with it, and
1326
1632
  reading statements off what is left. Tree-sitter would have been the modern
1327
1633
  answer and costs 94 MB unpacked against this package's 0.33 MB; the four things
1328
1634
  a real parser would genuinely see that this cannot are listed in
1329
- [ADR-0008](docs/adr/0008-polyglot-imports.md) rather than glossed over.
1635
+ [ADR-0008](https://github.com/DescentVTT/spec-guard/blob/main/docs/adr/0008-polyglot-imports.md) rather than glossed over.
1330
1636
 
1331
1637
  **There is no `--fix`.** Every edit a machine can make to a failing boundary
1332
1638
  assertion is an edit that records the rule no longer holding: widen the bound,
1333
1639
  add an exclusion, append to the baseline, insert an ignore comment. A one-flag
1334
1640
  path from red to green is a bad button for a person and a much worse one for an
1335
1641
  agent whose loop terminates on a green build. `--print-baseline` prints what you
1336
- could paste; it does not paste it ([ADR-0009](docs/adr/0009-debt-baselines.md)).
1642
+ could paste; it does not paste it ([ADR-0009](https://github.com/DescentVTT/spec-guard/blob/main/docs/adr/0009-debt-baselines.md)).
1337
1643
 
1338
1644
  ## spec-guard checks itself
1339
1645
 
1340
- The invariants in [`docs/adr/0001-invariants.md`](docs/adr/0001-invariants.md)
1341
- and [`docs/adr/0002-directive-format.md`](docs/adr/0002-directive-format.md) are
1646
+ The invariants in [`docs/adr/0001-invariants.md`](https://github.com/DescentVTT/spec-guard/blob/main/docs/adr/0001-invariants.md)
1647
+ and [`docs/adr/0002-directive-format.md`](https://github.com/DescentVTT/spec-guard/blob/main/docs/adr/0002-directive-format.md) are
1342
1648
  executed against this repository on every CI run. The CLI never calls
1343
1649
  `console.log`, nothing outside the engine spawns a process, and the parser and
1344
1650
  reporter never touch the filesystem - because those documents say so, and the
1345
- build fails if they stop being true.
1651
+ build fails if they stop being true. CI proves every one of those rules can
1652
+ fail, and `spec-guard cites` holds every ADR a comment in the code cites to
1653
+ existing and being in force.
1346
1654
 
1347
1655
  This README is executable too:
1348
1656
 
@@ -1370,13 +1678,13 @@ prebuilt binary and is never a runtime dependency.
1370
1678
 
1371
1679
  Coverage says a line ran. It does not say an assertion would notice if the line
1372
1680
  behaved differently. This repository measures the difference: line coverage is
1373
- **100%**, and **99.01%** of 8,644 mutants are killed in CI, with 83 survivors and
1681
+ **100%**, and **99.20%** of 11,549 mutants are killed in CI, with 89 survivors and
1374
1682
  no file below 95%. The second number is the one worth reading, and what was done
1375
1683
  about the survivors matters more than the score. At 0.5.1 every surviving mutant
1376
1684
  was checked individually, and the 101 left then produced byte-identical output.
1377
1685
  Each release since has replayed the survivors its own code added before it
1378
1686
  shipped: they were killed, deleted as dead code, or shown equivalent and
1379
- recorded in [ADR-0003](docs/adr/0003-mutation-testing.md) with the evidence.
1687
+ recorded in [ADR-0003](https://github.com/DescentVTT/spec-guard/blob/main/docs/adr/0003-mutation-testing.md) with the evidence.
1380
1688
 
1381
1689
  That gap is the point. The first run scored 77.23%, and the weakest file was the
1382
1690
  reporter at 65.48% - not because it lacked tests, but because its tests were
@@ -1405,7 +1713,7 @@ applied one at a time and run through a fingerprint of 4,176 observations, and
1405
1713
  that only matters when a second statement follows, a type-only test that marks
1406
1714
  `import A, { B } from 'x'` type-only when loosened, a comment check that opens a
1407
1715
  block comment on `2*3`. All 29 are now tested and all 29 die. All of it is in
1408
- [ADR-0003](docs/adr/0003-mutation-testing.md), including a Stryker limitation
1716
+ [ADR-0003](https://github.com/DescentVTT/spec-guard/blob/main/docs/adr/0003-mutation-testing.md), including a Stryker limitation
1409
1717
  found on the way: a mutant that stops a test file *loading* is reported as
1410
1718
  survived even though the suite is in fact killing it.
1411
1719
 
@@ -1433,7 +1741,7 @@ suite takes hours against 43 minutes of hosted time, and it scores *higher*,
1433
1741
  because far more mutants hang there and Stryker counts a hang as a kill. Linux
1434
1742
  CI is the measurement.
1435
1743
 
1436
- Eight cautionary tales are in [ADR-0003](docs/adr/0003-mutation-testing.md): a
1744
+ Eight cautionary tales are in [ADR-0003](https://github.com/DescentVTT/spec-guard/blob/main/docs/adr/0003-mutation-testing.md): a
1437
1745
  run whose score was pure fiction because the mutants were never activated, a
1438
1746
  tuning knob that lifted the score six points without adding a test, the platform
1439
1747
  gap above, the baseline being re-anchored when a tokenizer arrived, and a score
@@ -1446,6 +1754,60 @@ mutants" sounded like judgement rather than an unchecked assumption - along with
1446
1754
  the real bugs that chasing the gate uncovered, and a class of mutant the runner
1447
1755
  reports as survived while the suite is in fact killing it.
1448
1756
 
1757
+ ### Releasing
1758
+
1759
+ A release is a tag push. Nothing is published from a laptop, and there is no
1760
+ npm token in this repository or in its secrets.
1761
+
1762
+ ```bash
1763
+ npm version minor # or patch / major - writes package.json and makes the tag
1764
+ git push origin main --follow-tags
1765
+ ```
1766
+
1767
+ [`release.yml`](https://github.com/DescentVTT/spec-guard/blob/main/.github/workflows/release.yml) then:
1768
+
1769
+ 1. refuses the release unless the tag, `package.json` and a `## <version>`
1770
+ heading in `CHANGELOG.md` all agree;
1771
+ 2. type checks, builds, runs the suite and executes this repository's own ADRs.
1772
+ A tag push does not run CI, so the release job runs those steps itself;
1773
+ 3. `npm pack`s the tarball, prints its file list and its SHA-256, and hands that
1774
+ exact file to the publishing job. Nothing is rebuilt in between, so what
1775
+ reaches the registry is the artifact the tests ran against;
1776
+ 4. stages it with `npm stage publish --provenance`. The job authenticates with a
1777
+ short-lived OIDC token that GitHub mints for `release.yml` running in the
1778
+ `npm` environment, and npmjs.com accepts it only for that combination - npm's
1779
+ trusted publishing, so there is no credential to leak or to rotate.
1780
+
1781
+ The publishing job installs no dependencies and checks nothing out. Everything
1782
+ that runs third-party code - `npm ci`, a postinstall that downloads a ripgrep
1783
+ binary, the test suite - happens in the earlier job, which has no token.
1784
+
1785
+ Staging is not publishing. The version sits on npmjs.com visible to maintainers
1786
+ and installable by nobody until:
1787
+
1788
+ ```bash
1789
+ npm stage list @descent-vtt/spec-guard
1790
+ npm stage view <stage-id>
1791
+ npm stage approve <stage-id> # asks for a second factor
1792
+ ```
1793
+
1794
+ `npm stage reject <stage-id>` discards it instead, and the tag can be deleted
1795
+ and remade. Approving needs 2FA and therefore a person: CI can build a release,
1796
+ but it cannot decide to publish one.
1797
+
1798
+ Two repository settings hold the other end of this. The `npm` environment
1799
+ accepts deployments only from `main` and from `v*` tags, so no branch can reach
1800
+ the publishing job. A `Release tags` ruleset lets only repository admins create,
1801
+ move or delete a `v*` tag - under this workflow, pushing one is the release, and
1802
+ an accidental push should not be able to start it.
1803
+
1804
+ `Actions -> Release -> Run workflow` with `dry_run` left on verifies, packs and
1805
+ hands the tarball to npm without spending a version number. It stops at the
1806
+ registry's version check - a dispatch runs from `main`, whose version is already
1807
+ published - and it does not exercise authentication, because the OIDC exchange
1808
+ happens only on a publish that intends to write. The first real tag is what
1809
+ proves that end.
1810
+
1449
1811
  ## Requirements
1450
1812
 
1451
1813
  - Node.js 22 or newer (native ESM)