@descent-vtt/spec-graph 0.16.0 → 0.18.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 +158 -0
- package/README.md +92 -9
- package/dist/cli.d.ts +1 -1
- package/dist/cli.js +241 -46
- package/dist/cli.js.map +1 -1
- package/dist/config.js +14 -0
- package/dist/config.js.map +1 -1
- package/dist/extract.js +87 -8
- package/dist/extract.js.map +1 -1
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -10,10 +10,11 @@
|
|
|
10
10
|
* itself could not run. A usage mistake never masquerades as a passing build,
|
|
11
11
|
* and an error nothing here expected never as findings.
|
|
12
12
|
*/
|
|
13
|
+
import { resolve } from 'node:path';
|
|
13
14
|
import { applyBaseline, EMPTY_BASELINE, formatBaseline, parseBaseline, } from './baseline.js';
|
|
14
15
|
import { discoverConfig, loadConfig } from './config.js';
|
|
15
16
|
import { DiffInputError, diffExports, formatDiffJson, formatDiffMarkdown, formatDiffText, parseGraphExport, } from './diff.js';
|
|
16
|
-
import { isGlob, rebasePattern, underRoot } from './glob.js';
|
|
17
|
+
import { createGlobMatcher, isGlob, rebasePattern, underRoot, walkFiles } from './glob.js';
|
|
17
18
|
import { isFamilyName } from './identity.js';
|
|
18
19
|
import { analyse, DEFAULT_PATTERNS, withDiagnostics } from './runner.js';
|
|
19
20
|
import { formatGithub, formatGitlab, formatGraph, formatJson, formatMarkdown, formatReport, formatSarif, shouldUseAscii, shouldUseColor, } from './report.js';
|
|
@@ -83,13 +84,16 @@ OPTIONS
|
|
|
83
84
|
--history <glob> Files that log what was decided rather than deciding
|
|
84
85
|
it - journals, changelogs, minutes. Their links are
|
|
85
86
|
still checked; their obligations are not. Repeatable.
|
|
87
|
+
A pattern whose files an ignore leaves out is
|
|
88
|
+
refused: no journal it names would be read.
|
|
86
89
|
--baseline <file> Accept the findings recorded in this file and report
|
|
87
90
|
only what is new since. The file has to be there: a
|
|
88
91
|
path that reads nothing accepts nothing and says so
|
|
89
92
|
nowhere. A "baseline" in the configuration may name a
|
|
90
93
|
file not recorded yet; a path typed here may not.
|
|
91
94
|
--record-baseline <f> Write today's findings to this file as accepted debt,
|
|
92
|
-
and exit 0 without judging them.
|
|
95
|
+
and exit 0 without judging them. With --format json
|
|
96
|
+
the recording is a JSON document.
|
|
93
97
|
--ratchet Also fail when a baseline entry no longer occurs, so
|
|
94
98
|
a paid-off exemption cannot outlive the defect.
|
|
95
99
|
--no-config Ignore .spec-graph.json and the package.json key.
|
|
@@ -144,10 +148,34 @@ OPTIONS
|
|
|
144
148
|
decide and the earlier go unread. Either alone wins over the environment,
|
|
145
149
|
and the same option twice is that option once.
|
|
146
150
|
|
|
151
|
+
An option that takes one value is refused given two, as in --root a
|
|
152
|
+
--root b: the later would be read and the earlier go unread. That is each
|
|
153
|
+
option above with a value that is not marked Repeatable, and one rule
|
|
154
|
+
given two severities by --rule. Two values are compared as each is read,
|
|
155
|
+
so one value twice is that value once however it is spelled: --root .
|
|
156
|
+
beside --root ./ names one directory.
|
|
157
|
+
|
|
158
|
+
A baseline to read is refused beside one to record, --baseline beside
|
|
159
|
+
--record-baseline: a recording writes every finding and opens no
|
|
160
|
+
baseline. So is each option a recording does not read, since it judges,
|
|
161
|
+
shows and colours no finding: --ratchet, --strict, --max, --max-warnings,
|
|
162
|
+
--color, --no-ascii, and --format sarif, github or gitlab. So is one
|
|
163
|
+
family given to both --family and --ignore-family, whose citations are
|
|
164
|
+
passed over whatever asked for them. One family asked for beside another
|
|
165
|
+
ignored is taken.
|
|
166
|
+
|
|
147
167
|
A value that begins with -- reads as an option, so the option before it is
|
|
148
168
|
refused as missing its value. A name that does begin so is written ./--name
|
|
149
169
|
for a file, [-]-name in a pattern and \\--name for a reference target.
|
|
150
170
|
|
|
171
|
+
A line that is wrong is refused with --help or --version on it too, an
|
|
172
|
+
option a recording does not read among them, and --help beside --version
|
|
173
|
+
is refused: one of the two answers would go unread. What a command still
|
|
174
|
+
needs, a selector or two exports, is what --help is for: query --help and
|
|
175
|
+
diff --help print it. The help reads no disk, so what only the run finds,
|
|
176
|
+
a --root that is not there or a --history an ignore leaves out, is
|
|
177
|
+
answered by it.
|
|
178
|
+
|
|
151
179
|
SELECTORS
|
|
152
180
|
A selector is a path through the graph.
|
|
153
181
|
|
|
@@ -176,8 +204,12 @@ EXIT CODES
|
|
|
176
204
|
An input that is set and names nothing is a 2, named on stderr, and never
|
|
177
205
|
read as if it had not been given: an empty value, a family name no family
|
|
178
206
|
has, a list of families no document here belongs to, a --root that is no
|
|
179
|
-
directory, a second word after rules, an option of another command. So
|
|
180
|
-
|
|
207
|
+
directory, a second word after rules, an option of another command. So
|
|
208
|
+
are two things of which one would go unread: an option beside its own
|
|
209
|
+
opposite, an option that takes one value given two, --baseline beside
|
|
210
|
+
--record-baseline, an option a recording does not read, a family both
|
|
211
|
+
asked for and ignored, a --history whose files an ignore leaves out,
|
|
212
|
+
--help beside --version.
|
|
181
213
|
|
|
182
214
|
CONFIGURATION
|
|
183
215
|
Anything repeated on every run belongs in the repository rather than in the
|
|
@@ -196,12 +228,13 @@ CONFIGURATION
|
|
|
196
228
|
"maxRelated": 3 }
|
|
197
229
|
|
|
198
230
|
A flag always wins over the file, and list flags add to it rather than
|
|
199
|
-
replacing it
|
|
231
|
+
replacing it: --family RFC reads the citations "ignoreFamilies" passes
|
|
232
|
+
over. maxRelated has no flag: it caps how many related locations one
|
|
200
233
|
finding lists, 8 unless set, and 0 lists none.
|
|
201
234
|
|
|
202
235
|
A problem in that file stops the run with exit 2, named on stderr: invalid
|
|
203
|
-
JSON, an unknown key, a rule that does not compile,
|
|
204
|
-
cannot be read. A configuration that did not load checks a different
|
|
236
|
+
JSON, an unknown key, a rule that does not compile, one family in both
|
|
237
|
+
"families" and "ignoreFamilies", a file that is there and cannot be read. A configuration that did not load checks a different
|
|
205
238
|
repository than the one configured, and would report that one as consistent.
|
|
206
239
|
--no-config runs on defaults instead.
|
|
207
240
|
|
|
@@ -318,27 +351,61 @@ function readersOf(option) {
|
|
|
318
351
|
return undefined;
|
|
319
352
|
}
|
|
320
353
|
/**
|
|
321
|
-
* The option
|
|
354
|
+
* The option beside which this one goes unread, or nothing for an option
|
|
355
|
+
* that has none.
|
|
322
356
|
*
|
|
323
|
-
*
|
|
324
|
-
*
|
|
325
|
-
*
|
|
326
|
-
*
|
|
327
|
-
*
|
|
357
|
+
* Two of the three are an option and the one that undoes it. Given both,
|
|
358
|
+
* the later decided and the earlier went unread: a script that adds
|
|
359
|
+
* `--no-color` to a line already holding `--color` was told nothing of which
|
|
360
|
+
* it got, and two things of which one would go unread are refused together
|
|
361
|
+
* (spec-core's ADR-0005, 2026-10-11). Either alone still wins over the
|
|
362
|
+
* environment, which is what it is for.
|
|
328
363
|
*
|
|
329
|
-
*
|
|
330
|
-
*
|
|
331
|
-
*
|
|
364
|
+
* The third is a baseline to read beside one to record. A recording writes
|
|
365
|
+
* every finding of the run and opens no baseline, so `--baseline nowhere.json
|
|
366
|
+
* --record-baseline out.json` recorded and exited 0, where the first option
|
|
367
|
+
* alone is refused for a file that is not there.
|
|
368
|
+
*
|
|
369
|
+
* Asked of the first of each pair alone, so each pair is found once, by the
|
|
370
|
+
* option that names it, and each name here decides a refusal. A function and
|
|
371
|
+
* not a table, for the reason `readersOf` is one.
|
|
332
372
|
*/
|
|
333
|
-
function
|
|
373
|
+
function unreadBeside(option) {
|
|
334
374
|
switch (option) {
|
|
335
375
|
case '--color':
|
|
336
376
|
return { option: '--no-color', decided: 'a report is coloured or it is not' };
|
|
337
377
|
case '--ascii':
|
|
338
378
|
return { option: '--no-ascii', decided: 'the glyphs are drawn one way' };
|
|
379
|
+
case '--baseline':
|
|
380
|
+
return { option: '--record-baseline', decided: 'a check holds its findings against a baseline or records them as one' };
|
|
339
381
|
}
|
|
340
382
|
return undefined;
|
|
341
383
|
}
|
|
384
|
+
/**
|
|
385
|
+
* Whether a recording leaves this option of a check unread.
|
|
386
|
+
*
|
|
387
|
+
* A recording writes every finding of the run down and judges none, shows
|
|
388
|
+
* none and colours nothing: `--record-baseline debt.json --strict` recorded
|
|
389
|
+
* what the line without `--strict` records, and said it was strict. Each of
|
|
390
|
+
* these is refused beside it, as `--baseline` is (spec-core's ADR-0005,
|
|
391
|
+
* 2026-10-11). The options a recording does read are not here: the ones that
|
|
392
|
+
* choose what is found, `--rule` turning a rule off among them, `--verbose`,
|
|
393
|
+
* which names the configuration read, and `--no-color` and `--ascii`, which
|
|
394
|
+
* ask for what is already so. A function and not a table, for the reason
|
|
395
|
+
* `readersOf` is one.
|
|
396
|
+
*/
|
|
397
|
+
function unreadByRecording(option) {
|
|
398
|
+
switch (option) {
|
|
399
|
+
case '--ratchet':
|
|
400
|
+
case '--strict':
|
|
401
|
+
case '--max':
|
|
402
|
+
case '--max-warnings':
|
|
403
|
+
case '--color':
|
|
404
|
+
case '--no-ascii':
|
|
405
|
+
return true;
|
|
406
|
+
}
|
|
407
|
+
return false;
|
|
408
|
+
}
|
|
342
409
|
/** `a`, `a or b`, `a, b or c`: one name or more in a sentence, with the word that joins the last. */
|
|
343
410
|
function listed(names, joint) {
|
|
344
411
|
return names.length < 2 ? names[0] : `${names.slice(0, -1).join(', ')} ${joint} ${names[names.length - 1]}`;
|
|
@@ -415,6 +482,21 @@ export function parseArgs(argv, cwd) {
|
|
|
415
482
|
throw new UsageError(`${flag} expects one family's name, such as ADR, got "${value}"`);
|
|
416
483
|
return value;
|
|
417
484
|
};
|
|
485
|
+
// What each option that takes one value was given, in the order given, and
|
|
486
|
+
// what the run reads it as. Given two, the later was read and the earlier
|
|
487
|
+
// went unread: `--root nowhere --root .` checked `.` and never said that
|
|
488
|
+
// `nowhere` is not there, and `query --format sarif --format json` wrote
|
|
489
|
+
// JSON where the two the other way round are refused. Two values are
|
|
490
|
+
// compared as each is read, not as it is written, so `--root .` beside
|
|
491
|
+
// `--root ./` names one directory and is taken once (spec-core's ADR-0005,
|
|
492
|
+
// 2026-10-18). Each is read once the whole line has been, since a
|
|
493
|
+
// baseline typed beside `--root` is read from the root, wherever on the
|
|
494
|
+
// line the root stands. A mutant that seeds this list with a string holds
|
|
495
|
+
// it under no option's key, where no value is compared with it: equivalent.
|
|
496
|
+
const valued = [];
|
|
497
|
+
const once = (flag, typed, read, key = flag, takes = 'takes one value') => {
|
|
498
|
+
valued.push({ flag, key, typed, read, takes });
|
|
499
|
+
};
|
|
418
500
|
for (let i = 0; i < args.length; i += 1) {
|
|
419
501
|
const arg = args[i];
|
|
420
502
|
if (arg === '--') {
|
|
@@ -465,11 +547,17 @@ export function parseArgs(argv, cwd) {
|
|
|
465
547
|
case '--explain':
|
|
466
548
|
verbose = true;
|
|
467
549
|
break;
|
|
468
|
-
case '--root':
|
|
469
|
-
|
|
550
|
+
case '--root': {
|
|
551
|
+
// Resolved from where the line was typed and compared as text: the
|
|
552
|
+
// disk is not asked, so a link and its target are two values, and
|
|
553
|
+
// no case is folded.
|
|
554
|
+
const value = filled(arg, i, 'a directory');
|
|
555
|
+
once(arg, value, () => resolve(cwd, value));
|
|
556
|
+
root = value;
|
|
470
557
|
rootExplicit = true;
|
|
471
558
|
i += 1;
|
|
472
559
|
break;
|
|
560
|
+
}
|
|
473
561
|
case '--ignore':
|
|
474
562
|
ignore.push(next(arg, i));
|
|
475
563
|
i += 1;
|
|
@@ -490,14 +578,22 @@ export function parseArgs(argv, cwd) {
|
|
|
490
578
|
historyPatterns.push(next(arg, i));
|
|
491
579
|
i += 1;
|
|
492
580
|
break;
|
|
493
|
-
|
|
494
|
-
|
|
581
|
+
// A baseline typed here is read from the root where `--root` names one,
|
|
582
|
+
// and from where it was typed otherwise, which is the root's default.
|
|
583
|
+
case '--baseline': {
|
|
584
|
+
const value = filled(arg, i, 'a file');
|
|
585
|
+
once(arg, value, () => resolve(cwd, root, value));
|
|
586
|
+
baseline = value;
|
|
495
587
|
i += 1;
|
|
496
588
|
break;
|
|
497
|
-
|
|
498
|
-
|
|
589
|
+
}
|
|
590
|
+
case '--record-baseline': {
|
|
591
|
+
const value = filled(arg, i, 'a file');
|
|
592
|
+
once(arg, value, () => resolve(cwd, root, value));
|
|
593
|
+
recordBaseline = value;
|
|
499
594
|
i += 1;
|
|
500
595
|
break;
|
|
596
|
+
}
|
|
501
597
|
case '--no-config':
|
|
502
598
|
noConfig = true;
|
|
503
599
|
break;
|
|
@@ -512,6 +608,7 @@ export function parseArgs(argv, cwd) {
|
|
|
512
608
|
throw new UsageError(`--format must be human, json, sarif, markdown, gitlab or github, got "${value}"`);
|
|
513
609
|
}
|
|
514
610
|
format = value;
|
|
611
|
+
once(arg, value, () => value);
|
|
515
612
|
i += 1;
|
|
516
613
|
break;
|
|
517
614
|
}
|
|
@@ -521,6 +618,7 @@ export function parseArgs(argv, cwd) {
|
|
|
521
618
|
throw new UsageError(`--graph-format must be dot, mermaid or json, got "${value}"`);
|
|
522
619
|
}
|
|
523
620
|
graphFormat = value;
|
|
621
|
+
once(arg, value, () => value);
|
|
524
622
|
i += 1;
|
|
525
623
|
break;
|
|
526
624
|
}
|
|
@@ -544,16 +642,27 @@ export function parseArgs(argv, cwd) {
|
|
|
544
642
|
throw new UsageError(`unknown severity "${level}", expected one of: ${SEVERITIES.join(', ')}`);
|
|
545
643
|
}
|
|
546
644
|
severities[id] = level;
|
|
645
|
+
// The id is the key and is read as written, so the severity alone
|
|
646
|
+
// tells two values apart, and it is read as written too.
|
|
647
|
+
once(arg, value, () => value, `${arg} ${id}`, 'a rule has one severity');
|
|
547
648
|
i += 1;
|
|
548
649
|
break;
|
|
549
650
|
}
|
|
651
|
+
// A count is read past the spaces a shell variable brings, so ` 1` is
|
|
652
|
+
// the `1` it reads as.
|
|
550
653
|
case '--max': {
|
|
551
|
-
|
|
654
|
+
const value = next(arg, i);
|
|
655
|
+
const count = parseCount(arg, value);
|
|
656
|
+
once(arg, value, () => count);
|
|
657
|
+
max = count;
|
|
552
658
|
i += 1;
|
|
553
659
|
break;
|
|
554
660
|
}
|
|
555
661
|
case '--max-warnings': {
|
|
556
|
-
|
|
662
|
+
const value = next(arg, i);
|
|
663
|
+
const count = parseCount(arg, value);
|
|
664
|
+
once(arg, value, () => count);
|
|
665
|
+
maxWarnings = count;
|
|
557
666
|
i += 1;
|
|
558
667
|
break;
|
|
559
668
|
}
|
|
@@ -561,53 +670,98 @@ export function parseArgs(argv, cwd) {
|
|
|
561
670
|
throw new UsageError(`unknown option "${arg}"\n run "spec-graph --help" to see the available options`);
|
|
562
671
|
}
|
|
563
672
|
}
|
|
673
|
+
// A line that is wrong is refused with `--help` or `--version` on it, as an
|
|
674
|
+
// option nobody knows always was: answered first, the help told a script
|
|
675
|
+
// nothing of the line it had written, and the version answered it as if
|
|
676
|
+
// the line were right (spec-core's ADR-0005, 2026-10-18). What a command
|
|
677
|
+
// still lacks, a selector or an export, is what the help is asked for, and
|
|
678
|
+
// is refused only where neither is asked.
|
|
564
679
|
// SARIF, GitHub's workflow commands and GitLab's Code Quality are reports
|
|
565
680
|
// about findings, and only `check` produces those. Falling back to JSON would
|
|
566
681
|
// hand a pipeline something its uploader rejects with a message about a
|
|
567
682
|
// schema rather than about the command that was run. Markdown is for a pull
|
|
568
683
|
// request, which reads a check or a diff.
|
|
569
|
-
if ((format === 'sarif' || format === 'gitlab' || format === 'github') && command !== 'check'
|
|
684
|
+
if ((format === 'sarif' || format === 'gitlab' || format === 'github') && command !== 'check') {
|
|
570
685
|
throw new UsageError(`--format ${format} reports findings, so it belongs to check, not to ${command}`);
|
|
571
686
|
}
|
|
572
|
-
if (format === 'markdown' && command !== 'check' && command !== 'diff'
|
|
687
|
+
if (format === 'markdown' && command !== 'check' && command !== 'diff') {
|
|
573
688
|
throw new UsageError(`--format markdown is a report for a pull request, so it belongs to check or diff, not to ${command}`);
|
|
574
689
|
}
|
|
575
690
|
// JSON is refused where the other reports are, and for the reason above:
|
|
576
691
|
// `graph --format json` wrote DOT and `rules --format json` a list, each
|
|
577
692
|
// with exit 0, to whatever was waiting for JSON.
|
|
578
|
-
if (format === 'json' && (command === 'graph' || command === 'rules')
|
|
693
|
+
if (format === 'json' && (command === 'graph' || command === 'rules')) {
|
|
579
694
|
const instead = command === 'graph' ? 'graph writes JSON with --graph-format json' : 'rules lists the rules as text';
|
|
580
695
|
throw new UsageError(`--format json belongs to check, query or diff, not to ${command}: ${instead}`);
|
|
581
696
|
}
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
697
|
+
for (const option of given) {
|
|
698
|
+
const readers = readersOf(option);
|
|
699
|
+
if (readers !== undefined && !readers.includes(command)) {
|
|
700
|
+
throw new UsageError(`${option} belongs to ${listed(readers, 'or')}, not to ${command}`);
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
// Two answers, of which one would go unread: the help was printed and the
|
|
704
|
+
// version was not, whichever came first.
|
|
705
|
+
if (help && version) {
|
|
706
|
+
throw new UsageError('--help is given beside --version, and a run prints the help or the version: give one of the two');
|
|
707
|
+
}
|
|
708
|
+
// After the loop above, so that an option of another command is named
|
|
709
|
+
// before the pair it is half of: `query --color --no-color` is told that
|
|
710
|
+
// `--color` is a check's, which is the line to act on first.
|
|
711
|
+
for (const option of given) {
|
|
712
|
+
const other = unreadBeside(option);
|
|
713
|
+
if (other !== undefined && given.has(other.option)) {
|
|
714
|
+
throw new UsageError(`${option} is given beside ${other.option}, and ${other.decided}: give one of the two`);
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
// Each value is held to the first its option was given, so the line names
|
|
718
|
+
// the first two that read as two, wherever on it they stand.
|
|
719
|
+
const firstOf = new Map();
|
|
720
|
+
for (const value of valued) {
|
|
721
|
+
const held = firstOf.get(value.key);
|
|
722
|
+
if (held === undefined)
|
|
723
|
+
firstOf.set(value.key, value);
|
|
724
|
+
else if (held.read() !== value.read()) {
|
|
725
|
+
throw new UsageError(`${value.flag} is given "${held.typed}" and "${value.typed}", and ${value.takes}: give one of the two`);
|
|
588
726
|
}
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
727
|
+
}
|
|
728
|
+
// A family on both lists is passed over, since the list that ignores is
|
|
729
|
+
// asked first, and its name on the other says nothing: `--family ADR
|
|
730
|
+
// --family RFC --ignore-family RFC` is `--family ADR`. Where it is the
|
|
731
|
+
// only name there, every citation in prose is passed over, and the check
|
|
732
|
+
// that fails without the two reported a consistent graph. Two families,
|
|
733
|
+
// one asked for and another ignored, are both read, and are taken.
|
|
734
|
+
for (const asked of families) {
|
|
735
|
+
const ignored = ignoreFamilies.find((name) => name.trim().toUpperCase() === asked.trim().toUpperCase());
|
|
736
|
+
if (ignored !== undefined) {
|
|
737
|
+
throw new UsageError(`--family ${asked} is given beside --ignore-family ${ignored}, and a family's citations are read or they are passed over: give one of the two`);
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
// Three formats are reports of findings, and a recording reports none:
|
|
741
|
+
// what it wrote in their place was the line for a person, which is none
|
|
742
|
+
// of the three. JSON is written as a document of what was recorded, and
|
|
743
|
+
// Markdown is the line, which reads as Markdown already. Refused beside
|
|
744
|
+
// the help and the version as every other wrong line is.
|
|
745
|
+
if (recordBaseline !== null) {
|
|
592
746
|
for (const option of given) {
|
|
593
|
-
const
|
|
594
|
-
if (
|
|
595
|
-
throw new UsageError(`${
|
|
747
|
+
const unread = unreadByRecording(option) ? option : option === '--format' && (format === 'sarif' || format === 'github' || format === 'gitlab') ? `--format ${format}` : null;
|
|
748
|
+
if (unread !== null) {
|
|
749
|
+
throw new UsageError(`${unread} is given beside --record-baseline, and a recording writes the findings down and reports none: give one of the two`);
|
|
596
750
|
}
|
|
597
751
|
}
|
|
598
752
|
}
|
|
599
753
|
// The second word given to `rules` was dropped, so `rules a b` listed `a`
|
|
600
754
|
// and said nothing of `b`.
|
|
601
|
-
if (command === 'rules' && patterns.length > 1
|
|
755
|
+
if (command === 'rules' && patterns.length > 1) {
|
|
602
756
|
throw new UsageError(`rules takes one rule id, got ${patterns.length}: ${patterns.join(', ')}`);
|
|
603
757
|
}
|
|
604
|
-
if (command === 'diff' && patterns.length
|
|
758
|
+
if (command === 'diff' && (patterns.length > 2 || (patterns.length < 2 && !help && !version))) {
|
|
605
759
|
throw new UsageError('diff compares two graph exports, for example:\n spec-graph diff base.json head.json\n (make each with spec-graph graph --graph-format json)');
|
|
606
760
|
}
|
|
607
761
|
// An empty name resolved to the directory the run was started in, and the
|
|
608
762
|
// refusal was the read's: `cannot read : EISDIR`.
|
|
609
763
|
const blank = patterns.findIndex((path) => path.trim() === '');
|
|
610
|
-
if (command === 'diff' && blank !== -1
|
|
764
|
+
if (command === 'diff' && blank !== -1) {
|
|
611
765
|
throw new UsageError(`diff expects a graph export on each side, got "${patterns[blank]}" for the ${blank === 0 ? 'first' : 'second'}`);
|
|
612
766
|
}
|
|
613
767
|
if (command === 'query' && selector === null && !help && !version) {
|
|
@@ -844,8 +998,20 @@ async function run(io, err) {
|
|
|
844
998
|
const { severities, escalated } = resolveStrict(severityOverrides, options.strict || (file.strict ?? false), projectRules);
|
|
845
999
|
// The list the check is held against, once it has read the documents, and
|
|
846
1000
|
// the families taken out on purpose, which that list is not held to.
|
|
1001
|
+
//
|
|
1002
|
+
// A flag says what the run does where the file says otherwise. The list
|
|
1003
|
+
// that ignores is asked first, so `--family RFC` over "ignoreFamilies":
|
|
1004
|
+
// ["RFC"] passed over every RFC it asked for, and the run reported a
|
|
1005
|
+
// consistent graph where the file alone fails it: a family a flag asks for
|
|
1006
|
+
// is taken off the file's list of those to pass over. The other way needs
|
|
1007
|
+
// nothing: `--ignore-family ADR` over "families": ["ADR"] passes over the
|
|
1008
|
+
// ADRs, as the flag says (ADR-0010, 2026-10-18).
|
|
1009
|
+
const asked = options.families.map((family) => family.trim().toUpperCase());
|
|
847
1010
|
const families = [...(file.families ?? []), ...options.families];
|
|
848
|
-
const ignoreFamilies = [
|
|
1011
|
+
const ignoreFamilies = [
|
|
1012
|
+
...(file.ignoreFamilies ?? []).filter((family) => !asked.includes(family.trim().toUpperCase())),
|
|
1013
|
+
...options.ignoreFamilies,
|
|
1014
|
+
];
|
|
849
1015
|
const analyseOptions = {
|
|
850
1016
|
root,
|
|
851
1017
|
patterns,
|
|
@@ -875,6 +1041,30 @@ async function run(io, err) {
|
|
|
875
1041
|
err(`spec-graph: no specifications matched ${patterns.map((p) => `"${p}"`).join(', ')}\n looked under ${root}\n`);
|
|
876
1042
|
return EXIT_ERROR;
|
|
877
1043
|
}
|
|
1044
|
+
// A journal named on the line and never read. A `--history` pattern marks
|
|
1045
|
+
// the files the run reads, so one whose files an ignore leaves out marked
|
|
1046
|
+
// nothing and said nothing: `--ignore archive --history "docs/archive/**"`
|
|
1047
|
+
// read no journal, and the archive's links went unchecked with the rest of
|
|
1048
|
+
// it. Beside an `--ignore` one of the two goes unread, and beside the
|
|
1049
|
+
// file's "ignore" the flag cannot say what the run does: either way the run
|
|
1050
|
+
// stops, naming the ignores there are. The walk that finds it is made once,
|
|
1051
|
+
// and only for a pattern that matched nothing read; one that matches no
|
|
1052
|
+
// file at all is as it was. With no ignore that walk is the run's own, so
|
|
1053
|
+
// it finds nothing more and names no ignore (ADR-0010, 2026-10-18).
|
|
1054
|
+
const ignores = [...(typed.ignore.length > 0 ? ['--ignore'] : []), ...((file.ignore ?? []).length > 0 ? [`"ignore" in ${loaded.source}`] : [])];
|
|
1055
|
+
let unignored;
|
|
1056
|
+
for (const [at, pattern] of typed.history.entries()) {
|
|
1057
|
+
const marks = createGlobMatcher([pattern]);
|
|
1058
|
+
if (result.files.some(marks))
|
|
1059
|
+
continue;
|
|
1060
|
+
// A mutant that plants a name in this list prunes a directory of that
|
|
1061
|
+
// name, which no repository has: equivalent, and left untested.
|
|
1062
|
+
unignored ??= (await walkFiles({ root, patterns, ignore: [] })).map((walk) => walk.path);
|
|
1063
|
+
if (unignored.some(marks)) {
|
|
1064
|
+
err(`spec-graph: --history "${options.historyPatterns[at]}" names only files ${listed(ignores, 'or')} leaves out, and a journal is read or it is left out: give one of the two\n`);
|
|
1065
|
+
return EXIT_ERROR;
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
878
1068
|
switch (options.command) {
|
|
879
1069
|
case 'graph':
|
|
880
1070
|
out(formatGraph(result.graph, options.graphFormat, {
|
|
@@ -980,7 +1170,13 @@ async function run(io, err) {
|
|
|
980
1170
|
return EXIT_ERROR;
|
|
981
1171
|
}
|
|
982
1172
|
const entries = JSON.parse(text).findings.length;
|
|
983
|
-
|
|
1173
|
+
// A document asked for is a document: the line in its place was what
|
|
1174
|
+
// a script handed `--format json` failed to parse. No `ok` in it,
|
|
1175
|
+
// since a recording judges nothing.
|
|
1176
|
+
const recorded = { file: options.recordBaseline, findings: result.diagnostics.length, entries };
|
|
1177
|
+
out(options.format === 'json'
|
|
1178
|
+
? `${JSON.stringify({ version: 1, recorded }, null, 2)}\n`
|
|
1179
|
+
: `recorded ${count(recorded.findings, 'finding')} as ${count(entries, 'entry', 'entries')} in ${recorded.file}\n`);
|
|
984
1180
|
return EXIT_OK;
|
|
985
1181
|
}
|
|
986
1182
|
const source = options.baseline === null ? (file.baseline ?? null) : anchorFile(here, options.baseline);
|
|
@@ -1238,7 +1434,6 @@ function renderQueryError(selector, error) {
|
|
|
1238
1434
|
*/
|
|
1239
1435
|
async function runDiff(paths, cwd, format, out, err) {
|
|
1240
1436
|
const { readFile } = await import('node:fs/promises');
|
|
1241
|
-
const { resolve } = await import('node:path');
|
|
1242
1437
|
const sides = [];
|
|
1243
1438
|
for (const path of paths) {
|
|
1244
1439
|
let raw;
|