@descent-vtt/spec-graph 0.17.0 → 0.19.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 +157 -0
- package/README.md +91 -14
- package/dist/cli.d.ts +1 -1
- package/dist/cli.js +234 -79
- package/dist/cli.js.map +1 -1
- package/dist/config.js +14 -0
- package/dist/config.js.map +1 -1
- package/dist/extract.js +92 -8
- package/dist/extract.js.map +1 -1
- package/dist/glob.d.ts +5 -0
- package/dist/glob.js +6 -1
- package/dist/glob.js.map +1 -1
- package/dist/paths.d.ts +12 -0
- package/dist/paths.js +17 -0
- package/dist/paths.js.map +1 -1
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -10,15 +10,16 @@
|
|
|
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';
|
|
20
21
|
import { DEFAULT_SEVERITIES, resolveStrict, RULE_DECISIONS, decisionUrl, RULE_DESCRIPTIONS, RULE_IDS, RULE_QUERIES, } from './rules.js';
|
|
21
|
-
import { isAbsolutePath, joinPosix, toPosix } from './paths.js';
|
|
22
|
+
import { foldDriveLetter, isAbsolutePath, joinPosix, toPosix } from './paths.js';
|
|
22
23
|
import { formatRef } from './source.js';
|
|
23
24
|
import { execute, parseQuery, QueryError, renderMatch } from './select.js';
|
|
24
25
|
import { isProjectRule } from './types.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.
|
|
@@ -147,18 +151,32 @@ OPTIONS
|
|
|
147
151
|
An option that takes one value is refused given two, as in --root a
|
|
148
152
|
--root b: the later would be read and the earlier go unread. That is each
|
|
149
153
|
option above with a value that is not marked Repeatable, and one rule
|
|
150
|
-
given two severities by --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, and so on Windows does C:\\x beside
|
|
157
|
+
c:\\x, a drive letter naming its volume in either case.
|
|
151
158
|
|
|
152
159
|
A baseline to read is refused beside one to record, --baseline beside
|
|
153
160
|
--record-baseline: a recording writes every finding and opens no
|
|
154
|
-
baseline. So is
|
|
155
|
-
|
|
156
|
-
|
|
161
|
+
baseline. So is each option a recording does not read, since it judges,
|
|
162
|
+
shows and colours no finding: --ratchet, --strict, --max, --max-warnings,
|
|
163
|
+
--color, --no-ascii, and --format sarif, github or gitlab. So is one
|
|
164
|
+
family given to both --family and --ignore-family, whose citations are
|
|
165
|
+
passed over whatever asked for them. One family asked for beside another
|
|
166
|
+
ignored is taken.
|
|
157
167
|
|
|
158
168
|
A value that begins with -- reads as an option, so the option before it is
|
|
159
169
|
refused as missing its value. A name that does begin so is written ./--name
|
|
160
170
|
for a file, [-]-name in a pattern and \\--name for a reference target.
|
|
161
171
|
|
|
172
|
+
A line that is wrong is refused with --help or --version on it too, an
|
|
173
|
+
option a recording does not read among them, and --help beside --version
|
|
174
|
+
is refused: one of the two answers would go unread. What a command still
|
|
175
|
+
needs, a selector or two exports, is what --help is for: query --help and
|
|
176
|
+
diff --help print it. The help reads no disk, so what only the run finds,
|
|
177
|
+
a --root that is not there or a --history an ignore leaves out, is
|
|
178
|
+
answered by it.
|
|
179
|
+
|
|
162
180
|
SELECTORS
|
|
163
181
|
A selector is a path through the graph.
|
|
164
182
|
|
|
@@ -190,7 +208,9 @@ EXIT CODES
|
|
|
190
208
|
directory, a second word after rules, an option of another command. So
|
|
191
209
|
are two things of which one would go unread: an option beside its own
|
|
192
210
|
opposite, an option that takes one value given two, --baseline beside
|
|
193
|
-
--record-baseline, a
|
|
211
|
+
--record-baseline, an option a recording does not read, a family both
|
|
212
|
+
asked for and ignored, a --history whose files an ignore leaves out,
|
|
213
|
+
--help beside --version.
|
|
194
214
|
|
|
195
215
|
CONFIGURATION
|
|
196
216
|
Anything repeated on every run belongs in the repository rather than in the
|
|
@@ -209,12 +229,13 @@ CONFIGURATION
|
|
|
209
229
|
"maxRelated": 3 }
|
|
210
230
|
|
|
211
231
|
A flag always wins over the file, and list flags add to it rather than
|
|
212
|
-
replacing it
|
|
232
|
+
replacing it: --family RFC reads the citations "ignoreFamilies" passes
|
|
233
|
+
over. maxRelated has no flag: it caps how many related locations one
|
|
213
234
|
finding lists, 8 unless set, and 0 lists none.
|
|
214
235
|
|
|
215
236
|
A problem in that file stops the run with exit 2, named on stderr: invalid
|
|
216
|
-
JSON, an unknown key, a rule that does not compile,
|
|
217
|
-
cannot be read. A configuration that did not load checks a different
|
|
237
|
+
JSON, an unknown key, a rule that does not compile, one family in both
|
|
238
|
+
"families" and "ignoreFamilies", a file that is there and cannot be read. A configuration that did not load checks a different
|
|
218
239
|
repository than the one configured, and would report that one as consistent.
|
|
219
240
|
--no-config runs on defaults instead.
|
|
220
241
|
|
|
@@ -361,6 +382,31 @@ function unreadBeside(option) {
|
|
|
361
382
|
}
|
|
362
383
|
return undefined;
|
|
363
384
|
}
|
|
385
|
+
/**
|
|
386
|
+
* Whether a recording leaves this option of a check unread.
|
|
387
|
+
*
|
|
388
|
+
* A recording writes every finding of the run down and judges none, shows
|
|
389
|
+
* none and colours nothing: `--record-baseline debt.json --strict` recorded
|
|
390
|
+
* what the line without `--strict` records, and said it was strict. Each of
|
|
391
|
+
* these is refused beside it, as `--baseline` is (spec-core's ADR-0005,
|
|
392
|
+
* 2026-10-11). The options a recording does read are not here: the ones that
|
|
393
|
+
* choose what is found, `--rule` turning a rule off among them, `--verbose`,
|
|
394
|
+
* which names the configuration read, and `--no-color` and `--ascii`, which
|
|
395
|
+
* ask for what is already so. A function and not a table, for the reason
|
|
396
|
+
* `readersOf` is one.
|
|
397
|
+
*/
|
|
398
|
+
function unreadByRecording(option) {
|
|
399
|
+
switch (option) {
|
|
400
|
+
case '--ratchet':
|
|
401
|
+
case '--strict':
|
|
402
|
+
case '--max':
|
|
403
|
+
case '--max-warnings':
|
|
404
|
+
case '--color':
|
|
405
|
+
case '--no-ascii':
|
|
406
|
+
return true;
|
|
407
|
+
}
|
|
408
|
+
return false;
|
|
409
|
+
}
|
|
364
410
|
/** `a`, `a or b`, `a, b or c`: one name or more in a sentence, with the word that joins the last. */
|
|
365
411
|
function listed(names, joint) {
|
|
366
412
|
return names.length < 2 ? names[0] : `${names.slice(0, -1).join(', ')} ${joint} ${names[names.length - 1]}`;
|
|
@@ -437,24 +483,22 @@ export function parseArgs(argv, cwd) {
|
|
|
437
483
|
throw new UsageError(`${flag} expects one family's name, such as ADR, got "${value}"`);
|
|
438
484
|
return value;
|
|
439
485
|
};
|
|
440
|
-
// What
|
|
441
|
-
// the
|
|
486
|
+
// What each option that takes one value was given, in the order given, and
|
|
487
|
+
// what the run reads it as. Given two, the later was read and the earlier
|
|
442
488
|
// went unread: `--root nowhere --root .` checked `.` and never said that
|
|
443
489
|
// `nowhere` is not there, and `query --format sarif --format json` wrote
|
|
444
|
-
// JSON where the two the other way round are refused.
|
|
445
|
-
//
|
|
446
|
-
//
|
|
447
|
-
//
|
|
448
|
-
//
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
second ??= `${flag} is given "${held}" and "${value}", and ${takes}: give one of the two`;
|
|
457
|
-
return value;
|
|
490
|
+
// JSON where the two the other way round are refused. Two values are
|
|
491
|
+
// compared as each is read, not as it is written, so `--root .` beside
|
|
492
|
+
// `--root ./` names one directory and is taken once (spec-core's ADR-0005,
|
|
493
|
+
// 2026-10-18), and so do `C:\x` and `c:\x` on Windows, where a drive letter
|
|
494
|
+
// names its volume in either case (the same, 2026-10-19). Each is read
|
|
495
|
+
// once the whole line has been, since a
|
|
496
|
+
// baseline typed beside `--root` is read from the root, wherever on the
|
|
497
|
+
// line the root stands. A mutant that seeds this list with a string holds
|
|
498
|
+
// it under no option's key, where no value is compared with it: equivalent.
|
|
499
|
+
const valued = [];
|
|
500
|
+
const once = (flag, typed, read, key = flag, takes = 'takes one value') => {
|
|
501
|
+
valued.push({ flag, key, typed, read, takes });
|
|
458
502
|
};
|
|
459
503
|
for (let i = 0; i < args.length; i += 1) {
|
|
460
504
|
const arg = args[i];
|
|
@@ -506,11 +550,18 @@ export function parseArgs(argv, cwd) {
|
|
|
506
550
|
case '--explain':
|
|
507
551
|
verbose = true;
|
|
508
552
|
break;
|
|
509
|
-
case '--root':
|
|
510
|
-
|
|
553
|
+
case '--root': {
|
|
554
|
+
// Resolved from where the line was typed, with a `\` read as the run
|
|
555
|
+
// reads it, and compared as text past the drive letter: the disk is
|
|
556
|
+
// not asked, so a link and its target are two values, and no other
|
|
557
|
+
// case is folded.
|
|
558
|
+
const value = filled(arg, i, 'a directory');
|
|
559
|
+
once(arg, value, () => foldDriveLetter(resolve(cwd, toPosix(value))));
|
|
560
|
+
root = value;
|
|
511
561
|
rootExplicit = true;
|
|
512
562
|
i += 1;
|
|
513
563
|
break;
|
|
564
|
+
}
|
|
514
565
|
case '--ignore':
|
|
515
566
|
ignore.push(next(arg, i));
|
|
516
567
|
i += 1;
|
|
@@ -531,14 +582,22 @@ export function parseArgs(argv, cwd) {
|
|
|
531
582
|
historyPatterns.push(next(arg, i));
|
|
532
583
|
i += 1;
|
|
533
584
|
break;
|
|
534
|
-
|
|
535
|
-
|
|
585
|
+
// A baseline typed here is read from the root where `--root` names one,
|
|
586
|
+
// and from where it was typed otherwise, which is the root's default.
|
|
587
|
+
case '--baseline': {
|
|
588
|
+
const value = filled(arg, i, 'a file');
|
|
589
|
+
once(arg, value, () => foldDriveLetter(resolve(cwd, toPosix(root), toPosix(value))));
|
|
590
|
+
baseline = value;
|
|
536
591
|
i += 1;
|
|
537
592
|
break;
|
|
538
|
-
|
|
539
|
-
|
|
593
|
+
}
|
|
594
|
+
case '--record-baseline': {
|
|
595
|
+
const value = filled(arg, i, 'a file');
|
|
596
|
+
once(arg, value, () => foldDriveLetter(resolve(cwd, toPosix(root), toPosix(value))));
|
|
597
|
+
recordBaseline = value;
|
|
540
598
|
i += 1;
|
|
541
599
|
break;
|
|
600
|
+
}
|
|
542
601
|
case '--no-config':
|
|
543
602
|
noConfig = true;
|
|
544
603
|
break;
|
|
@@ -553,7 +612,7 @@ export function parseArgs(argv, cwd) {
|
|
|
553
612
|
throw new UsageError(`--format must be human, json, sarif, markdown, gitlab or github, got "${value}"`);
|
|
554
613
|
}
|
|
555
614
|
format = value;
|
|
556
|
-
once(arg, value);
|
|
615
|
+
once(arg, value, () => value);
|
|
557
616
|
i += 1;
|
|
558
617
|
break;
|
|
559
618
|
}
|
|
@@ -563,7 +622,7 @@ export function parseArgs(argv, cwd) {
|
|
|
563
622
|
throw new UsageError(`--graph-format must be dot, mermaid or json, got "${value}"`);
|
|
564
623
|
}
|
|
565
624
|
graphFormat = value;
|
|
566
|
-
once(arg, value);
|
|
625
|
+
once(arg, value, () => value);
|
|
567
626
|
i += 1;
|
|
568
627
|
break;
|
|
569
628
|
}
|
|
@@ -587,17 +646,27 @@ export function parseArgs(argv, cwd) {
|
|
|
587
646
|
throw new UsageError(`unknown severity "${level}", expected one of: ${SEVERITIES.join(', ')}`);
|
|
588
647
|
}
|
|
589
648
|
severities[id] = level;
|
|
590
|
-
|
|
649
|
+
// The id is the key and is read as written, so the severity alone
|
|
650
|
+
// tells two values apart, and it is read as written too.
|
|
651
|
+
once(arg, value, () => value, `${arg} ${id}`, 'a rule has one severity');
|
|
591
652
|
i += 1;
|
|
592
653
|
break;
|
|
593
654
|
}
|
|
655
|
+
// A count is read past the spaces a shell variable brings, so ` 1` is
|
|
656
|
+
// the `1` it reads as.
|
|
594
657
|
case '--max': {
|
|
595
|
-
|
|
658
|
+
const value = next(arg, i);
|
|
659
|
+
const count = parseCount(arg, value);
|
|
660
|
+
once(arg, value, () => count);
|
|
661
|
+
max = count;
|
|
596
662
|
i += 1;
|
|
597
663
|
break;
|
|
598
664
|
}
|
|
599
665
|
case '--max-warnings': {
|
|
600
|
-
|
|
666
|
+
const value = next(arg, i);
|
|
667
|
+
const count = parseCount(arg, value);
|
|
668
|
+
once(arg, value, () => count);
|
|
669
|
+
maxWarnings = count;
|
|
601
670
|
i += 1;
|
|
602
671
|
break;
|
|
603
672
|
}
|
|
@@ -605,67 +674,98 @@ export function parseArgs(argv, cwd) {
|
|
|
605
674
|
throw new UsageError(`unknown option "${arg}"\n run "spec-graph --help" to see the available options`);
|
|
606
675
|
}
|
|
607
676
|
}
|
|
677
|
+
// A line that is wrong is refused with `--help` or `--version` on it, as an
|
|
678
|
+
// option nobody knows always was: answered first, the help told a script
|
|
679
|
+
// nothing of the line it had written, and the version answered it as if
|
|
680
|
+
// the line were right (spec-core's ADR-0005, 2026-10-18). What a command
|
|
681
|
+
// still lacks, a selector or an export, is what the help is asked for, and
|
|
682
|
+
// is refused only where neither is asked.
|
|
608
683
|
// SARIF, GitHub's workflow commands and GitLab's Code Quality are reports
|
|
609
684
|
// about findings, and only `check` produces those. Falling back to JSON would
|
|
610
685
|
// hand a pipeline something its uploader rejects with a message about a
|
|
611
686
|
// schema rather than about the command that was run. Markdown is for a pull
|
|
612
687
|
// request, which reads a check or a diff.
|
|
613
|
-
if ((format === 'sarif' || format === 'gitlab' || format === 'github') && command !== 'check'
|
|
688
|
+
if ((format === 'sarif' || format === 'gitlab' || format === 'github') && command !== 'check') {
|
|
614
689
|
throw new UsageError(`--format ${format} reports findings, so it belongs to check, not to ${command}`);
|
|
615
690
|
}
|
|
616
|
-
if (format === 'markdown' && command !== 'check' && command !== 'diff'
|
|
691
|
+
if (format === 'markdown' && command !== 'check' && command !== 'diff') {
|
|
617
692
|
throw new UsageError(`--format markdown is a report for a pull request, so it belongs to check or diff, not to ${command}`);
|
|
618
693
|
}
|
|
619
694
|
// JSON is refused where the other reports are, and for the reason above:
|
|
620
695
|
// `graph --format json` wrote DOT and `rules --format json` a list, each
|
|
621
696
|
// with exit 0, to whatever was waiting for JSON.
|
|
622
|
-
if (format === 'json' && (command === 'graph' || command === 'rules')
|
|
697
|
+
if (format === 'json' && (command === 'graph' || command === 'rules')) {
|
|
623
698
|
const instead = command === 'graph' ? 'graph writes JSON with --graph-format json' : 'rules lists the rules as text';
|
|
624
699
|
throw new UsageError(`--format json belongs to check, query or diff, not to ${command}: ${instead}`);
|
|
625
700
|
}
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
throw new UsageError(`${option} belongs to ${listed(readers, 'or')}, not to ${command}`);
|
|
631
|
-
}
|
|
701
|
+
for (const option of given) {
|
|
702
|
+
const readers = readersOf(option);
|
|
703
|
+
if (readers !== undefined && !readers.includes(command)) {
|
|
704
|
+
throw new UsageError(`${option} belongs to ${listed(readers, 'or')}, not to ${command}`);
|
|
632
705
|
}
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
706
|
+
}
|
|
707
|
+
// Two answers, of which one would go unread: the help was printed and the
|
|
708
|
+
// version was not, whichever came first.
|
|
709
|
+
if (help && version) {
|
|
710
|
+
throw new UsageError('--help is given beside --version, and a run prints the help or the version: give one of the two');
|
|
711
|
+
}
|
|
712
|
+
// After the loop above, so that an option of another command is named
|
|
713
|
+
// before the pair it is half of: `query --color --no-color` is told that
|
|
714
|
+
// `--color` is a check's, which is the line to act on first.
|
|
715
|
+
for (const option of given) {
|
|
716
|
+
const other = unreadBeside(option);
|
|
717
|
+
if (other !== undefined && given.has(other.option)) {
|
|
718
|
+
throw new UsageError(`${option} is given beside ${other.option}, and ${other.decided}: give one of the two`);
|
|
641
719
|
}
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
720
|
+
}
|
|
721
|
+
// Each value is held to the first its option was given, so the line names
|
|
722
|
+
// the first two that read as two, wherever on it they stand.
|
|
723
|
+
const firstOf = new Map();
|
|
724
|
+
for (const value of valued) {
|
|
725
|
+
const held = firstOf.get(value.key);
|
|
726
|
+
if (held === undefined)
|
|
727
|
+
firstOf.set(value.key, value);
|
|
728
|
+
else if (held.read() !== value.read()) {
|
|
729
|
+
throw new UsageError(`${value.flag} is given "${held.typed}" and "${value.typed}", and ${value.takes}: give one of the two`);
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
// A family on both lists is passed over, since the list that ignores is
|
|
733
|
+
// asked first, and its name on the other says nothing: `--family ADR
|
|
734
|
+
// --family RFC --ignore-family RFC` is `--family ADR`. Where it is the
|
|
735
|
+
// only name there, every citation in prose is passed over, and the check
|
|
736
|
+
// that fails without the two reported a consistent graph. Two families,
|
|
737
|
+
// one asked for and another ignored, are both read, and are taken.
|
|
738
|
+
for (const asked of families) {
|
|
739
|
+
const ignored = ignoreFamilies.find((name) => name.trim().toUpperCase() === asked.trim().toUpperCase());
|
|
740
|
+
if (ignored !== undefined) {
|
|
741
|
+
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`);
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
// Three formats are reports of findings, and a recording reports none:
|
|
745
|
+
// what it wrote in their place was the line for a person, which is none
|
|
746
|
+
// of the three. JSON is written as a document of what was recorded, and
|
|
747
|
+
// Markdown is the line, which reads as Markdown already. Refused beside
|
|
748
|
+
// the help and the version as every other wrong line is.
|
|
749
|
+
if (recordBaseline !== null) {
|
|
750
|
+
for (const option of given) {
|
|
751
|
+
const unread = unreadByRecording(option) ? option : option === '--format' && (format === 'sarif' || format === 'github' || format === 'gitlab') ? `--format ${format}` : null;
|
|
752
|
+
if (unread !== null) {
|
|
753
|
+
throw new UsageError(`${unread} is given beside --record-baseline, and a recording writes the findings down and reports none: give one of the two`);
|
|
654
754
|
}
|
|
655
755
|
}
|
|
656
756
|
}
|
|
657
757
|
// The second word given to `rules` was dropped, so `rules a b` listed `a`
|
|
658
758
|
// and said nothing of `b`.
|
|
659
|
-
if (command === 'rules' && patterns.length > 1
|
|
759
|
+
if (command === 'rules' && patterns.length > 1) {
|
|
660
760
|
throw new UsageError(`rules takes one rule id, got ${patterns.length}: ${patterns.join(', ')}`);
|
|
661
761
|
}
|
|
662
|
-
if (command === 'diff' && patterns.length
|
|
762
|
+
if (command === 'diff' && (patterns.length > 2 || (patterns.length < 2 && !help && !version))) {
|
|
663
763
|
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)');
|
|
664
764
|
}
|
|
665
765
|
// An empty name resolved to the directory the run was started in, and the
|
|
666
766
|
// refusal was the read's: `cannot read : EISDIR`.
|
|
667
767
|
const blank = patterns.findIndex((path) => path.trim() === '');
|
|
668
|
-
if (command === 'diff' && blank !== -1
|
|
768
|
+
if (command === 'diff' && blank !== -1) {
|
|
669
769
|
throw new UsageError(`diff expects a graph export on each side, got "${patterns[blank]}" for the ${blank === 0 ? 'first' : 'second'}`);
|
|
670
770
|
}
|
|
671
771
|
if (command === 'query' && selector === null && !help && !version) {
|
|
@@ -801,6 +901,13 @@ async function run(io, err) {
|
|
|
801
901
|
// it can compare exports from a checkout it is not running in.
|
|
802
902
|
if (options.command === 'diff')
|
|
803
903
|
return runDiff(options.patterns, cwd, options.format, out, err);
|
|
904
|
+
// Where the run starts: the root a `--root` names, or the directory the
|
|
905
|
+
// line was typed in. Either is read from that directory, which a caller of
|
|
906
|
+
// the library names with `io.cwd`, and where two values of `--root` were
|
|
907
|
+
// compared: left relative, the disk read it from Node's own directory, so
|
|
908
|
+
// `--root docs` checked, and recorded a baseline into, another tree than
|
|
909
|
+
// the caller's. A `\` is a separator, as the walk has always read it.
|
|
910
|
+
const start = resolve(cwd, toPosix(options.root));
|
|
804
911
|
// A root that was named is a directory, for every command that reads one.
|
|
805
912
|
// `rules --root` with a path that is not there listed the built-in rules and
|
|
806
913
|
// exited 0, as if the repository declared none, and the others said only
|
|
@@ -809,7 +916,7 @@ async function run(io, err) {
|
|
|
809
916
|
const { stat } = await import('node:fs/promises');
|
|
810
917
|
// A path that cannot be reached reads as one that is not there: either
|
|
811
918
|
// way there is nothing under it to read.
|
|
812
|
-
const found = await stat(
|
|
919
|
+
const found = await stat(start).catch(() => null);
|
|
813
920
|
if (found === null || !found.isDirectory()) {
|
|
814
921
|
err(`spec-graph: --root "${options.root}" is not ${found === null ? 'there' : 'a directory'}\n`);
|
|
815
922
|
return EXIT_ERROR;
|
|
@@ -825,10 +932,10 @@ async function run(io, err) {
|
|
|
825
932
|
// Read before `rules` prints anything, so that command lists the conventions
|
|
826
933
|
// this repository will actually check rather than the ones spec-graph ships.
|
|
827
934
|
const loaded = options.noConfig
|
|
828
|
-
? { config: {}, source: null, problems: [], root:
|
|
935
|
+
? { config: {}, source: null, problems: [], root: start }
|
|
829
936
|
: options.rootExplicit
|
|
830
|
-
? { ...loadConfig(
|
|
831
|
-
: discoverConfig(
|
|
937
|
+
? { ...loadConfig(start), root: start }
|
|
938
|
+
: discoverConfig(start);
|
|
832
939
|
for (const problem of loaded.problems)
|
|
833
940
|
err(`spec-graph: ${problem}\n`);
|
|
834
941
|
// A configuration that did not load means a run against a different
|
|
@@ -848,7 +955,7 @@ async function run(io, err) {
|
|
|
848
955
|
// How far the root moved up. Everything a repository declares about itself is
|
|
849
956
|
// relative to the root; everything typed on the command line is relative to
|
|
850
957
|
// where it was typed, and this is what keeps those two readings apart.
|
|
851
|
-
const here = below(root,
|
|
958
|
+
const here = below(root, start);
|
|
852
959
|
// A `--rule` naming a project rule that does not exist is a flag that
|
|
853
960
|
// silently does nothing, and nothing in the output would distinguish that
|
|
854
961
|
// from a rule that ran and found none.
|
|
@@ -902,8 +1009,20 @@ async function run(io, err) {
|
|
|
902
1009
|
const { severities, escalated } = resolveStrict(severityOverrides, options.strict || (file.strict ?? false), projectRules);
|
|
903
1010
|
// The list the check is held against, once it has read the documents, and
|
|
904
1011
|
// the families taken out on purpose, which that list is not held to.
|
|
1012
|
+
//
|
|
1013
|
+
// A flag says what the run does where the file says otherwise. The list
|
|
1014
|
+
// that ignores is asked first, so `--family RFC` over "ignoreFamilies":
|
|
1015
|
+
// ["RFC"] passed over every RFC it asked for, and the run reported a
|
|
1016
|
+
// consistent graph where the file alone fails it: a family a flag asks for
|
|
1017
|
+
// is taken off the file's list of those to pass over. The other way needs
|
|
1018
|
+
// nothing: `--ignore-family ADR` over "families": ["ADR"] passes over the
|
|
1019
|
+
// ADRs, as the flag says (ADR-0010, 2026-10-18).
|
|
1020
|
+
const asked = options.families.map((family) => family.trim().toUpperCase());
|
|
905
1021
|
const families = [...(file.families ?? []), ...options.families];
|
|
906
|
-
const ignoreFamilies = [
|
|
1022
|
+
const ignoreFamilies = [
|
|
1023
|
+
...(file.ignoreFamilies ?? []).filter((family) => !asked.includes(family.trim().toUpperCase())),
|
|
1024
|
+
...options.ignoreFamilies,
|
|
1025
|
+
];
|
|
907
1026
|
const analyseOptions = {
|
|
908
1027
|
root,
|
|
909
1028
|
patterns,
|
|
@@ -929,10 +1048,36 @@ async function run(io, err) {
|
|
|
929
1048
|
err(`spec-graph: ${error.message}\n`);
|
|
930
1049
|
return EXIT_ERROR;
|
|
931
1050
|
}
|
|
1051
|
+
// A root that was named is named as it was typed, as every other refusal
|
|
1052
|
+
// of it is; the one discovery found is named as it was found.
|
|
932
1053
|
if (result.files.length === 0) {
|
|
933
|
-
err(`spec-graph: no specifications matched ${patterns.map((p) => `"${p}"`).join(', ')}\n looked under ${root}\n`);
|
|
1054
|
+
err(`spec-graph: no specifications matched ${patterns.map((p) => `"${p}"`).join(', ')}\n looked under ${options.rootExplicit ? options.root : root}\n`);
|
|
934
1055
|
return EXIT_ERROR;
|
|
935
1056
|
}
|
|
1057
|
+
// A journal named on the line and never read. A `--history` pattern marks
|
|
1058
|
+
// the files the run reads, so one whose files an ignore leaves out marked
|
|
1059
|
+
// nothing and said nothing: `--ignore archive --history "docs/archive/**"`
|
|
1060
|
+
// read no journal, and the archive's links went unchecked with the rest of
|
|
1061
|
+
// it. Beside an `--ignore` one of the two goes unread, and beside the
|
|
1062
|
+
// file's "ignore" the flag cannot say what the run does: either way the run
|
|
1063
|
+
// stops, naming the ignores there are. The walk that finds it is made once,
|
|
1064
|
+
// and only for a pattern that matched nothing read; one that matches no
|
|
1065
|
+
// file at all is as it was. With no ignore that walk is the run's own, so
|
|
1066
|
+
// it finds nothing more and names no ignore (ADR-0010, 2026-10-18).
|
|
1067
|
+
const ignores = [...(typed.ignore.length > 0 ? ['--ignore'] : []), ...((file.ignore ?? []).length > 0 ? [`"ignore" in ${loaded.source}`] : [])];
|
|
1068
|
+
let unignored;
|
|
1069
|
+
for (const [at, pattern] of typed.history.entries()) {
|
|
1070
|
+
const marks = createGlobMatcher([pattern]);
|
|
1071
|
+
if (result.files.some(marks))
|
|
1072
|
+
continue;
|
|
1073
|
+
// A mutant that plants a name in this list prunes a directory of that
|
|
1074
|
+
// name, which no repository has: equivalent, and left untested.
|
|
1075
|
+
unignored ??= (await walkFiles({ root, patterns, ignore: [] })).map((walk) => walk.path);
|
|
1076
|
+
if (unignored.some(marks)) {
|
|
1077
|
+
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`);
|
|
1078
|
+
return EXIT_ERROR;
|
|
1079
|
+
}
|
|
1080
|
+
}
|
|
936
1081
|
switch (options.command) {
|
|
937
1082
|
case 'graph':
|
|
938
1083
|
out(formatGraph(result.graph, options.graphFormat, {
|
|
@@ -1038,7 +1183,13 @@ async function run(io, err) {
|
|
|
1038
1183
|
return EXIT_ERROR;
|
|
1039
1184
|
}
|
|
1040
1185
|
const entries = JSON.parse(text).findings.length;
|
|
1041
|
-
|
|
1186
|
+
// A document asked for is a document: the line in its place was what
|
|
1187
|
+
// a script handed `--format json` failed to parse. No `ok` in it,
|
|
1188
|
+
// since a recording judges nothing.
|
|
1189
|
+
const recorded = { file: options.recordBaseline, findings: result.diagnostics.length, entries };
|
|
1190
|
+
out(options.format === 'json'
|
|
1191
|
+
? `${JSON.stringify({ version: 1, recorded }, null, 2)}\n`
|
|
1192
|
+
: `recorded ${count(recorded.findings, 'finding')} as ${count(entries, 'entry', 'entries')} in ${recorded.file}\n`);
|
|
1042
1193
|
return EXIT_OK;
|
|
1043
1194
|
}
|
|
1044
1195
|
const source = options.baseline === null ? (file.baseline ?? null) : anchorFile(here, options.baseline);
|
|
@@ -1124,6 +1275,11 @@ async function run(io, err) {
|
|
|
1124
1275
|
* arithmetic.
|
|
1125
1276
|
*/
|
|
1126
1277
|
function below(root, from) {
|
|
1278
|
+
// `from` is where the run starts as `path.resolve` gives it, which ends in
|
|
1279
|
+
// no separator but at a filesystem root, `/` or `C:\`, and there `below` is
|
|
1280
|
+
// `''` whether the strip takes every slash, takes one, or writes a word in
|
|
1281
|
+
// their place: the mutants of this line are equivalent since the run
|
|
1282
|
+
// resolves where it starts. The strip stays for a path passed as typed.
|
|
1127
1283
|
const start = toPosix(from).replace(/[/]+$/, '');
|
|
1128
1284
|
// The prefix test only guards a root that is neither `start` nor above it,
|
|
1129
1285
|
// which nothing returns; for `start` itself, slicing past its end is `''`
|
|
@@ -1296,7 +1452,6 @@ function renderQueryError(selector, error) {
|
|
|
1296
1452
|
*/
|
|
1297
1453
|
async function runDiff(paths, cwd, format, out, err) {
|
|
1298
1454
|
const { readFile } = await import('node:fs/promises');
|
|
1299
|
-
const { resolve } = await import('node:path');
|
|
1300
1455
|
const sides = [];
|
|
1301
1456
|
for (const path of paths) {
|
|
1302
1457
|
let raw;
|