@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 +430 -5
- package/README.md +137 -7
- package/dist/baseline.d.ts +18 -4
- package/dist/baseline.js +14 -2
- package/dist/baseline.js.map +1 -1
- package/dist/cli.d.ts +12 -4
- package/dist/cli.js +267 -59
- package/dist/cli.js.map +1 -1
- package/dist/config.d.ts +45 -2
- package/dist/config.js +61 -2
- package/dist/config.js.map +1 -1
- package/dist/extract.d.ts +20 -11
- package/dist/extract.js +58 -44
- package/dist/extract.js.map +1 -1
- package/dist/identity.d.ts +10 -0
- package/dist/identity.js +37 -0
- package/dist/identity.js.map +1 -1
- package/dist/index.d.ts +13 -9
- package/dist/index.js +8 -6
- package/dist/index.js.map +1 -1
- package/dist/project-rules.d.ts +92 -0
- package/dist/project-rules.js +201 -0
- package/dist/project-rules.js.map +1 -0
- package/dist/regex.d.ts +64 -0
- package/dist/regex.js +708 -0
- package/dist/regex.js.map +1 -0
- package/dist/report.d.ts +34 -7
- package/dist/report.js +172 -5
- package/dist/report.js.map +1 -1
- package/dist/resolve.d.ts +8 -1
- package/dist/resolve.js +154 -30
- package/dist/resolve.js.map +1 -1
- package/dist/rules.d.ts +21 -5
- package/dist/rules.js +92 -5
- package/dist/rules.js.map +1 -1
- package/dist/runner.d.ts +5 -2
- package/dist/runner.js +1 -0
- package/dist/runner.js.map +1 -1
- package/dist/select.d.ts +19 -0
- package/dist/select.js +64 -14
- package/dist/select.js.map +1 -1
- package/dist/types.d.ts +36 -1
- package/dist/types.js +5 -0
- package/dist/types.js.map +1 -1
- package/package.json +1 -1
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 **
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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 [
|
|
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
|
-
|
|
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
|
|
682
|
-
written here, so the whole
|
|
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
|