@descent-vtt/spec-graph 0.2.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 (70) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +627 -0
  3. package/bin/spec-graph.js +24 -0
  4. package/dist/baseline.d.ts +93 -0
  5. package/dist/baseline.js +187 -0
  6. package/dist/baseline.js.map +1 -0
  7. package/dist/cli.d.ts +66 -0
  8. package/dist/cli.js +565 -0
  9. package/dist/cli.js.map +1 -0
  10. package/dist/config.d.ts +66 -0
  11. package/dist/config.js +183 -0
  12. package/dist/config.js.map +1 -0
  13. package/dist/directives.d.ts +56 -0
  14. package/dist/directives.js +125 -0
  15. package/dist/directives.js.map +1 -0
  16. package/dist/extract.d.ts +116 -0
  17. package/dist/extract.js +1043 -0
  18. package/dist/extract.js.map +1 -0
  19. package/dist/glob.d.ts +73 -0
  20. package/dist/glob.js +284 -0
  21. package/dist/glob.js.map +1 -0
  22. package/dist/graph.d.ts +55 -0
  23. package/dist/graph.js +201 -0
  24. package/dist/graph.js.map +1 -0
  25. package/dist/identity.d.ts +87 -0
  26. package/dist/identity.js +294 -0
  27. package/dist/identity.js.map +1 -0
  28. package/dist/index.d.ts +63 -0
  29. package/dist/index.js +45 -0
  30. package/dist/index.js.map +1 -0
  31. package/dist/lifecycle.d.ts +57 -0
  32. package/dist/lifecycle.js +258 -0
  33. package/dist/lifecycle.js.map +1 -0
  34. package/dist/markdown.d.ts +152 -0
  35. package/dist/markdown.js +820 -0
  36. package/dist/markdown.js.map +1 -0
  37. package/dist/paths.d.ts +22 -0
  38. package/dist/paths.js +58 -0
  39. package/dist/paths.js.map +1 -0
  40. package/dist/report.d.ts +89 -0
  41. package/dist/report.js +326 -0
  42. package/dist/report.js.map +1 -0
  43. package/dist/resolve.d.ts +61 -0
  44. package/dist/resolve.js +340 -0
  45. package/dist/resolve.js.map +1 -0
  46. package/dist/rules.d.ts +61 -0
  47. package/dist/rules.js +557 -0
  48. package/dist/rules.js.map +1 -0
  49. package/dist/runner.d.ts +108 -0
  50. package/dist/runner.js +186 -0
  51. package/dist/runner.js.map +1 -0
  52. package/dist/sections.d.ts +94 -0
  53. package/dist/sections.js +372 -0
  54. package/dist/sections.js.map +1 -0
  55. package/dist/select.d.ts +96 -0
  56. package/dist/select.js +413 -0
  57. package/dist/select.js.map +1 -0
  58. package/dist/source.d.ts +43 -0
  59. package/dist/source.js +109 -0
  60. package/dist/source.js.map +1 -0
  61. package/dist/state.d.ts +45 -0
  62. package/dist/state.js +330 -0
  63. package/dist/state.js.map +1 -0
  64. package/dist/types.d.ts +294 -0
  65. package/dist/types.js +52 -0
  66. package/dist/types.js.map +1 -0
  67. package/dist/yaml.d.ts +37 -0
  68. package/dist/yaml.js +240 -0
  69. package/dist/yaml.js.map +1 -0
  70. package/package.json +71 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 spec-graph contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,627 @@
1
+ # spec-graph
2
+
3
+ **Turn Markdown specifications into a verifiable relational graph.**
4
+
5
+ Your code has a compiler. Your specifications have nothing. `spec-graph` gives
6
+ them a type system: documents become nodes with a lifecycle, tasks become
7
+ obligations with real state, and citations become foreign keys that are actually
8
+ checked.
9
+
10
+ ```text
11
+ ✖ docs/adr/0003-event-log.md:13:65 ghost-handover
12
+ open obligation delegates to ADR-0002, which is retired
13
+ ↳ docs/adr/0002-single-node-storage.md:2:9 ADR-0002 is retired ("Superseded by ADR-0007")
14
+ ↳ docs/adr/0003-event-log.md:13:1 the obligation: Which compaction policy do we use? Deferred to ADR-0002.
15
+ → nothing will be read from ADR-0002 again - re-home this in a live document, or close it here
16
+ ```
17
+
18
+ Exit code 1. The obligation was about to disappear.
19
+
20
+ ---
21
+
22
+ ## Why this exists
23
+
24
+ Specifications rot in ways nobody notices, because nothing checks them.
25
+
26
+ **Ghost handovers.** An active decision defers an open question to a document
27
+ that was archived three months ago. The question is now nowhere. Nobody deleted
28
+ it; it just stopped existing.
29
+
30
+ **Stale premises.** A decision closes, but the sentence explaining the *original*
31
+ constraint stays in the text. Two quarters later a new design is still working
32
+ around a 4 KB row limit that was abolished in the rewrite. The premise is
33
+ disproven and still load-bearing.
34
+
35
+ **Fragile verification.** Teams reach for `grep -r '\- \[ \]'`. It breaks on
36
+ wrapped lines, matches inside code samples, harvests `adr-0007` out of a URL, and
37
+ cannot tell an open question from one that was narrowed, satisfied, or
38
+ consciously accepted as debt.
39
+
40
+ **Broken reference graphs.** `ADR-0009` is cited forty times and has never
41
+ existed.
42
+
43
+ Every one of these is a *relational* defect. They are invisible when you read one
44
+ document and obvious when you can query all of them at once.
45
+
46
+ ## Quickstart
47
+
48
+ ```bash
49
+ npm install --save-dev @descent-vtt/spec-graph
50
+ npx spec-graph
51
+ ```
52
+
53
+ No configuration, no annotations, no migration. It reads what your repository
54
+ already looks like:
55
+
56
+ ```bash
57
+ npx spec-graph "docs/**/*.md"
58
+ ```
59
+
60
+ **The package is scoped; the command is not.** After a local install the binary
61
+ is plain `spec-graph`. Without one, `npx` needs the full name:
62
+
63
+ ```bash
64
+ npx @descent-vtt/spec-graph "docs/**/*.md"
65
+ ```
66
+
67
+ ## What it checks
68
+
69
+ | Rule | Default | What it catches |
70
+ | --- | --- | --- |
71
+ | `ghost-handover` | error | An open obligation handed to a document that can no longer absorb it |
72
+ | `stale-premise` | error | A live document resting on a decision that was retired or obviated |
73
+ | `broken-reference` | error | A citation naming a document or anchor that does not exist |
74
+ | `circular-delegation` | error | Obligations or supersessions in a cycle, so none can ever land |
75
+ | `orphaned-obligation` | error | A retired or frozen document still holding open work |
76
+ | `live-supersession` | error | A superseded document still presenting itself as current |
77
+ | `reference-outside-corpus` | warn | A citation naming a real document the include patterns did not reach |
78
+ | `ambiguous-reference` | warn | A citation matching more than one document |
79
+ | `unreciprocated-supersession` | warn | A retired document that never says what replaced it |
80
+ | `state-conflict` | warn | An item whose two state signals disagree about whether work remains |
81
+ | `self-reference` | info | A document delegating to, or depending on, itself |
82
+
83
+ Override any of them:
84
+
85
+ ```bash
86
+ spec-graph --rule self-reference=off --rule state-conflict=error
87
+ ```
88
+
89
+ Once a repository is clean, `--strict` raises every warning to an error so the
90
+ build stops tolerating them:
91
+
92
+ ```bash
93
+ spec-graph --strict
94
+ ```
95
+
96
+ ```text
97
+ ✖ docs/adr/0001-single-writer.md:2:9 unreciprocated-supersession (strict: warn -> error)
98
+ ADR-0001 is retired but never says that ADR-0002 replaced it
99
+ ↳ docs/adr/0002-multi-writer.md:3:13 only ADR-0002 records the relationship
100
+ → add "superseded-by: ADR-0002" to docs/adr/0001-single-writer.md so a reader who lands there is redirected
101
+
102
+ 2 errors · 20.73ms · 2 raised by --strict
103
+ ```
104
+
105
+ Every escalated finding says so, so you can always tell what the build would do
106
+ without the flag. Two deliberate limits: strict leaves `info` rules alone, since
107
+ those are advisory by design and promoting them would reintroduce exactly the
108
+ noise the defaults avoid; and an explicit `--rule` always wins, so
109
+ `--strict --rule state-conflict=warn` turns strict on and exempts one rule
110
+ rather than making you choose between all of it and none.
111
+
112
+ ## How it reads your documents
113
+
114
+ ### Lifecycle, normalised
115
+
116
+ Every ecosystem invented its own status vocabulary. `spec-graph` maps all of them
117
+ onto four phases, chosen so one question can be answered from the phase alone:
118
+ **can this document still absorb new work?**
119
+
120
+ | Phase | Recognised from | Absorbs work? |
121
+ | --- | --- | --- |
122
+ | `draft` | `proposed`, `provisional`, `wip`, `in-review`, `experimental`, … | yes |
123
+ | `active` | `accepted`, `implementable`, `implemented`, `merged`, `adopted`, … | yes |
124
+ | `frozen` | `final`, `ratified`, `locked`, `published`, … | **no** |
125
+ | `retired` | `superseded`, `deprecated`, `rejected`, `withdrawn`, `archived`, … | **no** |
126
+
127
+ The status is found wherever your team writes it — front matter (`status:`,
128
+ `state:`, `stage:`), a `## Status` section, `**Accepted** (2026-03-01) ✅`, or a
129
+ `docs/adr/archive/` directory. `Accepted, later superseded by ADR-0009` is
130
+ retired: retirement is terminal, so a retirement word anywhere wins.
131
+
132
+ An unrecognised status becomes `unknown`, and rules treat it permissively. It
133
+ never guesses.
134
+
135
+ ### Obligations, with real state
136
+
137
+ A checkbox is the *least* reliable thing a document says about a task:
138
+
139
+ ```md
140
+ - [ ] Should the write path shard before the migration?
141
+ **Resolved (2026-03):** no — one node holds three years of growth.
142
+ ```
143
+
144
+ `grep '\- \[ \]'` re-opens a settled debate. `grep '\- \[x\]'` misses it. Both
145
+ are confidently wrong.
146
+
147
+ `spec-graph` reads every signal — a directive, a prose marker, strikethrough, the
148
+ checkbox, the enclosing section — lets the most specific one win, and **reports
149
+ the disagreement** as `state-conflict` rather than hiding it.
150
+
151
+ Obligations have seven dispositions, not two:
152
+
153
+ | Disposition | Work remains? | Written as |
154
+ | --- | --- | --- |
155
+ | `unresolved` | open | `- [ ]`, under `## Open Questions` |
156
+ | `narrowed` | partial | `- [~]`, `**Narrowed:** only the payment path` |
157
+ | `delegated` | open | `**Tracked in** …`, `deferred to …` |
158
+ | `satisfied` | closed | `- [x]`, `**Resolved:**`, `~~struck through~~` |
159
+ | `accepted-debt` | closed | `**Accepted debt:**`, `**Won't fix:**` |
160
+ | `rejected` | closed | `- [-]`, `**Rejected:**` |
161
+ | `obviated` | closed | `**Moot** — the limit was removed in v9` |
162
+
163
+ `obviated` is the one that matters most: it is the marker that turns every
164
+ load-bearing citation of that item into a stale premise.
165
+
166
+ Markers only count in the forms documents actually use — punctuated
167
+ (`Resolved:`), emphasised (`**Moot**`) or shouted (`RESOLVED`) — and never inside
168
+ code. *"We resolved to keep the queue"* does not close anything.
169
+
170
+ ### References, resolved forgivingly and validated strictly
171
+
172
+ All of these reach the same document:
173
+
174
+ ```md
175
+ [ADR-0007](../adr/0007-sharding.md) [[0007-sharding]] [ADR-7][a7]
176
+ As decided in ADR-0007, ... see 0007 (within the same family)
177
+ ```
178
+
179
+ Resolution is deliberately asymmetric, because the alternative is a tool nobody
180
+ trusts:
181
+
182
+ - **Deliberate references are validated.** A link, a front-matter field or a
183
+ directive that does not resolve is a broken foreign key and is reported.
184
+ - **Bare identifiers in prose are opportunistic.** They are reported only when
185
+ their family already exists in the corpus. `ADR-0099` in a repository of ADRs
186
+ is a real dangling citation; `SHA-256` in the same repository is a sentence.
187
+ - **Bare numbers are family-scoped.** `0007` inside an RFC means `RFC-0007`,
188
+ never `ADR-0007`. A repository with both is ordinary, and guessing would be
189
+ worse than silence.
190
+ - **Two candidates is worse than none.** An ambiguous citation is reported, not
191
+ bound to whichever document was indexed first.
192
+ - **Code never counts.** Links inside fences and inline spans are masked before
193
+ anything is extracted, and link constructs are blanked before prose is scanned,
194
+ so `[ADR-7](https://example.com/adr-7)` yields one reference rather than three.
195
+ - **Source links are not spec links.** `[rules.ts](../../src/rules.ts)` is not a
196
+ broken reference. That is [spec-guard](#relationship-to-spec-guard)'s job.
197
+
198
+ ### Registers: many specifications in one file
199
+
200
+ One file per decision is the MADR, KEP and RFC layout, and it is what most
201
+ repositories do. Plenty keep a register instead — one document holding dozens of
202
+ decisions. **A specification is a region of a file, not a file**, so both work:
203
+
204
+ ```md
205
+ ## ADR-0007: Shard the write path
206
+
207
+ **Status:** Superseded by ADR-0012
208
+
209
+ Shard by tenant id.
210
+ ```
211
+
212
+ Each section with an identifier *and* a status becomes a specification in its own
213
+ right, with its own lifecycle, its own obligations and its own relations —
214
+ resolvable from anywhere in the corpus as `ADR-0007`, whether it lives in a
215
+ register or in its own file. Moving it out later breaks no citation.
216
+
217
+ A register kept as a table works the same way, with relations typed by the column
218
+ header you wrote:
219
+
220
+ ```md
221
+ | ID | Title | Status | Depends on | Superseded by |
222
+ | :------- | :------------- | :--------- | :--------- | :------------ |
223
+ | ADR-0001 | Use one writer | Superseded | - | ADR-0003 |
224
+ | ADR-0002 | Cache eviction | Accepted | ADR-0001 | - |
225
+ ```
226
+
227
+ Findings point at the declaring cell, not at the file.
228
+
229
+ The register itself stays in the graph as the thing that holds them, so
230
+ `spec-graph query 'document[id=register] -contains-> document'` lists what is
231
+ inside it.
232
+
233
+ Both forms need **two** signals, and that is deliberate: `## Q3 2026 Roadmap`
234
+ parses as family `Q`, number 3, and `## v1.2.0` in a changelog parses as family
235
+ `v`, number 1. Neither declares a status, so neither is a specification. A table
236
+ of identifiers and prose is a citation list, not a register. See
237
+ [ADR-0009](docs/adr/0009-a-specification-is-a-region.md).
238
+
239
+ ### When `[[...]]` tags a concept
240
+
241
+ A Markdown link with a path says where the target lives. A wiki link says only
242
+ what it is called — and whether that name means a *document* is a property of
243
+ your repository, not of the link:
244
+
245
+ ```md
246
+ [[0007-sharding]] a file stem, in an Obsidian-style vault
247
+ [[trap 55]] an entry in a catalogue that lives inside another document
248
+ ```
249
+
250
+ spec-graph cannot tell them apart, so it validates by default — an unresolved
251
+ `[[...]]` in a vault is the most common broken reference there is. Where the
252
+ convention is the other one, say so once:
253
+
254
+ ```bash
255
+ spec-graph --ignore-ref "trap *"
256
+ ```
257
+
258
+ The tool names that flag in the hint, so it costs one run to find:
259
+
260
+ ```text
261
+ > fix the identifier, or - if [[...]] tags a concept here - exclude it: --ignore-ref "trap *"
262
+ ```
263
+
264
+ `--ignore-ref` suppresses *findings*, never edges. A reference that resolves is
265
+ still a relation in the graph no matter what you exclude — not even
266
+ `--ignore-ref "*"` can delete one. See
267
+ [ADR-0008](docs/adr/0008-wiki-links-carry-no-path.md).
268
+
269
+ ### Relations
270
+
271
+ Relations are read from front matter, from link context, and from explicit
272
+ directives:
273
+
274
+ ```yaml
275
+ ---
276
+ status: accepted
277
+ supersedes: ADR-0002
278
+ depends-on: [ADR-0004, RFC-0011]
279
+ ---
280
+ ```
281
+
282
+ ```md
283
+ The chunking scheme is constrained by [ADR-0002](0002-rows.md). → assumes
284
+ Which policy? Deferred to [ADR-0011](0011-policy.md). → delegates-to
285
+ Blocked by [ADR-0009](0009-compliance.md). → blocked-by
286
+
287
+ ## See also
288
+ - [ADR-0001](0001-intro.md) → relates-to
289
+ ```
290
+
291
+ Two properties do the work. `loadBearing` relations (`assumes`, `depends-on`,
292
+ `blocked-by`, `amends`) make the source's validity depend on the target — those
293
+ are what turn stale. `transfersObligation` relations (`delegates-to`,
294
+ `blocked-by`) move work — those are what become ghosts. A "See also" link is
295
+ neither, and is never reported, which is what keeps the signal-to-noise ratio
296
+ high enough to leave the tool switched on.
297
+
298
+ ## The query language
299
+
300
+ The two flagship rules are not hand-written traversals. They are selectors,
301
+ parsed at startup:
302
+
303
+ ```text
304
+ ghost-handover *[openness!=closed][phase!=retired] -delegates-to,blocked-by-> *[receptivity=sealed]
305
+ stale-premise *[phase!=retired] -depends-on,assumes,amends,blocked-by-> document[phase=retired]
306
+ ```
307
+
308
+ Which means anything a built-in rule can find, you can find too:
309
+
310
+ ```bash
311
+ # What open work is stranded in documents nobody reads?
312
+ spec-graph query 'document[receptivity=sealed] -contains-> item[openness!=closed]'
313
+
314
+ # Which accepted decisions rest on a draft?
315
+ spec-graph query 'document[phase=active] -depends-on-> document[phase=draft]'
316
+
317
+ # What did ADR-0009 replace, transitively?
318
+ spec-graph query 'document[id=ADR-0009] =supersedes=> document'
319
+
320
+ # Where is our accepted technical debt?
321
+ spec-graph query 'item[state=accepted-debt]'
322
+ ```
323
+
324
+ `query` exits non-zero when nothing matches, so a team convention becomes a build
325
+ gate without writing any code:
326
+
327
+ ```yaml
328
+ - name: No PRD may depend on a draft decision
329
+ run: |
330
+ ! npx spec-graph query 'document[path^=docs/prd] -depends-on-> document[phase=draft]'
331
+ ```
332
+
333
+ **Grammar**
334
+
335
+ ```text
336
+ document[phase=active] -delegates-to-> item[openness=open]
337
+ └──────┘└────────────┘ └─────────────┘ └─────────────────┘
338
+ node predicate relation node
339
+
340
+ Node types document · item · *
341
+ Attributes id kind title path file line phase status receptivity alias
342
+ document state (or disposition) openness section text body
343
+ evidence conflicted fm.<front-matter-key>
344
+ Operators = != ^= $= *= ~= and [attr] for "is present"
345
+ Relations -kind-> <-kind- =kind=> <=kind= (= forms are transitive)
346
+ comma-separate kinds: -assumes,depends-on->
347
+ ```
348
+
349
+ Items inherit their document's lifecycle, so `item[phase=retired]` means what you
350
+ expect. A query evaluates to **paths**, not endpoints, which is why a finding can
351
+ name both ends and the line that connects them.
352
+
353
+ ## Directives
354
+
355
+ Inference covers almost everything, but inference you cannot override is a trap.
356
+ Directives are ordinary HTML comments — invisible in every Markdown renderer:
357
+
358
+ ```md
359
+ <!-- @spec-node id="ADR-0007" status="accepted" aliases="sharding, adr-7" -->
360
+ <!-- @spec-item id="shard-key" state="narrowed" -->
361
+ <!-- @spec-edge kind="delegates-to" to="ADR-0011#scope" -->
362
+ <!-- @spec-ignore -->
363
+ <!-- @spec-history -->
364
+ ```
365
+
366
+ A directive always wins, and the report says the state came from a directive, so
367
+ an override is visible rather than mysterious.
368
+
369
+ ## Journals, changelogs and minutes
370
+
371
+ A 2024 journal noting *"decision deferred to ADR-002"* is not delegating
372
+ anything. It is reporting that somebody once did. When ADR-002 retires in 2026,
373
+ the note does not become a defect — there is nothing in it for anyone to fix.
374
+
375
+ Declare those files and spec-graph stops holding them to a lifecycle they never
376
+ had:
377
+
378
+ ```json
379
+ { "historyPatterns": ["**/JOURNAL_*.md", "archive/**"] }
380
+ ```
381
+
382
+ or, for one file, `<!-- @spec-history -->` at the top of it.
383
+
384
+ | | |
385
+ | --- | --- |
386
+ | its links resolve | **still checked** — a broken link is broken whoever wrote it |
387
+ | its checkboxes | not obligations, and not in the headline count |
388
+ | its delegations | reports of what was said |
389
+ | work handed **into** it | **still checked** — a log will never act on it |
390
+
391
+ That last row is the point. Excluding the file with `--ignore` would have
392
+ silenced the whole lot, including the links, and a journal full of 404s is
393
+ exactly what this tool is for. See
394
+ [ADR-0011](docs/adr/0011-a-record-is-not-a-specification.md).
395
+
396
+ ## Adopting this on a repository that predates it
397
+
398
+ Fifty findings on day one is not a report, it is a decision to be ignored. So
399
+ record what is already wrong, and check only what happens next:
400
+
401
+ ```bash
402
+ spec-graph check --record-baseline .spec-graph-baseline.json
403
+ git add .spec-graph-baseline.json
404
+ ```
405
+
406
+ ```json
407
+ {
408
+ "version": 1,
409
+ "findings": [
410
+ { "rule": "broken-reference", "document": "ADR-0004", "subject": "docs/plans/x.md", "count": 1 }
411
+ ]
412
+ }
413
+ ```
414
+
415
+ CI is green from the first commit, every specification written afterwards is
416
+ checked in full, and a new finding fails the build. When debt is paid, the run
417
+ says so:
418
+
419
+ ```text
420
+ 16ms · 2 accepted by .spec-graph-baseline.json
421
+ 1 baseline entry no longer occurs - tighten it: spec-graph check --record-baseline ...
422
+ ```
423
+
424
+ **It is keyed on the specification and the citation, never on a line number.**
425
+ A baseline that expires when somebody reformats a paragraph is worse than none,
426
+ so the entry survives edits, reordering, and the file being renamed — because
427
+ `ADR-0004` is the decision's name, not its location. Repeats are counted rather
428
+ than told apart, which is the deliberate cost of a key with no position in it.
429
+ See [ADR-0012](docs/adr/0012-a-baseline-is-a-ratchet.md).
430
+
431
+ ## Configuration
432
+
433
+ Anything you repeat on every run belongs to the repository rather than to the
434
+ command. spec-graph reads the first of `.spec-graph.json`,
435
+ `spec-graph.config.json`, or a `"spec-graph"` key in `package.json`:
436
+
437
+ ```json
438
+ {
439
+ "patterns": ["docs/**/*.md"],
440
+ "ignoreReferences": ["trap *"],
441
+ "ignoreFamilies": ["RFC"],
442
+ "historyPatterns": ["**/JOURNAL_*.md"],
443
+ "baseline": ".spec-graph-baseline.json",
444
+ "severities": { "self-reference": "off" },
445
+ "strict": true
446
+ }
447
+ ```
448
+
449
+ A flag always wins over the file, and list flags **add** to it rather than
450
+ replacing it — a `--ignore-ref` on the command line is one more exclusion, not a
451
+ decision to discard what the repository already declared. `--verbose` prints
452
+ which file was read; `--no-config` skips the mechanism entirely.
453
+
454
+ A broken config is reported and the run continues on defaults, including an
455
+ unknown key: a silently ignored `ignoreReference` is a configuration that looks
456
+ applied and is not.
457
+
458
+ ### Family rules
459
+
460
+ Every specification cites RFC 2119. In a repository that also keeps its own
461
+ `RFC-*` documents, that sentence reads as a dangling reference to a local RFC
462
+ 2119 it does not have:
463
+
464
+ ```json
465
+ { "ignoreFamilies": ["RFC"] }
466
+ ```
467
+
468
+ `families` is the stronger statement — *these* are the families this repository
469
+ has — and turns every other noun-number construct back into prose.
470
+
471
+ Both are consulted only after resolution has already failed, so `RFC 0001` still
472
+ resolves to your local RFC-0001 with `RFC` on the ignore list. No configuration
473
+ can delete an edge. See
474
+ [ADR-0010](docs/adr/0010-configuration-belongs-to-the-repository.md).
475
+
476
+ Prose like `Phase 1`, `R69`, `Q-120`, `Table 2` and `Step 4` has never needed
477
+ this: an identifier found in prose is only read as a citation when its family
478
+ already exists in the corpus.
479
+
480
+ ## CLI
481
+
482
+ ```text
483
+ spec-graph [check] [patterns...] Validate the graph. The default.
484
+ spec-graph query <selector> Run a selector, print matching paths.
485
+ spec-graph graph [patterns...] Export for Graphviz, Mermaid or JSON.
486
+ spec-graph rules [--explain] List the diagnostics.
487
+
488
+ --root <dir> Directory patterns resolve against
489
+ --ignore <glob> Skip paths (repeatable)
490
+ --ignore-ref <glob> Do not report these reference targets (repeatable)
491
+ --family <name> Families a bare identifier may name (repeatable)
492
+ --ignore-family <name> Families that are never citations (repeatable)
493
+ --history <glob> Files that log decisions rather than making them
494
+ --baseline <file> Accept these findings; report only what is new
495
+ --record-baseline <f> Write today's findings as accepted debt, exit 0
496
+ --no-config Ignore .spec-graph.json and the package.json key
497
+ --format human|json Report format
498
+ --graph-format <fmt> dot | mermaid | json
499
+ --documents-only Hide items; their relations lift onto their documents
500
+ --rule <id>=<severity> error | warn | info | off (repeatable)
501
+ --strict Raise every warning to an error
502
+ --max <n> Show at most n findings
503
+ --max-warnings <n> Fail when warnings exceed n
504
+ --color / --no-color Force colour
505
+ --ascii ASCII glyphs only
506
+ --verbose Include parse problems
507
+ ```
508
+
509
+ Exit codes: `0` clean, `1` findings, `2` the tool could not run. A mistyped flag
510
+ never masquerades as a passing build, and a pattern matching nothing is an error
511
+ rather than a silent success.
512
+
513
+ ## Continuous integration
514
+
515
+ ```yaml
516
+ - name: Check the specification graph
517
+ run: npx spec-graph "docs/**/*.md" --format json > spec-graph.json
518
+ ```
519
+
520
+ The JSON report is versioned, flat, and carries start and end positions for
521
+ every finding, so a bot can annotate a diff:
522
+
523
+ ```json
524
+ {
525
+ "version": 1,
526
+ "ok": false,
527
+ "summary": { "documents": 7, "items": 7, "edges": 18, "openObligations": 6, "errors": 5 },
528
+ "diagnostics": [
529
+ {
530
+ "rule": "ghost-handover",
531
+ "severity": "error",
532
+ "message": "open obligation delegates to ADR-0002, which is retired",
533
+ "hint": "nothing will be read from ADR-0002 again - re-home this in a live document, or close it here",
534
+ "nodes": ["ADR-0003#open-questions.1", "ADR-0002"],
535
+ "file": "docs/adr/0003-event-log.md",
536
+ "line": 13,
537
+ "column": 65,
538
+ "related": [{ "file": "docs/adr/0002-single-node-storage.md", "line": 2, "note": "ADR-0002 is retired" }]
539
+ }
540
+ ]
541
+ }
542
+ ```
543
+
544
+ ## Visualising
545
+
546
+ ```bash
547
+ spec-graph graph --documents-only --graph-format mermaid > graph.mmd
548
+ spec-graph graph --graph-format dot | dot -Tsvg > graph.svg
549
+ ```
550
+
551
+ Nodes are coloured by phase, so an archived document with arrows still pointing
552
+ into it is visible at a glance. Hiding items lifts their relations onto the
553
+ owning document rather than dropping them.
554
+
555
+ ## Programmatic API
556
+
557
+ ```ts
558
+ import { analyse, formatReport } from '@descent-vtt/spec-graph';
559
+
560
+ const result = await analyse({ root: process.cwd(), patterns: ['docs/**/*.md'] });
561
+ if (!result.ok) console.error(formatReport(result, { color: true }));
562
+ ```
563
+
564
+ The filesystem is optional. Everything below the runner is a pure function of
565
+ text, so a monorepo tool or a language server can skip disk entirely:
566
+
567
+ ```ts
568
+ import { analyseSources, query } from '@descent-vtt/spec-graph';
569
+
570
+ const { graph, diagnostics } = analyseSources([{ path: 'adr/0001.md', text: source }]);
571
+ const ghosts = query(graph, 'item[openness=open] -delegates-to-> document[phase=retired]');
572
+ ```
573
+
574
+ The graph, the scanner, the selector engine and every vocabulary table are
575
+ exported. Reporters, editor extensions and custom rules are all first-class.
576
+
577
+ ## Design
578
+
579
+ Twelve ADRs, which `spec-graph` validates on every CI run:
580
+
581
+ - [ADR-0001 — A hand-written Markdown scanner](docs/adr/0001-hand-written-markdown-scanner.md)
582
+ - [ADR-0002 — A four-phase lifecycle lattice](docs/adr/0002-lifecycle-lattice.md)
583
+ - [ADR-0003 — Item state is resolved from competing signals](docs/adr/0003-item-state-signals.md)
584
+ - [ADR-0004 — Reference resolution is deliberately asymmetric](docs/adr/0004-reference-resolution.md)
585
+ - [ADR-0005 — The rules and the query language share one engine](docs/adr/0005-rules-are-queries.md)
586
+ - [ADR-0006 — False positives cost more than misses](docs/adr/0006-false-positives-cost-more.md)
587
+ - [ADR-0007 — Mutation testing, and what the score actually means](docs/adr/0007-mutation-testing.md)
588
+ - [ADR-0008 — A wiki link carries a name, not a path](docs/adr/0008-wiki-links-carry-no-path.md)
589
+ - [ADR-0009 — A specification is a region of a file, not a file](docs/adr/0009-a-specification-is-a-region.md)
590
+ - [ADR-0010 — Configuration belongs to the repository](docs/adr/0010-configuration-belongs-to-the-repository.md)
591
+ - [ADR-0011 — A historical record is not a specification](docs/adr/0011-a-record-is-not-a-specification.md)
592
+ - [ADR-0012 — A baseline is a ratchet, keyed on identity](docs/adr/0012-a-baseline-is-a-ratchet.md)
593
+
594
+ **Zero runtime dependencies.** Node 22+, native ESM, TypeScript strict with
595
+ `noUncheckedIndexedAccess` and `exactOptionalPropertyTypes`. The Markdown
596
+ scanner, the front-matter reader, the glob matcher and the query parser are all
597
+ written here, so the whole package is auditable in an afternoon.
598
+
599
+ **Verified, not just covered.** 411 tests; 94.2% statement and 96.9% line
600
+ coverage. Coverage says a line ran, so the suite is also held to a mutation
601
+ score - 74.05% over 6,150 mutants - because a vocabulary entry or a boundary
602
+ condition can be weakened by an ordinary-looking refactor without a single test
603
+ going red. [ADR-0007](docs/adr/0007-mutation-testing.md) is straight about what
604
+ that number is and is not.
605
+
606
+ **Fast enough to run on save.** A synthetic corpus of 2,000 documents — 8.9 MB,
607
+ 6,000 obligations, 12,285 relations — is read, parsed, resolved and checked in
608
+ about 1.3 seconds on a laptop, roughly a third of which is file I/O. A typical
609
+ repository with a few dozen ADRs finishes in tens of milliseconds.
610
+
611
+ ## Relationship to spec-guard
612
+
613
+ [`spec-guard`](https://github.com/DescentVTT/spec-guard) checks the **vertical**
614
+ dimension: whether your source code still honours what a specification claims
615
+ about it.
616
+
617
+ ```md
618
+ <!-- @assert-absence target="src/services" symbol="LegacyPaymentGateway" -->
619
+ ```
620
+
621
+ `spec-graph` checks the **horizontal** dimension: whether your specifications are
622
+ consistent with *each other*. They share conventions and a philosophy, compose
623
+ cleanly, and neither requires the other.
624
+
625
+ ## Licence
626
+
627
+ MIT
@@ -0,0 +1,24 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Thin launcher. All logic lives in dist/cli.js so the published binary stays a
4
+ * short shim that is trivially auditable.
5
+ */
6
+ import { pathToFileURL } from 'node:url';
7
+
8
+ const entry = new URL('../dist/cli.js', import.meta.url);
9
+
10
+ let cli;
11
+ try {
12
+ cli = await import(entry.href);
13
+ } catch (error) {
14
+ if (error?.code === 'ERR_MODULE_NOT_FOUND') {
15
+ process.stderr.write(
16
+ `spec-graph: build output missing at ${pathToFileURL(entry.pathname).pathname}\n` +
17
+ 'Run "npm run build" first (or install the published package).\n',
18
+ );
19
+ process.exit(2);
20
+ }
21
+ throw error;
22
+ }
23
+
24
+ process.exitCode = await cli.main();