@descent-vtt/spec-graph 0.9.10 → 0.10.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 (43) hide show
  1. package/CHANGELOG.md +68 -2
  2. package/README.md +522 -319
  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.d.ts +14 -1
  7. package/dist/extract.js +114 -10
  8. package/dist/extract.js.map +1 -1
  9. package/dist/glob.d.ts +11 -0
  10. package/dist/glob.js +17 -1
  11. package/dist/glob.js.map +1 -1
  12. package/dist/index.d.ts +1 -1
  13. package/dist/index.js +1 -1
  14. package/dist/index.js.map +1 -1
  15. package/dist/lifecycle.d.ts +11 -1
  16. package/dist/lifecycle.js +187 -28
  17. package/dist/lifecycle.js.map +1 -1
  18. package/dist/report.d.ts +22 -0
  19. package/dist/report.js +50 -3
  20. package/dist/report.js.map +1 -1
  21. package/dist/sections.d.ts +7 -1
  22. package/dist/sections.js +10 -2
  23. package/dist/sections.js.map +1 -1
  24. package/dist/state.js +19 -3
  25. package/dist/state.js.map +1 -1
  26. package/dist/vendor/spec-core/markdown/frontmatter.d.ts +11 -1
  27. package/dist/vendor/spec-core/markdown/frontmatter.js +20 -3
  28. package/dist/vendor/spec-core/markdown/frontmatter.js.map +1 -1
  29. package/dist/vendor/spec-core/pattern/glob.d.ts +85 -0
  30. package/dist/vendor/spec-core/pattern/glob.js +207 -17
  31. package/dist/vendor/spec-core/pattern/glob.js.map +1 -1
  32. package/dist/vendor/spec-core/pattern/index.d.ts +2 -2
  33. package/dist/vendor/spec-core/pattern/index.js +1 -1
  34. package/dist/vendor/spec-core/pattern/index.js.map +1 -1
  35. package/dist/vendor/spec-core/text/index.d.ts +1 -0
  36. package/dist/vendor/spec-core/text/index.js +1 -0
  37. package/dist/vendor/spec-core/text/index.js.map +1 -1
  38. package/dist/vendor/spec-core/text/width.d.ts +30 -0
  39. package/dist/vendor/spec-core/text/width.js +113 -0
  40. package/dist/vendor/spec-core/text/width.js.map +1 -0
  41. package/dist/yaml.js +6 -2
  42. package/dist/yaml.js.map +1 -1
  43. 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,18 @@ 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, in English or in Chinese
137
+ (`## 狀態` / `已接受`), and however it is dressed (`**Accepted** (2026-03-01) ✅`):
138
+
139
+ - front matter: `status:`, `state:`, `stage:`
140
+ - a `## Status` section
141
+ - a `| Status | Accepted |` table at the top
142
+ - a `docs/adr/archive/` directory
143
+
144
+ `Accepted, later superseded by ADR-0009` is retired: retirement is terminal, so
145
+ a retirement word anywhere wins.
146
+
147
+ Front matter is read as YAML reads it, and a value YAML would not read -
133
148
  `status: Superseded by ADR-9: see notes`, where a plain value cannot hold `: ` -
134
149
  is left out rather than guessed at. Every run says how many statuses, ids and
135
150
  relations it left out that way, and `--verbose` says where and why: quote the
@@ -152,21 +167,66 @@ that `provisionally accepted` is a draft.
152
167
  | `active` | `accepted`, `active`, `approved`, `adopted`, `agreed`, `implementable`, `implemented`, `implementing`, `current`, `effective`, `in-effect`, `enforced`, `stable`, `merged`, `released`, `shipped`, `live`, `done`, `complete`, `completed`, `signed-off`, `committed` |
153
168
  | `draft` | `draft`, `drafting`, `proposed`, `proposal`, `provisional`, `provisionally-accepted`, `prospective`, `wip`, `work-in-progress`, `in-progress`, `in_progress`, `review`, `in-review`, `under-review`, `reviewing`, `discussion`, `discussing`, `pending`, `idea`, `exploratory`, `candidate`, `open`, `new`, `unreviewed`, `rfc`, `experimental`, `alpha`, `beta`, `incubating` |
154
169
 
170
+ Chinese is read in Traditional and Simplified characters, each word as the
171
+ English word it translates, so it lands in that word's row: `延後` is
172
+ `deferred` and retired, `封存` is `archived` and a record, and `暫定` is
173
+ `provisionally accepted`, so `暫定接受` is a draft.
174
+
175
+ - **A Chinese word counts wherever it stands in the status**, because Chinese
176
+ puts no space between words: `已接受,後被ADR-0003取代` holds `已接受` and a
177
+ supersession.
178
+ - **Except directly after a negation:** `不`, `未`, `非`, `沒`, `没`, `無`,
179
+ `无`, `勿`, `尚未` or `不再`. `未接受`, `尚未核准` and `不再生效` say nothing;
180
+ a word that starts with one, `不採納`, is itself.
181
+ - **Superseded is also `被` followed by `取代`, `替代` or `取而代之`**, within 30
182
+ characters and in the same sentence: `已被 ADR-0003 取代` is retired, and
183
+ names ADR-0003 as what replaced it.
184
+ - **`已取代` alone is retired.** Followed by a name, after spaces or a colon -
185
+ `已取代 ADR-0002` - it says what this document replaced, and is not read.
186
+ - **`取代` with no `被`**, as in `已接受(取代 ADR-0002)`, retires nothing.
187
+
188
+ | Phase | Chinese status words |
189
+ | --- | --- |
190
+ | `retired` | `已被取代`, `被取代`, `已取代`, `已棄用`, `棄用`, `已弃用`, `弃用`, `已廢棄`, `廢棄`, `已废弃`, `废弃`, `已停用`, `已過時`, `已过时`, `已否決`, `否決`, `已否决`, `否决`, `已拒絕`, `已拒绝`, `不採納`, `不采纳`, `已撤回`, `撤回`, `已作廢`, `作廢`, `已作废`, `作废`, `延後`, `延后`, `暫緩`, `暂缓`, `擱置`, `搁置` |
191
+ | `record` | `封存`, `已封存`, `歸檔`, `已歸檔`, `归档`, `已归档` |
192
+ | `frozen` | `已定案`, `定案`, `已凍結`, `已冻结` |
193
+ | `active` | `已接受`, `接受`, `已採納`, `採納`, `已采纳`, `采纳`, `已核准`, `核准`, `已批准`, `批准`, `已生效`, `生效`, `已實施`, `已实施`, `已完成` |
194
+ | `draft` | `暫定`, `暂定`, `草稿`, `草案`, `提議`, `提议`, `提案`, `審查中`, `审查中`, `審核中`, `审核中`, `討論中`, `讨论中`, `待審`, `待审`, `待審核`, `待审核` |
195
+
155
196
  | Where | Read from |
156
197
  | --- | --- |
157
- | 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` |
198
+ | Front matter, the first key present | `status`, `狀態`, `状态`, `state`, `stage`, `lifecycle`, `phase`, `adr-status` |
199
+ | A section, under the heading, with or without a colon, `:` or `:` | `Status`, `State`, `Stage`, `Lifecycle`, `Current status`, `狀態`, `状态` |
200
+ | 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
201
  | A directory, for a document that declares no status | `archive`, `archived`, `attic`, `deprecated`, `graveyard`, `historical`, `obsolete`, `rejected`, `retired`, `superseded`, `superceded`, `withdrawn` |
160
202
 
203
+ In front matter only an ASCII colon ends a key, as YAML has it: `狀態:已接受`
204
+ there is a line left unread, where a heading or a label takes either colon.
205
+
206
+ The table is where many teams put a document's fields, `| 狀態 | 已接受 |` or
207
+ `| Status | Accepted |`:
208
+
209
+ - It is read where a `## Status` section is, after one: a document with both is
210
+ read from the section, and front matter still wins over either.
211
+ - A key is compared whole, in any case, emphasis stripped.
212
+ - A table with more columns, one under a heading below the title, and one
213
+ spec-graph reads as a register are never read for the document's status: a
214
+ legend or a register describes other things.
215
+
161
216
  </details>
162
217
 
163
218
  `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
219
+ it has closed, and every spec-* tool reads it as a **record**:
220
+
221
+ - depending on it is normal;
222
+ - its unticked boxes are not anyone's work;
223
+ - its links are still checked;
224
+ - new work handed to it is still a ghost handover.
225
+
226
+ See [Journals, changelogs and minutes](#journals-changelogs-and-minutes). Unlike
227
+ a journal, it still answers for what it claims: a supersession it declares, a
169
228
  cycle it closes and a link to itself are reported as they would be anywhere.
229
+
170
230
  To retire a decision, say `superseded` or `deprecated` -
171
231
  `archived, superseded by ADR-0009` is retired - and a document under an
172
232
  `archive/` directory that says nothing about itself is still retired.
@@ -215,33 +275,42 @@ code. *"We resolved to keep the queue"* does not close anything.
215
275
  | Disposition | Checkboxes | Markers | `state=` |
216
276
  | --- | --- | --- | --- |
217
277
  | `unresolved` | `[ ]` `[?]` `[!]` `[*]` | `unresolved`, `still open`, `open question`, `tbd`, `undecided`, `reopened` | `open`, `unresolved`, `todo` |
218
- | `narrowed` | `[~]` `[/]` | `partially resolved`, `partially answered`, `partially done`, `partly resolved`, `narrowed`, `narrowed to`, `scoped down`, `reduced scope`, `reduced to`, `partial`, `in progress`, `in_progress`, `remaining`, `split` | `narrowed`, `partial` |
219
- | `delegated` | | `delegated`, `delegated to`, `moved to`, `tracked in`, `tracked by`, `handed off`, `handed off to`, `handed to`, `deferred to`, `follow up in`, `follow-up in`, `followup in`, `continued in`, `owned by` | `delegated` |
220
- | `satisfied` | `[x]` `[X]` `[+]` | `resolved`, `answered`, `decided`, `settled`, `done`, `complete`, `completed`, `closed`, `fixed`, `shipped`, `addressed`, `implemented`, `solved`, `confirmed` | `satisfied`, `resolved`, `done`, `closed` |
221
- | `accepted-debt` | | `accepted debt`, `accepted as debt`, `accepted risk`, `known limitation`, `known issue`, `known gap`, `technical debt`, `tech debt`, `wontfix`, `won't fix`, `won’t fix`, `will not fix`, `by design`, `tolerated`, `living with it` | `accepted-debt`, `debt`, `wontfix` |
278
+ | `narrowed` | `[~]` `[/]` | `partially resolved`, `partially answered`, `partially done`, `partly resolved`, `narrowed`, `narrowed to`, `scoped down`, `reduced scope`, `reduced to`, `partial`, `in progress`, `in_progress`, `remaining`, `split`, `部分解決`, `部分解决` | `narrowed`, `partial` |
279
+ | `delegated` | | `delegated`, `delegated to`, `moved to`, `tracked in`, `tracked by`, `handed off`, `handed off to`, `handed to`, `deferred to`, `follow up in`, `follow-up in`, `followup in`, `continued in`, `owned by`, `已移交` | `delegated` |
280
+ | `satisfied` | `[x]` `[X]` `[+]` | `resolved`, `answered`, `decided`, `settled`, `done`, `complete`, `completed`, `closed`, `fixed`, `shipped`, `addressed`, `implemented`, `solved`, `confirmed`, `已解決`, `已解决`, `已決定`, `已决定`, `已定案` | `satisfied`, `resolved`, `done`, `closed` |
281
+ | `accepted-debt` | | `accepted debt`, `accepted as debt`, `accepted risk`, `known limitation`, `known issue`, `known gap`, `technical debt`, `tech debt`, `wontfix`, `won't fix`, `won’t fix`, `will not fix`, `by design`, `tolerated`, `living with it`, `技術債`, `技术债`, `已知限制` | `accepted-debt`, `debt`, `wontfix` |
222
282
  | `rejected` | `[-]` | `rejected`, `declined`, `dropped`, `not doing`, `will not do`, `abandoned`, `cancelled`, `canceled`, `withdrawn` | `rejected`, `declined` |
223
283
  | `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
284
 
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.
285
+ Where a marker counts, and how it may be written:
286
+
287
+ - A marker counts where a statement starts: at the start of the item or of a
288
+ line, after the end of a sentence - a Chinese one, `。`, `?`, `!` or `;`,
289
+ with a space after it or none - or after `(` or `[`.
290
+ - Punctuated means followed by `:`, `:`, `-`, `–`, `—` or `.`, so `Done.`
291
+ closes an item; emphasised means the emphasis closes on the marker, so
292
+ `**Closed beta**` does not.
293
+ - A marker of more than one word may be spaced, hyphenated or joined by an
294
+ underscore, whichever the table has: `**Tech-debt:**` and `TECH_DEBT:` are
295
+ `tech debt`. An apostrophe may be straight or curly.
296
+ - A Chinese marker is the English one it translates, and has no case to shout
297
+ in: `已決定:` and `**已決定**` close an item, and `我們已決定採用` does not.
232
298
 
233
299
  </details>
234
300
 
235
301
  Without a box, a bullet is an obligation only at the top level of a section
236
302
  whose heading names open work, however the heading is cased, spaced, emphasised
237
303
  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`,
304
+ heading of its level. Every other bullet is the document's own prose.
305
+
306
+ **Headings that hold obligations:** `Open Questions`, `Open Question`,
307
+ `Unresolved Questions`, `Unanswered Questions`, `Questions`, `Open Issues`,
308
+ `Action Items`, `Actions`, `TODO`, `To Do`, `To-Do`, `TODOs`, `Next Steps`,
309
+ `Follow Ups`, `Follow-Ups`, `Followups`, `Follow Up`, `Follow-Up`,
242
310
  `Unresolved`, `Outstanding`, `Outstanding Questions`, `Tasks`, `Task List`,
243
311
  `Work Items`, `Remaining Work`, `Decisions Needed`, `Blockers`, `Parking Lot`,
244
- `Future Work`, `Deferred`. Every other bullet is the document's own prose.
312
+ `Future Work`, `Deferred`, `待決事項`, `待决事项`, `未決問題`, `未决问题`,
313
+ `待辦事項`, `待办事项`, `後續工作`, `后续工作`.
245
314
 
246
315
  ### References, resolved forgivingly and validated strictly
247
316
 
@@ -260,15 +329,16 @@ trusts:
260
329
  - **Bare identifiers in prose are opportunistic.** They are reported only when
261
330
  their family already exists in the corpus. `ADR-0099` in a repository of ADRs
262
331
  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`.
332
+ - A one-letter prefix is never a family - `Q3`, `x86`, `p99` - and nor are
333
+ the prefixes of versions, pages, protocols and platforms, even in a
334
+ repository that has a family of that name.
335
+ - **Never a citation:** `ver 3`, `version 4`, `pp.12`, `Fig.3`, `Figure 4`,
336
+ `Table 2`, `tbl 1`, `section 4`, `sect 2`, `sec 5`, `step 2`, `item 3`,
337
+ `No.5`, `num 7`, `line 42`, `ln 12`, `col 3`, `port 8080`, `PR 45`,
338
+ `issue 42`, `GH-123`, `UTF-8`, `ASCII 7`, `SHA-256`, `MD5`, `HTTP 2`,
339
+ `HTTPS 2`, `IPv6`, `IPv4 32`, `IPv6 128`, `TLS 1.3`, `SSL 3`, `ES2015`,
340
+ `ECMA-262`, `p99 250`, `base64`, `SQL-92`, `ARM64`, `x86-64`, `Win32`,
341
+ `Node 22`, `Python 3`, `Java 21`, `Go 1.22`, `cpp20`.
272
342
  - **Bare numbers are family-scoped.** `0007` inside an RFC means `RFC-0007`,
273
343
  never `ADR-0007`. A repository with both is ordinary, and guessing would be
274
344
  worse than silence. A document's family is the prefix of its identifier, or
@@ -283,66 +353,20 @@ trusts:
283
353
  so `[ADR-7](https://example.com/adr-7)` yields one reference rather than three.
284
354
  - **Source links are not spec links.** `[rules.ts](../../src/rules.ts)` is not a
285
355
  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).
356
+ Nor is a link to:
357
+ - any other file that is not a document: an image, a dotfile such as
358
+ `../.nvmrc`, or a drawing such as `arch.excalidraw`, however long its
359
+ extension. A dot followed by a digit, as in `ADR-0007.1`, is part of an
360
+ identifier and is still resolved.
361
+ - a directory, `../guides/` or `../guides` alike - without the slash the disk
362
+ says which it is, and a name with no slash at all, `guides`, is an
363
+ identifier.
364
+
365
+ A document is named by an explicit `id:` (or `adr:`, `adr-id:`, `rfc:`,
366
+ `rfc-id:`, `kep-number:`, `number:` or `slug:`), then by its file name, then by
367
+ its H1, and the H1 is read as a name only when neither of the others gave one.
368
+ A row of a register is named by its ID column instead: see
369
+ [Registers](#registers-many-specifications-in-one-file).
346
370
 
347
371
  ### When `[[...]]` tags a concept
348
372
 
@@ -374,7 +398,7 @@ still a relation in the graph no matter what you exclude — not even
374
398
  `--ignore-ref "*"` can delete one. See
375
399
  [ADR-0008](https://github.com/DescentVTT/spec-graph/blob/main/docs/adr/0008-wiki-links-carry-no-path.md).
376
400
 
377
- #### A gap that is deliberate
401
+ ### A gap that is deliberate
378
402
 
379
403
  Real filing histories have holes: an ADR number reserved and then withdrawn, a
380
404
  document that was folded into another before it was ever committed. Something
@@ -447,27 +471,178 @@ Blocked by [ADR-0009](0009-compliance.md). → blocked-by
447
471
 
448
472
  A phrase governs a reference when it stands no more than 40 characters before
449
473
  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.
474
+
475
+ - A negation between the two - `owned by nobody` - cancels it.
476
+ - Prose reads both directions too: `supersedes` and `superseded by`, `amends`
477
+ and `amended by`, `blocked by` and `blocks` or `blocking`, `depends on` and
478
+ `depended on by` - the second of each pair points back at the document that
479
+ wrote it.
480
+ - A phrase that follows the reference makes the reference its subject -
481
+ `[ADR-0009](0009.md) supersedes this` - except a `depends-on` or `assumes`
482
+ phrase: `[ADR-0002](0002.md) requires a migration` says nothing about this
483
+ document.
484
+
485
+ In Chinese a phrase before the reference hands the work on, as `deferred to`
486
+ does. **Chinese hand-off phrases:** `延後至`, `延后至`, `延至`, `移交至`,
487
+ `移交給`, `移交给`, `留待`, `交由`, `併入`, `并入`.
488
+
489
+ - Chinese puts no space between words, so one counts wherever it stands,
490
+ `此問題延後至ADR-0002`, except directly after a negation - `不`, `未`, `非`,
491
+ `沒`, `没`, `無`, `无`, `勿`, `尚未` or `不再` - so `未移交給 ADR-0002` hands
492
+ nothing on.
493
+ - An English phrase counts beside Chinese as well, `此問題deferred to ADR-0002`:
494
+ a Han character is not part of an English word.
495
+ - A statement also ends at `。`, `?`, `!` and `;`, with a space after it or
496
+ none, so `上次延後至別處。另見 ADR-0002` hands nothing to ADR-0002.
497
+ - A phrase written around the reference, `由 ADR-0002 決定` or
498
+ `在 ADR-0002 中追蹤`, is not read, and neither are `根據`, `依據` and `基於`:
499
+ Chinese uses them for any citation, and an `assumes` read from one would make
500
+ a stale premise of it.
457
501
 
458
502
  A link under a heading that files it as bookkeeping is `relates-to` unless a
459
503
  phrase beside it says more. **Bookkeeping headings:** `See also`, `References`,
460
504
  `Reference`, `Related`, `Related work`, `Related decisions`,
461
505
  `Related documents`, `Links`, `Further reading`, `Prior art`, `History`,
462
506
  `Changelog`, `Change log`, `Revision history`, `Bibliography`, `Sources`,
463
- `Appendix`, `More information`, `Resources`, `Index`.
507
+ `Appendix`, `More information`, `Resources`, `Index`, `參考資料`, `参考资料`,
508
+ `相關文件`, `相关文件`, `延伸閱讀`, `延伸阅读`, `附錄`, `附录`, `修訂紀錄`, `修订记录`.
509
+
510
+ Two properties do the work:
464
511
 
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.
512
+ - `loadBearing` relations (`assumes`, `depends-on`, `blocked-by`, `amends`) make
513
+ the source's validity depend on the target — those are what turn stale.
514
+ - `transfersObligation` relations (`delegates-to`, `blocked-by`) move work —
515
+ those are what become ghosts.
516
+
517
+ A "See also" link is neither, and is never reported, which is what keeps the
518
+ signal-to-noise ratio high enough to leave the tool switched on.
519
+
520
+ ### Registers: many specifications in one file
521
+
522
+ One file per decision is the MADR, KEP and RFC layout, and it is what most
523
+ repositories do. Plenty keep a register instead — one document holding dozens of
524
+ decisions. **A specification is a region of a file, not a file**, so both work:
525
+
526
+ ```md
527
+ ## ADR-0007: Shard the write path
528
+
529
+ **Status:** Superseded by ADR-0012
530
+
531
+ Shard by tenant id.
532
+ ```
533
+
534
+ Each section with an identifier *and* a status - a `**Status:**` line,
535
+ `**狀態:**` in Chinese, or a `### Status` section under its heading - becomes a
536
+ specification in its own right, with its own lifecycle, its own obligations and
537
+ its own relations — resolvable from anywhere in the corpus as `ADR-0007`,
538
+ whether it lives in a register or in its own file. Moving it out later breaks
539
+ no citation.
540
+
541
+ A register kept as a table works the same way, with relations typed by the column
542
+ header you wrote:
543
+
544
+ ```md
545
+ | ID | Title | Status | Depends on | Superseded by |
546
+ | :------- | :------------- | :--------- | :--------- | :------------ |
547
+ | ADR-0001 | Use one writer | Superseded | - | ADR-0003 |
548
+ | ADR-0002 | Cache eviction | Accepted | ADR-0001 | - |
549
+ ```
550
+
551
+ Findings point at the declaring cell, not at the file. A cell that is empty or
552
+ holds only a placeholder — `-`, `—`, `n/a`, `none`, `nil`, `TBD` — says nothing.
553
+ A status cell is read as a status line under a heading is, so
554
+ `Superseded by ADR-0003` there names the row's successor.
555
+
556
+ A row is named by its ID column, and a title is only a title:
557
+
558
+ - A row whose ID cell names nothing — empty, a placeholder, or a `?` nobody has
559
+ numbered yet — is not a specification.
560
+ - An identifier a title opens with belongs to the document the row is *about* —
561
+ an issue reading `| OI-V-05 | ADR-040's enforcement point has no browser test |`
562
+ is filed under `OI-V-05`, and citations of `ADR-040` still reach `ADR-040`.
563
+
564
+ The same holds for a whole file, whose H1 is a name only when nothing else gave
565
+ one: see [References](#references-resolved-forgivingly-and-validated-strictly).
566
+
567
+ The register itself stays in the graph as the thing that holds them, so
568
+ `spec-graph query 'document[id=register] -contains-> document'` lists what is
569
+ inside it.
570
+
571
+ Both forms need **two** signals, and that is deliberate: `## Q3 2026 Roadmap`
572
+ parses as family `Q`, number 3, and `## v1.2.0` in a changelog parses as family
573
+ `v`, number 1. Neither declares a status, so neither is a specification. A table
574
+ of identifiers and prose is a citation list, not a register. See
575
+ [ADR-0009](https://github.com/DescentVTT/spec-graph/blob/main/docs/adr/0009-a-specification-is-a-region.md).
576
+
577
+ ## Directives
578
+
579
+ Inference covers almost everything, but inference you cannot override is a trap.
580
+ Directives are ordinary HTML comments — invisible in every Markdown renderer:
581
+
582
+ ```md
583
+ <!-- @spec-node id="ADR-0007" status="accepted" aliases="sharding, adr-7" -->
584
+ <!-- @spec-item id="shard-key" state="narrowed" -->
585
+ <!-- @spec-edge kind="delegates-to" to="ADR-0011#scope" -->
586
+ <!-- @spec-ignore -->
587
+ <!-- @spec-history -->
588
+ ```
589
+
590
+ Each takes these attributes. Any other is a parse problem, listed by
591
+ `--verbose`, and the directive still applies:
592
+
593
+ | directive | attributes |
594
+ | --- | --- |
595
+ | `@spec-node` | `id`, `status`, `title`, `aliases`, `kind` |
596
+ | `@spec-item` | `id`, `state`, `title`, `owner`, `note` |
597
+ | `@spec-edge` | `kind`, `to`, `from`, `reason` |
598
+ | `@spec-ignore` | `reason` |
599
+ | `@spec-history` | none |
600
+
601
+ - `reason`, `note`, `owner` and a node's `kind` are for whoever reads the
602
+ source; spec-graph reads nothing from them.
603
+ - `aliases` splits at commas, semicolons and spaces.
604
+ - A value is quoted with either quote mark, or bare when it holds no space or
605
+ quote, and an attribute with no value is a flag.
606
+ - A directive is the first thing in its comment, so a comment that only
607
+ mentions one is a comment.
608
+
609
+ A directive always wins, and the report says the state came from a directive, so
610
+ an override is visible rather than mysterious.
611
+
612
+ A `@spec-item` annotates one item: the one directly below it, with nothing but
613
+ blank lines or other comments between, or else the one it is written on or
614
+ indented under. An id that two items end up with is a parse problem, listed by
615
+ `--verbose`, and the first item keeps it.
616
+
617
+ ## Journals, changelogs and minutes
618
+
619
+ A 2024 journal noting *"decision deferred to ADR-002"* is not delegating
620
+ anything. It is reporting that somebody once did. When ADR-002 retires in 2026,
621
+ the note does not become a defect — there is nothing in it for anyone to fix.
622
+
623
+ Declare those files and spec-graph stops holding them to a lifecycle they never
624
+ had:
625
+
626
+ ```json
627
+ { "historyPatterns": ["**/JOURNAL_*.md", "archive/**"] }
628
+ ```
629
+
630
+ or, for one file, `<!-- @spec-history -->` at the top of it. A brief whose
631
+ status is `archived` is a record already, with nothing declared, though not a
632
+ log: the supersessions, cycles and self-references it declares are still
633
+ checked.
634
+
635
+ | | |
636
+ | --- | --- |
637
+ | its links resolve | **still checked** — a broken link is broken whoever wrote it |
638
+ | its checkboxes | not obligations, and not in the headline count |
639
+ | its delegations | reports of what was said |
640
+ | work handed **into** it | **still checked** — a log will never act on it |
641
+
642
+ That last row is the point. Excluding the file with `--ignore` would have
643
+ silenced the whole lot, including the links, and a journal full of 404s is
644
+ exactly what this tool is for. See
645
+ [ADR-0011](https://github.com/DescentVTT/spec-graph/blob/main/docs/adr/0011-a-record-is-not-a-specification.md).
471
646
 
472
647
  ## The query language
473
648
 
@@ -535,12 +710,10 @@ predicate is linear in the subject whatever the pattern — `^([A-Za-z0-9_]+[ ]?
535
710
  against a fifty-four-character title takes `RegExp` 103 seconds and this 13
536
711
  microseconds. It reads the usual syntax minus backreferences and lookaround,
537
712
  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.
713
+ character pointed at. The automaton is spec-core's, the library the spec-* tools
714
+ share, copied into this package rather than installed, and so is the one
715
+ [globs](#patterns) are matched by. See
716
+ [ADR-0017](https://github.com/DescentVTT/spec-graph/blob/main/docs/adr/0017-a-predicate-must-finish.md).
544
717
 
545
718
  On a register, a region answers its file's front matter for every descriptive
546
719
  key — `fm.owner` on a decision inside a register is the register's owner — while
@@ -612,74 +785,6 @@ spec-graph: .spec-graph.json: rules.owner: "{1.phse}" asks for an attribute
612
785
 
613
786
  See [ADR-0016](https://github.com/DescentVTT/spec-graph/blob/main/docs/adr/0016-a-query-needs-a-sentence.md).
614
787
 
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
788
  ## Adopting this on a repository that predates it
684
789
 
685
790
  Fifty findings on day one is not a report, it is a decision to be ignored. So
@@ -736,64 +841,6 @@ the other case and stays optional, because a repository declares the path before
736
841
  the first run records the file — `--verbose` says when it read nothing. Either
737
842
  may be absolute. See [ADR-0012](https://github.com/DescentVTT/spec-graph/blob/main/docs/adr/0012-a-baseline-is-a-ratchet.md).
738
843
 
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
844
  ## Configuration
798
845
 
799
846
  Anything you repeat on every run belongs to the repository rather than to the
@@ -823,10 +870,15 @@ command. spec-graph reads the first of `.spec-graph.json`,
823
870
  The file is looked for in the working directory and then upward, as far as the
824
871
  repository — a directory holding `.git` — and **the directory holding it becomes
825
872
  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).
873
+ reports, byte for byte, because every path here is relative to the root.
874
+
875
+ - The nearest file wins.
876
+ - `--root` names the root yourself and turns discovery off.
877
+ - A path typed on the command line stays relative to where you typed it, brace
878
+ alternative by brace alternative: `{/docs,x}` typed in `sub` is `/docs` or
879
+ `sub/x`.
880
+
881
+ See [ADR-0018](https://github.com/DescentVTT/spec-graph/blob/main/docs/adr/0018-the-configuration-file-is-the-root.md).
830
882
 
831
883
  A flag always wins over the file, and list flags **add** to it rather than
832
884
  replacing it — a `--ignore-ref` on the command line is one more exclusion, not a
@@ -839,15 +891,19 @@ it can be combined with any `--format` and leave the document on stdout intact.
839
891
  — the lines under it in the terminal, `related` in JSON, `relatedLocations` in
840
892
  SARIF — and is 8 unless set; `0` lists none.
841
893
 
842
- A broken config stops the run with exit `2`, each problem named on stderr —
894
+ A broken config stops the run with exit `2`, each problem named on stderr:
843
895
  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.
896
+ compile. Why a mistake stops the run:
897
+
898
+ - A configuration that did not load checks a different repository than the one
899
+ configured, and would report *that* one as consistent: a `{1.phse}` for
900
+ `{1.phase}` in one rule's message is a green build over the files the
901
+ correctly spelled rule fails.
902
+ - An unknown key counts, for the same reason it is reported at all — a silently
903
+ ignored `ignoreReference` is a configuration that looks applied and is not.
904
+
905
+ `--no-config` checks on defaults instead, and the exit code then says which run
906
+ it was.
851
907
 
852
908
  ### Patterns
853
909
 
@@ -870,52 +926,76 @@ recorded on a Windows checkout holds in Linux CI:
870
926
  | `!docs/drafts/**` | takes back what an earlier pattern matched; the last to match wins |
871
927
  | `docs\adr\*.md` | the same as `docs/adr/*.md`: a `\` is a separator |
872
928
 
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`.
929
+ Three kinds of pattern stop the run with exit `2`, and the run names the
930
+ pattern, wherever it was written:
931
+
932
+ - a pattern that cannot mean a path under the root — an unclosed `[` or `{`,
933
+ `..`, a lone `.` or `./`, an empty one, or braces that give one of them,
934
+ `{./,docs}`;
935
+ - an extended glob, `+(a|b)`: write `{a,b}`;
936
+ - one too large to compile: braces that expand to more than 256 patterns, or a
937
+ pattern past 65,536 automaton states.
938
+
939
+ Globs - the patterns to check, `--ignore`, `--ignore-ref`, `--history` - are
940
+ matched by an automaton rather than by `RegExp`, because a glob with three stars
941
+ in it is enough to keep `RegExp` busy for two minutes over a long reference
942
+ target. See
943
+ [ADR-0017](https://github.com/DescentVTT/spec-graph/blob/main/docs/adr/0017-a-predicate-must-finish.md).
944
+
945
+ A class never matches a `/`. A parenthesis with no `|` in its group is itself,
946
+ so `C++(notes).md` names that file. A `..` typed below the root,
947
+ `spec-graph "../*.md"` from `docs/deep`, is resolved against where you typed it;
948
+ one that climbs past the root is refused, and so is a pattern typed in a
949
+ directory whose name a pattern would read as syntax, such as `notes[1]`.
950
+
951
+ A bare name and a path read differently as an `--ignore`:
952
+
953
+ - A bare `--ignore` name, `--ignore drafts`, still prunes that directory at any
954
+ depth, even where a pattern starts inside it or names it.
955
+ - A `!` before one gives the name back, as a `.gitignore` line does:
956
+ `--ignore drafts --ignore '!drafts'` reads every `drafts`, and the last bare
957
+ name to name a directory decides.
958
+ - A path, `--ignore "docs/*"`, takes out what it matches wherever a pattern
959
+ starts: `docs/drafts/**/*.md` finds nothing under it, as `docs/**/*.md` finds
960
+ nothing in `docs/drafts`.
890
961
 
891
962
  The walk never enters `.git`, `.hg`, `.svn`, `.cache`, `.next`, `.nuxt`,
892
963
  `.turbo`, `.venv`, `node_modules`, `bower_components`, `vendor`, `dist`,
893
964
  `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).
965
+ patterns name. Unlike an `--ignore`, these yield:
966
+
967
+ - to a pattern that starts inside one: `vendor/specs/*.md` is read from there,
968
+ whatever the other patterns reach;
969
+ - to a plain name for one: `vendor` and `docs/vendor` read those directories
970
+ as `vendor/` and `docs/vendor/` do, or the file, where one of them is a file;
971
+ - to a `!` before their name: `--ignore '!vendor'` reads every `vendor`, the
972
+ rest of the list still skipped inside it. A `!` before a path takes back only
973
+ what a path `--ignore` took out, so `!docs/vendor` gives back nothing the walk
974
+ skips: name it among the patterns.
975
+
976
+ Nor does the walk follow a link, a pattern that starts beyond one included:
977
+ `docs/linked/*.md` reads nothing through the link `docs/linked`, as
978
+ `docs/**/*.md` reads nothing there. `followSymlinks` in the
979
+ [API](#programmatic-api) follows links, and:
980
+
981
+ - prunes a link to a directory as it prunes a directory of that name and path:
982
+ a linked `node_modules` is skipped, and an `--ignore` takes a link out;
983
+ - never follows a link back into a directory the walk is inside, so a cycle is
984
+ read once, and says nothing of it, as it says nothing of a link it does not
985
+ follow.
986
+
987
+ `--ignore-ref` is matched against reference targets rather than paths:
988
+
989
+ - a bare pattern names one target exactly;
990
+ - case is ignored on every host;
991
+ - a `\` escapes the next character;
992
+ - `.` and `..` are the text of the link, in a brace alternative as anywhere
993
+ else: `docs/{.,x}` names `docs/.` and `docs/x`, and `{./,docs}` the targets
994
+ under `./`, and `docs`;
995
+ - a leading `/` is the text of the link too: `{/docs/a.md,x}` names the target
996
+ `/docs/a.md`, as `/docs/a.md` does, and not `docs/a.md`.
997
+
998
+ See [ADR-0022](https://github.com/DescentVTT/spec-graph/blob/main/docs/adr/0022-globs-are-the-family-path-dialect.md).
919
999
 
920
1000
  ### Family rules
921
1001
 
@@ -976,8 +1056,8 @@ spec-graph diff <before> <after> Compare two JSON graph exports.
976
1056
  --record-baseline <f> Write today's findings as accepted debt, exit 0
977
1057
  --ratchet Also fail when a baseline entry no longer occurs
978
1058
  --no-config Ignore .spec-graph.json and the package.json key
979
- --format <fmt> human | json | sarif (check) | gitlab (check)
980
- | markdown (check, diff)
1059
+ --format <fmt> human | json | sarif (check) | github (check)
1060
+ | gitlab (check) | markdown (check, diff)
981
1061
  --graph-format <fmt> dot | mermaid | json
982
1062
  --documents-only Hide items; their relations lift onto their documents
983
1063
  --rule <id>=<severity> error | warn | info | off (repeatable)
@@ -994,13 +1074,110 @@ never masquerades as a passing build, and neither does a pattern that matches
994
1074
  nothing, a configuration file that did not load, or a `--baseline` that is not
995
1075
  there.
996
1076
 
997
- ## Continuous integration
1077
+ **There is no `--watch`.** A full run on this repository takes 60 ms, so the
1078
+ loop that would justify a resident process is one line of shell, and it belongs
1079
+ to whatever the developer already uses rather than to this tool:
1080
+
1081
+ ```bash
1082
+ npx spec-graph --format json | jq -r '.summary'
1083
+ ```
1084
+
1085
+ ## In CI
1086
+
1087
+ The exit code is the contract: `0` clean, `1` findings, `2` the tool could not
1088
+ run. That is all most pipelines need.
1089
+
1090
+ Beyond that, a format puts the findings where somebody will look:
1091
+
1092
+ | Format | Where the findings land |
1093
+ | --- | --- |
1094
+ | `--format sarif` | GitHub code scanning: comments on the changed lines |
1095
+ | `--format github` | annotations on the diff, from the job's log, with no upload step |
1096
+ | `--format gitlab` | the Code Quality report of a GitLab merge request |
1097
+ | `--format markdown` | a table for the job summary, where reviewers look |
1098
+ | `--format json` | a script or a bot of your own |
1099
+
1100
+ ### Code scanning, with SARIF
1101
+
1102
+ For annotations on the diff rather than a line in a log, emit SARIF and hand it
1103
+ to the uploader that already exists. The upload needs `security-events: write`,
1104
+ and a job that declares its permissions has only those, so it names
1105
+ `contents: read` for its checkout as well:
998
1106
 
999
1107
  ```yaml
1000
- - name: Check the specification graph
1001
- run: npx spec-graph "docs/**/*.md" --format json > spec-graph.json
1108
+ permissions:
1109
+ contents: read
1110
+ security-events: write
1111
+ steps:
1112
+ # check out the repository and set up Node, then:
1113
+ - run: npx spec-graph --format sarif > spec-graph.sarif
1114
+ continue-on-error: true
1115
+ - uses: github/codeql-action/upload-sarif@v4
1116
+ with:
1117
+ sarif_file: spec-graph.sarif
1118
+ ```
1119
+
1120
+ Findings land as comments on the changed lines, and they persist across commits
1121
+ without drifting, because the `partialFingerprints` spec-graph writes are the
1122
+ same identity a baseline is keyed on — the rule, the specification, and the
1123
+ citation, with no line number in it (ADR-0012).
1124
+
1125
+ ### Annotations, with `--format github`
1126
+
1127
+ Without code scanning, `--format github` writes one GitHub Actions workflow
1128
+ command per finding, and the job's log turns each into an annotation on the
1129
+ diff, with no upload step:
1130
+
1131
+ ```yaml
1132
+ - run: npx spec-graph --format github
1002
1133
  ```
1003
1134
 
1135
+ ```text
1136
+ ::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
1137
+ ```
1138
+
1139
+ - An `error` finding is an `error`, a `warn` a `warning` and an `info` a
1140
+ `notice`, as spec-guard writes them, and one only `--strict` made an error is
1141
+ titled `ghost-handover (strict)`.
1142
+ - The message carries the hint.
1143
+ - `%` and line breaks are escaped everywhere, and `:` and `,` in the file and
1144
+ the title, as GitHub reads them.
1145
+ - A clean run writes nothing.
1146
+
1147
+ ### Code Quality, with `--format gitlab`
1148
+
1149
+ On GitLab, `--format gitlab` writes the Code Quality report a merge request
1150
+ reads, and the merge request shows which findings it introduced and which it
1151
+ resolved:
1152
+
1153
+ ```yaml
1154
+ spec-graph:
1155
+ script:
1156
+ - npx spec-graph --format gitlab > gl-code-quality-report.json
1157
+ artifacts:
1158
+ when: always
1159
+ reports:
1160
+ codequality: gl-code-quality-report.json
1161
+ ```
1162
+
1163
+ Each finding is an issue:
1164
+
1165
+ - `check_name` is its rule, `description` its message and hint, and `location`
1166
+ its file and line.
1167
+ - Severities map as `error` to `critical` - or `major` when only `--strict` made
1168
+ it an error - `warn` to `minor` and `info` to `info`.
1169
+ - The `fingerprint` is a SHA-256 of the rule, the specification and what within
1170
+ it - a citation's target, or the document at the other end - which is what a
1171
+ baseline counts by. There is no line in it, so a finding a paragraph moved is
1172
+ not reported as new, and no message, so ticking one of three open boxes does
1173
+ not make an `orphaned-obligation` new either; the second of two findings
1174
+ identical in all three is told apart by its order.
1175
+
1176
+ `when: always` keeps the report when the job fails, which it does, with exit
1177
+ `1`, whenever there is an error to report.
1178
+
1179
+ ### A summary for reviewers, with `--format markdown`
1180
+
1004
1181
  `--format markdown` writes the same facts as a table, for the place reviewers
1005
1182
  actually look:
1006
1183
 
@@ -1014,6 +1191,17 @@ It leads with the verdict, counts the corpus, gives every finding its hint, and
1014
1191
  *names* the baseline entries that no longer occur rather than counting them —
1015
1192
  on a pull request a count is the one thing a reader cannot act on.
1016
1193
 
1194
+ ### JSON, for anything of your own
1195
+
1196
+ `--format json` is the machine-readable output to parse if you are building
1197
+ something of your own: it carries the baseline note, the summary and the
1198
+ per-file detail that SARIF and Code Quality have nowhere to put.
1199
+
1200
+ ```yaml
1201
+ - name: Check the specification graph
1202
+ run: npx spec-graph "docs/**/*.md" --format json > spec-graph.json
1203
+ ```
1204
+
1017
1205
  The JSON report is versioned, flat, and carries start and end positions for
1018
1206
  every finding, so a bot can annotate a diff:
1019
1207
 
@@ -1038,6 +1226,8 @@ every finding, so a bot can annotate a diff:
1038
1226
  }
1039
1227
  ```
1040
1228
 
1229
+ ### What a pull request changed
1230
+
1041
1231
  A check says what is wrong. `spec-graph diff` says what a pull request changed:
1042
1232
  documents added, removed, moved or accepted, relations added or removed, and
1043
1233
  obligations resolved or reopened. It compares two exports made by the same
@@ -1050,15 +1240,20 @@ binary, so CI makes one from the base branch and one from the pull request:
1050
1240
  - run: npx spec-graph diff base.json head.json --format markdown >> "$GITHUB_STEP_SUMMARY"
1051
1241
  ```
1052
1242
 
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).
1243
+ It names only what it can tell apart:
1244
+
1245
+ - A document keeps its id through a rename, so a moved document reads as moved.
1246
+ - An obligation's id is its position in its section, so inserting a question
1247
+ renumbers every question below it. An obligation is therefore paired only by
1248
+ an id its author declared, or by its document, section and title when nothing
1249
+ else shares them.
1250
+ - Anything unpaired is reported as having appeared or disappeared, never as
1251
+ resolved or reopened.
1252
+ - Findings are left to `--baseline`, which already reports what is new.
1253
+
1254
+ The exit code is `0` whether anything changed or not: a diff describes, and
1255
+ `check` gates. See
1256
+ [ADR-0020](https://github.com/DescentVTT/spec-graph/blob/main/docs/adr/0020-a-diff-names-what-it-can-tell-apart.md).
1062
1257
 
1063
1258
  ## Visualising
1064
1259
 
@@ -1130,9 +1325,9 @@ tools share, whose code is copied into `src/vendor/` and checked by hash rather
1130
1325
  than installed. So the whole package is auditable in an afternoon — and nothing
1131
1326
  in the pipeline can take longer than its input.
1132
1327
 
1133
- **Verified, not just covered.** 1,826 tests; 99.3% statement and 99.7% line
1328
+ **Verified, not just covered.** 1,996 tests; 99.4% statement and 99.8% line
1134
1329
  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
1330
+ score - 96.34% over 8,575 mutants, measured by CI's full sweep, and a build
1136
1331
  fails under 93 - because a vocabulary entry or a boundary
1137
1332
  condition can be weakened by an ordinary-looking refactor without a single test
1138
1333
  going red. [ADR-0007](https://github.com/DescentVTT/spec-graph/blob/main/docs/adr/0007-mutation-testing.md) is straight about what
@@ -1157,6 +1352,14 @@ about it.
1157
1352
  consistent with *each other*. They share conventions and a philosophy, compose
1158
1353
  cleanly, and neither requires the other.
1159
1354
 
1355
+ ## Contributing
1356
+
1357
+ How to build, test and release spec-graph, and where a change usually goes, is
1358
+ in [CONTRIBUTING.md](https://github.com/DescentVTT/spec-graph/blob/main/CONTRIBUTING.md).
1359
+ Report a vulnerability privately, as
1360
+ [SECURITY.md](https://github.com/DescentVTT/spec-graph/blob/main/SECURITY.md)
1361
+ describes, and not in a public issue.
1362
+
1160
1363
  ## Licence
1161
1364
 
1162
1365
  MIT