@descent-vtt/spec-graph 0.9.10 → 0.11.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 (39) hide show
  1. package/CHANGELOG.md +86 -2
  2. package/README.md +468 -313
  3. package/dist/cli.d.ts +2 -2
  4. package/dist/cli.js +112 -67
  5. package/dist/cli.js.map +1 -1
  6. package/dist/extract.js +66 -6
  7. package/dist/extract.js.map +1 -1
  8. package/dist/glob.d.ts +11 -0
  9. package/dist/glob.js +17 -1
  10. package/dist/glob.js.map +1 -1
  11. package/dist/index.d.ts +1 -1
  12. package/dist/index.js +1 -1
  13. package/dist/index.js.map +1 -1
  14. package/dist/lifecycle.js +5 -2
  15. package/dist/lifecycle.js.map +1 -1
  16. package/dist/report.d.ts +22 -0
  17. package/dist/report.js +50 -3
  18. package/dist/report.js.map +1 -1
  19. package/dist/sections.d.ts +7 -1
  20. package/dist/sections.js +9 -1
  21. package/dist/sections.js.map +1 -1
  22. package/dist/vendor/spec-core/markdown/frontmatter.d.ts +11 -1
  23. package/dist/vendor/spec-core/markdown/frontmatter.js +20 -3
  24. package/dist/vendor/spec-core/markdown/frontmatter.js.map +1 -1
  25. package/dist/vendor/spec-core/pattern/glob.d.ts +85 -0
  26. package/dist/vendor/spec-core/pattern/glob.js +207 -17
  27. package/dist/vendor/spec-core/pattern/glob.js.map +1 -1
  28. package/dist/vendor/spec-core/pattern/index.d.ts +2 -2
  29. package/dist/vendor/spec-core/pattern/index.js +1 -1
  30. package/dist/vendor/spec-core/pattern/index.js.map +1 -1
  31. package/dist/vendor/spec-core/text/index.d.ts +1 -0
  32. package/dist/vendor/spec-core/text/index.js +1 -0
  33. package/dist/vendor/spec-core/text/index.js.map +1 -1
  34. package/dist/vendor/spec-core/text/width.d.ts +30 -0
  35. package/dist/vendor/spec-core/text/width.js +113 -0
  36. package/dist/vendor/spec-core/text/width.js.map +1 -0
  37. package/dist/yaml.js +6 -2
  38. package/dist/yaml.js.map +1 -1
  39. package/package.json +1 -1
package/README.md CHANGED
@@ -17,6 +17,12 @@ checked.
17
17
 
18
18
  Exit code 1. The obligation was about to disappear.
19
19
 
20
+ The terms the spec-* tools share are defined in the family
21
+ [glossary](https://github.com/DescentVTT/spec-core/blob/main/docs/concepts.md),
22
+ and the [tutorial](https://github.com/DescentVTT/spec-core/blob/main/docs/tutorial.md)
23
+ takes one small repository through a round of work, from the brief to the
24
+ archive, in ten steps.
25
+
20
26
  ---
21
27
 
22
28
  ## Why this exists
@@ -104,11 +110,13 @@ spec-graph --strict
104
110
  ```
105
111
 
106
112
  Every escalated finding says so, so you can always tell what the build would do
107
- without the flag. Two deliberate limits: strict leaves `info` rules alone, since
108
- those are advisory by design and promoting them would reintroduce exactly the
109
- noise the defaults avoid; and an explicit `--rule` always wins, so
110
- `--strict --rule state-conflict=warn` turns strict on and exempts one rule
111
- rather than making you choose between all of it and none.
113
+ without the flag. Two deliberate limits:
114
+
115
+ - Strict leaves `info` rules alone. Those are advisory by design, and promoting
116
+ them would reintroduce exactly the noise the defaults avoid.
117
+ - An explicit `--rule` always wins, so `--strict --rule state-conflict=warn`
118
+ turns strict on and exempts one rule rather than making you choose between
119
+ all of it and none.
112
120
 
113
121
  ## How it reads your documents
114
122
 
@@ -125,11 +133,19 @@ onto four phases, chosen so one question can be answered from the phase alone:
125
133
  | `frozen` | `final`, `ratified`, `locked`, `published`, … | **no** |
126
134
  | `retired` | `superseded`, `deprecated`, `rejected`, `withdrawn`, … | **no** |
127
135
 
128
- The status is found wherever your team writes it — front matter (`status:`,
129
- `state:`, `stage:`), a `## Status` section, `**Accepted** (2026-03-01) ✅`, or a
130
- `docs/adr/archive/` directory. `Accepted, later superseded by ADR-0009` is
131
- retired: retirement is terminal, so a retirement word anywhere wins. Front
132
- matter is read as YAML reads it, and a value YAML would not read -
136
+ The status is found wherever your team writes it, and however it is dressed
137
+ (`**Accepted** (2026-03-01) ✅`). A document may be written in any language; its
138
+ status is read in English (`status: superseded`):
139
+
140
+ - front matter: `status:`, `state:`, `stage:`
141
+ - a `## Status` section
142
+ - a `| Status | Accepted |` table at the top
143
+ - a `docs/adr/archive/` directory
144
+
145
+ `Accepted, later superseded by ADR-0009` is retired: retirement is terminal, so
146
+ a retirement word anywhere wins.
147
+
148
+ Front matter is read as YAML reads it, and a value YAML would not read -
133
149
  `status: Superseded by ADR-9: see notes`, where a plain value cannot hold `: ` -
134
150
  is left out rather than guessed at. Every run says how many statuses, ids and
135
151
  relations it left out that way, and `--verbose` says where and why: quote the
@@ -155,18 +171,33 @@ that `provisionally accepted` is a draft.
155
171
  | Where | Read from |
156
172
  | --- | --- |
157
173
  | Front matter, the first key present | `status`, `state`, `stage`, `lifecycle`, `phase`, `adr-status` |
158
- | A section, under the heading | `Status`, `State`, `Stage`, `Lifecycle`, `Current status` |
174
+ | A section, under the heading, with or without a colon | `Status`, `State`, `Stage`, `Lifecycle`, `Current status` |
175
+ | A two-column table above the first `##`, in the cell right of the first key in the left column, the header row included | `Status`, `State` |
159
176
  | A directory, for a document that declares no status | `archive`, `archived`, `attic`, `deprecated`, `graveyard`, `historical`, `obsolete`, `rejected`, `retired`, `superseded`, `superceded`, `withdrawn` |
160
177
 
178
+ The table is where many teams put a document's fields, `| Status | Accepted |`:
179
+
180
+ - It is read where a `## Status` section is, after one: a document with both is
181
+ read from the section, and front matter still wins over either.
182
+ - A key is compared whole, in any case, emphasis stripped.
183
+ - A table with more columns, one under a heading below the title, and one
184
+ spec-graph reads as a register are never read for the document's status: a
185
+ legend or a register describes other things.
186
+
161
187
  </details>
162
188
 
163
189
  `archived` is none of the four. It is what spec-brief writes on a round of work
164
- it has closed, and every spec-* tool reads it as a **record**: depending on it
165
- is normal, its unticked boxes are not anyone's work, its links are still
166
- checked, and new work handed to it is still a ghost handover. See
167
- [Journals, changelogs and minutes](#journals-changelogs-and-minutes). Unlike a
168
- journal, it still answers for what it claims: a supersession it declares, a
190
+ it has closed, and every spec-* tool reads it as a **record**:
191
+
192
+ - depending on it is normal;
193
+ - its unticked boxes are not anyone's work;
194
+ - its links are still checked;
195
+ - new work handed to it is still a ghost handover.
196
+
197
+ See [Journals, changelogs and minutes](#journals-changelogs-and-minutes). Unlike
198
+ a journal, it still answers for what it claims: a supersession it declares, a
169
199
  cycle it closes and a link to itself are reported as they would be anywhere.
200
+
170
201
  To retire a decision, say `superseded` or `deprecated` -
171
202
  `archived, superseded by ADR-0009` is retired - and a document under an
172
203
  `archive/` directory that says nothing about itself is still retired.
@@ -222,26 +253,31 @@ code. *"We resolved to keep the queue"* does not close anything.
222
253
  | `rejected` | `[-]` | `rejected`, `declined`, `dropped`, `not doing`, `will not do`, `abandoned`, `cancelled`, `canceled`, `withdrawn` | `rejected`, `declined` |
223
254
  | `obviated` | | `no longer applicable`, `no longer relevant`, `no longer needed`, `overtaken by events`, `premise invalid`, `obviated`, `obsolete`, `not applicable`, `moot`, `void`, `n/a`, `obe` | `obviated`, `moot` |
224
255
 
225
- A marker counts where a statement starts: at the start of the item or of a line,
226
- after the end of a sentence, or after `(` or `[`. Punctuated means followed by
227
- `:`, `:`, `-`, `–`, `—` or `.`, so `Done.` closes an item; emphasised means the
228
- emphasis closes on the marker, so `**Closed beta**` does not. A marker of more
229
- than one word may be spaced, hyphenated or joined by an underscore, whichever
230
- the table has: `**Tech-debt:**` and `TECH_DEBT:` are `tech debt`. An apostrophe
231
- may be straight or curly.
256
+ Where a marker counts, and how it may be written:
257
+
258
+ - A marker counts where a statement starts: at the start of the item or of a
259
+ line, after the end of a sentence, or after `(` or `[`.
260
+ - Punctuated means followed by `:`, `:`, `-`, `–`, `—` or `.`, so `Done.`
261
+ closes an item; emphasised means the emphasis closes on the marker, so
262
+ `**Closed beta**` does not.
263
+ - A marker of more than one word may be spaced, hyphenated or joined by an
264
+ underscore, whichever the table has: `**Tech-debt:**` and `TECH_DEBT:` are
265
+ `tech debt`. An apostrophe may be straight or curly.
232
266
 
233
267
  </details>
234
268
 
235
269
  Without a box, a bullet is an obligation only at the top level of a section
236
270
  whose heading names open work, however the heading is cased, spaced, emphasised
237
271
  or punctuated - `## ❓ **Open questions:**` is one. The section ends at the next
238
- heading of its level. **Headings that hold obligations:** `Open Questions`,
239
- `Open Question`, `Unresolved Questions`, `Unanswered Questions`, `Questions`,
240
- `Open Issues`, `Action Items`, `Actions`, `TODO`, `To Do`, `To-Do`, `TODOs`,
241
- `Next Steps`, `Follow Ups`, `Follow-Ups`, `Followups`, `Follow Up`, `Follow-Up`,
272
+ heading of its level. Every other bullet is the document's own prose.
273
+
274
+ **Headings that hold obligations:** `Open Questions`, `Open Question`,
275
+ `Unresolved Questions`, `Unanswered Questions`, `Questions`, `Open Issues`,
276
+ `Action Items`, `Actions`, `TODO`, `To Do`, `To-Do`, `TODOs`, `Next Steps`,
277
+ `Follow Ups`, `Follow-Ups`, `Followups`, `Follow Up`, `Follow-Up`,
242
278
  `Unresolved`, `Outstanding`, `Outstanding Questions`, `Tasks`, `Task List`,
243
279
  `Work Items`, `Remaining Work`, `Decisions Needed`, `Blockers`, `Parking Lot`,
244
- `Future Work`, `Deferred`. Every other bullet is the document's own prose.
280
+ `Future Work`, `Deferred`.
245
281
 
246
282
  ### References, resolved forgivingly and validated strictly
247
283
 
@@ -260,15 +296,16 @@ trusts:
260
296
  - **Bare identifiers in prose are opportunistic.** They are reported only when
261
297
  their family already exists in the corpus. `ADR-0099` in a repository of ADRs
262
298
  is a real dangling citation; `SHA-256` in the same repository is a sentence.
263
- A one-letter prefix is never a family - `Q3`, `x86`, `p99` - and nor are the
264
- prefixes of versions, pages, protocols and platforms, even in a repository
265
- that has a family of that name. **Never a citation:** `ver 3`, `version 4`,
266
- `pp.12`, `Fig.3`, `Figure 4`, `Table 2`, `tbl 1`, `section 4`, `sect 2`,
267
- `sec 5`, `step 2`, `item 3`, `No.5`, `num 7`, `line 42`, `ln 12`, `col 3`,
268
- `port 8080`, `PR 45`, `issue 42`, `GH-123`, `UTF-8`, `ASCII 7`, `SHA-256`,
269
- `MD5`, `HTTP 2`, `HTTPS 2`, `IPv6`, `IPv4 32`, `IPv6 128`, `TLS 1.3`,
270
- `SSL 3`, `ES2015`, `ECMA-262`, `p99 250`, `base64`, `SQL-92`, `ARM64`,
271
- `x86-64`, `Win32`, `Node 22`, `Python 3`, `Java 21`, `Go 1.22`, `cpp20`.
299
+ - A one-letter prefix is never a family - `Q3`, `x86`, `p99` - and nor are
300
+ the prefixes of versions, pages, protocols and platforms, even in a
301
+ repository that has a family of that name.
302
+ - **Never a citation:** `ver 3`, `version 4`, `pp.12`, `Fig.3`, `Figure 4`,
303
+ `Table 2`, `tbl 1`, `section 4`, `sect 2`, `sec 5`, `step 2`, `item 3`,
304
+ `No.5`, `num 7`, `line 42`, `ln 12`, `col 3`, `port 8080`, `PR 45`,
305
+ `issue 42`, `GH-123`, `UTF-8`, `ASCII 7`, `SHA-256`, `MD5`, `HTTP 2`,
306
+ `HTTPS 2`, `IPv6`, `IPv4 32`, `IPv6 128`, `TLS 1.3`, `SSL 3`, `ES2015`,
307
+ `ECMA-262`, `p99 250`, `base64`, `SQL-92`, `ARM64`, `x86-64`, `Win32`,
308
+ `Node 22`, `Python 3`, `Java 21`, `Go 1.22`, `cpp20`.
272
309
  - **Bare numbers are family-scoped.** `0007` inside an RFC means `RFC-0007`,
273
310
  never `ADR-0007`. A repository with both is ordinary, and guessing would be
274
311
  worse than silence. A document's family is the prefix of its identifier, or
@@ -283,66 +320,20 @@ trusts:
283
320
  so `[ADR-7](https://example.com/adr-7)` yields one reference rather than three.
284
321
  - **Source links are not spec links.** `[rules.ts](../../src/rules.ts)` is not a
285
322
  broken reference. That is [spec-guard](#relationship-to-spec-guard)'s job.
286
- Nor is a link to any other file that is not a document: an image, a dotfile
287
- such as `../.nvmrc`, or a drawing such as `arch.excalidraw`, however long its
288
- extension. A dot followed by a digit, as in `ADR-0007.1`, is part of an
289
- identifier and is still resolved. A link to a directory is not a reference
290
- either, `../guides/` or `../guides` alike - without the slash the disk says
291
- which it is, and a name with no slash at all, `guides`, is an identifier.
292
-
293
- ### Registers: many specifications in one file
294
-
295
- One file per decision is the MADR, KEP and RFC layout, and it is what most
296
- repositories do. Plenty keep a register instead — one document holding dozens of
297
- decisions. **A specification is a region of a file, not a file**, so both work:
298
-
299
- ```md
300
- ## ADR-0007: Shard the write path
301
-
302
- **Status:** Superseded by ADR-0012
303
-
304
- Shard by tenant id.
305
- ```
306
-
307
- Each section with an identifier *and* a status becomes a specification in its own
308
- right, with its own lifecycle, its own obligations and its own relations —
309
- resolvable from anywhere in the corpus as `ADR-0007`, whether it lives in a
310
- register or in its own file. Moving it out later breaks no citation.
311
-
312
- A register kept as a table works the same way, with relations typed by the column
313
- header you wrote:
314
-
315
- ```md
316
- | ID | Title | Status | Depends on | Superseded by |
317
- | :------- | :------------- | :--------- | :--------- | :------------ |
318
- | ADR-0001 | Use one writer | Superseded | - | ADR-0003 |
319
- | ADR-0002 | Cache eviction | Accepted | ADR-0001 | - |
320
- ```
321
-
322
- Findings point at the declaring cell, not at the file. A cell that is empty or
323
- holds only a placeholder — `-`, `—`, `n/a`, `none`, `nil`, `TBD` — says nothing.
324
- A status cell is read as a status line under a heading is, so
325
- `Superseded by ADR-0003` there names the row's successor.
326
-
327
- A row is named by its ID column, and a title is only a title. A row whose ID cell
328
- names nothing — empty, a placeholder, or a `?` nobody has numbered yet — is not a
329
- specification. An identifier a title opens with belongs to the document the row
330
- is *about* — an issue reading
331
- `| OI-V-05 | ADR-040's enforcement point has no browser test |` is filed under
332
- `OI-V-05`, and citations of `ADR-040` still reach `ADR-040`. The same holds for
333
- a whole file: an explicit `id:` (or `adr:`, `adr-id:`, `rfc:`, `rfc-id:`,
334
- `kep-number:`, `number:` or `slug:`), then the file name, then the H1, and the
335
- H1 is read as a name only when neither of the others gave one.
336
-
337
- The register itself stays in the graph as the thing that holds them, so
338
- `spec-graph query 'document[id=register] -contains-> document'` lists what is
339
- inside it.
340
-
341
- Both forms need **two** signals, and that is deliberate: `## Q3 2026 Roadmap`
342
- parses as family `Q`, number 3, and `## v1.2.0` in a changelog parses as family
343
- `v`, number 1. Neither declares a status, so neither is a specification. A table
344
- of identifiers and prose is a citation list, not a register. See
345
- [ADR-0009](https://github.com/DescentVTT/spec-graph/blob/main/docs/adr/0009-a-specification-is-a-region.md).
323
+ Nor is a link to:
324
+ - any other file that is not a document: an image, a dotfile such as
325
+ `../.nvmrc`, or a drawing such as `arch.excalidraw`, however long its
326
+ extension. A dot followed by a digit, as in `ADR-0007.1`, is part of an
327
+ identifier and is still resolved.
328
+ - a directory, `../guides/` or `../guides` alike - without the slash the disk
329
+ says which it is, and a name with no slash at all, `guides`, is an
330
+ identifier.
331
+
332
+ A document is named by an explicit `id:` (or `adr:`, `adr-id:`, `rfc:`,
333
+ `rfc-id:`, `kep-number:`, `number:` or `slug:`), then by its file name, then by
334
+ its H1, and the H1 is read as a name only when neither of the others gave one.
335
+ A row of a register is named by its ID column instead: see
336
+ [Registers](#registers-many-specifications-in-one-file).
346
337
 
347
338
  ### When `[[...]]` tags a concept
348
339
 
@@ -374,7 +365,7 @@ still a relation in the graph no matter what you exclude — not even
374
365
  `--ignore-ref "*"` can delete one. See
375
366
  [ADR-0008](https://github.com/DescentVTT/spec-graph/blob/main/docs/adr/0008-wiki-links-carry-no-path.md).
376
367
 
377
- #### A gap that is deliberate
368
+ ### A gap that is deliberate
378
369
 
379
370
  Real filing histories have holes: an ADR number reserved and then withdrawn, a
380
371
  document that was folded into another before it was ever committed. Something
@@ -447,13 +438,19 @@ Blocked by [ADR-0009](0009-compliance.md). → blocked-by
447
438
 
448
439
  A phrase governs a reference when it stands no more than 40 characters before
449
440
  it, in the same statement: a sentence, a paragraph, a list item or a table cell.
450
- A negation between the two - `owned by nobody` - cancels it. Prose reads both
451
- directions too: `supersedes` and `superseded by`, `amends` and `amended by`,
452
- `blocked by` and `blocks` or `blocking`, `depends on` and `depended on by` - the
453
- second of each pair points back at the document that wrote it. A phrase that follows the reference
454
- makes the reference its subject - `[ADR-0009](0009.md) supersedes this` - except
455
- a `depends-on` or `assumes` phrase: `[ADR-0002](0002.md) requires a migration`
456
- says nothing about this document.
441
+
442
+ - A negation between the two - `owned by nobody` - cancels it.
443
+ - A sentence written in Chinese ends at `。`, `?`, `!` or `;`, with a space
444
+ after it or none, so `Deferred to later。另見 ADR-0002` hands nothing to
445
+ ADR-0002. A stop only ends a statement sooner: it reads less, never more.
446
+ - Prose reads both directions too: `supersedes` and `superseded by`, `amends`
447
+ and `amended by`, `blocked by` and `blocks` or `blocking`, `depends on` and
448
+ `depended on by` - the second of each pair points back at the document that
449
+ wrote it.
450
+ - A phrase that follows the reference makes the reference its subject -
451
+ `[ADR-0009](0009.md) supersedes this` - except a `depends-on` or `assumes`
452
+ phrase: `[ADR-0002](0002.md) requires a migration` says nothing about this
453
+ document.
457
454
 
458
455
  A link under a heading that files it as bookkeeping is `relates-to` unless a
459
456
  phrase beside it says more. **Bookkeeping headings:** `See also`, `References`,
@@ -462,12 +459,142 @@ phrase beside it says more. **Bookkeeping headings:** `See also`, `References`,
462
459
  `Changelog`, `Change log`, `Revision history`, `Bibliography`, `Sources`,
463
460
  `Appendix`, `More information`, `Resources`, `Index`.
464
461
 
465
- Two properties do the work. `loadBearing` relations (`assumes`, `depends-on`,
466
- `blocked-by`, `amends`) make the source's validity depend on the target — those
467
- are what turn stale. `transfersObligation` relations (`delegates-to`,
468
- `blocked-by`) move work — those are what become ghosts. A "See also" link is
469
- neither, and is never reported, which is what keeps the signal-to-noise ratio
470
- high enough to leave the tool switched on.
462
+ Two properties do the work:
463
+
464
+ - `loadBearing` relations (`assumes`, `depends-on`, `blocked-by`, `amends`) make
465
+ the source's validity depend on the target — those are what turn stale.
466
+ - `transfersObligation` relations (`delegates-to`, `blocked-by`) move work —
467
+ those are what become ghosts.
468
+
469
+ A "See also" link is neither, and is never reported, which is what keeps the
470
+ signal-to-noise ratio high enough to leave the tool switched on.
471
+
472
+ ### Registers: many specifications in one file
473
+
474
+ One file per decision is the MADR, KEP and RFC layout, and it is what most
475
+ repositories do. Plenty keep a register instead — one document holding dozens of
476
+ decisions. **A specification is a region of a file, not a file**, so both work:
477
+
478
+ ```md
479
+ ## ADR-0007: Shard the write path
480
+
481
+ **Status:** Superseded by ADR-0012
482
+
483
+ Shard by tenant id.
484
+ ```
485
+
486
+ Each section with an identifier *and* a status - a `**Status:**` line or a
487
+ `### Status` section under its heading - becomes a
488
+ specification in its own right, with its own lifecycle, its own obligations and
489
+ its own relations — resolvable from anywhere in the corpus as `ADR-0007`,
490
+ whether it lives in a register or in its own file. Moving it out later breaks
491
+ no citation.
492
+
493
+ A register kept as a table works the same way, with relations typed by the column
494
+ header you wrote:
495
+
496
+ ```md
497
+ | ID | Title | Status | Depends on | Superseded by |
498
+ | :------- | :------------- | :--------- | :--------- | :------------ |
499
+ | ADR-0001 | Use one writer | Superseded | - | ADR-0003 |
500
+ | ADR-0002 | Cache eviction | Accepted | ADR-0001 | - |
501
+ ```
502
+
503
+ Findings point at the declaring cell, not at the file. A cell that is empty or
504
+ holds only a placeholder — `-`, `—`, `n/a`, `none`, `nil`, `TBD` — says nothing.
505
+ A status cell is read as a status line under a heading is, so
506
+ `Superseded by ADR-0003` there names the row's successor.
507
+
508
+ A row is named by its ID column, and a title is only a title:
509
+
510
+ - A row whose ID cell names nothing — empty, a placeholder, or a `?` nobody has
511
+ numbered yet — is not a specification.
512
+ - An identifier a title opens with belongs to the document the row is *about* —
513
+ an issue reading `| OI-V-05 | ADR-040's enforcement point has no browser test |`
514
+ is filed under `OI-V-05`, and citations of `ADR-040` still reach `ADR-040`.
515
+
516
+ The same holds for a whole file, whose H1 is a name only when nothing else gave
517
+ one: see [References](#references-resolved-forgivingly-and-validated-strictly).
518
+
519
+ The register itself stays in the graph as the thing that holds them, so
520
+ `spec-graph query 'document[id=register] -contains-> document'` lists what is
521
+ inside it.
522
+
523
+ Both forms need **two** signals, and that is deliberate: `## Q3 2026 Roadmap`
524
+ parses as family `Q`, number 3, and `## v1.2.0` in a changelog parses as family
525
+ `v`, number 1. Neither declares a status, so neither is a specification. A table
526
+ of identifiers and prose is a citation list, not a register. See
527
+ [ADR-0009](https://github.com/DescentVTT/spec-graph/blob/main/docs/adr/0009-a-specification-is-a-region.md).
528
+
529
+ ## Directives
530
+
531
+ Inference covers almost everything, but inference you cannot override is a trap.
532
+ Directives are ordinary HTML comments — invisible in every Markdown renderer:
533
+
534
+ ```md
535
+ <!-- @spec-node id="ADR-0007" status="accepted" aliases="sharding, adr-7" -->
536
+ <!-- @spec-item id="shard-key" state="narrowed" -->
537
+ <!-- @spec-edge kind="delegates-to" to="ADR-0011#scope" -->
538
+ <!-- @spec-ignore -->
539
+ <!-- @spec-history -->
540
+ ```
541
+
542
+ Each takes these attributes. Any other is a parse problem, listed by
543
+ `--verbose`, and the directive still applies:
544
+
545
+ | directive | attributes |
546
+ | --- | --- |
547
+ | `@spec-node` | `id`, `status`, `title`, `aliases`, `kind` |
548
+ | `@spec-item` | `id`, `state`, `title`, `owner`, `note` |
549
+ | `@spec-edge` | `kind`, `to`, `from`, `reason` |
550
+ | `@spec-ignore` | `reason` |
551
+ | `@spec-history` | none |
552
+
553
+ - `reason`, `note`, `owner` and a node's `kind` are for whoever reads the
554
+ source; spec-graph reads nothing from them.
555
+ - `aliases` splits at commas, semicolons and spaces.
556
+ - A value is quoted with either quote mark, or bare when it holds no space or
557
+ quote, and an attribute with no value is a flag.
558
+ - A directive is the first thing in its comment, so a comment that only
559
+ mentions one is a comment.
560
+
561
+ A directive always wins, and the report says the state came from a directive, so
562
+ an override is visible rather than mysterious.
563
+
564
+ A `@spec-item` annotates one item: the one directly below it, with nothing but
565
+ blank lines or other comments between, or else the one it is written on or
566
+ indented under. An id that two items end up with is a parse problem, listed by
567
+ `--verbose`, and the first item keeps it.
568
+
569
+ ## Journals, changelogs and minutes
570
+
571
+ A 2024 journal noting *"decision deferred to ADR-002"* is not delegating
572
+ anything. It is reporting that somebody once did. When ADR-002 retires in 2026,
573
+ the note does not become a defect — there is nothing in it for anyone to fix.
574
+
575
+ Declare those files and spec-graph stops holding them to a lifecycle they never
576
+ had:
577
+
578
+ ```json
579
+ { "historyPatterns": ["**/JOURNAL_*.md", "archive/**"] }
580
+ ```
581
+
582
+ or, for one file, `<!-- @spec-history -->` at the top of it. A brief whose
583
+ status is `archived` is a record already, with nothing declared, though not a
584
+ log: the supersessions, cycles and self-references it declares are still
585
+ checked.
586
+
587
+ | | |
588
+ | --- | --- |
589
+ | its links resolve | **still checked** — a broken link is broken whoever wrote it |
590
+ | its checkboxes | not obligations, and not in the headline count |
591
+ | its delegations | reports of what was said |
592
+ | work handed **into** it | **still checked** — a log will never act on it |
593
+
594
+ That last row is the point. Excluding the file with `--ignore` would have
595
+ silenced the whole lot, including the links, and a journal full of 404s is
596
+ exactly what this tool is for. See
597
+ [ADR-0011](https://github.com/DescentVTT/spec-graph/blob/main/docs/adr/0011-a-record-is-not-a-specification.md).
471
598
 
472
599
  ## The query language
473
600
 
@@ -535,12 +662,10 @@ predicate is linear in the subject whatever the pattern — `^([A-Za-z0-9_]+[ ]?
535
662
  against a fifty-four-character title takes `RegExp` 103 seconds and this 13
536
663
  microseconds. It reads the usual syntax minus backreferences and lookaround,
537
664
  which are not regular; both are refused when the selector is read, with the
538
- character pointed at. Globs - the patterns to check, `--ignore`, `--ignore-ref`,
539
- `--history` - are matched by an automaton too, because a glob with three stars
540
- in it is enough to keep `RegExp` busy for two minutes over a long reference
541
- target. See [ADR-0017](https://github.com/DescentVTT/spec-graph/blob/main/docs/adr/0017-a-predicate-must-finish.md). Both automata
542
- are spec-core's, the library the spec-* tools share, copied into this package
543
- rather than installed.
665
+ character pointed at. The automaton is spec-core's, the library the spec-* tools
666
+ share, copied into this package rather than installed, and so is the one
667
+ [globs](#patterns) are matched by. See
668
+ [ADR-0017](https://github.com/DescentVTT/spec-graph/blob/main/docs/adr/0017-a-predicate-must-finish.md).
544
669
 
545
670
  On a register, a region answers its file's front matter for every descriptive
546
671
  key — `fm.owner` on a decision inside a register is the register's owner — while
@@ -612,74 +737,6 @@ spec-graph: .spec-graph.json: rules.owner: "{1.phse}" asks for an attribute
612
737
 
613
738
  See [ADR-0016](https://github.com/DescentVTT/spec-graph/blob/main/docs/adr/0016-a-query-needs-a-sentence.md).
614
739
 
615
- ## Directives
616
-
617
- Inference covers almost everything, but inference you cannot override is a trap.
618
- Directives are ordinary HTML comments — invisible in every Markdown renderer:
619
-
620
- ```md
621
- <!-- @spec-node id="ADR-0007" status="accepted" aliases="sharding, adr-7" -->
622
- <!-- @spec-item id="shard-key" state="narrowed" -->
623
- <!-- @spec-edge kind="delegates-to" to="ADR-0011#scope" -->
624
- <!-- @spec-ignore -->
625
- <!-- @spec-history -->
626
- ```
627
-
628
- Each takes these attributes. Any other is a parse problem, listed by
629
- `--verbose`, and the directive still applies:
630
-
631
- | directive | attributes |
632
- | --- | --- |
633
- | `@spec-node` | `id`, `status`, `title`, `aliases`, `kind` |
634
- | `@spec-item` | `id`, `state`, `title`, `owner`, `note` |
635
- | `@spec-edge` | `kind`, `to`, `from`, `reason` |
636
- | `@spec-ignore` | `reason` |
637
- | `@spec-history` | none |
638
-
639
- `reason`, `note`, `owner` and a node's `kind` are for whoever reads the source;
640
- spec-graph reads nothing from them. `aliases` splits at commas, semicolons and
641
- spaces. A value is quoted with either quote mark, or bare when it holds no space
642
- or quote, and an attribute with no value is a flag. A directive is the first
643
- thing in its comment, so a comment that only mentions one is a comment.
644
-
645
- A directive always wins, and the report says the state came from a directive, so
646
- an override is visible rather than mysterious.
647
-
648
- A `@spec-item` annotates one item: the one directly below it, with nothing but
649
- blank lines or other comments between, or else the one it is written on or
650
- indented under. An id that two items end up with is a parse problem, listed by
651
- `--verbose`, and the first item keeps it.
652
-
653
- ## Journals, changelogs and minutes
654
-
655
- A 2024 journal noting *"decision deferred to ADR-002"* is not delegating
656
- anything. It is reporting that somebody once did. When ADR-002 retires in 2026,
657
- the note does not become a defect — there is nothing in it for anyone to fix.
658
-
659
- Declare those files and spec-graph stops holding them to a lifecycle they never
660
- had:
661
-
662
- ```json
663
- { "historyPatterns": ["**/JOURNAL_*.md", "archive/**"] }
664
- ```
665
-
666
- or, for one file, `<!-- @spec-history -->` at the top of it. A brief whose
667
- status is `archived` is a record already, with nothing declared, though not a
668
- log: the supersessions, cycles and self-references it declares are still
669
- checked.
670
-
671
- | | |
672
- | --- | --- |
673
- | its links resolve | **still checked** — a broken link is broken whoever wrote it |
674
- | its checkboxes | not obligations, and not in the headline count |
675
- | its delegations | reports of what was said |
676
- | work handed **into** it | **still checked** — a log will never act on it |
677
-
678
- That last row is the point. Excluding the file with `--ignore` would have
679
- silenced the whole lot, including the links, and a journal full of 404s is
680
- exactly what this tool is for. See
681
- [ADR-0011](https://github.com/DescentVTT/spec-graph/blob/main/docs/adr/0011-a-record-is-not-a-specification.md).
682
-
683
740
  ## Adopting this on a repository that predates it
684
741
 
685
742
  Fifty findings on day one is not a report, it is a decision to be ignored. So
@@ -736,64 +793,6 @@ the other case and stays optional, because a repository declares the path before
736
793
  the first run records the file — `--verbose` says when it read nothing. Either
737
794
  may be absolute. See [ADR-0012](https://github.com/DescentVTT/spec-graph/blob/main/docs/adr/0012-a-baseline-is-a-ratchet.md).
738
795
 
739
- ## In CI
740
-
741
- The exit code is the contract: `0` clean, `1` findings, `2` the tool could not
742
- run. That is all most pipelines need.
743
-
744
- For annotations on the diff rather than a line in a log, emit SARIF and hand it
745
- to the uploader that already exists:
746
-
747
- ```yaml
748
- - run: npx spec-graph --format sarif > spec-graph.sarif
749
- continue-on-error: true
750
- - uses: github/codeql-action/upload-sarif@v3
751
- with:
752
- sarif_file: spec-graph.sarif
753
- ```
754
-
755
- Findings land as comments on the changed lines, and they persist across commits
756
- without drifting, because the `partialFingerprints` spec-graph writes are the
757
- same identity a baseline is keyed on — the rule, the specification, and the
758
- citation, with no line number in it (ADR-0012).
759
-
760
- On GitLab, `--format gitlab` writes the Code Quality report a merge request
761
- reads, and the merge request shows which findings it introduced and which it
762
- resolved:
763
-
764
- ```yaml
765
- spec-graph:
766
- script:
767
- - npx spec-graph --format gitlab > gl-code-quality-report.json
768
- artifacts:
769
- when: always
770
- reports:
771
- codequality: gl-code-quality-report.json
772
- ```
773
-
774
- Each finding is an issue with its rule as `check_name`, its message and hint as
775
- `description`, and its file and line as `location`. Severities map as `error`
776
- to `critical` - or `major` when only `--strict` made it an error - `warn` to
777
- `minor` and `info` to `info`. The `fingerprint` is a SHA-256 of the rule, the
778
- specification and what within it - a citation's target, or the document at the
779
- other end - which is what a baseline counts by. There is no line in it, so a
780
- finding a paragraph moved is not reported as new, and no message, so ticking
781
- one of three open boxes does not make an `orphaned-obligation` new either; the
782
- second of two findings identical in all three is told apart by its order. `when: always` keeps the report when the job fails, which
783
- it does, with exit `1`, whenever there is an error to report.
784
-
785
- `--format json` is the machine-readable output to parse if you are building
786
- something of your own: it carries the baseline note, the summary and the
787
- per-file detail that SARIF and Code Quality have nowhere to put.
788
-
789
- **There is no `--watch`.** A full run on this repository takes 60 ms, so the
790
- loop that would justify a resident process is one line of shell, and it belongs
791
- to whatever the developer already uses rather than to this tool:
792
-
793
- ```bash
794
- npx spec-graph --format json | jq -r '.summary'
795
- ```
796
-
797
796
  ## Configuration
798
797
 
799
798
  Anything you repeat on every run belongs to the repository rather than to the
@@ -823,10 +822,15 @@ command. spec-graph reads the first of `.spec-graph.json`,
823
822
  The file is looked for in the working directory and then upward, as far as the
824
823
  repository — a directory holding `.git` — and **the directory holding it becomes
825
824
  the root**. So a run from `packages/auth` reports what a run from the top
826
- reports, byte for byte, because every path here is relative to the root. The
827
- nearest file wins, `--root` names the root yourself and turns discovery off, and
828
- a path typed on the command line stays relative to where you typed it. See
829
- [ADR-0018](https://github.com/DescentVTT/spec-graph/blob/main/docs/adr/0018-the-configuration-file-is-the-root.md).
825
+ reports, byte for byte, because every path here is relative to the root.
826
+
827
+ - The nearest file wins.
828
+ - `--root` names the root yourself and turns discovery off.
829
+ - A path typed on the command line stays relative to where you typed it, brace
830
+ alternative by brace alternative: `{/docs,x}` typed in `sub` is `/docs` or
831
+ `sub/x`.
832
+
833
+ See [ADR-0018](https://github.com/DescentVTT/spec-graph/blob/main/docs/adr/0018-the-configuration-file-is-the-root.md).
830
834
 
831
835
  A flag always wins over the file, and list flags **add** to it rather than
832
836
  replacing it — a `--ignore-ref` on the command line is one more exclusion, not a
@@ -839,15 +843,19 @@ it can be combined with any `--format` and leave the document on stdout intact.
839
843
  — the lines under it in the terminal, `related` in JSON, `relatedLocations` in
840
844
  SARIF — and is 8 unless set; `0` lists none.
841
845
 
842
- A broken config stops the run with exit `2`, each problem named on stderr —
846
+ A broken config stops the run with exit `2`, each problem named on stderr:
843
847
  invalid JSON, a value of the wrong type, an unknown key, a rule that does not
844
- compile. A configuration that did not load checks a different repository than
845
- the one configured, and would report *that* one as consistent: a `{1.phse}` for
846
- `{1.phase}` in one rule's message is a green build over the files the correctly
847
- spelled rule fails. An unknown key counts, for the same reason it is reported at
848
- all — a silently ignored `ignoreReference` is a configuration that looks applied
849
- and is not. `--no-config` checks on defaults instead, and the exit code then
850
- says which run it was.
848
+ compile. Why a mistake stops the run:
849
+
850
+ - A configuration that did not load checks a different repository than the one
851
+ configured, and would report *that* one as consistent: a `{1.phse}` for
852
+ `{1.phase}` in one rule's message is a green build over the files the
853
+ correctly spelled rule fails.
854
+ - An unknown key counts, for the same reason it is reported at all — a silently
855
+ ignored `ignoreReference` is a configuration that looks applied and is not.
856
+
857
+ `--no-config` checks on defaults instead, and the exit code then says which run
858
+ it was.
851
859
 
852
860
  ### Patterns
853
861
 
@@ -870,52 +878,76 @@ recorded on a Windows checkout holds in Linux CI:
870
878
  | `!docs/drafts/**` | takes back what an earlier pattern matched; the last to match wins |
871
879
  | `docs\adr\*.md` | the same as `docs/adr/*.md`: a `\` is a separator |
872
880
 
873
- A class never matches a `/`, and a pattern that cannot mean a path under the
874
- root — an unclosed `[` or `{`, `..`, a lone `.` or `./`, an empty one, or
875
- braces that give one of them, `{./,docs}` — stops the run with exit `2` and
876
- names the pattern, wherever it was written. So does an
877
- extended glob, `+(a|b)`: write `{a,b}`. So does one too large to compile:
878
- braces that expand to more than 256 patterns, or a pattern past 65,536
879
- automaton states. A parenthesis with no `|` in its group
880
- is itself, so `C++(notes).md` names that file. A `..` typed below the root,
881
- `spec-graph "../*.md"` from `docs/deep`, is resolved against where you typed
882
- it. A bare `--ignore` name, `--ignore drafts`, still prunes that directory at
883
- any depth, even where a pattern starts inside it or names it. A `!` before one
884
- gives the name back, as a `.gitignore` line does:
885
- `--ignore drafts --ignore '!drafts'` reads every `drafts`, and the last bare
886
- name to name a directory decides. A path,
887
- `--ignore "docs/*"`, takes out what it matches wherever a pattern starts:
888
- `docs/drafts/**/*.md` finds nothing under it, as `docs/**/*.md` finds nothing
889
- in `docs/drafts`.
881
+ Three kinds of pattern stop the run with exit `2`, and the run names the
882
+ pattern, wherever it was written:
883
+
884
+ - a pattern that cannot mean a path under the root — an unclosed `[` or `{`,
885
+ `..`, a lone `.` or `./`, an empty one, or braces that give one of them,
886
+ `{./,docs}`;
887
+ - an extended glob, `+(a|b)`: write `{a,b}`;
888
+ - one too large to compile: braces that expand to more than 256 patterns, or a
889
+ pattern past 65,536 automaton states.
890
+
891
+ Globs - the patterns to check, `--ignore`, `--ignore-ref`, `--history` - are
892
+ matched by an automaton rather than by `RegExp`, because a glob with three stars
893
+ in it is enough to keep `RegExp` busy for two minutes over a long reference
894
+ target. See
895
+ [ADR-0017](https://github.com/DescentVTT/spec-graph/blob/main/docs/adr/0017-a-predicate-must-finish.md).
896
+
897
+ A class never matches a `/`. A parenthesis with no `|` in its group is itself,
898
+ so `C++(notes).md` names that file. A `..` typed below the root,
899
+ `spec-graph "../*.md"` from `docs/deep`, is resolved against where you typed it;
900
+ one that climbs past the root is refused, and so is a pattern typed in a
901
+ directory whose name a pattern would read as syntax, such as `notes[1]`.
902
+
903
+ A bare name and a path read differently as an `--ignore`:
904
+
905
+ - A bare `--ignore` name, `--ignore drafts`, still prunes that directory at any
906
+ depth, even where a pattern starts inside it or names it.
907
+ - A `!` before one gives the name back, as a `.gitignore` line does:
908
+ `--ignore drafts --ignore '!drafts'` reads every `drafts`, and the last bare
909
+ name to name a directory decides.
910
+ - A path, `--ignore "docs/*"`, takes out what it matches wherever a pattern
911
+ starts: `docs/drafts/**/*.md` finds nothing under it, as `docs/**/*.md` finds
912
+ nothing in `docs/drafts`.
890
913
 
891
914
  The walk never enters `.git`, `.hg`, `.svn`, `.cache`, `.next`, `.nuxt`,
892
915
  `.turbo`, `.venv`, `node_modules`, `bower_components`, `vendor`, `dist`,
893
916
  `build`, `out`, `target`, `coverage` or `__pycache__` on its way to what the
894
- patterns name. Unlike an `--ignore`, these yield to a pattern that starts inside
895
- one: `vendor/specs/*.md` is read from there, whatever the other patterns reach.
896
- They yield to a plain name for one as well: `vendor` and `docs/vendor` read
897
- those directories as `vendor/` and `docs/vendor/` do, or the file, where one
898
- of them is a file. And they yield to a `!` before their name:
899
- `--ignore '!vendor'` reads every `vendor`, the rest of the list still skipped
900
- inside it. A `!` before a path takes back only what a path `--ignore` took
901
- out, so `!docs/vendor` gives back nothing the walk skips: name it among the
902
- patterns. Nor does the walk follow a link, a pattern that starts
903
- beyond one included: `docs/linked/*.md` reads nothing through the link
904
- `docs/linked`, as `docs/**/*.md` reads nothing there. `followSymlinks` in the
905
- [API](#programmatic-api) follows links, and prunes a link to a directory as it
906
- prunes a directory of that name and path: a linked `node_modules` is skipped,
907
- and an `--ignore` takes a link out. It never follows a link back into a
908
- directory the walk is inside, so a cycle is read once, and says nothing of it,
909
- as it says nothing of a link it does not follow.
910
-
911
- `--ignore-ref` is matched against reference targets rather than paths: a bare
912
- pattern names one target exactly, case is ignored on every host, a `\` escapes
913
- the next character, and `.` and `..` are the text of the link, in a brace
914
- alternative as anywhere else: `docs/{.,x}` names `docs/.` and `docs/x`, and
915
- `{./,docs}` the targets under `./`, and `docs`. A leading `/` is the text of
916
- the link too: `{/docs/a.md,x}` names the target `/docs/a.md`, as `/docs/a.md`
917
- does, and not `docs/a.md`. See
918
- [ADR-0022](https://github.com/DescentVTT/spec-graph/blob/main/docs/adr/0022-globs-are-the-family-path-dialect.md).
917
+ patterns name. Unlike an `--ignore`, these yield:
918
+
919
+ - to a pattern that starts inside one: `vendor/specs/*.md` is read from there,
920
+ whatever the other patterns reach;
921
+ - to a plain name for one: `vendor` and `docs/vendor` read those directories
922
+ as `vendor/` and `docs/vendor/` do, or the file, where one of them is a file;
923
+ - to a `!` before their name: `--ignore '!vendor'` reads every `vendor`, the
924
+ rest of the list still skipped inside it. A `!` before a path takes back only
925
+ what a path `--ignore` took out, so `!docs/vendor` gives back nothing the walk
926
+ skips: name it among the patterns.
927
+
928
+ Nor does the walk follow a link, a pattern that starts beyond one included:
929
+ `docs/linked/*.md` reads nothing through the link `docs/linked`, as
930
+ `docs/**/*.md` reads nothing there. `followSymlinks` in the
931
+ [API](#programmatic-api) follows links, and:
932
+
933
+ - prunes a link to a directory as it prunes a directory of that name and path:
934
+ a linked `node_modules` is skipped, and an `--ignore` takes a link out;
935
+ - never follows a link back into a directory the walk is inside, so a cycle is
936
+ read once, and says nothing of it, as it says nothing of a link it does not
937
+ follow.
938
+
939
+ `--ignore-ref` is matched against reference targets rather than paths:
940
+
941
+ - a bare pattern names one target exactly;
942
+ - case is ignored on every host;
943
+ - a `\` escapes the next character;
944
+ - `.` and `..` are the text of the link, in a brace alternative as anywhere
945
+ else: `docs/{.,x}` names `docs/.` and `docs/x`, and `{./,docs}` the targets
946
+ under `./`, and `docs`;
947
+ - a leading `/` is the text of the link too: `{/docs/a.md,x}` names the target
948
+ `/docs/a.md`, as `/docs/a.md` does, and not `docs/a.md`.
949
+
950
+ See [ADR-0022](https://github.com/DescentVTT/spec-graph/blob/main/docs/adr/0022-globs-are-the-family-path-dialect.md).
919
951
 
920
952
  ### Family rules
921
953
 
@@ -976,8 +1008,8 @@ spec-graph diff <before> <after> Compare two JSON graph exports.
976
1008
  --record-baseline <f> Write today's findings as accepted debt, exit 0
977
1009
  --ratchet Also fail when a baseline entry no longer occurs
978
1010
  --no-config Ignore .spec-graph.json and the package.json key
979
- --format <fmt> human | json | sarif (check) | gitlab (check)
980
- | markdown (check, diff)
1011
+ --format <fmt> human | json | sarif (check) | github (check)
1012
+ | gitlab (check) | markdown (check, diff)
981
1013
  --graph-format <fmt> dot | mermaid | json
982
1014
  --documents-only Hide items; their relations lift onto their documents
983
1015
  --rule <id>=<severity> error | warn | info | off (repeatable)
@@ -994,13 +1026,110 @@ never masquerades as a passing build, and neither does a pattern that matches
994
1026
  nothing, a configuration file that did not load, or a `--baseline` that is not
995
1027
  there.
996
1028
 
997
- ## Continuous integration
1029
+ **There is no `--watch`.** A full run on this repository takes 60 ms, so the
1030
+ loop that would justify a resident process is one line of shell, and it belongs
1031
+ to whatever the developer already uses rather than to this tool:
1032
+
1033
+ ```bash
1034
+ npx spec-graph --format json | jq -r '.summary'
1035
+ ```
1036
+
1037
+ ## In CI
1038
+
1039
+ The exit code is the contract: `0` clean, `1` findings, `2` the tool could not
1040
+ run. That is all most pipelines need.
1041
+
1042
+ Beyond that, a format puts the findings where somebody will look:
1043
+
1044
+ | Format | Where the findings land |
1045
+ | --- | --- |
1046
+ | `--format sarif` | GitHub code scanning: comments on the changed lines |
1047
+ | `--format github` | annotations on the diff, from the job's log, with no upload step |
1048
+ | `--format gitlab` | the Code Quality report of a GitLab merge request |
1049
+ | `--format markdown` | a table for the job summary, where reviewers look |
1050
+ | `--format json` | a script or a bot of your own |
1051
+
1052
+ ### Code scanning, with SARIF
1053
+
1054
+ For annotations on the diff rather than a line in a log, emit SARIF and hand it
1055
+ to the uploader that already exists. The upload needs `security-events: write`,
1056
+ and a job that declares its permissions has only those, so it names
1057
+ `contents: read` for its checkout as well:
998
1058
 
999
1059
  ```yaml
1000
- - name: Check the specification graph
1001
- run: npx spec-graph "docs/**/*.md" --format json > spec-graph.json
1060
+ permissions:
1061
+ contents: read
1062
+ security-events: write
1063
+ steps:
1064
+ # check out the repository and set up Node, then:
1065
+ - run: npx spec-graph --format sarif > spec-graph.sarif
1066
+ continue-on-error: true
1067
+ - uses: github/codeql-action/upload-sarif@v4
1068
+ with:
1069
+ sarif_file: spec-graph.sarif
1002
1070
  ```
1003
1071
 
1072
+ Findings land as comments on the changed lines, and they persist across commits
1073
+ without drifting, because the `partialFingerprints` spec-graph writes are the
1074
+ same identity a baseline is keyed on — the rule, the specification, and the
1075
+ citation, with no line number in it (ADR-0012).
1076
+
1077
+ ### Annotations, with `--format github`
1078
+
1079
+ Without code scanning, `--format github` writes one GitHub Actions workflow
1080
+ command per finding, and the job's log turns each into an annotation on the
1081
+ diff, with no upload step:
1082
+
1083
+ ```yaml
1084
+ - run: npx spec-graph --format github
1085
+ ```
1086
+
1087
+ ```text
1088
+ ::error file=docs/adr/0003-event-log.md,line=13,title=ghost-handover::open obligation delegates to ADR-0002, which is retired. nothing will be read from ADR-0002 again - re-home this in a live document, or close it here
1089
+ ```
1090
+
1091
+ - An `error` finding is an `error`, a `warn` a `warning` and an `info` a
1092
+ `notice`, as spec-guard writes them, and one only `--strict` made an error is
1093
+ titled `ghost-handover (strict)`.
1094
+ - The message carries the hint.
1095
+ - `%` and line breaks are escaped everywhere, and `:` and `,` in the file and
1096
+ the title, as GitHub reads them.
1097
+ - A clean run writes nothing.
1098
+
1099
+ ### Code Quality, with `--format gitlab`
1100
+
1101
+ On GitLab, `--format gitlab` writes the Code Quality report a merge request
1102
+ reads, and the merge request shows which findings it introduced and which it
1103
+ resolved:
1104
+
1105
+ ```yaml
1106
+ spec-graph:
1107
+ script:
1108
+ - npx spec-graph --format gitlab > gl-code-quality-report.json
1109
+ artifacts:
1110
+ when: always
1111
+ reports:
1112
+ codequality: gl-code-quality-report.json
1113
+ ```
1114
+
1115
+ Each finding is an issue:
1116
+
1117
+ - `check_name` is its rule, `description` its message and hint, and `location`
1118
+ its file and line.
1119
+ - Severities map as `error` to `critical` - or `major` when only `--strict` made
1120
+ it an error - `warn` to `minor` and `info` to `info`.
1121
+ - The `fingerprint` is a SHA-256 of the rule, the specification and what within
1122
+ it - a citation's target, or the document at the other end - which is what a
1123
+ baseline counts by. There is no line in it, so a finding a paragraph moved is
1124
+ not reported as new, and no message, so ticking one of three open boxes does
1125
+ not make an `orphaned-obligation` new either; the second of two findings
1126
+ identical in all three is told apart by its order.
1127
+
1128
+ `when: always` keeps the report when the job fails, which it does, with exit
1129
+ `1`, whenever there is an error to report.
1130
+
1131
+ ### A summary for reviewers, with `--format markdown`
1132
+
1004
1133
  `--format markdown` writes the same facts as a table, for the place reviewers
1005
1134
  actually look:
1006
1135
 
@@ -1014,6 +1143,17 @@ It leads with the verdict, counts the corpus, gives every finding its hint, and
1014
1143
  *names* the baseline entries that no longer occur rather than counting them —
1015
1144
  on a pull request a count is the one thing a reader cannot act on.
1016
1145
 
1146
+ ### JSON, for anything of your own
1147
+
1148
+ `--format json` is the machine-readable output to parse if you are building
1149
+ something of your own: it carries the baseline note, the summary and the
1150
+ per-file detail that SARIF and Code Quality have nowhere to put.
1151
+
1152
+ ```yaml
1153
+ - name: Check the specification graph
1154
+ run: npx spec-graph "docs/**/*.md" --format json > spec-graph.json
1155
+ ```
1156
+
1017
1157
  The JSON report is versioned, flat, and carries start and end positions for
1018
1158
  every finding, so a bot can annotate a diff:
1019
1159
 
@@ -1038,6 +1178,8 @@ every finding, so a bot can annotate a diff:
1038
1178
  }
1039
1179
  ```
1040
1180
 
1181
+ ### What a pull request changed
1182
+
1041
1183
  A check says what is wrong. `spec-graph diff` says what a pull request changed:
1042
1184
  documents added, removed, moved or accepted, relations added or removed, and
1043
1185
  obligations resolved or reopened. It compares two exports made by the same
@@ -1050,15 +1192,20 @@ binary, so CI makes one from the base branch and one from the pull request:
1050
1192
  - run: npx spec-graph diff base.json head.json --format markdown >> "$GITHUB_STEP_SUMMARY"
1051
1193
  ```
1052
1194
 
1053
- It names only what it can tell apart. A document keeps its id through a rename,
1054
- so a moved document reads as moved. An obligation's id is its position in its
1055
- section, so inserting a question renumbers every question below it; an
1056
- obligation is therefore paired only by an id its author declared, or by its
1057
- document, section and title when nothing else shares them. Anything unpaired
1058
- is reported as having appeared or disappeared, never as resolved or reopened.
1059
- Findings are left to `--baseline`, which already reports what is new. The exit
1060
- code is `0` whether anything changed or not: a diff describes, and `check`
1061
- gates. See [ADR-0020](https://github.com/DescentVTT/spec-graph/blob/main/docs/adr/0020-a-diff-names-what-it-can-tell-apart.md).
1195
+ It names only what it can tell apart:
1196
+
1197
+ - A document keeps its id through a rename, so a moved document reads as moved.
1198
+ - An obligation's id is its position in its section, so inserting a question
1199
+ renumbers every question below it. An obligation is therefore paired only by
1200
+ an id its author declared, or by its document, section and title when nothing
1201
+ else shares them.
1202
+ - Anything unpaired is reported as having appeared or disappeared, never as
1203
+ resolved or reopened.
1204
+ - Findings are left to `--baseline`, which already reports what is new.
1205
+
1206
+ The exit code is `0` whether anything changed or not: a diff describes, and
1207
+ `check` gates. See
1208
+ [ADR-0020](https://github.com/DescentVTT/spec-graph/blob/main/docs/adr/0020-a-diff-names-what-it-can-tell-apart.md).
1062
1209
 
1063
1210
  ## Visualising
1064
1211
 
@@ -1130,9 +1277,9 @@ tools share, whose code is copied into `src/vendor/` and checked by hash rather
1130
1277
  than installed. So the whole package is auditable in an afternoon — and nothing
1131
1278
  in the pipeline can take longer than its input.
1132
1279
 
1133
- **Verified, not just covered.** 1,826 tests; 99.3% statement and 99.7% line
1280
+ **Verified, not just covered.** 1,880 tests; 99.4% statement and 99.8% line
1134
1281
  coverage. Coverage says a line ran, so the suite is also held to a mutation
1135
- score - 96.27% over 8,314 mutants, measured by CI's full sweep, and a build
1282
+ score - 96.32% over 8,393 mutants, measured by CI's full sweep, and a build
1136
1283
  fails under 93 - because a vocabulary entry or a boundary
1137
1284
  condition can be weakened by an ordinary-looking refactor without a single test
1138
1285
  going red. [ADR-0007](https://github.com/DescentVTT/spec-graph/blob/main/docs/adr/0007-mutation-testing.md) is straight about what
@@ -1157,6 +1304,14 @@ about it.
1157
1304
  consistent with *each other*. They share conventions and a philosophy, compose
1158
1305
  cleanly, and neither requires the other.
1159
1306
 
1307
+ ## Contributing
1308
+
1309
+ How to build, test and release spec-graph, and where a change usually goes, is
1310
+ in [CONTRIBUTING.md](https://github.com/DescentVTT/spec-graph/blob/main/CONTRIBUTING.md).
1311
+ Report a vulnerability privately, as
1312
+ [SECURITY.md](https://github.com/DescentVTT/spec-graph/blob/main/SECURITY.md)
1313
+ describes, and not in a public issue.
1314
+
1160
1315
  ## Licence
1161
1316
 
1162
1317
  MIT