@descent-vtt/spec-graph 0.2.3 → 0.4.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/CHANGELOG.md CHANGED
@@ -3,6 +3,281 @@
3
3
  Notable changes, newest first. Versions follow [semver](https://semver.org):
4
4
  a patch fixes behaviour without asking anything of a repository that upgrades.
5
5
 
6
+ ## 0.4.0
7
+
8
+ Ten open questions across the ADR suite, answered. Five shipped, three declined
9
+ with a reason, one settled by measuring it, and one withdrawn because it turned
10
+ out not to be true.
11
+
12
+ ### Added
13
+
14
+ **A repository can write its own rules.** `rules` in `.spec-graph.json` takes a
15
+ selector, a message, a hint and a severity, and runs it beside the built-ins:
16
+
17
+ ```json
18
+ "rules": {
19
+ "no-draft-dependency": {
20
+ "query": "document[phase=active] -depends-on-> document[phase=draft]",
21
+ "message": "{0} depends on {1}, which is still a draft",
22
+ "hint": "wait for {1} to be accepted, or drop it from {0.path}",
23
+ "severity": "error"
24
+ }
25
+ }
26
+ ```
27
+
28
+ [ADR-0005](docs/adr/0005-rules-are-queries.md) claimed for three releases that
29
+ the selector language was expressive enough for a team to write the equivalent
30
+ of a built-in rule. It was only true at a prompt: `spec-graph query` could find
31
+ the thing, and nothing could make finding it fail. Three ADRs recorded the same
32
+ gap and stopped at the same sentence - a query needs a severity and a message
33
+ before it is a rule.
34
+
35
+ The id is the name with `project:` in front, and a built-in id can never contain
36
+ a colon. That one fact is why `--rule`, `--strict`, `--record-baseline`, the
37
+ severity table, the record exemption, the sort order and the SARIF `ruleId` all
38
+ carry a user-defined rule without being taught what one is.
39
+
40
+ Messages interpolate over the path the query already numbers: `{0}` and `{1}`
41
+ for node ids, `{1.phase}` and `{0.fm.owner}` for anything a selector can read. A
42
+ selector that does not parse, a `{2}` no query can reach, or an attribute
43
+ nothing answers to is reported when the file is read - a rule that cannot work
44
+ has to say so, because silence from a linter is indistinguishable from health.
45
+ `query` also takes a list, read as a union.
46
+
47
+ See [ADR-0016](docs/adr/0016-a-query-needs-a-sentence.md).
48
+
49
+ **A broken reference suggests the document it was probably meant to name.**
50
+ `ARD-0015` gets `did you mean ADR-0015?`; `0002-cacheing.md` gets its sibling
51
+ one letter away. `ADR-0003` in a repository of two ADRs gets nothing, and that
52
+ restraint is the design rather than a limitation of it: a family name is a word
53
+ people misremember, a number *is* the identity, and every number sits one edit
54
+ from its neighbours. Each gate ends the same way - exactly one candidate, or
55
+ silence. See [ADR-0004](docs/adr/0004-reference-resolution.md).
56
+
57
+ **A stale baseline entry says `paid` or `gone`.** `paid` is the ratchet working.
58
+ `gone` means the document was not in this corpus at all, so nothing is known
59
+ about the defect - and the ordinary way to produce one is to narrow an include
60
+ pattern. Both still trip `--ratchet`, but the verdict now says how many entries
61
+ name documents the run did not see and asks a team to check their patterns,
62
+ rather than congratulating them for losing sight of a problem.
63
+
64
+ **Stale entries are named in the JSON report**, under `baseline.entries`, each
65
+ with its label. A count is enough to know the file has slack and never enough to
66
+ strike it.
67
+
68
+ **`--verbose` lists what configuration silenced.** Every reference
69
+ `ignoreReferences` or `ignoreFamilies` suppressed, grouped by target and naming
70
+ which of the two did it; the JSON report carries every site under `suppressed`
71
+ without the flag. A single over-broad glob looks exactly like a clean repository
72
+ from the outside, and nothing said otherwise. Not listed: a bare identifier
73
+ spec-graph read as prose on its own - that was nobody's decision.
74
+
75
+ **A NUL byte is reported as a parse problem.** Not a finding: nothing about the
76
+ graph is wrong and it never fails a build. It earns the line because spec-graph
77
+ is likely the only tool that read the file at all - grep, diff and every review
78
+ interface treat it as binary - and the message names the usual cause, which is
79
+ UTF-16 read as UTF-8 rather than anybody's keystroke.
80
+
81
+ ### Fixed
82
+
83
+ Nothing that a corpus had hit. Two findings came out of probing rather than out
84
+ of the suite, which is the practice [CLAUDE.md](CLAUDE.md) asks for:
85
+
86
+ - A near-miss gate that suggested a file which had moved **never fired**.
87
+ Resolution already binds a path by its basename, so the suggestion had nothing
88
+ left to suggest. Removed before it shipped rather than after.
89
+ - Hand-mutating each remaining gate found two that no test could kill. One was
90
+ genuinely equivalent code and is now one line shorter; the other was masked by
91
+ a length floor, and the test now writes a number long enough to clear it.
92
+
93
+ ### Not added
94
+
95
+ - **SARIF `fixes`.** Counted the findings with a fix that is an edit rather than
96
+ a judgement, and there is one. Everything else has to be *inserted*, into front
97
+ matter that may not exist, at a position only a human can choose. The new
98
+ near-miss suggestions made the case weaker rather than stronger: they are
99
+ deliberately a guess, and promoting a guess to a machine-applicable edit throws
100
+ away the restraint that makes it worth printing.
101
+ - **Hybrid documents** - a specification with a changelog section. The premise
102
+ turned out to be wrong: regions cannot express it, because `record` is decided
103
+ once per file and inherited by everything in it. Making it work means a second
104
+ source of truth for `record`, traded against a free workaround - put the
105
+ changelog in its own file, which is where it belongs.
106
+ - **Cross-repository references.** Every way of doing it puts network or
107
+ discovery underneath a pipeline whose test strategy rests on being a pure
108
+ function of text.
109
+ - **A guard for `~=`.** Probing confirmed `[title~=(a+)+$]` never returns. The
110
+ hang has been reachable from `query` since 0.1.0; what 0.4.0 changes is that it
111
+ now lives in a file that runs on every build. Rejecting nested quantifiers
112
+ catches `(a+)+` and misses `(a|a)+`, and a guard that is incomplete and says it
113
+ is safe is worse than no guard. Documented instead, with the alternatives
114
+ weighed in ADR-0016.
115
+
116
+ ### Measured
117
+
118
+ **The near-miss gate for front-matter keys stays at one edit**, and this is the
119
+ first time anybody checked. Across 73 keys that Jekyll, Hugo, Docusaurus, Astro,
120
+ MADR, KEP, the IETF datatracker and Obsidian actually write, one edit reads
121
+ *none* of them as a near-miss and two edits reads exactly one: `rfc`, which it
122
+ would tell an author to spell `refs`. [ADR-0014](docs/adr/0014-a-relation-is-spelled-both-ways.md)
123
+ said two edits would catch `dependancies`; one edit already did, along with
124
+ every other typo anybody produced to argue the case. Two buys nothing and costs
125
+ a false positive on a key that carries citations in exactly the corpora this
126
+ tool is pointed at.
127
+
128
+ ### Verified
129
+
130
+ | | |
131
+ |---|---|
132
+ | `npm run lint` | pass |
133
+ | `npm test` | 754 passing, 20 files |
134
+ | `npm run selfcheck` | pass, over 19 documents and 124 relations |
135
+ | `npm run test:mutation` | 79.41% over 8,585 mutants, 64m30s |
136
+ | hosted full run, which governs | **75.40%**, 102m48s |
137
+ | `break` | unchanged at 70 |
138
+
139
+ 79.69% over 8,126 mutants at 0.3.0 against 79.41% over 8,585 here, on the same
140
+ machine. The new code carries itself: `project-rules.ts` at **99.50%** with one
141
+ survivor - a genuinely equivalent `<` against `<=` in a comparator whose inputs
142
+ are object keys and therefore never equal.
143
+
144
+ That 99.50% is what reading the survivor list bought. The first full run put the
145
+ module at 86.89%, a targeted re-measurement with `coverageAnalysis all` agreed at
146
+ 86.41% - so the gap was real and not `perTest` mis-attribution - and sixteen of
147
+ those survivors turned out to be tests nobody had written. The whole-corpus
148
+ figure moved 0.38 points for it.
149
+
150
+ The hosted number is the one that actually governs, because it is where the build
151
+ fails: **75.40%**, up from 75.14% at 0.3.0. Lose every timeout on a loaded runner
152
+ and it reads 71.76%, which is the most margin `break: 70` has ever had.
153
+
154
+ That run also caught the other half of a claim
155
+ [ADR-0007](docs/adr/0007-mutation-testing.md) narrowed at 0.3.0. The `main` push
156
+ and the `v0.4.0` tag were the same commit on the same runner, and the incremental
157
+ read **1.29 points low** against the rebuild - having read 3.18 points *high* at
158
+ 0.3.0. Two observations, opposite signs. The direction is not a property, and the
159
+ incremental run took 110 minutes to the rebuild's 103, because a change this wide
160
+ leaves it nothing to reuse.
161
+
162
+ ### Withdrawn
163
+
164
+ [ADR-0013](docs/adr/0013-the-scanner-hands-back-prose.md) said a lone `\r` was
165
+ not treated as a line ending. It always was - `createLineIndex` has recognised
166
+ all three terminators since 0.1.0 and says so in its own doc comment. The claim
167
+ came from reading the scanner and not the table underneath it, and nobody had
168
+ executed it. One corpus through `\n`, `\r\n` and `\r` now produces identical
169
+ items, findings and line numbers, as a test.
170
+
171
+ ### Changed
172
+
173
+ For anyone using the programmatic API: `Diagnostic.rule` is now `AnyRuleId`,
174
+ which is `RuleId` plus the `project:` namespace. An exhaustive `switch` over the
175
+ built-ins needs a default arm. `BaselineOutcome.stale` carries `reason`, and
176
+ `ResolvedCorpus` carries `suppressed`.
177
+
178
+ ## 0.3.0
179
+
180
+ Found by writing a corpus designed to break the scanner and reading the edges it
181
+ produced, which is the practice [CLAUDE.md](CLAUDE.md) asks for and the reason
182
+ it is there. The mutation score could not have found any of it: a mutant is a
183
+ change to code that exists, and a construct nobody parsed has no code to mutate.
184
+
185
+ ### Fixed
186
+
187
+ **A reference-style link was read as a citation of its own label.**
188
+
189
+ ```md
190
+ This depends on [ADR-0001][one].
191
+
192
+ [one]: 0001-target.md
193
+ ```
194
+
195
+ Every renderer reads that as a link to `0001-target.md`. spec-graph read it as a
196
+ citation of the word `one`, which resolved to nothing - so those two lines
197
+ produced a false `broken-reference` **and** no `depends-on` edge at all. The
198
+ missing half is the worse one: an absent edge is invisible in a report that says
199
+ everything is consistent.
200
+
201
+ Definitions are now the destination table the labelled forms are read through. A
202
+ label nothing defines is prose, because `[design][one]` renders verbatim when
203
+ nothing says what `one` is. A broken one is reported at the definition, which is
204
+ the line that fixes it, however many places use the label.
205
+
206
+ **A `@spec-node` directive inside `<script>` or `<pre>` was honoured**, so a page
207
+ explaining how to annotate a document could annotate itself. The four elements
208
+ whose content is not Markdown are masked now, which is the CommonMark reading.
209
+ `<div>` and `<details>` keep their content: a decision written inside a collapsed
210
+ section is still a decision.
211
+
212
+ **A document could take an id that was not a name.** `id=\"ADR-9\"` inside a
213
+ JavaScript string parses its bare value as a lone backslash, and the document
214
+ then collided with itself, reported a duplicate id against its own file,
215
+ contained itself, and exported as invalid Mermaid. A declared id must carry a
216
+ letter or a digit.
217
+
218
+ **A percent-escaped destination resolved to nothing.**
219
+ `[Design](docs/my%20design.md)` is what a renderer, a documentation site and
220
+ GitHub's own copy-link all produce for a file with a space in its name. The
221
+ escapes are read as a fallback, so a filename that genuinely contains one keeps
222
+ resolving by the spelling it was written with, and a stray `100%` is left alone.
223
+
224
+ See [ADR-0013](docs/adr/0013-the-scanner-hands-back-prose.md).
225
+
226
+ **Front matter knew half of each relation.** `depends-on` was a key and
227
+ `depended-on-by` was not, so a repository whose filing convention records
228
+ dependents wrote the inverse spelling and got no edge, no warning, and a graph
229
+ that reported itself as consistent. Every directional kind is spelled both ways
230
+ now - twenty-five keys became fifty-six - and a test holds the invariant.
231
+ `relates-to` is symmetric and exempt.
232
+
233
+ Keys also fold on separators and case, so `depends-on`, `depends_on` and
234
+ `dependsOn` are one key rather than the two that happened to be written out.
235
+ See [ADR-0014](docs/adr/0014-a-relation-is-spelled-both-ways.md).
236
+
237
+ **`--verbose --format json` printed human lines onto stdout beside the JSON.**
238
+
239
+ ### Added
240
+
241
+ - **`unknown-relation-key`** (warn). A front-matter key one edit from a relation,
242
+ carrying something that could name a document, is reported rather than
243
+ ignored. Both halves of that gate matter: `deprecated: true` is one edit from
244
+ `deprecates` and a boolean is not a citation. It says what is true - *"the key
245
+ declares no relation"* - and offers the near spellings rather than asserting
246
+ one. See [ADR-0014](docs/adr/0014-a-relation-is-spelled-both-ways.md).
247
+ - **`--ratchet`**, and `"ratchet": true` in the configuration. The other side of
248
+ a baseline: with it, a declared finding that no longer occurs fails the build,
249
+ named, with `--record-baseline` as the fix. Off by default, because
250
+ [ADR-0012](docs/adr/0012-a-baseline-is-a-ratchet.md) is right that failing a
251
+ build because somebody fixed something is a strange way to encourage them -
252
+ and on for a team that has decided its debt only moves one way, because a note
253
+ on a green build is a line that scrolls past.
254
+ - **`--format sarif`**. SARIF 2.1.0, for `check`, so
255
+ `github/codeql-action/upload-sarif` can turn findings into annotations on the
256
+ diff with no integration on either side. The `partialFingerprints` are the
257
+ same rule/specification/citation identity a baseline is keyed on, so an
258
+ annotation survives a reformat. Deterministic and timestamp-free like every
259
+ other output here. See
260
+ [ADR-0015](docs/adr/0015-feedback-goes-where-the-tools-already-look.md).
261
+
262
+ ### Not added
263
+
264
+ **`--watch`.** A full run is 60 ms, so the loop is one line of shell in whatever
265
+ the developer already uses. A resident process, a debounce timer and `fs.watch`
266
+ semantics that differ on all three platforms would be the one part of this
267
+ program that could not honour byte-determinism, inside a codebase whose whole
268
+ test strategy rests on everything between the edges being a pure function of
269
+ text. Reasoning in
270
+ [ADR-0015](docs/adr/0015-feedback-goes-where-the-tools-already-look.md).
271
+
272
+ ### Measured
273
+
274
+ 684 tests, and a mutation score of **75.14% on the hosted runner** - the figure
275
+ that governs, up from 73.32% at v0.2.0 - against 79.69% over 8,126 mutants on a
276
+ developer machine. The guard stays at `break: 70`. See
277
+ [ADR-0007](docs/adr/0007-mutation-testing.md), which 0.3.0 also amended: an
278
+ incremental run read 3.18 points *high* against a full rebuild of the same
279
+ commit, so the claim that incremental errs low was wrong.
280
+
6
281
  ## 0.2.3
7
282
 
8
283
  ### Fixed
package/README.md CHANGED
@@ -78,6 +78,7 @@ npx @descent-vtt/spec-graph "docs/**/*.md"
78
78
  | `ambiguous-reference` | warn | A citation matching more than one document |
79
79
  | `unreciprocated-supersession` | warn | A retired document that never says what replaced it |
80
80
  | `state-conflict` | warn | An item whose two state signals disagree about whether work remains |
81
+ | `unknown-relation-key` | warn | A front-matter key one edit from a relation, carrying what looks like a citation |
81
82
  | `self-reference` | info | A document delegating to, or depending on, itself |
82
83
 
83
84
  Override any of them:
@@ -266,6 +267,38 @@ still a relation in the graph no matter what you exclude — not even
266
267
  `--ignore-ref "*"` can delete one. See
267
268
  [ADR-0008](docs/adr/0008-wiki-links-carry-no-path.md).
268
269
 
270
+ #### A gap that is deliberate
271
+
272
+ Real filing histories have holes: an ADR number reserved and then withdrawn, a
273
+ document that was folded into another before it was ever committed. Something
274
+ still cites it, and the citation is not a mistake.
275
+
276
+ There are two mechanisms and the difference is what you are declaring.
277
+
278
+ **The target is never going to exist** — `ADR-0006` was withdrawn before filing.
279
+ That is a fact about the repository, so declare it once in
280
+ `.spec-graph.json`:
281
+
282
+ ```json
283
+ { "ignoreReferences": ["ADR-0006"] }
284
+ ```
285
+
286
+ Every citation of it stays quiet, wherever it is written, forever. A glob is
287
+ available and rarely what you want here: `ADR-0006` names one target exactly.
288
+
289
+ **This particular citation is accepted for now** — it should resolve, one day it
290
+ will, and until then the build should not stop. That is debt, not a fact, and it
291
+ belongs in the baseline, keyed to the one document and the one target:
292
+
293
+ ```json
294
+ { "rule": "broken-reference", "document": "ADR-0004", "subject": "docs/plans/x.md", "count": 1 }
295
+ ```
296
+
297
+ The same citation from a different document is still reported, and `--ratchet`
298
+ makes the entry disappear the day it is paid. Reach for the first when the
299
+ absence is permanent, the second when it is temporary — and if you cannot say
300
+ which, it is the second.
301
+
269
302
  ### Relations
270
303
 
271
304
  Relations are read from front matter, from link context, and from explicit
@@ -330,6 +363,9 @@ gate without writing any code:
330
363
  ! npx spec-graph query 'document[path^=docs/prd] -depends-on-> document[phase=draft]'
331
364
  ```
332
365
 
366
+ That shell line is a rule wearing a disguise. Give it a name and a sentence and
367
+ it becomes one — see [your own rules](#your-own-rules) below.
368
+
333
369
  **Grammar**
334
370
 
335
371
  ```text
@@ -350,6 +386,64 @@ Items inherit their document's lifecycle, so `item[phase=retired]` means what yo
350
386
  expect. A query evaluates to **paths**, not endpoints, which is why a finding can
351
387
  name both ends and the line that connects them.
352
388
 
389
+ ## Your own rules
390
+
391
+ A convention spec-graph never anticipated is a selector plus a sentence. Write
392
+ both in the config file and it becomes a rule like any other:
393
+
394
+ ```json
395
+ {
396
+ "rules": {
397
+ "no-draft-dependency": {
398
+ "query": "document[phase=active] -depends-on-> document[phase=draft]",
399
+ "message": "{0} depends on {1}, which is still a draft",
400
+ "hint": "wait for {1} to be accepted, or drop it from {0.path}",
401
+ "severity": "error"
402
+ }
403
+ }
404
+ }
405
+ ```
406
+
407
+ ```text
408
+ x docs/adr/0002-delivery.md:4:13 project:no-draft-dependency
409
+ ADR-0002 depends on ADR-0001, which is still a draft
410
+ > wait for ADR-0001 to be accepted, or drop it from docs/adr/0002-delivery.md
411
+ ```
412
+
413
+ The finding points at the line that *declares* the relation, because that is
414
+ where a human goes to change the answer — the same discipline every built-in
415
+ follows.
416
+
417
+ | Field | |
418
+ |---|---|
419
+ | `query` | One selector, or a list of them read as a union. |
420
+ | `message` | The headline. `{0}` is the first node on the path, `{1}` the next; `{1.phase}` and `{0.fm.owner}` read any selector attribute. |
421
+ | `hint` | The next action. Optional, templated the same way. |
422
+ | `severity` | `error`, `warn`, `info` or `off`. Defaults to `warn`. |
423
+
424
+ It defaults to `warn` on purpose: a rule a team has just written has not yet
425
+ earned the right to fail their build, and the first run of a new convention is
426
+ when it is most likely to be wrong.
427
+
428
+ The id is the name with `project:` in front, which is why it can never collide
429
+ with a built-in — and why everything else already works. It is baselined by
430
+ `--record-baseline`, silenced by `--rule project:no-draft-dependency=off`,
431
+ escalated by `--strict`, listed by `spec-graph rules`, exempted on journals like
432
+ every other rule about obligations, and emitted as a first-class `ruleId` in
433
+ SARIF, where a project rule describes itself by the selector it is.
434
+
435
+ Mistakes are reported when the file is read, not discovered as a rule that
436
+ quietly finds nothing:
437
+
438
+ ```text
439
+ spec-graph: .spec-graph.json: rules.no-drafts: mismatched arrow: use -kind-> for
440
+ one hop or =kind=> for transitive (at character 10)
441
+ spec-graph: .spec-graph.json: rules.owner: "{1.phse}" asks for an attribute
442
+ nothing has
443
+ ```
444
+
445
+ See [ADR-0016](docs/adr/0016-a-query-needs-a-sentence.md).
446
+
353
447
  ## Directives
354
448
 
355
449
  Inference covers almost everything, but inference you cannot override is a trap.
@@ -426,7 +520,54 @@ A baseline that expires when somebody reformats a paragraph is worse than none,
426
520
  so the entry survives edits, reordering, and the file being renamed — because
427
521
  `ADR-0004` is the decision's name, not its location. Repeats are counted rather
428
522
  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).
523
+
524
+ By default that line is a note and the build stays green: failing because
525
+ somebody fixed something is a strange way to encourage them. But a note in CI
526
+ is a line that scrolls past, and an exemption nobody strikes outlives the defect
527
+ it was written for. A team that has decided its debt only moves one way turns on
528
+ the other side of the ratchet:
529
+
530
+ ```bash
531
+ spec-graph check --baseline .spec-graph-baseline.json --ratchet
532
+ ```
533
+
534
+ Now an undeclared finding fails **and** a declared one that no longer occurs
535
+ fails, each named, with `--record-baseline` as the fix — and the diff of that
536
+ file is the record of what was paid off. See
537
+ [ADR-0012](docs/adr/0012-a-baseline-is-a-ratchet.md).
538
+
539
+ ## In CI
540
+
541
+ The exit code is the contract: `0` clean, `1` findings, `2` the tool could not
542
+ run. That is all most pipelines need.
543
+
544
+ For annotations on the diff rather than a line in a log, emit SARIF and hand it
545
+ to the uploader that already exists:
546
+
547
+ ```yaml
548
+ - run: npx spec-graph --format sarif > spec-graph.sarif
549
+ continue-on-error: true
550
+ - uses: github/codeql-action/upload-sarif@v3
551
+ with:
552
+ sarif_file: spec-graph.sarif
553
+ ```
554
+
555
+ Findings land as comments on the changed lines, and they persist across commits
556
+ without drifting, because the `partialFingerprints` spec-graph writes are the
557
+ same identity a baseline is keyed on — the rule, the specification, and the
558
+ citation, with no line number in it (ADR-0012).
559
+
560
+ `--format json` is the other machine-readable output, and the one to parse if
561
+ you are building something of your own: it carries the baseline note, the
562
+ summary and the per-file detail that SARIF has nowhere to put.
563
+
564
+ **There is no `--watch`.** A full run on this repository takes 60 ms, so the
565
+ loop that would justify a resident process is one line of shell, and it belongs
566
+ to whatever the developer already uses rather than to this tool:
567
+
568
+ ```bash
569
+ npx spec-graph --format json | jq -r '.summary'
570
+ ```
430
571
 
431
572
  ## Configuration
432
573
 
@@ -441,8 +582,15 @@ command. spec-graph reads the first of `.spec-graph.json`,
441
582
  "ignoreFamilies": ["RFC"],
442
583
  "historyPatterns": ["**/JOURNAL_*.md"],
443
584
  "baseline": ".spec-graph-baseline.json",
585
+ "ratchet": true,
444
586
  "severities": { "self-reference": "off" },
445
- "strict": true
587
+ "strict": true,
588
+ "rules": {
589
+ "no-draft-dependency": {
590
+ "query": "document[phase=active] -depends-on-> document[phase=draft]",
591
+ "message": "{0} depends on {1}, which is still a draft"
592
+ }
593
+ }
446
594
  }
447
595
  ```
448
596
 
@@ -473,6 +621,20 @@ resolves to your local RFC-0001 with `RFC` on the ignore list. No configuration
473
621
  can delete an edge. See
474
622
  [ADR-0010](docs/adr/0010-configuration-belongs-to-the-repository.md).
475
623
 
624
+ `--verbose` lists everything they silenced, so one over-broad glob cannot quietly
625
+ turn the check off and look like a clean repository:
626
+
627
+ ```text
628
+ i docs/adr/0001-traps.md:15:3 ignored reference: trap 55 (2 times)
629
+ i docs/rfcs/0002-transport.md:7:69 ignored family: RFC 2119
630
+ ```
631
+
632
+ The JSON report carries the same rows under `suppressed`, without needing the
633
+ flag. What is deliberately *not* listed is a bare identifier spec-graph read as
634
+ prose on its own — `T-1000` was nobody's decision, and listing it would bury the
635
+ entries that were. See
636
+ [ADR-0008](docs/adr/0008-wiki-links-carry-no-path.md).
637
+
476
638
  Prose like `Phase 1`, `R69`, `Q-120`, `Table 2` and `Step 4` has never needed
477
639
  this: an identifier found in prose is only read as a citation when its family
478
640
  already exists in the corpus.
@@ -493,8 +655,9 @@ spec-graph rules [--explain] List the diagnostics.
493
655
  --history <glob> Files that log decisions rather than making them
494
656
  --baseline <file> Accept these findings; report only what is new
495
657
  --record-baseline <f> Write today's findings as accepted debt, exit 0
658
+ --ratchet Also fail when a baseline entry no longer occurs
496
659
  --no-config Ignore .spec-graph.json and the package.json key
497
- --format human|json Report format
660
+ --format <fmt> human | json | sarif
498
661
  --graph-format <fmt> dot | mermaid | json
499
662
  --documents-only Hide items; their relations lift onto their documents
500
663
  --rule <id>=<severity> error | warn | info | off (repeatable)
@@ -590,6 +753,9 @@ Twelve ADRs, which `spec-graph` validates on every CI run:
590
753
  - [ADR-0010 — Configuration belongs to the repository](docs/adr/0010-configuration-belongs-to-the-repository.md)
591
754
  - [ADR-0011 — A historical record is not a specification](docs/adr/0011-a-record-is-not-a-specification.md)
592
755
  - [ADR-0012 — A baseline is a ratchet, keyed on identity](docs/adr/0012-a-baseline-is-a-ratchet.md)
756
+ - [ADR-0013 — When the scanner is unsure, it hands back prose](docs/adr/0013-the-scanner-hands-back-prose.md)
757
+ - [ADR-0014 — A relation is spelled in both directions](docs/adr/0014-a-relation-is-spelled-both-ways.md)
758
+ - [ADR-0015 — Feedback goes where the tools already look](docs/adr/0015-feedback-goes-where-the-tools-already-look.md)
593
759
 
594
760
  **Zero runtime dependencies.** Node 22+, native ESM, TypeScript strict with
595
761
  `noUncheckedIndexedAccess` and `exactOptionalPropertyTypes`. The Markdown
@@ -14,12 +14,12 @@
14
14
  * the same reason extraction never touches a disk.
15
15
  */
16
16
  import type { SpecGraph } from './graph.js';
17
- import type { Diagnostic, RuleId } from './types.js';
17
+ import { type AnyRuleId, type Diagnostic } from './types.js';
18
18
  /** The current file format. Bumped only for a change old files cannot survive. */
19
19
  export declare const BASELINE_VERSION = 1;
20
20
  /** One class of accepted finding, and how many of it there were. */
21
21
  export interface BaselineEntry {
22
- readonly rule: RuleId;
22
+ readonly rule: AnyRuleId;
23
23
  /** The specification the finding is about, not the file it was found in. */
24
24
  readonly document: string;
25
25
  /** What within it: a citation target, or the document at the other end. */
@@ -70,18 +70,32 @@ export interface ParsedBaseline {
70
70
  }
71
71
  /** Parses a baseline file. Never throws; an unusable file yields an empty one. */
72
72
  export declare function parseBaseline(raw: string, source: string): ParsedBaseline;
73
+ /**
74
+ * An entry the run did not spend, and why.
75
+ *
76
+ * The distinction is the whole value of the label. `paid` is the ratchet
77
+ * working: the defect was fixed and the exemption can be struck. `gone` is not
78
+ * an achievement at all - the document the entry is about was not in this
79
+ * corpus, so nothing whatever is known about the defect. Both make the file
80
+ * stale and both need it re-recorded, but only one of them is good news, and a
81
+ * report that says "paid" over a narrowed `patterns` glob is congratulating a
82
+ * team for losing sight of the problem.
83
+ */
84
+ export interface StaleEntry extends BaselineEntry {
85
+ readonly reason: 'paid' | 'gone';
86
+ }
73
87
  export interface BaselineOutcome {
74
88
  /** Findings the baseline does not account for. These are the report. */
75
89
  readonly kept: readonly Diagnostic[];
76
90
  /** How many were accounted for. Reported as a number, not as a list. */
77
91
  readonly suppressed: number;
78
92
  /**
79
- * Entries whose debt has been paid, wholly or partly.
93
+ * Entries whose debt was not spent, wholly or partly.
80
94
  *
81
95
  * The ratchet: once a repository is clean of something, saying so in the file
82
96
  * stops it coming back unnoticed.
83
97
  */
84
- readonly stale: readonly BaselineEntry[];
98
+ readonly stale: readonly StaleEntry[];
85
99
  }
86
100
  /**
87
101
  * Removes the findings a baseline accounts for.
package/dist/baseline.js CHANGED
@@ -14,6 +14,7 @@
14
14
  * the same reason extraction never touches a disk.
15
15
  */
16
16
  import { RULE_IDS } from './rules.js';
17
+ import { isProjectRule } from './types.js';
17
18
  /** The current file format. Bumped only for a change old files cannot survive. */
18
19
  export const BASELINE_VERSION = 1;
19
20
  export const EMPTY_BASELINE = Object.freeze({ version: BASELINE_VERSION, findings: [] });
@@ -128,7 +129,12 @@ function readEntry(row, where, problems) {
128
129
  }
129
130
  const fields = row;
130
131
  const rule = fields['rule'];
131
- if (typeof rule !== 'string' || !RULE_IDS.includes(rule)) {
132
+ // A project rule is accepted on its namespace alone, because the baseline is
133
+ // read before the configuration that defines it has any say - and an entry
134
+ // rejected here would silently un-accept debt a team had already signed off.
135
+ // A name nothing defines suppresses nothing and is reported as stale, which
136
+ // is the same answer by a route that keeps the file readable.
137
+ if (typeof rule !== 'string' || !(RULE_IDS.includes(rule) || isProjectRule(rule))) {
132
138
  problems.push(`${where}: unknown rule ${JSON.stringify(rule)}`);
133
139
  return null;
134
140
  }
@@ -177,7 +183,13 @@ export function applyBaseline(graph, diagnostics, baseline) {
177
183
  const unspent = budget.get(key) ?? 0;
178
184
  if (unspent <= 0)
179
185
  continue;
180
- stale.push({ ...entry, count: Math.min(unspent, entry.count) });
186
+ // Asked of the graph rather than of the filesystem, because the graph is
187
+ // what this run looked at. A document that still exists on disk and fell
188
+ // outside the include patterns is gone for the purposes of the answer, and
189
+ // that is the case worth telling apart - it is the one a team reaches by
190
+ // editing a glob rather than by fixing anything.
191
+ const reason = graph.document(entry.document) === undefined ? 'gone' : 'paid';
192
+ stale.push({ ...entry, count: Math.min(unspent, entry.count), reason });
181
193
  // An entry counted once; a duplicate key in a hand-edited file must not
182
194
  // claim the same surplus twice.
183
195
  budget.set(key, 0);