@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.
- package/LICENSE +21 -0
- package/README.md +627 -0
- package/bin/spec-graph.js +24 -0
- package/dist/baseline.d.ts +93 -0
- package/dist/baseline.js +187 -0
- package/dist/baseline.js.map +1 -0
- package/dist/cli.d.ts +66 -0
- package/dist/cli.js +565 -0
- package/dist/cli.js.map +1 -0
- package/dist/config.d.ts +66 -0
- package/dist/config.js +183 -0
- package/dist/config.js.map +1 -0
- package/dist/directives.d.ts +56 -0
- package/dist/directives.js +125 -0
- package/dist/directives.js.map +1 -0
- package/dist/extract.d.ts +116 -0
- package/dist/extract.js +1043 -0
- package/dist/extract.js.map +1 -0
- package/dist/glob.d.ts +73 -0
- package/dist/glob.js +284 -0
- package/dist/glob.js.map +1 -0
- package/dist/graph.d.ts +55 -0
- package/dist/graph.js +201 -0
- package/dist/graph.js.map +1 -0
- package/dist/identity.d.ts +87 -0
- package/dist/identity.js +294 -0
- package/dist/identity.js.map +1 -0
- package/dist/index.d.ts +63 -0
- package/dist/index.js +45 -0
- package/dist/index.js.map +1 -0
- package/dist/lifecycle.d.ts +57 -0
- package/dist/lifecycle.js +258 -0
- package/dist/lifecycle.js.map +1 -0
- package/dist/markdown.d.ts +152 -0
- package/dist/markdown.js +820 -0
- package/dist/markdown.js.map +1 -0
- package/dist/paths.d.ts +22 -0
- package/dist/paths.js +58 -0
- package/dist/paths.js.map +1 -0
- package/dist/report.d.ts +89 -0
- package/dist/report.js +326 -0
- package/dist/report.js.map +1 -0
- package/dist/resolve.d.ts +61 -0
- package/dist/resolve.js +340 -0
- package/dist/resolve.js.map +1 -0
- package/dist/rules.d.ts +61 -0
- package/dist/rules.js +557 -0
- package/dist/rules.js.map +1 -0
- package/dist/runner.d.ts +108 -0
- package/dist/runner.js +186 -0
- package/dist/runner.js.map +1 -0
- package/dist/sections.d.ts +94 -0
- package/dist/sections.js +372 -0
- package/dist/sections.js.map +1 -0
- package/dist/select.d.ts +96 -0
- package/dist/select.js +413 -0
- package/dist/select.js.map +1 -0
- package/dist/source.d.ts +43 -0
- package/dist/source.js +109 -0
- package/dist/source.js.map +1 -0
- package/dist/state.d.ts +45 -0
- package/dist/state.js +330 -0
- package/dist/state.js.map +1 -0
- package/dist/types.d.ts +294 -0
- package/dist/types.js +52 -0
- package/dist/types.js.map +1 -0
- package/dist/yaml.d.ts +37 -0
- package/dist/yaml.js +240 -0
- package/dist/yaml.js.map +1 -0
- 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();
|