@descent-vtt/spec-guard 0.10.3 → 0.12.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 (162) hide show
  1. package/CHANGELOG.md +410 -0
  2. package/README.md +385 -27
  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 +51 -2
  12. package/dist/comments.d.ts.map +1 -1
  13. package/dist/comments.js +353 -57
  14. package/dist/comments.js.map +1 -1
  15. package/dist/config.d.ts +3 -0
  16. package/dist/config.d.ts.map +1 -1
  17. package/dist/config.js +46 -5
  18. package/dist/config.js.map +1 -1
  19. package/dist/engine.d.ts.map +1 -1
  20. package/dist/engine.js +38 -14
  21. package/dist/engine.js.map +1 -1
  22. package/dist/glob.d.ts +128 -24
  23. package/dist/glob.d.ts.map +1 -1
  24. package/dist/glob.js +304 -61
  25. package/dist/glob.js.map +1 -1
  26. package/dist/impact.d.ts +179 -0
  27. package/dist/impact.d.ts.map +1 -0
  28. package/dist/impact.js +360 -0
  29. package/dist/impact.js.map +1 -0
  30. package/dist/imports.d.ts.map +1 -1
  31. package/dist/imports.js +5 -58
  32. package/dist/imports.js.map +1 -1
  33. package/dist/index.d.ts +13 -5
  34. package/dist/index.d.ts.map +1 -1
  35. package/dist/index.js +7 -3
  36. package/dist/index.js.map +1 -1
  37. package/dist/mcp.d.ts +16 -75
  38. package/dist/mcp.d.ts.map +1 -1
  39. package/dist/mcp.js +198 -370
  40. package/dist/mcp.js.map +1 -1
  41. package/dist/overlay.d.ts +39 -0
  42. package/dist/overlay.d.ts.map +1 -0
  43. package/dist/overlay.js +133 -0
  44. package/dist/overlay.js.map +1 -0
  45. package/dist/parser.d.ts +41 -24
  46. package/dist/parser.d.ts.map +1 -1
  47. package/dist/parser.js +217 -160
  48. package/dist/parser.js.map +1 -1
  49. package/dist/prove.d.ts +46 -0
  50. package/dist/prove.d.ts.map +1 -0
  51. package/dist/prove.js +549 -0
  52. package/dist/prove.js.map +1 -0
  53. package/dist/query.d.ts +11 -0
  54. package/dist/query.d.ts.map +1 -1
  55. package/dist/query.js +9 -3
  56. package/dist/query.js.map +1 -1
  57. package/dist/reporter.d.ts +139 -1
  58. package/dist/reporter.d.ts.map +1 -1
  59. package/dist/reporter.js +593 -21
  60. package/dist/reporter.js.map +1 -1
  61. package/dist/rules.js +3 -3
  62. package/dist/rules.js.map +1 -1
  63. package/dist/runner.d.ts +24 -1
  64. package/dist/runner.d.ts.map +1 -1
  65. package/dist/runner.js +84 -21
  66. package/dist/runner.js.map +1 -1
  67. package/dist/specs.d.ts +5 -1
  68. package/dist/specs.d.ts.map +1 -1
  69. package/dist/specs.js +5 -1
  70. package/dist/specs.js.map +1 -1
  71. package/dist/structure.d.ts.map +1 -1
  72. package/dist/structure.js +7 -6
  73. package/dist/structure.js.map +1 -1
  74. package/dist/types.d.ts +193 -0
  75. package/dist/types.d.ts.map +1 -1
  76. package/dist/vendor/spec-core/jsonrpc/index.d.ts +3 -0
  77. package/dist/vendor/spec-core/jsonrpc/index.d.ts.map +1 -0
  78. package/dist/vendor/spec-core/jsonrpc/index.js +2 -0
  79. package/dist/vendor/spec-core/jsonrpc/index.js.map +1 -0
  80. package/dist/vendor/spec-core/jsonrpc/mcp.d.ts +158 -0
  81. package/dist/vendor/spec-core/jsonrpc/mcp.d.ts.map +1 -0
  82. package/dist/vendor/spec-core/jsonrpc/mcp.js +353 -0
  83. package/dist/vendor/spec-core/jsonrpc/mcp.js.map +1 -0
  84. package/dist/vendor/spec-core/markdown/frontmatter.d.ts +104 -0
  85. package/dist/vendor/spec-core/markdown/frontmatter.d.ts.map +1 -0
  86. package/dist/vendor/spec-core/markdown/frontmatter.js +459 -0
  87. package/dist/vendor/spec-core/markdown/frontmatter.js.map +1 -0
  88. package/dist/vendor/spec-core/markdown/headings.d.ts +46 -0
  89. package/dist/vendor/spec-core/markdown/headings.d.ts.map +1 -0
  90. package/dist/vendor/spec-core/markdown/headings.js +199 -0
  91. package/dist/vendor/spec-core/markdown/headings.js.map +1 -0
  92. package/dist/vendor/spec-core/markdown/index.d.ts +6 -0
  93. package/dist/vendor/spec-core/markdown/index.d.ts.map +1 -0
  94. package/dist/vendor/spec-core/markdown/index.js +4 -0
  95. package/dist/vendor/spec-core/markdown/index.js.map +1 -0
  96. package/dist/vendor/spec-core/markdown/layout.d.ts +40 -0
  97. package/dist/vendor/spec-core/markdown/layout.d.ts.map +1 -0
  98. package/dist/vendor/spec-core/markdown/layout.js +28 -0
  99. package/dist/vendor/spec-core/markdown/layout.js.map +1 -0
  100. package/dist/vendor/spec-core/markdown/links.d.ts +23 -0
  101. package/dist/vendor/spec-core/markdown/links.d.ts.map +1 -0
  102. package/dist/vendor/spec-core/markdown/links.js +364 -0
  103. package/dist/vendor/spec-core/markdown/links.js.map +1 -0
  104. package/dist/vendor/spec-core/markdown/lists.d.ts +23 -0
  105. package/dist/vendor/spec-core/markdown/lists.d.ts.map +1 -0
  106. package/dist/vendor/spec-core/markdown/lists.js +127 -0
  107. package/dist/vendor/spec-core/markdown/lists.js.map +1 -0
  108. package/dist/vendor/spec-core/markdown/scan.d.ts +37 -0
  109. package/dist/vendor/spec-core/markdown/scan.d.ts.map +1 -0
  110. package/dist/vendor/spec-core/markdown/scan.js +439 -0
  111. package/dist/vendor/spec-core/markdown/scan.js.map +1 -0
  112. package/dist/vendor/spec-core/markdown/syntax.d.ts +76 -0
  113. package/dist/vendor/spec-core/markdown/syntax.d.ts.map +1 -0
  114. package/dist/vendor/spec-core/markdown/syntax.js +181 -0
  115. package/dist/vendor/spec-core/markdown/syntax.js.map +1 -0
  116. package/dist/vendor/spec-core/markdown/tables.d.ts +19 -0
  117. package/dist/vendor/spec-core/markdown/tables.d.ts.map +1 -0
  118. package/dist/vendor/spec-core/markdown/tables.js +86 -0
  119. package/dist/vendor/spec-core/markdown/tables.js.map +1 -0
  120. package/dist/vendor/spec-core/markdown/types.d.ts +288 -0
  121. package/dist/vendor/spec-core/markdown/types.d.ts.map +1 -0
  122. package/dist/vendor/spec-core/markdown/types.js +9 -0
  123. package/dist/vendor/spec-core/markdown/types.js.map +1 -0
  124. package/dist/vendor/spec-core/path/index.d.ts +2 -0
  125. package/dist/vendor/spec-core/path/index.d.ts.map +1 -0
  126. package/dist/vendor/spec-core/path/index.js +2 -0
  127. package/dist/vendor/spec-core/path/index.js.map +1 -0
  128. package/dist/vendor/spec-core/path/posix.d.ts +84 -0
  129. package/dist/vendor/spec-core/path/posix.d.ts.map +1 -0
  130. package/dist/vendor/spec-core/path/posix.js +150 -0
  131. package/dist/vendor/spec-core/path/posix.js.map +1 -0
  132. package/dist/vendor/spec-core/pattern/automaton.d.ts +130 -0
  133. package/dist/vendor/spec-core/pattern/automaton.d.ts.map +1 -0
  134. package/dist/vendor/spec-core/pattern/automaton.js +340 -0
  135. package/dist/vendor/spec-core/pattern/automaton.js.map +1 -0
  136. package/dist/vendor/spec-core/pattern/charset.d.ts +59 -0
  137. package/dist/vendor/spec-core/pattern/charset.d.ts.map +1 -0
  138. package/dist/vendor/spec-core/pattern/charset.js +116 -0
  139. package/dist/vendor/spec-core/pattern/charset.js.map +1 -0
  140. package/dist/vendor/spec-core/pattern/glob.d.ts +122 -0
  141. package/dist/vendor/spec-core/pattern/glob.d.ts.map +1 -0
  142. package/dist/vendor/spec-core/pattern/glob.js +481 -0
  143. package/dist/vendor/spec-core/pattern/glob.js.map +1 -0
  144. package/dist/vendor/spec-core/pattern/index.d.ts +7 -0
  145. package/dist/vendor/spec-core/pattern/index.d.ts.map +1 -0
  146. package/dist/vendor/spec-core/pattern/index.js +4 -0
  147. package/dist/vendor/spec-core/pattern/index.js.map +1 -0
  148. package/dist/vendor/spec-core/pattern/regex.d.ts +68 -0
  149. package/dist/vendor/spec-core/pattern/regex.d.ts.map +1 -0
  150. package/dist/vendor/spec-core/pattern/regex.js +813 -0
  151. package/dist/vendor/spec-core/pattern/regex.js.map +1 -0
  152. package/dist/vendor/spec-core/text/index.d.ts +3 -0
  153. package/dist/vendor/spec-core/text/index.d.ts.map +1 -0
  154. package/dist/vendor/spec-core/text/index.js +2 -0
  155. package/dist/vendor/spec-core/text/index.js.map +1 -0
  156. package/dist/vendor/spec-core/text/lines.d.ts +73 -0
  157. package/dist/vendor/spec-core/text/lines.d.ts.map +1 -0
  158. package/dist/vendor/spec-core/text/lines.js +156 -0
  159. package/dist/vendor/spec-core/text/lines.js.map +1 -0
  160. package/package.json +8 -1
  161. package/src/vendor/spec-core/LICENSE +21 -0
  162. 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
 
@@ -206,13 +222,16 @@ touching code, a run that passed this way says so:
206
222
  ✔ every spec assertion holds
207
223
  ```
208
224
 
209
- Comment syntax is known for around 68 extensions across 10 families (JS/TS, C,
210
- C#, Rust, Go, Python-style `#`, shell and YAML `#`, SQL-style `--`, markup -
225
+ Comment syntax is known for around 68 extensions across 11 families (JS/TS, C,
226
+ C#, Rust, Go, Python-style `#`, shell `#`, YAML, SQL-style `--`, markup -
211
227
  MSBuild project files included - and formats with no comments at all). Strings
212
228
  are tracked too, because `//` inside a URL is not a comment and reading it as
213
- one would hide real code. So is what only looks like either: a Rust lifetime
214
- (`&'static str`), a C++ digit separator (`100'000`) and a `#` inside a shell
215
- word (`${#items[@]}`) open nothing. Where spec-guard is unsure —
229
+ one would hide real code. So are JavaScript's regular expressions, because the
230
+ quote in `/["']/` is not a quote. And so is what only looks like either: a Rust
231
+ lifetime (`&'static str`), a C++ digit separator (`100'000`), a `#` inside a
232
+ shell word (`${#items[@]}`), an apostrophe in a YAML value (`name: it's fine`)
233
+ and a `/*` in JSX text (`<div>/*</div>`) all open nothing. Where spec-guard is
234
+ unsure —
216
235
  an unknown extension, an unterminated literal — the text counts as code, and the
217
236
  report says which files it could not classify. A match wrongly kept is a visible
218
237
  failure you can argue with; a match wrongly dropped is a lie.
@@ -733,8 +752,11 @@ long before anyone deletes it - because deleting it deletes the reason a
733
752
  decision was made.
734
753
 
735
754
  spec-guard reads that status from the document and honours it. A document whose
736
- status is `draft`, `proposed`, `rejected`, `deprecated` or `superseded` is
737
- 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.
738
760
 
739
761
  Three spellings are recognised, because three are in use:
740
762
 
@@ -757,7 +779,13 @@ Accepted (0.3.0).
757
779
  ```
758
780
 
759
781
  Front-matter wins, then the `## Status` section, then the bold label (which is
760
- 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
761
789
  misspelled `Supersedded`, or no status at all - keeps enforcing. That asymmetry
762
790
  is deliberate: an unanticipated word that keeps enforcing is a visible failure
763
791
  with an obvious fix, while one that stops enforcing is a green build over a rule
@@ -869,6 +897,12 @@ to its checks.
869
897
  | --- | --- |
870
898
  | `get_architectural_rules(path, include_inactive?)` | The query above, as text and as structured content |
871
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.
872
906
 
873
907
  | Resource | What it holds |
874
908
  | --- | --- |
@@ -881,6 +915,174 @@ version on every request and probe with `server/discover`. Nothing is cached, so
881
915
  an ADR edited mid-session is read as edited. ADR-0012 has the shapes, the
882
916
  sources they follow, and what is deliberately not implemented.
883
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](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](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](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](docs/adr/0018-impact.md) has the table and what it leaves out.
1085
+
884
1086
  ## CLI
885
1087
 
886
1088
  ```bash
@@ -888,6 +1090,9 @@ spec-guard [patterns...] [options] # execute the directives
888
1090
  spec-guard --watch [patterns...] # execute them again whenever the tree changes
889
1091
  spec-guard query <paths...> [options] # the rules in force for files or directories
890
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
891
1096
  ```
892
1097
 
893
1098
  | Option | Description |
@@ -897,8 +1102,8 @@ spec-guard mcp [options] # serve the rules over MCP on stdio
897
1102
  | `-v, --verbose` | Print passing assertions too |
898
1103
  | `--watch` | Report, then report again as the tree changes, until Ctrl+C |
899
1104
  | `--fail-fast` | Stop at the first failing assertion |
900
- | `--json` | Machine-readable report on stdout (same as `--format json`) |
901
- | `--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 |
902
1107
  | `--engine <auto\|rg\|js>` | Search engine (default `auto`: scanner for small trees, ripgrep for big ones) |
903
1108
  | `--strict` / `--no-strict` | Treat analysis that could not be completed as a failure |
904
1109
  | `--allow-missing-targets` / `--no-allow-missing-targets` | Warn instead of failing when a `target` path does not exist |
@@ -906,11 +1111,12 @@ spec-guard mcp [options] # serve the rules over MCP on stdio
906
1111
  | `--print-baseline` | Print the `baseline="..."` that would exempt today's violations, and exit |
907
1112
  | `--no-default-skips` / `--default-skips` | Search `.git`, `.hg`, `.svn` and `node_modules` too |
908
1113
  | `--exclude <globs>` | Paths no assertion looks at, beside each directive's own `exclude`. Repeatable; replaces the configuration's list, and `--exclude=` clears it |
909
- | `--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 |
910
1115
  | `--include-specs` / `--no-include-specs` | Also count matches inside the spec files themselves |
911
1116
  | `--max-snippets <n>` | Failure snippets per assertion (default 5) |
912
1117
  | `--concurrency <n>` | Search passes in flight at once (default 8) |
913
- | `--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) |
914
1120
  | `--color` / `--no-color` | Force colour on or off (`NO_COLOR` honoured) |
915
1121
 
916
1122
  Patterns are expanded by spec-guard itself, so quoted globs behave identically
@@ -955,6 +1161,7 @@ same options, at the top level, in `.spec-guard.json`:
955
1161
  | `strict`, `allowMissingTargets`, `allowEmptyScope`, `ignoreStatus`, `includeSpecs`, `defaultSkips` | the flag of that name | `true` or `false` |
956
1162
  | `maxSnippets` | `--max-snippets` | an integer, 0 or more |
957
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 |
958
1165
 
959
1166
  - **Only the root's files** are read: the `--root` directory, or the working
960
1167
  directory. Nothing is inherited from a parent directory.
@@ -1042,8 +1249,8 @@ symbolic link, and anything outside the root. CI stays the authority.
1042
1249
  | Code | Meaning |
1043
1250
  | --- | --- |
1044
1251
  | `0` | Every assertion held |
1045
- | `1` | An assertion failed, or a directive was malformed |
1046
- | `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 |
1047
1254
  | `130` | A `--watch` session was stopped |
1048
1255
 
1049
1256
  ## CI integration
@@ -1101,6 +1308,43 @@ where the fix goes. Alerts carry a fingerprint derived from what the assertion
1101
1308
  is about rather than where its matches landed, so inserting a line above a
1102
1309
  violation does not close the alert and open a new one.
1103
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
+
1104
1348
  **There is no language server, and that is a decision rather than a gap.**
1105
1349
  spec-guard's claims are about a whole repository - "this symbol appears nowhere
1106
1350
  in `src`" - and a language server is handed one buffer at a time. Answering a
@@ -1118,9 +1362,11 @@ parser ──▶ Directive[] ──▶ runner ──▶ Assertion[] ──▶ e
1118
1362
  (pure) (I/O) (rg | js) (pure)
1119
1363
  ```
1120
1364
 
1121
- 1. **Parse.** Markdown is scanned for `<!-- @assert-* -->` comments. Fenced code
1122
- blocks and inline code spans are masked first, so documentation that shows
1123
- 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.
1124
1370
  2. **Resolve.** Each directive becomes a typed assertion. Bad numbers, unknown
1125
1371
  booleans, unknown attributes and paths escaping `--root` are rejected here,
1126
1372
  before any I/O.
@@ -1235,6 +1481,39 @@ observed count, match locations, warnings and timings. The parser
1235
1481
  (`parseDirectives`) and reporter (`formatReport`, `formatJson`) are pure
1236
1482
  functions you can use on their own.
1237
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
+
1238
1517
  ## Design decisions
1239
1518
 
1240
1519
  This tool was specified loosely and built opinionatedly. Where the
@@ -1249,11 +1528,27 @@ themselves stay uncounted, because a directive is an HTML comment and a rule
1249
1528
  whose own text trips it can never be satisfied. `comments="include"` counts even
1250
1529
  those.
1251
1530
 
1252
- **Fenced code and inline code are masked before parsing.** A README documenting
1253
- the syntax must not execute it. Masking preserves byte offsets, so reported line
1254
- numbers stay exact. (This is subtle: pairing backtick runs the naive way
1255
- desynchronises after a stray unmatched run and un-masks real prose. spec-guard
1256
- uses CommonMark's equal-length pairing rule, and there is a regression test.)
1531
+ **Code is masked before parsing.** A README documenting the syntax must not
1532
+ execute it. What is code is what CommonMark says it is, decided by
1533
+ [spec-core](https://github.com/DescentVTT/spec-core)'s Markdown scanner, which
1534
+ every spec-* tool reads documents with and which is copied into
1535
+ `src/vendor/spec-core` and verified by hash: fenced and indented code, code
1536
+ spans, `<script>`, `<pre>`, `<style>` and `<textarea>` blocks, and front matter.
1537
+ Comments and code spans are resolved left to right in one pass, so a backtick
1538
+ inside a comment is a character and `<!--` inside a code span is code, and a
1539
+ code span ends with its paragraph, so a stray backtick cannot hide the
1540
+ directives after it. A fence may be indented with the list item it sits in.
1541
+ Masking preserves every offset and line terminator, so reported line numbers
1542
+ stay exact. [ADR-0002](docs/adr/0002-directive-format.md)'s amendment lists
1543
+ where this differs from 0.11.0.
1544
+
1545
+ What is masked is still counted. A comment shaped like a directive -
1546
+ `<!-- @assert-...` - in code, raw HTML or front matter is not run, and the
1547
+ report says how many there were and where, on one line (every one under
1548
+ `--verbose`, and in `maskedDirectives` in JSON), so text read as code by
1549
+ accident is not a rule gone quiet without a word. A code fence or raw-text
1550
+ block that is never closed, which makes the rest of its document code, is a
1551
+ warning on its opening line.
1257
1552
 
1258
1553
  **A document's lifecycle status is read; a directive's is not.** Status is
1259
1554
  document-level and visible in every rendered Markdown view. A per-directive
@@ -1287,6 +1582,13 @@ anything - it answers only *which files contain this text*, and the scanner
1287
1582
  counts each pattern separately over the shared file contents. The batching
1288
1583
  checks were deleted along with the risk they guarded.
1289
1584
 
1585
+ **Globs are matched by an automaton, not a regular expression.** Every
1586
+ pattern - `glob`, `exclude`, `module`, a layer, a spec pattern - is read by the
1587
+ glob engine the spec-* tools share, copied from spec-core and checked by hash.
1588
+ A `RegExp` backtracks: `*-*-*-*-*-*x` took 55 seconds to fail one long file
1589
+ name. The automaton cannot, and ripgrep is handed each pattern spelled as the
1590
+ automaton reads it ([ADR-0015](docs/adr/0015-globs-from-spec-core.md)).
1591
+
1290
1592
  **Exit code 2 exists.** "Your specs failed" and "spec-guard could not run" are
1291
1593
  different facts, and CI should be able to tell them apart.
1292
1594
 
@@ -1339,7 +1641,9 @@ and [`docs/adr/0002-directive-format.md`](docs/adr/0002-directive-format.md) are
1339
1641
  executed against this repository on every CI run. The CLI never calls
1340
1642
  `console.log`, nothing outside the engine spawns a process, and the parser and
1341
1643
  reporter never touch the filesystem - because those documents say so, and the
1342
- build fails if they stop being true.
1644
+ build fails if they stop being true. CI proves every one of those rules can
1645
+ fail, and `spec-guard cites` holds every ADR a comment in the code cites to
1646
+ existing and being in force.
1343
1647
 
1344
1648
  This README is executable too:
1345
1649
 
@@ -1367,7 +1671,7 @@ prebuilt binary and is never a runtime dependency.
1367
1671
 
1368
1672
  Coverage says a line ran. It does not say an assertion would notice if the line
1369
1673
  behaved differently. This repository measures the difference: line coverage is
1370
- **100%**, and **99.00%** of 8,420 mutants are killed in CI, with 81 survivors and
1674
+ **100%**, and **99.20%** of 11,537 mutants are killed in CI, with 89 survivors and
1371
1675
  no file below 95%. The second number is the one worth reading, and what was done
1372
1676
  about the survivors matters more than the score. At 0.5.1 every surviving mutant
1373
1677
  was checked individually, and the 101 left then produced byte-identical output.
@@ -1443,6 +1747,60 @@ mutants" sounded like judgement rather than an unchecked assumption - along with
1443
1747
  the real bugs that chasing the gate uncovered, and a class of mutant the runner
1444
1748
  reports as survived while the suite is in fact killing it.
1445
1749
 
1750
+ ### Releasing
1751
+
1752
+ A release is a tag push. Nothing is published from a laptop, and there is no
1753
+ npm token in this repository or in its secrets.
1754
+
1755
+ ```bash
1756
+ npm version minor # or patch / major - writes package.json and makes the tag
1757
+ git push origin main --follow-tags
1758
+ ```
1759
+
1760
+ [`release.yml`](.github/workflows/release.yml) then:
1761
+
1762
+ 1. refuses the release unless the tag, `package.json` and a `## <version>`
1763
+ heading in `CHANGELOG.md` all agree;
1764
+ 2. type checks, builds, runs the suite and executes this repository's own ADRs.
1765
+ A tag push does not run CI, so the release job runs those steps itself;
1766
+ 3. `npm pack`s the tarball, prints its file list and its SHA-256, and hands that
1767
+ exact file to the publishing job. Nothing is rebuilt in between, so what
1768
+ reaches the registry is the artifact the tests ran against;
1769
+ 4. stages it with `npm stage publish --provenance`. The job authenticates with a
1770
+ short-lived OIDC token that GitHub mints for `release.yml` running in the
1771
+ `npm` environment, and npmjs.com accepts it only for that combination - npm's
1772
+ trusted publishing, so there is no credential to leak or to rotate.
1773
+
1774
+ The publishing job installs no dependencies and checks nothing out. Everything
1775
+ that runs third-party code - `npm ci`, a postinstall that downloads a ripgrep
1776
+ binary, the test suite - happens in the earlier job, which has no token.
1777
+
1778
+ Staging is not publishing. The version sits on npmjs.com visible to maintainers
1779
+ and installable by nobody until:
1780
+
1781
+ ```bash
1782
+ npm stage list @descent-vtt/spec-guard
1783
+ npm stage view <stage-id>
1784
+ npm stage approve <stage-id> # asks for a second factor
1785
+ ```
1786
+
1787
+ `npm stage reject <stage-id>` discards it instead, and the tag can be deleted
1788
+ and remade. Approving needs 2FA and therefore a person: CI can build a release,
1789
+ but it cannot decide to publish one.
1790
+
1791
+ Two repository settings hold the other end of this. The `npm` environment
1792
+ accepts deployments only from `main` and from `v*` tags, so no branch can reach
1793
+ the publishing job. A `Release tags` ruleset lets only repository admins create,
1794
+ move or delete a `v*` tag - under this workflow, pushing one is the release, and
1795
+ an accidental push should not be able to start it.
1796
+
1797
+ `Actions -> Release -> Run workflow` with `dry_run` left on verifies, packs and
1798
+ hands the tarball to npm without spending a version number. It stops at the
1799
+ registry's version check - a dispatch runs from `main`, whose version is already
1800
+ published - and it does not exercise authentication, because the OIDC exchange
1801
+ happens only on a publish that intends to write. The first real tag is what
1802
+ proves that end.
1803
+
1446
1804
  ## Requirements
1447
1805
 
1448
1806
  - Node.js 22 or newer (native ESM)