@descent-vtt/spec-graph 0.6.0 → 0.8.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,157 @@
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.8.0
7
+
8
+ A register row took its identifier from the first thing its *title* said, not
9
+ from its ID column. Found on the same 885-document repository as 0.7.0: 27 of
10
+ 232 rows of one issues register were filed under the wrong entity.
11
+
12
+ A minor rather than a patch, and the baseline is the reason. A baseline is
13
+ fingerprinted on document ids, and the whole of this fix is that those ids move.
14
+ Measured on an unchanged corpus against a baseline recorded by 0.7.0: the
15
+ accepted finding came back as new, its entry was reported as no longer
16
+ occurring, and `check --baseline` went from exit `0` to exit `1`. One
17
+ `--record-baseline` settles it, and the entries it writes are the ones that were
18
+ meant all along. The rule at the top of this file says a patch asks nothing of a
19
+ repository that upgrades, and this asks for that run.
20
+
21
+ ### Fixed
22
+
23
+ **A title names nothing.** `| OI-V-05 | ADR-040's enforcement point has no
24
+ browser test |` was read as `ADR-040`. Eighteen rows collided with the ADR they
25
+ cite that way, and the collision does not merely mislabel a row: run against a
26
+ corpus holding both, the ADR keeps the id and the issue has no node in the graph
27
+ at all. Nine more opened with a noun phrase, inventing a `STAGE-1`, a
28
+ `GUARDRAIL-5` and a `CLOSED-2026` that nobody had written, each of them open -
29
+ which is what drew the false `ghost-handover` findings the register was reported
30
+ for; two rows collapsed onto one `STAGE-1`.
31
+
32
+ The identifier a title opens with almost always belongs to the document the
33
+ title is *about*, so a title is read as a name only where nothing else has given
34
+ one - and where it is not the name it registers no alias either, because an
35
+ alias sends every citation of the real document to whatever quoted it.
36
+ `# ADR-0040 considered harmful` at the top of `0007-sharding.md` was registering
37
+ `adr0040` against `ADR-0007`.
38
+
39
+ Two things count as having given one: a number, from a declaration or a file
40
+ name, and for a register row its id column, whatever shape the id is in. A whole
41
+ file keeps the looser reading and its H1 can still name it, because there
42
+ nothing separates a title about another document from a title about this one:
43
+ `slug:` holds a URL segment rather than an id, so `slug: sharding` in
44
+ `sharding.md` under `# ADR-0007: Sharding` has to stay `ADR-0007`. A declaration
45
+ and the file name are still weighed together rather than ranked, so
46
+ `slug: sharding-the-write-path` in `0007-sharding.md` is `ADR-0007` as before.
47
+
48
+ **A region no longer reads a number out of the file it sits in.** Every row of
49
+ a register kept in `0042-open-issues.md` answered to `ADR-0042`, and so did the
50
+ file. ADR-0009 already said a region does not claim the file's *path*, for the
51
+ reason that two nodes answering to one name make every link to it ambiguous; the
52
+ file's name is that same claim spelled differently, and the rule was written
53
+ down as two.
54
+
55
+ **A file's own title heading is matched by its number, not its spelling.**
56
+ `# ADR-040` at the top of `0040-enforce.md` was read as a region *inside* the
57
+ file it names: one decision, two nodes, a split lifecycle, and a citation
58
+ arriving at whichever padding it happened to use. Padding is not part of an
59
+ identity anywhere else - `ADR-40`, `ADR-040` and `ADR-0040` all resolve to one
60
+ document - and now it is not part of this comparison either.
61
+
62
+ ### Verified
63
+
64
+ `npm run lint`, 950 tests and `npm run selfcheck`. Thirteen of the fifteen new
65
+ tests fail against the unfixed source; the other two are the guards on the
66
+ layouts that must not move, which have to pass both ways. `identity.ts` and
67
+ `sections.ts` were measured on their own before and after: 72.77% and 66.89%
68
+ became 75.18% and 71.93%, and no mutant on the new lines survived, so nothing
69
+ was masked. ADR-0004 carries the rule as a
70
+ fifth asymmetry, with the hyphenated-prefix question declined under it, and
71
+ ADR-0009 is amended in place.
72
+
73
+ ## 0.7.0
74
+
75
+ Three ways a run handed back something other than what it said it was: a
76
+ configuration that did not load reporting a clean graph, a `--baseline` that
77
+ read nothing accepting nothing, and `--verbose` breaking the document it was
78
+ printed above. The first two were found on an 885-document repository, and the
79
+ third while fixing them.
80
+
81
+ A minor rather than a patch. Two of the three turn a build that was passing
82
+ into one that fails, which is the whole point of them and exactly what the rule
83
+ at the top of this file says a patch may not do.
84
+
85
+ ### Changed
86
+
87
+ **A configuration that did not load stops the run, with exit `2`.** Invalid
88
+ JSON, an unknown key, a value of the wrong type, a project rule that does not
89
+ compile: each was printed to stderr, and the run then went on with defaults -
90
+ reporting `the specification graph is consistent`, exiting `0`, and writing
91
+ `"ok": true` into JSON, SARIF and Markdown. Under `--strict` as well. A
92
+ `{1.phse}` for `{1.phase}` in one rule's message was a green build over exactly
93
+ the files that rule fails.
94
+
95
+ It was the one place the exit codes disagreed with themselves, too:
96
+ `--rule not-a-rule=off` on the command line has always exited `2`, and the same
97
+ misspelling inside `"severities"` printed a line and passed. `--no-config`
98
+ checks on defaults, and the exit code then says which run it was. The old
99
+ behaviour was the decision in ADR-0010, and that ADR now carries why it moved.
100
+
101
+ **A `--baseline` that cannot be read stops the run.** Read as an empty baseline,
102
+ a typo in the path reports every accepted finding as new, passes `--ratchet`
103
+ with nothing left to be stale about, and looks like a regression nobody
104
+ introduced. A `"baseline"` named in configuration is unchanged and still
105
+ optional, because a repository declares the path before the first run records
106
+ the file - `--verbose` now says when it read nothing. Unreadable, as opposed to
107
+ absent, is an error wherever the path was written. A baseline that cannot be
108
+ *parsed* is still reported and ignored: it only ever suppresses, so a row that
109
+ is dropped is a finding reported. See
110
+ [ADR-0012](docs/adr/0012-a-baseline-is-a-ratchet.md).
111
+
112
+ ### Fixed
113
+
114
+ **`--verbose` no longer breaks the format it was combined with.** The line
115
+ naming which configuration file was read, and the one naming a project rule's
116
+ selector under `query`, went to stdout above the report. With `--format json`
117
+ that made the output unparseable; with `--format sarif` it made a file the
118
+ code-scanning uploader rejects over a schema rather than over the command that
119
+ was run. Both now go to stderr, with everything else a run says about itself.
120
+ [ADR-0015](docs/adr/0015-feedback-goes-where-the-tools-already-look.md) already
121
+ had the rule - rows "cannot go on stdout beside a JSON document without
122
+ breaking the parse" - and these two lines were the places that did not follow
123
+ it.
124
+
125
+ **An absolute baseline path is no longer resolved under the root.**
126
+ `--baseline /tmp/b.json` read `<root>/tmp/b.json` and `--record-baseline`
127
+ wrote there: the recording landed inside the corpus, or failed naming a
128
+ directory nobody had typed, and the read found nothing and said nothing. Both
129
+ platforms' spellings are absolute on either platform, since one repository is
130
+ read on a Windows checkout and in Linux CI from the same file.
131
+
132
+ - **A `package.json` configuration problem names `package.json`.** It reported
133
+ `"spec-graph" must be an object` with no file in it, and a problem that names
134
+ no file sends the reader to a `.spec-graph.json` that is not there.
135
+
136
+ ### Verified
137
+
138
+ | | |
139
+ | --- | --- |
140
+ | `npm run lint` | pass |
141
+ | `npm test` | 935 passing, 23 files |
142
+ | `npm run selfcheck` | pass, over 23 documents and 184 relations |
143
+ | focused sweep, every changed region | 100% |
144
+ | `src/cli.ts` alone, before and after | 80.43% over 797 mutants, then 81.11% over 826 |
145
+ | `break` | unchanged at 70 |
146
+
147
+ Each behaviour was also run against the built binary rather than only through
148
+ `main()`, including the absolute paths, which is where a Windows drive letter
149
+ either survives `toPosix` or does not.
150
+
151
+ Moving two lines from stdout to stderr cost two mutants that a JSON parse used
152
+ to kill for free, and both were replaced with an assertion: a flag that is only
153
+ ever asserted when it is passed stops meaning anything. Both `cli.ts` figures
154
+ above are single-file runs on one machine with `--coverageAnalysis all`, which
155
+ is the comparison ADR-0007 asks for and not the hosted sweep that governs.
156
+
6
157
  ## 0.6.0
7
158
 
8
159
  A diff of two graph exports that names only the changes it can tell apart,
package/README.md CHANGED
@@ -227,6 +227,13 @@ header you wrote:
227
227
 
228
228
  Findings point at the declaring cell, not at the file.
229
229
 
230
+ A row is named by its ID column, and a title is only a title. An identifier a
231
+ title opens with belongs to the document the row is *about* — an issue reading
232
+ `| OI-V-05 | ADR-040's enforcement point has no browser test |` is filed under
233
+ `OI-V-05`, and citations of `ADR-040` still reach `ADR-040`. The same holds for
234
+ a whole file: an explicit `id:`, then the file name, then the H1, and the H1 is
235
+ read as a name only when neither of the others gave one.
236
+
230
237
  The register itself stays in the graph as the thing that holds them, so
231
238
  `spec-graph query 'document[id=register] -contains-> document'` lists what is
232
239
  inside it.
@@ -561,8 +568,15 @@ spec-graph check --baseline .spec-graph-baseline.json --ratchet
561
568
 
562
569
  Now an undeclared finding fails **and** a declared one that no longer occurs
563
570
  fails, each named, with `--record-baseline` as the fix — and the diff of that
564
- file is the record of what was paid off. See
565
- [ADR-0012](docs/adr/0012-a-baseline-is-a-ratchet.md).
571
+ file is the record of what was paid off.
572
+
573
+ **A `--baseline` you typed has to be there.** A path that reads nothing accepts
574
+ nothing, reports every accepted finding as new, and passes `--ratchet` with
575
+ nothing left to be stale about, so a typo in it looks exactly like a regression
576
+ nobody introduced: it exits `2` instead. A `"baseline"` in the configuration is
577
+ the other case and stays optional, because a repository declares the path before
578
+ the first run records the file — `--verbose` says when it read nothing. Either
579
+ may be absolute. See [ADR-0012](docs/adr/0012-a-baseline-is-a-ratchet.md).
566
580
 
567
581
  ## In CI
568
582
 
@@ -633,11 +647,19 @@ a path typed on the command line stays relative to where you typed it. See
633
647
  A flag always wins over the file, and list flags **add** to it rather than
634
648
  replacing it — a `--ignore-ref` on the command line is one more exclusion, not a
635
649
  decision to discard what the repository already declared. `--verbose` prints
636
- which file was read; `--no-config` skips the mechanism entirely.
637
-
638
- A broken config is reported and the run continues on defaults, including an
639
- unknown key: a silently ignored `ignoreReference` is a configuration that looks
640
- applied and is not.
650
+ which file was read, on stderr with everything else a run says about itself, so
651
+ it can be combined with any `--format` and leave the document on stdout intact.
652
+ `--no-config` skips the mechanism entirely.
653
+
654
+ A broken config stops the run with exit `2`, each problem named on stderr —
655
+ invalid JSON, a value of the wrong type, an unknown key, a rule that does not
656
+ compile. A configuration that did not load checks a different repository than
657
+ the one configured, and would report *that* one as consistent: a `{1.phse}` for
658
+ `{1.phase}` in one rule's message is a green build over the files the correctly
659
+ spelled rule fails. An unknown key counts, for the same reason it is reported at
660
+ all — a silently ignored `ignoreReference` is a configuration that looks applied
661
+ and is not. `--no-config` checks on defaults instead, and the exit code then
662
+ says which run it was.
641
663
 
642
664
  ### Family rules
643
665
 
@@ -711,8 +733,9 @@ spec-graph diff <before> <after> Compare two JSON graph exports.
711
733
  ```
712
734
 
713
735
  Exit codes: `0` clean, `1` findings, `2` the tool could not run. A mistyped flag
714
- never masquerades as a passing build, and a pattern matching nothing is an error
715
- rather than a silent success.
736
+ never masquerades as a passing build, and neither does a pattern that matches
737
+ nothing, a configuration file that did not load, or a `--baseline` that is not
738
+ there.
716
739
 
717
740
  ## Continuous integration
718
741
 
package/dist/cli.d.ts CHANGED
@@ -77,7 +77,7 @@ export interface CliIO {
77
77
  readonly env?: Readonly<Record<string, string | undefined>> | undefined;
78
78
  readonly isTTY?: boolean | undefined;
79
79
  }
80
- export declare const HELP = "spec-graph - turn Markdown specifications into a verifiable graph\n\nUSAGE\n spec-graph [check] [patterns...] [options]\n spec-graph query <selector|project:rule> [patterns...] [options]\n spec-graph graph [patterns...] [--graph-format dot|mermaid|json]\n spec-graph rules [rule-id] [--explain]\n spec-graph diff <before.json> <after.json> [--format human|json|markdown]\n\nCOMMANDS\n check Validate the specification graph. The default.\n query Run a selector - or a registered project rule, by its id - and\n print the matching paths.\n graph Export the graph for Graphviz, Mermaid, or another tool.\n rules List the diagnostics that will run, built in and project. Name one\n to see only that one; --explain adds its selector and the ADR that\n decided it.\n diff Compare two graph exports: documents added, removed, moved or\n accepted; relations added or removed; and obligations resolved or\n reopened where they can be told apart. Make both exports with the\n same spec-graph. Exits 0 whether anything changed or not.\n\nOPTIONS\n --root <dir> Directory the patterns resolve against. Without it,\n .spec-graph.json is looked for in the working\n directory and then upward as far as the repository,\n and the directory holding it becomes the root - so a\n run from a subdirectory reports what a run from the\n top reports. Paths typed on the command line stay\n relative to where they were typed.\n --ignore <glob> Skip paths. Repeatable.\n --ignore-ref <glob> Do not report these reference targets when they fail\n to resolve, for repositories where [[...]] tags a\n concept rather than naming a file. Repeatable.\n Suppresses findings only, never edges.\n --family <name> Families a bare identifier in prose may name. When\n given, everything else stays prose. Repeatable.\n --ignore-family <name> Families that are never citations - RFC when the repo\n cites RFC 2119 and keeps its own RFCs. Repeatable.\n --history <glob> Files that log what was decided rather than deciding\n it - journals, changelogs, minutes. Their links are\n still checked; their obligations are not. Repeatable.\n --baseline <file> Accept the findings recorded in this file and report\n only what is new since. Missing file = accept nothing.\n --record-baseline <f> Write today's findings to this file as accepted debt,\n and exit 0 without judging them.\n --ratchet Also fail when a baseline entry no longer occurs, so\n a paid-off exemption cannot outlive the defect.\n --no-config Ignore .spec-graph.json and the package.json key.\n --format <fmt> human, json, sarif or markdown. sarif is the\n interchange format GitHub code scanning and editors\n already read, for check; markdown is a table for a\n pull-request comment or $GITHUB_STEP_SUMMARY, for check\n or diff (default: human)\n --graph-format <fmt> dot, mermaid or json (default: dot)\n --documents-only Leave items out of the exported graph\n --rule <id>=<severity> Override one rule: error, warn, info or off. A project\n rule is named in full: project:no-draft-dependency.\n Repeatable.\n --max <n> Show at most n findings (0 = no limit)\n --max-warnings <n> Fail when warnings exceed n (default: no limit)\n --strict Raise every warning to an error. An explicit --rule\n still wins, so --strict --rule x=warn exempts x.\n --color / --no-color Force colour on or off\n --ascii Use ASCII glyphs only\n --verbose Include parse problems, per-file detail, and every\n reference --ignore-ref or --ignore-family silenced\n -h, --help Show this help\n -v, --version Show the version\n\nSELECTORS\n A selector is a path through the graph.\n\n item[openness=open] -delegates-to-> document[phase=retired]\n document[phase=active] -assumes-> document[phase=retired]\n document =supersedes=> document\n *[path^=docs/adr] -contains-> item[state=accepted-debt]\n\n Node types: document, item, * (any)\n Attributes: id, kind, title, path, file, line, phase, status, receptivity,\n alias, document, state (or disposition), openness, section,\n text, body, evidence, conflicted, fm.<front-matter-key>\n Operators: = != ^= $= *= ~= and [attr] for \"is present\"\n ~= is a regular expression, matched by an automaton that cannot\n backtrack: linear in the subject, whatever the pattern. It reads\n the usual syntax minus backreferences and lookaround, which are\n not regular - both are refused when the selector is read, with\n the character pointed at\n Relations: -kind-> <-kind- =kind=> <=kind= (= forms are transitive)\n\nEXIT CODES\n 0 clean\n 1 findings at error severity (or over --max-warnings)\n 2 the tool could not run\n\nCONFIGURATION\n Anything repeated on every run belongs in the repository rather than in the\n command. spec-graph reads the first of these that exists:\n\n .spec-graph.json, spec-graph.config.json, or a \"spec-graph\" key in\n package.json\n\n { \"patterns\": [\"docs/**/*.md\"],\n \"ignoreReferences\": [\"trap *\"],\n \"ignoreFamilies\": [\"RFC\"],\n \"historyPatterns\": [\"**/JOURNAL_*.md\", \"archive/**\"],\n \"baseline\": \".spec-graph-baseline.json\",\n \"severities\": { \"self-reference\": \"off\" },\n \"strict\": true }\n\n A flag always wins over the file, and list flags add to it rather than\n replacing it.\n\nPROJECT RULES\n A convention spec-graph never anticipated is a selector plus a sentence, and\n belongs in the same file. Every rule declared here runs beside the built-ins\n and is reported, baselined, escalated by --strict and silenced by --rule in\n exactly the same way.\n\n { \"rules\": {\n \"no-draft-dependency\": {\n \"query\": \"document[phase=active] -depends-on-> document[phase=draft]\",\n \"message\": \"{0} depends on {1}, which is still a draft\",\n \"hint\": \"wait for {1} to be accepted, or drop it from {0.path}\",\n \"severity\": \"error\" } } }\n\n query One selector, or a list of them read as a union.\n message The headline. {0} is the first node on the path, {1} the next;\n {1.phase} and {0.fm.owner} read any selector attribute.\n hint The next action. Optional, and templated the same way.\n severity error, warn, info or off. Defaults to warn - a rule a team has\n just written has not yet earned the right to fail their build.\n\n The id is the name with project: in front, which is why it can never collide\n with a built-in. A selector that does not parse, or a {2} the query can never\n reach, is reported when the file is read rather than found missing later.\n\n To see what one matches without copying its selector out of the file:\n\n spec-graph query project:no-draft-dependency --verbose\n\nADOPTING THIS ON AN OLD REPOSITORY\n Record what is already wrong, then report only what happens next:\n\n spec-graph check --record-baseline .spec-graph-baseline.json\n git add .spec-graph-baseline.json\n\n The file is keyed by specification and citation, not by line number, so it\n survives edits, moves and renames. Findings that stop occurring are reported\n so the file can be tightened; nothing new gets in.\n\nEXAMPLES\n spec-graph \"docs/**/*.md\"\n spec-graph check --rule self-reference=off --format json\n spec-graph query 'item[openness=open] -delegates-to-> document[phase=retired]'\n spec-graph check --ignore-ref \"trap *\" # [[trap 55]] tags a concept, not a file\n spec-graph graph --documents-only --graph-format mermaid > graph.mmd\n spec-graph check --history \"**/JOURNAL_*.md\" # a log is not a specification\n spec-graph check --baseline .spec-graph-baseline.json\n spec-graph rules --explain # including this repository's own\n spec-graph rules ghost-handover --explain # and why it exists\n spec-graph check --rule project:no-draft-dependency=off\n spec-graph query project:no-draft-dependency # what does that rule match?\n spec-graph check --format markdown >> \"$GITHUB_STEP_SUMMARY\"\n spec-graph diff base.json head.json --format markdown >> \"$GITHUB_STEP_SUMMARY\"\n";
80
+ export declare const HELP = "spec-graph - turn Markdown specifications into a verifiable graph\n\nUSAGE\n spec-graph [check] [patterns...] [options]\n spec-graph query <selector|project:rule> [patterns...] [options]\n spec-graph graph [patterns...] [--graph-format dot|mermaid|json]\n spec-graph rules [rule-id] [--explain]\n spec-graph diff <before.json> <after.json> [--format human|json|markdown]\n\nCOMMANDS\n check Validate the specification graph. The default.\n query Run a selector - or a registered project rule, by its id - and\n print the matching paths.\n graph Export the graph for Graphviz, Mermaid, or another tool.\n rules List the diagnostics that will run, built in and project. Name one\n to see only that one; --explain adds its selector and the ADR that\n decided it.\n diff Compare two graph exports: documents added, removed, moved or\n accepted; relations added or removed; and obligations resolved or\n reopened where they can be told apart. Make both exports with the\n same spec-graph. Exits 0 whether anything changed or not.\n\nOPTIONS\n --root <dir> Directory the patterns resolve against. Without it,\n .spec-graph.json is looked for in the working\n directory and then upward as far as the repository,\n and the directory holding it becomes the root - so a\n run from a subdirectory reports what a run from the\n top reports. Paths typed on the command line stay\n relative to where they were typed.\n --ignore <glob> Skip paths. Repeatable.\n --ignore-ref <glob> Do not report these reference targets when they fail\n to resolve, for repositories where [[...]] tags a\n concept rather than naming a file. Repeatable.\n Suppresses findings only, never edges.\n --family <name> Families a bare identifier in prose may name. When\n given, everything else stays prose. Repeatable.\n --ignore-family <name> Families that are never citations - RFC when the repo\n cites RFC 2119 and keeps its own RFCs. Repeatable.\n --history <glob> Files that log what was decided rather than deciding\n it - journals, changelogs, minutes. Their links are\n still checked; their obligations are not. Repeatable.\n --baseline <file> Accept the findings recorded in this file and report\n only what is new since. The file has to be there: a\n path that reads nothing accepts nothing and says so\n nowhere. A \"baseline\" in the configuration may name a\n file not recorded yet; a path typed here may not.\n --record-baseline <f> Write today's findings to this file as accepted debt,\n and exit 0 without judging them.\n --ratchet Also fail when a baseline entry no longer occurs, so\n a paid-off exemption cannot outlive the defect.\n --no-config Ignore .spec-graph.json and the package.json key.\n --format <fmt> human, json, sarif or markdown. sarif is the\n interchange format GitHub code scanning and editors\n already read, for check; markdown is a table for a\n pull-request comment or $GITHUB_STEP_SUMMARY, for check\n or diff (default: human)\n --graph-format <fmt> dot, mermaid or json (default: dot)\n --documents-only Leave items out of the exported graph\n --rule <id>=<severity> Override one rule: error, warn, info or off. A project\n rule is named in full: project:no-draft-dependency.\n Repeatable.\n --max <n> Show at most n findings (0 = no limit)\n --max-warnings <n> Fail when warnings exceed n (default: no limit)\n --strict Raise every warning to an error. An explicit --rule\n still wins, so --strict --rule x=warn exempts x.\n --color / --no-color Force colour on or off\n --ascii Use ASCII glyphs only\n --verbose Include parse problems, per-file detail, and every\n reference --ignore-ref or --ignore-family silenced\n -h, --help Show this help\n -v, --version Show the version\n\nSELECTORS\n A selector is a path through the graph.\n\n item[openness=open] -delegates-to-> document[phase=retired]\n document[phase=active] -assumes-> document[phase=retired]\n document =supersedes=> document\n *[path^=docs/adr] -contains-> item[state=accepted-debt]\n\n Node types: document, item, * (any)\n Attributes: id, kind, title, path, file, line, phase, status, receptivity,\n alias, document, state (or disposition), openness, section,\n text, body, evidence, conflicted, fm.<front-matter-key>\n Operators: = != ^= $= *= ~= and [attr] for \"is present\"\n ~= is a regular expression, matched by an automaton that cannot\n backtrack: linear in the subject, whatever the pattern. It reads\n the usual syntax minus backreferences and lookaround, which are\n not regular - both are refused when the selector is read, with\n the character pointed at\n Relations: -kind-> <-kind- =kind=> <=kind= (= forms are transitive)\n\nEXIT CODES\n 0 clean\n 1 findings at error severity (or over --max-warnings)\n 2 the tool could not run\n\nCONFIGURATION\n Anything repeated on every run belongs in the repository rather than in the\n command. spec-graph reads the first of these that exists:\n\n .spec-graph.json, spec-graph.config.json, or a \"spec-graph\" key in\n package.json\n\n { \"patterns\": [\"docs/**/*.md\"],\n \"ignoreReferences\": [\"trap *\"],\n \"ignoreFamilies\": [\"RFC\"],\n \"historyPatterns\": [\"**/JOURNAL_*.md\", \"archive/**\"],\n \"baseline\": \".spec-graph-baseline.json\",\n \"severities\": { \"self-reference\": \"off\" },\n \"strict\": true }\n\n A flag always wins over the file, and list flags add to it rather than\n replacing it.\n\n A problem in that file stops the run with exit 2, named on stderr: invalid\n JSON, an unknown key, a rule that does not compile. A configuration that did\n not load checks a different repository than the one configured, and would\n report that one as consistent. --no-config runs on defaults instead.\n\nPROJECT RULES\n A convention spec-graph never anticipated is a selector plus a sentence, and\n belongs in the same file. Every rule declared here runs beside the built-ins\n and is reported, baselined, escalated by --strict and silenced by --rule in\n exactly the same way.\n\n { \"rules\": {\n \"no-draft-dependency\": {\n \"query\": \"document[phase=active] -depends-on-> document[phase=draft]\",\n \"message\": \"{0} depends on {1}, which is still a draft\",\n \"hint\": \"wait for {1} to be accepted, or drop it from {0.path}\",\n \"severity\": \"error\" } } }\n\n query One selector, or a list of them read as a union.\n message The headline. {0} is the first node on the path, {1} the next;\n {1.phase} and {0.fm.owner} read any selector attribute.\n hint The next action. Optional, and templated the same way.\n severity error, warn, info or off. Defaults to warn - a rule a team has\n just written has not yet earned the right to fail their build.\n\n The id is the name with project: in front, which is why it can never collide\n with a built-in. A selector that does not parse, or a {2} the query can never\n reach, is reported when the file is read rather than found missing later.\n\n To see what one matches without copying its selector out of the file:\n\n spec-graph query project:no-draft-dependency --verbose\n\nADOPTING THIS ON AN OLD REPOSITORY\n Record what is already wrong, then report only what happens next:\n\n spec-graph check --record-baseline .spec-graph-baseline.json\n git add .spec-graph-baseline.json\n\n The file is keyed by specification and citation, not by line number, so it\n survives edits, moves and renames. Findings that stop occurring are reported\n so the file can be tightened; nothing new gets in.\n\nEXAMPLES\n spec-graph \"docs/**/*.md\"\n spec-graph check --rule self-reference=off --format json\n spec-graph query 'item[openness=open] -delegates-to-> document[phase=retired]'\n spec-graph check --ignore-ref \"trap *\" # [[trap 55]] tags a concept, not a file\n spec-graph graph --documents-only --graph-format mermaid > graph.mmd\n spec-graph check --history \"**/JOURNAL_*.md\" # a log is not a specification\n spec-graph check --baseline .spec-graph-baseline.json\n spec-graph rules --explain # including this repository's own\n spec-graph rules ghost-handover --explain # and why it exists\n spec-graph check --rule project:no-draft-dependency=off\n spec-graph query project:no-draft-dependency # what does that rule match?\n spec-graph check --format markdown >> \"$GITHUB_STEP_SUMMARY\"\n spec-graph diff base.json head.json --format markdown >> \"$GITHUB_STEP_SUMMARY\"\n";
81
81
  /** Parses argv into options. Throws {@link UsageError} on anything malformed. */
82
82
  export declare function parseArgs(argv: readonly string[], cwd: string): CliOptions;
83
83
  /** Runs the CLI and returns the process exit code. Never throws. */
package/dist/cli.js CHANGED
@@ -16,7 +16,7 @@ import { isGlob, underRoot } from './glob.js';
16
16
  import { analyse, DEFAULT_PATTERNS, withDiagnostics } from './runner.js';
17
17
  import { formatGraph, formatJson, formatMarkdown, formatReport, formatSarif, shouldUseAscii, shouldUseColor, } from './report.js';
18
18
  import { DEFAULT_SEVERITIES, resolveStrict, RULE_DECISIONS, RULE_DESCRIPTIONS, RULE_IDS, RULE_QUERIES, } from './rules.js';
19
- import { toPosix } from './paths.js';
19
+ import { isAbsolutePath, toPosix } from './paths.js';
20
20
  import { formatRef } from './source.js';
21
21
  import { execute, parseQuery, QueryError, renderMatch } from './select.js';
22
22
  import { isProjectRule } from './types.js';
@@ -72,7 +72,10 @@ OPTIONS
72
72
  it - journals, changelogs, minutes. Their links are
73
73
  still checked; their obligations are not. Repeatable.
74
74
  --baseline <file> Accept the findings recorded in this file and report
75
- only what is new since. Missing file = accept nothing.
75
+ only what is new since. The file has to be there: a
76
+ path that reads nothing accepts nothing and says so
77
+ nowhere. A "baseline" in the configuration may name a
78
+ file not recorded yet; a path typed here may not.
76
79
  --record-baseline <f> Write today's findings to this file as accepted debt,
77
80
  and exit 0 without judging them.
78
81
  --ratchet Also fail when a baseline entry no longer occurs, so
@@ -142,6 +145,11 @@ CONFIGURATION
142
145
  A flag always wins over the file, and list flags add to it rather than
143
146
  replacing it.
144
147
 
148
+ A problem in that file stops the run with exit 2, named on stderr: invalid
149
+ JSON, an unknown key, a rule that does not compile. A configuration that did
150
+ not load checks a different repository than the one configured, and would
151
+ report that one as consistent. --no-config runs on defaults instead.
152
+
145
153
  PROJECT RULES
146
154
  A convention spec-graph never anticipated is a selector plus a sentence, and
147
155
  belongs in the same file. Every rule declared here runs beside the built-ins
@@ -424,9 +432,11 @@ function count(n, word, many) {
424
432
  /**
425
433
  * Reads a baseline file.
426
434
  *
427
- * A missing file is not an error: `--baseline` against a repository that has
428
- * not recorded one yet should report everything, which is exactly what an empty
429
- * baseline does.
435
+ * Absent and unreadable are different answers, and the caller needs both. A
436
+ * repository that has not recorded a baseline yet declares the path before the
437
+ * file exists, and an empty baseline accepts nothing, which is what that run
438
+ * should report (ADR-0012). Anything else - a directory, a permission, a path
439
+ * that went somewhere unintended - is the tool failing to do what it was told.
430
440
  */
431
441
  async function readBaseline(path, source) {
432
442
  let raw;
@@ -434,10 +444,15 @@ async function readBaseline(path, source) {
434
444
  const { readFile } = await import('node:fs/promises');
435
445
  raw = await readFile(path, 'utf8');
436
446
  }
437
- catch {
438
- return { baseline: EMPTY_BASELINE, problems: [] };
447
+ catch (error) {
448
+ const failure = error;
449
+ return {
450
+ baseline: EMPTY_BASELINE,
451
+ problems: [],
452
+ unread: { missing: failure.code === 'ENOENT', message: failure.message },
453
+ };
439
454
  }
440
- return parseBaseline(raw, source);
455
+ return { ...parseBaseline(raw, source), unread: null };
441
456
  }
442
457
  function parseCount(flag, value) {
443
458
  const parsed = Number.parseInt(value, 10);
@@ -495,6 +510,17 @@ export async function main(io = {}) {
495
510
  : discoverConfig(options.root);
496
511
  for (const problem of loaded.problems)
497
512
  err(`spec-graph: ${problem}\n`);
513
+ // A configuration that did not load means a run against a different
514
+ // repository than the one configured - other patterns, other rules, other
515
+ // severities - and the verdict printed is about that one. Reporting the
516
+ // problem and carrying on gave a green build with `ok: true` from a file with
517
+ // a typo in it, which is the one answer a checker must never reach by
518
+ // accident, and the same mistake in a `--rule` has always exited 2. The
519
+ // findings are one flag away, and the flag is named. Amends ADR-0010.
520
+ if (loaded.problems.length > 0) {
521
+ err(`spec-graph: ${count(loaded.problems.length, 'problem')} in the configuration, so nothing was checked\n fix it, or run again with --no-config to check on defaults\n`);
522
+ return EXIT_ERROR;
523
+ }
498
524
  const file = loaded.config;
499
525
  const projectRules = file.rules ?? [];
500
526
  const root = loaded.root;
@@ -543,9 +569,14 @@ export async function main(io = {}) {
543
569
  };
544
570
  // Named the way the reader would have to type it, because a discovered
545
571
  // configuration is often not the one in front of them.
572
+ //
573
+ // On stderr, with everything else this run says about itself. stdout is the
574
+ // report, and in three of the four formats it is a document: a line above it
575
+ // made `--verbose --format json` unparseable and handed `--format sarif` to a
576
+ // code-scanning uploader that rejects it over a schema.
546
577
  if (options.verbose && loaded.source !== null) {
547
578
  const depth = here === '' ? 0 : here.split('/').length;
548
- out(`configuration: ${'../'.repeat(depth)}${loaded.source}\n`);
579
+ err(`configuration: ${'../'.repeat(depth)}${loaded.source}\n`);
549
580
  }
550
581
  let result;
551
582
  try {
@@ -580,9 +611,11 @@ export async function main(io = {}) {
580
611
  return EXIT_ERROR;
581
612
  }
582
613
  queries = rule.queries;
614
+ // Beside the report rather than inside it, for the reason the
615
+ // configuration line above is: `--format json` is a document.
583
616
  if (options.verbose)
584
617
  for (const source of rule.sources)
585
- out(`${rule.id}: ${source}\n`);
618
+ err(`${rule.id}: ${source}\n`);
586
619
  }
587
620
  else {
588
621
  try {
@@ -624,6 +657,22 @@ export async function main(io = {}) {
624
657
  let note;
625
658
  if (source !== null) {
626
659
  const held = await readBaseline(underRoot(root, source), source);
660
+ // A path typed on the command line says the file is there for this run.
661
+ // Read as an empty baseline instead, a typo in it reports every accepted
662
+ // finding as new, and passes --ratchet with nothing left to be stale.
663
+ // A path in the configuration is the case ADR-0012 argued for, and only
664
+ // where the file is absent: unreadable is unreadable wherever the path
665
+ // was written.
666
+ if (held.unread !== null && (options.baseline !== null || !held.unread.missing)) {
667
+ err(`spec-graph: cannot read the baseline ${source}: ${held.unread.message}\n`);
668
+ return EXIT_ERROR;
669
+ }
670
+ // Quiet by default, because a configured path legitimately precedes the
671
+ // first --record-baseline. Under --verbose, where parse problems already
672
+ // go, a path that reads nothing says so rather than accepting nothing.
673
+ if (held.unread !== null && options.verbose) {
674
+ err(`spec-graph: baseline ${source} is not there, so nothing is accepted\n`);
675
+ }
627
676
  for (const problem of held.problems)
628
677
  err(`spec-graph: ${problem}\n`);
629
678
  const outcome = applyBaseline(result.graph, result.diagnostics, held.baseline);
@@ -687,11 +736,10 @@ function below(root, from) {
687
736
  * the root cannot change what it means.
688
737
  */
689
738
  function anchor(prefix, value) {
690
- if (prefix === '' || ABSOLUTE.test(value))
739
+ if (prefix === '' || isAbsolutePath(value))
691
740
  return value;
692
741
  return value.startsWith('!') ? `!${prefix}/${value.slice(1)}` : `${prefix}/${value}`;
693
742
  }
694
- const ABSOLUTE = /^(?:[/\\]|[A-Za-z]:)/;
695
743
  /** The same, for an ignore, where a bare name is a directory at any depth. */
696
744
  function anchorPath(prefix, pattern) {
697
745
  return isGlob(pattern) || pattern.includes('/') ? anchor(prefix, pattern) : pattern;