@descent-vtt/spec-graph 0.3.0 → 0.5.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,430 @@
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.5.0
7
+
8
+ One shipped feature turned out to have shipped a false positive, and the
9
+ predicate that was documented as dangerous is now an automaton that cannot
10
+ backtrack. Four of the ADR suite's open questions answered by building the
11
+ thing, seven directions declined with a reason, one deferred with its design
12
+ settled so it is not re-litigated.
13
+
14
+ ### Fixed
15
+
16
+ **A register's decisions answer for the front matter above them.** This was a
17
+ false positive in the feature 0.4.0 led with, and the worst kind: confident and
18
+ unanswerable. A region answered *nothing* for `fm.owner`, `!=` against an absent
19
+ value is a mismatch, so a repository writing
20
+
21
+ ```json
22
+ "rules": { "owned": { "query": "document[fm.owner!=platform]" } }
23
+ ```
24
+
25
+ was told twice that a register saying `owner: platform` is not owned by
26
+ platform — with the message rendering `{0.fm.owner}` as the literal placeholder,
27
+ because there was nothing to put there.
28
+
29
+ A region now carries its file's front matter minus two kinds of key. A
30
+ **relation** key stays behind: `supersedes:` at the top of a register supersedes
31
+ on behalf of the register, not of each decision in it. So does a key the region
32
+ **answers for itself** — its identifier, status, title and aliases, which are
33
+ precisely the things a register exists to vary row by row.
34
+ [ADR-0009](docs/adr/0009-a-specification-is-a-region.md) carried this as a
35
+ narrowed open question and called it a gap. It was a defect, and it was found by
36
+ pointing the binary at a register rather than by reading the code.
37
+
38
+ Read from the other side, this can *add* findings: a rule phrased positively,
39
+ `document[fm.deprecated]`, now matches a register's decisions as well as the
40
+ register. That is the same correction - the decisions really are described by
41
+ the front matter above them - and worth knowing before upgrading a repository
42
+ that keeps registers and writes rules about front matter.
43
+
44
+ ### Added
45
+
46
+ **`~=` is matched by an automaton that cannot backtrack.**
47
+ [ADR-0016](docs/adr/0016-a-query-needs-a-sentence.md) documented this hazard
48
+ with `(a+)+$`, which nobody writes, and priced the fix at "worth it only if a
49
+ real repository hangs". Both were wrong. `^([A-Za-z0-9_]+[ ]?)+$` is what
50
+ somebody writes to check that a title is words separated by single spaces:
51
+
52
+ | subject | `RegExp` | now |
53
+ | --- | ---: | ---: |
54
+ | `the quick brown fox jumps over the lazy dog!` | 0.9s | 11us |
55
+ | `... over the lazy dog and!` | 5.4s | 12us |
56
+ | `... over the lazy dog and keeps!` | 103s | 13us |
57
+
58
+ A word of k letters can be cut into pieces 2^(k-1) ways and a backtracking
59
+ engine tries the product, so a sixty-character title does not finish this year —
60
+ unattended, on every build, against a corpus whose titles the author of the rule
61
+ has not read. The work is now exactly O(pattern x subject), and **no stage of
62
+ this pipeline can take longer than its input.**
63
+
64
+ It costs a dialect. Backreferences and lookaround are not regular, so they are
65
+ refused while the selector is read, with the character pointed at — as are three
66
+ things `RegExp` accepts and should not: an unknown letter escape, which `RegExp`
67
+ reads as the letter, so a pattern meant as an anchor silently matches a capital
68
+ A; a Unicode property escape, which needs a table this package will not carry;
69
+ and octal escapes. Everything else works, and a pattern that does not parse is
70
+ now a usage error rather than a pattern that silently matches nothing.
71
+
72
+ Verified against the engine it replaces: **1.33 million pattern-subject pairs,
73
+ zero disagreements**, 19,000 of them committed as a gate. It earned that twice
74
+ over, on one clause of the language specification read backwards in two
75
+ different places - the word characters `` recognises, and whether a character
76
+ whose upper case is ASCII may match `[A-Z]`. The second was found by the
77
+ mutation score pointing at a corpus with a blind spot in it rather than at a
78
+ test. See [ADR-0017](docs/adr/0017-a-predicate-must-finish.md) and
79
+ [ADR-0007](docs/adr/0007-mutation-testing.md).
80
+
81
+ **Configuration is discovered upward, and the file holding it is the root.**
82
+
83
+ ```bash
84
+ cd packages/auth && spec-graph check # the repository's rules, the
85
+ # repository's patterns, the
86
+ # repository's paths
87
+ ```
88
+
89
+ [ADR-0010](docs/adr/0010-configuration-belongs-to-the-repository.md) worried
90
+ that discovery would make a run depend on where it started. Reading the working
91
+ directory already did, and silently: a check from a package directory found no
92
+ configuration, ran no project rules, used the default include patterns, and
93
+ printed a verdict in the same shape as the real one. Because the directory
94
+ holding the file becomes the root, and every path here is relative to the root,
95
+ a run from anywhere inside the repository now produces **byte-identical** output
96
+ to a run from the top. The walk stops at the repository — a directory holding
97
+ `.git` — so a stray file in a home directory cannot reach it. `--root` names the
98
+ root yourself and turns discovery off, and a path typed on the command line
99
+ stays relative to where you typed it. See
100
+ [ADR-0018](docs/adr/0018-the-configuration-file-is-the-root.md).
101
+
102
+ **`spec-graph query project:<rule>` runs a registered rule by name.** The rule
103
+ is already compiled by the time the command runs, and the alternative was
104
+ copying its selector back out of the configuration file by hand. Several
105
+ selectors are deduped the way the check dedupes them, so this prints the set the
106
+ check reports on; `--verbose` names them, which is the question a team
107
+ calibrating a convention is actually asking. Built-in rules are deliberately not
108
+ addressable this way: a project rule *is* its selector, while a built-in is a
109
+ selector plus judgement, and printing a set that differs from the findings would
110
+ invite the wrong conclusion.
111
+
112
+ **`spec-graph rules <rule-id> --explain` names the ADR that decided it.** A rule
113
+ that fires is a claim about somebody's repository, and the reasoning behind the
114
+ claim was in a document nobody could find from the message. The table holds a
115
+ pointer rather than a paraphrase, because a second copy of the reasoning would
116
+ drift from the first — and a test holds every pointer to a file this corpus
117
+ checks, so a renamed ADR breaks the link in the same run that breaks the
118
+ reference.
119
+
120
+ **`--format markdown`** writes the same facts as a GitHub-flavoured table, for
121
+ `$GITHUB_STEP_SUMMARY` or a pull-request comment:
122
+
123
+ ```yaml
124
+ - run: npx spec-graph --format markdown >> "$GITHUB_STEP_SUMMARY"
125
+ ```
126
+
127
+ SARIF puts a finding on the line that caused it, which is where somebody fixing
128
+ one wants it, and says nothing at all to the person deciding whether to merge.
129
+ This leads with the verdict, counts the corpus, gives every finding its hint,
130
+ and *names* the stale baseline entries rather than counting them — on a pull
131
+ request a count is the one thing a reader cannot act on. No environment
132
+ detection: a format that changed because a variable was set would be a format
133
+ nobody controls.
134
+
135
+ ### Changed
136
+
137
+ Four behaviours move, none of them a rule. Three can turn a run that passed
138
+ into a usage error, which is the point in each case; the fourth changes what a
139
+ run from a subdirectory reports.
140
+
141
+ - **A `~=` pattern that does not parse is a usage error**, with the character
142
+ pointed at, rather than a pattern that matches nothing. The old silence was
143
+ indistinguishable from a rule that ran and found none.
144
+ - **A `~=` pattern using a backreference, lookaround, `\p{...}`, an octal
145
+ escape or an unknown letter escape is refused**, for the reasons above. If
146
+ you have one, the message says which construct and why; `^=`, `$=` and `*=`
147
+ cover the fixed-prefix, suffix and substring cases without a pattern at all.
148
+ - **A run from a subdirectory now finds the repository's configuration**, and
149
+ reports repository-relative paths because the configuration's directory is
150
+ the root. A script that read paths out of a nested run will see them change.
151
+ `--root` keeps the old behaviour exactly.
152
+ - **`spec-graph rules <word>` reads the word as a rule id** and reports one
153
+ that names nothing. It used to be collected as an include pattern, which the
154
+ command has no use for.
155
+
156
+ ### Declined
157
+
158
+ Each with its reasoning in the ADR that owns the question, rather than in a
159
+ roadmap nobody reads:
160
+
161
+ - **An MCP server.** The capability is already here — `analyseSources()` takes
162
+ text, `--format json` is the answer — so what is left is a protocol
163
+ implementation, hand-written to keep the dependency count at zero, tracking a
164
+ specification that is still moving, to expose three tools that are three shell
165
+ commands an agent can already run.
166
+ - **A language server.** The right shape for editor feedback and a second
167
+ package's worth of work, and SARIF already reaches the problems pane. Two of
168
+ the three hard parts are done — `analyseSources()` needs no file on disk, and
169
+ every finding carries a span — so it should start when somebody wants hover
170
+ and go-to-definition, not as a way of delivering diagnostics that already
171
+ arrive.
172
+ - **A `--watch` daemon.** Declined again, with a better reason than last time:
173
+ the incremental half of it cannot work. Whether a bare `ADR-0099` in prose is
174
+ a citation depends on which families exist elsewhere, and a near-miss
175
+ suggestion is computed against every sibling — touch one file and the honest
176
+ set to recompute is all of them. Which is fine, because the pipeline is a pure
177
+ function and 60 ms long. And then the resident index buys nothing, and the
178
+ feature is a loop.
179
+ - **Code frames in the terminal.** The report already prints
180
+ `file:line:column`, which every terminal in use turns into a click, and
181
+ `renderMatch` already prints the shape of a multi-hop path.
182
+ - **Provenance and attestation metadata.** A timestamp in the output means no
183
+ two reports of one corpus are ever byte-identical, which is the property every
184
+ format here is built to have. The commit is something CI already knows.
185
+ - **An interactive SVG.** A layout engine or a vendored library is a runtime
186
+ dependency wherever it sits in the tarball, and its bytes depend on a layout
187
+ pass. `dot` and `mermaid` hand layout to tools built for it, and both are
188
+ text. The other half of that ask - phase styling and typed edges in the
189
+ Mermaid export - has been there since the first release. What is left is a
190
+ `subgraph` per directory, which changes the shape of an export somebody's
191
+ documentation build is parsing, to group a corpus that is one directory in
192
+ most repositories.
193
+ - **Definition lists as a register form.** Two unrelated syntaxes behind one
194
+ name: raw `<dl>`, which the scanner leaves as prose deliberately because
195
+ ADR-0001's masking guarantee is what keeps a code fence quiet, and the
196
+ PHP-Markdown-Extra form, which is not CommonMark and which no corpus here
197
+ contains. Either would need a new parser for a convention nobody has written
198
+ down.
199
+
200
+ One is deferred rather than declined, with half its design recorded so it is not
201
+ re-litigated: a **relational diff** between two states of the graph, taken
202
+ between two `--graph-format json` exports rather than between two git revisions,
203
+ because revisions mean spawning a binary underneath a pipeline that is a pure
204
+ function of text. See
205
+ [ADR-0015](docs/adr/0015-feedback-goes-where-the-tools-already-look.md).
206
+
207
+ ### Verified
208
+
209
+ | | |
210
+ |---|---|
211
+ | `npm run lint` | pass |
212
+ | `npm test` | 833 passing, 21 files |
213
+ | `npm run selfcheck` | pass, over 21 documents and 159 relations |
214
+ | `npm run test:mutation` | 80.54% over 9,697 mutants, 74m27s |
215
+ | hosted full run, which governs | **76.83%**, 168m06s |
216
+ | hosted incremental run, same commit | 76.43%, 64m27s |
217
+ | `break` | unchanged at 70 |
218
+
219
+ 79.41% over 8,585 mutants at 0.4.0 against 80.54% over 9,697 here, on the same
220
+ machine: 1,112 more mutants and a point higher. The new code carries itself -
221
+ `regex.ts` at **90.61%**, `config.ts` at 92.37%, `project-rules.ts` still at
222
+ 99.50% - and lose all 343 timeouts and the figure reads 77.00%.
223
+
224
+ The first full run of this release read 80.35%, and its survivor list is where
225
+ the last defect came from. A mutant on the line stepping past `^` in a negated
226
+ class survived 1.33 million differential comparisons, because the corpus only
227
+ ever tested classes against two-character subjects; one-character subjects
228
+ killed it and then caught `[A-Z]` matching U+017F. `report.ts` went from 66.30%
229
+ to 70.84% on the same pass, once the Markdown tests asserted the table's
230
+ structure - rectangular rows, a blank line before every block, every summary
231
+ number present - rather than fragments of its text. What remains there is
232
+ mostly the older formatters.
233
+
234
+ Between those two runs `rules.ts` moved from 79.42% to 76.02% and
235
+ `directives.ts` from 77.92% to 74.03%, with neither file touched: the tests added
236
+ in between changed which tests `perTest` believes cover which mutants.
237
+ [ADR-0007](docs/adr/0007-mutation-testing.md) has recorded that shape for two
238
+ releases, and it is why a per-file drop is a question and not an answer.
239
+
240
+ The hosted number is the one that governs, because it is where the build fails:
241
+ **76.83%**, up from 75.40% at 0.4.0. Lose every timeout and it reads 73.31%, the
242
+ first time the pessimistic figure has cleared the guard by more than three
243
+ points.
244
+
245
+ The incremental run on the same commit read 0.40 points low - the third
246
+ observation after 3.18 high at 0.3.0 and 1.29 low at 0.4.0, and the reason
247
+ [ADR-0007](docs/adr/0007-mutation-testing.md) calls it a signal rather than a
248
+ figure. The tag's run also saved its own report to the cache, which is the first
249
+ time the cache-key fix from 0.4.0 has been seen doing its job.
250
+
251
+ **The rebuild took 168 of its 180 minutes.** 13% more mutants than 0.4.0, and 63%
252
+ longer on the hosted runner against 15% longer locally. At 0.4.0's old cap of
253
+ 120 it would have been cancelled with nothing reported. The cause is not yet
254
+ attributed - runner variance and the new differential test are both candidates -
255
+ and the choice between raising the cap again and cutting per-mutant cost is left
256
+ open in ADR-0007 until it is.
257
+
258
+ ## 0.4.0
259
+
260
+ Ten open questions across the ADR suite, answered. Five shipped, three declined
261
+ with a reason, one settled by measuring it, and one withdrawn because it turned
262
+ out not to be true.
263
+
264
+ ### Added
265
+
266
+ **A repository can write its own rules.** `rules` in `.spec-graph.json` takes a
267
+ selector, a message, a hint and a severity, and runs it beside the built-ins:
268
+
269
+ ```json
270
+ "rules": {
271
+ "no-draft-dependency": {
272
+ "query": "document[phase=active] -depends-on-> document[phase=draft]",
273
+ "message": "{0} depends on {1}, which is still a draft",
274
+ "hint": "wait for {1} to be accepted, or drop it from {0.path}",
275
+ "severity": "error"
276
+ }
277
+ }
278
+ ```
279
+
280
+ [ADR-0005](docs/adr/0005-rules-are-queries.md) claimed for three releases that
281
+ the selector language was expressive enough for a team to write the equivalent
282
+ of a built-in rule. It was only true at a prompt: `spec-graph query` could find
283
+ the thing, and nothing could make finding it fail. Three ADRs recorded the same
284
+ gap and stopped at the same sentence - a query needs a severity and a message
285
+ before it is a rule.
286
+
287
+ The id is the name with `project:` in front, and a built-in id can never contain
288
+ a colon. That one fact is why `--rule`, `--strict`, `--record-baseline`, the
289
+ severity table, the record exemption, the sort order and the SARIF `ruleId` all
290
+ carry a user-defined rule without being taught what one is.
291
+
292
+ Messages interpolate over the path the query already numbers: `{0}` and `{1}`
293
+ for node ids, `{1.phase}` and `{0.fm.owner}` for anything a selector can read. A
294
+ selector that does not parse, a `{2}` no query can reach, or an attribute
295
+ nothing answers to is reported when the file is read - a rule that cannot work
296
+ has to say so, because silence from a linter is indistinguishable from health.
297
+ `query` also takes a list, read as a union.
298
+
299
+ See [ADR-0016](docs/adr/0016-a-query-needs-a-sentence.md).
300
+
301
+ **A broken reference suggests the document it was probably meant to name.**
302
+ `ARD-0015` gets `did you mean ADR-0015?`; `0002-cacheing.md` gets its sibling
303
+ one letter away. `ADR-0003` in a repository of two ADRs gets nothing, and that
304
+ restraint is the design rather than a limitation of it: a family name is a word
305
+ people misremember, a number *is* the identity, and every number sits one edit
306
+ from its neighbours. Each gate ends the same way - exactly one candidate, or
307
+ silence. See [ADR-0004](docs/adr/0004-reference-resolution.md).
308
+
309
+ **A stale baseline entry says `paid` or `gone`.** `paid` is the ratchet working.
310
+ `gone` means the document was not in this corpus at all, so nothing is known
311
+ about the defect - and the ordinary way to produce one is to narrow an include
312
+ pattern. Both still trip `--ratchet`, but the verdict now says how many entries
313
+ name documents the run did not see and asks a team to check their patterns,
314
+ rather than congratulating them for losing sight of a problem.
315
+
316
+ **Stale entries are named in the JSON report**, under `baseline.entries`, each
317
+ with its label. A count is enough to know the file has slack and never enough to
318
+ strike it.
319
+
320
+ **`--verbose` lists what configuration silenced.** Every reference
321
+ `ignoreReferences` or `ignoreFamilies` suppressed, grouped by target and naming
322
+ which of the two did it; the JSON report carries every site under `suppressed`
323
+ without the flag. A single over-broad glob looks exactly like a clean repository
324
+ from the outside, and nothing said otherwise. Not listed: a bare identifier
325
+ spec-graph read as prose on its own - that was nobody's decision.
326
+
327
+ **A NUL byte is reported as a parse problem.** Not a finding: nothing about the
328
+ graph is wrong and it never fails a build. It earns the line because spec-graph
329
+ is likely the only tool that read the file at all - grep, diff and every review
330
+ interface treat it as binary - and the message names the usual cause, which is
331
+ UTF-16 read as UTF-8 rather than anybody's keystroke.
332
+
333
+ ### Fixed
334
+
335
+ Nothing that a corpus had hit. Two findings came out of probing rather than out
336
+ of the suite, which is the practice [CLAUDE.md](CLAUDE.md) asks for:
337
+
338
+ - A near-miss gate that suggested a file which had moved **never fired**.
339
+ Resolution already binds a path by its basename, so the suggestion had nothing
340
+ left to suggest. Removed before it shipped rather than after.
341
+ - Hand-mutating each remaining gate found two that no test could kill. One was
342
+ genuinely equivalent code and is now one line shorter; the other was masked by
343
+ a length floor, and the test now writes a number long enough to clear it.
344
+
345
+ ### Not added
346
+
347
+ - **SARIF `fixes`.** Counted the findings with a fix that is an edit rather than
348
+ a judgement, and there is one. Everything else has to be *inserted*, into front
349
+ matter that may not exist, at a position only a human can choose. The new
350
+ near-miss suggestions made the case weaker rather than stronger: they are
351
+ deliberately a guess, and promoting a guess to a machine-applicable edit throws
352
+ away the restraint that makes it worth printing.
353
+ - **Hybrid documents** - a specification with a changelog section. The premise
354
+ turned out to be wrong: regions cannot express it, because `record` is decided
355
+ once per file and inherited by everything in it. Making it work means a second
356
+ source of truth for `record`, traded against a free workaround - put the
357
+ changelog in its own file, which is where it belongs.
358
+ - **Cross-repository references.** Every way of doing it puts network or
359
+ discovery underneath a pipeline whose test strategy rests on being a pure
360
+ function of text.
361
+ - **A guard for `~=`.** Probing confirmed `[title~=(a+)+$]` never returns. The
362
+ hang has been reachable from `query` since 0.1.0; what 0.4.0 changes is that it
363
+ now lives in a file that runs on every build. Rejecting nested quantifiers
364
+ catches `(a+)+` and misses `(a|a)+`, and a guard that is incomplete and says it
365
+ is safe is worse than no guard. Documented instead, with the alternatives
366
+ weighed in ADR-0016.
367
+
368
+ ### Measured
369
+
370
+ **The near-miss gate for front-matter keys stays at one edit**, and this is the
371
+ first time anybody checked. Across 73 keys that Jekyll, Hugo, Docusaurus, Astro,
372
+ MADR, KEP, the IETF datatracker and Obsidian actually write, one edit reads
373
+ *none* of them as a near-miss and two edits reads exactly one: `rfc`, which it
374
+ would tell an author to spell `refs`. [ADR-0014](docs/adr/0014-a-relation-is-spelled-both-ways.md)
375
+ said two edits would catch `dependancies`; one edit already did, along with
376
+ every other typo anybody produced to argue the case. Two buys nothing and costs
377
+ a false positive on a key that carries citations in exactly the corpora this
378
+ tool is pointed at.
379
+
380
+ ### Verified
381
+
382
+ | | |
383
+ |---|---|
384
+ | `npm run lint` | pass |
385
+ | `npm test` | 754 passing, 20 files |
386
+ | `npm run selfcheck` | pass, over 19 documents and 124 relations |
387
+ | `npm run test:mutation` | 79.41% over 8,585 mutants, 64m30s |
388
+ | hosted full run, which governs | **75.40%**, 102m48s |
389
+ | `break` | unchanged at 70 |
390
+
391
+ 79.69% over 8,126 mutants at 0.3.0 against 79.41% over 8,585 here, on the same
392
+ machine. The new code carries itself: `project-rules.ts` at **99.50%** with one
393
+ survivor - a genuinely equivalent `<` against `<=` in a comparator whose inputs
394
+ are object keys and therefore never equal.
395
+
396
+ That 99.50% is what reading the survivor list bought. The first full run put the
397
+ module at 86.89%, a targeted re-measurement with `coverageAnalysis all` agreed at
398
+ 86.41% - so the gap was real and not `perTest` mis-attribution - and sixteen of
399
+ those survivors turned out to be tests nobody had written. The whole-corpus
400
+ figure moved 0.38 points for it.
401
+
402
+ The hosted number is the one that actually governs, because it is where the build
403
+ fails: **75.40%**, up from 75.14% at 0.3.0. Lose every timeout on a loaded runner
404
+ and it reads 71.76%, which is the most margin `break: 70` has ever had.
405
+
406
+ That run also caught the other half of a claim
407
+ [ADR-0007](docs/adr/0007-mutation-testing.md) narrowed at 0.3.0. The `main` push
408
+ and the `v0.4.0` tag were the same commit on the same runner, and the incremental
409
+ read **1.29 points low** against the rebuild - having read 3.18 points *high* at
410
+ 0.3.0. Two observations, opposite signs. The direction is not a property, and the
411
+ incremental run took 110 minutes to the rebuild's 103, because a change this wide
412
+ leaves it nothing to reuse.
413
+
414
+ ### Withdrawn
415
+
416
+ [ADR-0013](docs/adr/0013-the-scanner-hands-back-prose.md) said a lone `\r` was
417
+ not treated as a line ending. It always was - `createLineIndex` has recognised
418
+ all three terminators since 0.1.0 and says so in its own doc comment. The claim
419
+ came from reading the scanner and not the table underneath it, and nobody had
420
+ executed it. One corpus through `\n`, `\r\n` and `\r` now produces identical
421
+ items, findings and line numbers, as a test.
422
+
423
+ ### Changed
424
+
425
+ For anyone using the programmatic API: `Diagnostic.rule` is now `AnyRuleId`,
426
+ which is `RuleId` plus the `project:` namespace. An exhaustive `switch` over the
427
+ built-ins needs a default arm. `BaselineOutcome.stale` carries `reason`, and
428
+ `ResolvedCorpus` carries `suppressed`.
429
+
6
430
  ## 0.3.0
7
431
 
8
432
  Found by writing a corpus designed to break the scanner and reading the edges it
@@ -99,11 +523,12 @@ text. Reasoning in
99
523
 
100
524
  ### Measured
101
525
 
102
- 684 tests, and a mutation score of **79.69% over 8,126 mutants** - up from
103
- 77.16% over 7,720. The guard stays at `break: 70`, because the same source
104
- measured twice on the same machine, with only test cases added between the runs,
105
- moved nine untouched files by more than a point each in both directions. See
106
- [ADR-0007](docs/adr/0007-mutation-testing.md).
526
+ 684 tests, and a mutation score of **75.14% on the hosted runner** - the figure
527
+ that governs, up from 73.32% at v0.2.0 - against 79.69% over 8,126 mutants on a
528
+ developer machine. The guard stays at `break: 70`. See
529
+ [ADR-0007](docs/adr/0007-mutation-testing.md), which 0.3.0 also amended: an
530
+ incremental run read 3.18 points *high* against a full rebuild of the same
531
+ commit, so the claim that incremental errs low was wrong.
107
532
 
108
533
  ## 0.2.3
109
534
 
package/README.md CHANGED
@@ -363,6 +363,9 @@ gate without writing any code:
363
363
  ! npx spec-graph query 'document[path^=docs/prd] -depends-on-> document[phase=draft]'
364
364
  ```
365
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
+
366
369
  **Grammar**
367
370
 
368
371
  ```text
@@ -383,6 +386,83 @@ Items inherit their document's lifecycle, so `item[phase=retired]` means what yo
383
386
  expect. A query evaluates to **paths**, not endpoints, which is why a finding can
384
387
  name both ends and the line that connects them.
385
388
 
389
+ `~=` is a regular expression matched by an automaton that cannot backtrack, so a
390
+ predicate is linear in the subject whatever the pattern — `^([A-Za-z0-9_]+[ ]?)+$`
391
+ against a fifty-four-character title takes `RegExp` 103 seconds and this 13
392
+ microseconds. It reads the usual syntax minus backreferences and lookaround,
393
+ which are not regular; both are refused when the selector is read, with the
394
+ character pointed at. See [ADR-0017](docs/adr/0017-a-predicate-must-finish.md).
395
+
396
+ On a register, a region answers its file's front matter for every descriptive
397
+ key — `fm.owner` on a decision inside a register is the register's owner — while
398
+ the keys a region owns itself, its identifier and status and title, and any key
399
+ that declares a relation, stay on the file. See
400
+ [ADR-0009](docs/adr/0009-a-specification-is-a-region.md).
401
+
402
+ ## Your own rules
403
+
404
+ A convention spec-graph never anticipated is a selector plus a sentence. Write
405
+ both in the config file and it becomes a rule like any other:
406
+
407
+ ```json
408
+ {
409
+ "rules": {
410
+ "no-draft-dependency": {
411
+ "query": "document[phase=active] -depends-on-> document[phase=draft]",
412
+ "message": "{0} depends on {1}, which is still a draft",
413
+ "hint": "wait for {1} to be accepted, or drop it from {0.path}",
414
+ "severity": "error"
415
+ }
416
+ }
417
+ }
418
+ ```
419
+
420
+ ```text
421
+ x docs/adr/0002-delivery.md:4:13 project:no-draft-dependency
422
+ ADR-0002 depends on ADR-0001, which is still a draft
423
+ > wait for ADR-0001 to be accepted, or drop it from docs/adr/0002-delivery.md
424
+ ```
425
+
426
+ The finding points at the line that *declares* the relation, because that is
427
+ where a human goes to change the answer — the same discipline every built-in
428
+ follows.
429
+
430
+ | Field | |
431
+ |---|---|
432
+ | `query` | One selector, or a list of them read as a union. |
433
+ | `message` | The headline. `{0}` is the first node on the path, `{1}` the next; `{1.phase}` and `{0.fm.owner}` read any selector attribute. |
434
+ | `hint` | The next action. Optional, templated the same way. |
435
+ | `severity` | `error`, `warn`, `info` or `off`. Defaults to `warn`. |
436
+
437
+ It defaults to `warn` on purpose: a rule a team has just written has not yet
438
+ earned the right to fail their build, and the first run of a new convention is
439
+ when it is most likely to be wrong.
440
+
441
+ The id is the name with `project:` in front, which is why it can never collide
442
+ with a built-in — and why everything else already works. It is baselined by
443
+ `--record-baseline`, silenced by `--rule project:no-draft-dependency=off`,
444
+ escalated by `--strict`, listed by `spec-graph rules`, exempted on journals like
445
+ every other rule about obligations, and emitted as a first-class `ruleId` in
446
+ SARIF, where a project rule describes itself by the selector it is.
447
+
448
+ To see what a rule matches without copying its selector back out of the file:
449
+
450
+ ```bash
451
+ spec-graph query project:no-draft-dependency --verbose
452
+ ```
453
+
454
+ Mistakes are reported when the file is read, not discovered as a rule that
455
+ quietly finds nothing:
456
+
457
+ ```text
458
+ spec-graph: .spec-graph.json: rules.no-drafts: mismatched arrow: use -kind-> for
459
+ one hop or =kind=> for transitive (at character 10)
460
+ spec-graph: .spec-graph.json: rules.owner: "{1.phse}" asks for an attribute
461
+ nothing has
462
+ ```
463
+
464
+ See [ADR-0016](docs/adr/0016-a-query-needs-a-sentence.md).
465
+
386
466
  ## Directives
387
467
 
388
468
  Inference covers almost everything, but inference you cannot override is a trap.
@@ -523,10 +603,24 @@ command. spec-graph reads the first of `.spec-graph.json`,
523
603
  "baseline": ".spec-graph-baseline.json",
524
604
  "ratchet": true,
525
605
  "severities": { "self-reference": "off" },
526
- "strict": true
606
+ "strict": true,
607
+ "rules": {
608
+ "no-draft-dependency": {
609
+ "query": "document[phase=active] -depends-on-> document[phase=draft]",
610
+ "message": "{0} depends on {1}, which is still a draft"
611
+ }
612
+ }
527
613
  }
528
614
  ```
529
615
 
616
+ The file is looked for in the working directory and then upward, as far as the
617
+ repository — a directory holding `.git` — and **the directory holding it becomes
618
+ the root**. So a run from `packages/auth` reports what a run from the top
619
+ reports, byte for byte, because every path here is relative to the root. The
620
+ nearest file wins, `--root` names the root yourself and turns discovery off, and
621
+ a path typed on the command line stays relative to where you typed it. See
622
+ [ADR-0018](docs/adr/0018-the-configuration-file-is-the-root.md).
623
+
530
624
  A flag always wins over the file, and list flags **add** to it rather than
531
625
  replacing it — a `--ignore-ref` on the command line is one more exclusion, not a
532
626
  decision to discard what the repository already declared. `--verbose` prints
@@ -554,6 +648,20 @@ resolves to your local RFC-0001 with `RFC` on the ignore list. No configuration
554
648
  can delete an edge. See
555
649
  [ADR-0010](docs/adr/0010-configuration-belongs-to-the-repository.md).
556
650
 
651
+ `--verbose` lists everything they silenced, so one over-broad glob cannot quietly
652
+ turn the check off and look like a clean repository:
653
+
654
+ ```text
655
+ i docs/adr/0001-traps.md:15:3 ignored reference: trap 55 (2 times)
656
+ i docs/rfcs/0002-transport.md:7:69 ignored family: RFC 2119
657
+ ```
658
+
659
+ The JSON report carries the same rows under `suppressed`, without needing the
660
+ flag. What is deliberately *not* listed is a bare identifier spec-graph read as
661
+ prose on its own — `T-1000` was nobody's decision, and listing it would bury the
662
+ entries that were. See
663
+ [ADR-0008](docs/adr/0008-wiki-links-carry-no-path.md).
664
+
557
665
  Prose like `Phase 1`, `R69`, `Q-120`, `Table 2` and `Step 4` has never needed
558
666
  this: an identifier found in prose is only read as a citation when its family
559
667
  already exists in the corpus.
@@ -563,10 +671,14 @@ already exists in the corpus.
563
671
  ```text
564
672
  spec-graph [check] [patterns...] Validate the graph. The default.
565
673
  spec-graph query <selector> Run a selector, print matching paths.
674
+ spec-graph query project:<rule> Run a registered rule by its id.
566
675
  spec-graph graph [patterns...] Export for Graphviz, Mermaid or JSON.
567
- spec-graph rules [--explain] List the diagnostics.
676
+ spec-graph rules [rule-id] List the diagnostics; --explain adds
677
+ each selector and the ADR behind it.
568
678
 
569
- --root <dir> Directory patterns resolve against
679
+ --root <dir> Directory patterns resolve against, and the root of the
680
+ run. Without it the config file is discovered upward and
681
+ its directory is the root
570
682
  --ignore <glob> Skip paths (repeatable)
571
683
  --ignore-ref <glob> Do not report these reference targets (repeatable)
572
684
  --family <name> Families a bare identifier may name (repeatable)
@@ -576,7 +688,7 @@ spec-graph rules [--explain] List the diagnostics.
576
688
  --record-baseline <f> Write today's findings as accepted debt, exit 0
577
689
  --ratchet Also fail when a baseline entry no longer occurs
578
690
  --no-config Ignore .spec-graph.json and the package.json key
579
- --format <fmt> human | json | sarif
691
+ --format <fmt> human | json | sarif | markdown
580
692
  --graph-format <fmt> dot | mermaid | json
581
693
  --documents-only Hide items; their relations lift onto their documents
582
694
  --rule <id>=<severity> error | warn | info | off (repeatable)
@@ -599,6 +711,19 @@ rather than a silent success.
599
711
  run: npx spec-graph "docs/**/*.md" --format json > spec-graph.json
600
712
  ```
601
713
 
714
+ `--format markdown` writes the same facts as a table, for the place reviewers
715
+ actually look:
716
+
717
+ ```yaml
718
+ - name: Summarise the specification graph
719
+ if: always()
720
+ run: npx spec-graph --format markdown >> "$GITHUB_STEP_SUMMARY"
721
+ ```
722
+
723
+ It leads with the verdict, counts the corpus, gives every finding its hint, and
724
+ *names* the baseline entries that no longer occur rather than counting them —
725
+ on a pull request a count is the one thing a reader cannot act on.
726
+
602
727
  The JSON report is versioned, flat, and carries start and end positions for
603
728
  every finding, so a bot can annotate a diff:
604
729
 
@@ -658,7 +783,7 @@ exported. Reporters, editor extensions and custom rules are all first-class.
658
783
 
659
784
  ## Design
660
785
 
661
- Twelve ADRs, which `spec-graph` validates on every CI run:
786
+ Eighteen ADRs, which `spec-graph` validates on every CI run:
662
787
 
663
788
  - [ADR-0001 — A hand-written Markdown scanner](docs/adr/0001-hand-written-markdown-scanner.md)
664
789
  - [ADR-0002 — A four-phase lifecycle lattice](docs/adr/0002-lifecycle-lattice.md)
@@ -675,11 +800,16 @@ Twelve ADRs, which `spec-graph` validates on every CI run:
675
800
  - [ADR-0013 — When the scanner is unsure, it hands back prose](docs/adr/0013-the-scanner-hands-back-prose.md)
676
801
  - [ADR-0014 — A relation is spelled in both directions](docs/adr/0014-a-relation-is-spelled-both-ways.md)
677
802
  - [ADR-0015 — Feedback goes where the tools already look](docs/adr/0015-feedback-goes-where-the-tools-already-look.md)
803
+ - [ADR-0016 — A query needs a sentence before it is a rule](docs/adr/0016-a-query-needs-a-sentence.md)
804
+ - [ADR-0017 — A predicate must finish](docs/adr/0017-a-predicate-must-finish.md)
805
+ - [ADR-0018 — The configuration file is the root](docs/adr/0018-the-configuration-file-is-the-root.md)
678
806
 
679
807
  **Zero runtime dependencies.** Node 22+, native ESM, TypeScript strict with
680
808
  `noUncheckedIndexedAccess` and `exactOptionalPropertyTypes`. The Markdown
681
- scanner, the front-matter reader, the glob matcher and the query parser are all
682
- written here, so the whole package is auditable in an afternoon.
809
+ scanner, the front-matter reader, the glob matcher, the query parser and the
810
+ regular-expression matcher behind `~=` are all written here, so the whole
811
+ package is auditable in an afternoon — and nothing in the pipeline can take
812
+ longer than its input.
683
813
 
684
814
  **Verified, not just covered.** 411 tests; 94.2% statement and 96.9% line
685
815
  coverage. Coverage says a line ran, so the suite is also held to a mutation