@descent-vtt/spec-graph 0.4.0 → 0.6.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 +355 -0
- package/README.md +89 -6
- package/dist/cli.d.ts +14 -5
- package/dist/cli.js +253 -63
- package/dist/cli.js.map +1 -1
- package/dist/config.d.ts +33 -0
- package/dist/config.js +40 -1
- package/dist/config.js.map +1 -1
- package/dist/diff.d.ts +120 -0
- package/dist/diff.js +404 -0
- package/dist/diff.js.map +1 -0
- package/dist/directives.d.ts +18 -1
- package/dist/directives.js +79 -0
- package/dist/directives.js.map +1 -1
- package/dist/extract.d.ts +20 -0
- package/dist/extract.js +43 -8
- package/dist/extract.js.map +1 -1
- package/dist/glob.d.ts +34 -0
- package/dist/glob.js +51 -4
- package/dist/glob.js.map +1 -1
- package/dist/index.d.ts +11 -9
- package/dist/index.js +8 -7
- package/dist/index.js.map +1 -1
- package/dist/regex.d.ts +76 -0
- package/dist/regex.js +822 -0
- package/dist/regex.js.map +1 -0
- package/dist/report.d.ts +29 -0
- package/dist/report.js +111 -0
- package/dist/report.js.map +1 -1
- package/dist/resolve.js +11 -0
- package/dist/resolve.js.map +1 -1
- package/dist/rules.d.ts +13 -0
- package/dist/rules.js +26 -0
- package/dist/rules.js.map +1 -1
- package/dist/select.d.ts +8 -0
- package/dist/select.js +33 -14
- package/dist/select.js.map +1 -1
- package/dist/types.d.ts +8 -0
- package/dist/types.js.map +1 -1
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -1,19 +1,22 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The command line.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* Five verbs, because there are five questions worth asking of a specification
|
|
5
5
|
* graph: is it consistent (`check`), what does it contain (`query`), what does
|
|
6
|
-
* it look like (`graph`),
|
|
6
|
+
* it look like (`graph`), what will you check for me (`rules`), and what did a
|
|
7
|
+
* change do to it (`diff`).
|
|
7
8
|
*
|
|
8
9
|
* Exit codes are the contract with CI: `0` clean, `1` findings, `2` the tool
|
|
9
10
|
* itself could not run. A usage mistake never masquerades as a passing build.
|
|
10
11
|
*/
|
|
11
12
|
import { applyBaseline, EMPTY_BASELINE, formatBaseline, parseBaseline, } from './baseline.js';
|
|
12
|
-
import { loadConfig } from './config.js';
|
|
13
|
-
import {
|
|
13
|
+
import { discoverConfig, loadConfig } from './config.js';
|
|
14
|
+
import { DiffInputError, diffExports, formatDiffJson, formatDiffMarkdown, formatDiffText, parseGraphExport, } from './diff.js';
|
|
15
|
+
import { isGlob, underRoot } from './glob.js';
|
|
14
16
|
import { analyse, DEFAULT_PATTERNS, withDiagnostics } from './runner.js';
|
|
15
|
-
import { formatGraph, formatJson, formatReport, formatSarif, shouldUseAscii, shouldUseColor, } from './report.js';
|
|
16
|
-
import { DEFAULT_SEVERITIES, resolveStrict, RULE_DESCRIPTIONS, RULE_IDS, RULE_QUERIES } from './rules.js';
|
|
17
|
+
import { formatGraph, formatJson, formatMarkdown, formatReport, formatSarif, shouldUseAscii, shouldUseColor, } from './report.js';
|
|
18
|
+
import { DEFAULT_SEVERITIES, resolveStrict, RULE_DECISIONS, RULE_DESCRIPTIONS, RULE_IDS, RULE_QUERIES, } from './rules.js';
|
|
19
|
+
import { toPosix } from './paths.js';
|
|
17
20
|
import { formatRef } from './source.js';
|
|
18
21
|
import { execute, parseQuery, QueryError, renderMatch } from './select.js';
|
|
19
22
|
import { isProjectRule } from './types.js';
|
|
@@ -30,18 +33,32 @@ export const HELP = `spec-graph - turn Markdown specifications into a verifiable
|
|
|
30
33
|
|
|
31
34
|
USAGE
|
|
32
35
|
spec-graph [check] [patterns...] [options]
|
|
33
|
-
spec-graph query <selector> [patterns...] [options]
|
|
36
|
+
spec-graph query <selector|project:rule> [patterns...] [options]
|
|
34
37
|
spec-graph graph [patterns...] [--graph-format dot|mermaid|json]
|
|
35
|
-
spec-graph rules [--explain]
|
|
38
|
+
spec-graph rules [rule-id] [--explain]
|
|
39
|
+
spec-graph diff <before.json> <after.json> [--format human|json|markdown]
|
|
36
40
|
|
|
37
41
|
COMMANDS
|
|
38
42
|
check Validate the specification graph. The default.
|
|
39
|
-
query Run a selector
|
|
43
|
+
query Run a selector - or a registered project rule, by its id - and
|
|
44
|
+
print the matching paths.
|
|
40
45
|
graph Export the graph for Graphviz, Mermaid, or another tool.
|
|
41
|
-
rules List the diagnostics that will run, built in and project.
|
|
46
|
+
rules List the diagnostics that will run, built in and project. Name one
|
|
47
|
+
to see only that one; --explain adds its selector and the ADR that
|
|
48
|
+
decided it.
|
|
49
|
+
diff Compare two graph exports: documents added, removed, moved or
|
|
50
|
+
accepted; relations added or removed; and obligations resolved or
|
|
51
|
+
reopened where they can be told apart. Make both exports with the
|
|
52
|
+
same spec-graph. Exits 0 whether anything changed or not.
|
|
42
53
|
|
|
43
54
|
OPTIONS
|
|
44
|
-
--root <dir> Directory the patterns resolve against
|
|
55
|
+
--root <dir> Directory the patterns resolve against. Without it,
|
|
56
|
+
.spec-graph.json is looked for in the working
|
|
57
|
+
directory and then upward as far as the repository,
|
|
58
|
+
and the directory holding it becomes the root - so a
|
|
59
|
+
run from a subdirectory reports what a run from the
|
|
60
|
+
top reports. Paths typed on the command line stay
|
|
61
|
+
relative to where they were typed.
|
|
45
62
|
--ignore <glob> Skip paths. Repeatable.
|
|
46
63
|
--ignore-ref <glob> Do not report these reference targets when they fail
|
|
47
64
|
to resolve, for repositories where [[...]] tags a
|
|
@@ -61,9 +78,11 @@ OPTIONS
|
|
|
61
78
|
--ratchet Also fail when a baseline entry no longer occurs, so
|
|
62
79
|
a paid-off exemption cannot outlive the defect.
|
|
63
80
|
--no-config Ignore .spec-graph.json and the package.json key.
|
|
64
|
-
--format <fmt> human, json, or sarif
|
|
65
|
-
GitHub code scanning and editors
|
|
66
|
-
|
|
81
|
+
--format <fmt> human, json, sarif or markdown. sarif is the
|
|
82
|
+
interchange format GitHub code scanning and editors
|
|
83
|
+
already read, for check; markdown is a table for a
|
|
84
|
+
pull-request comment or $GITHUB_STEP_SUMMARY, for check
|
|
85
|
+
or diff (default: human)
|
|
67
86
|
--graph-format <fmt> dot, mermaid or json (default: dot)
|
|
68
87
|
--documents-only Leave items out of the exported graph
|
|
69
88
|
--rule <id>=<severity> Override one rule: error, warn, info or off. A project
|
|
@@ -93,8 +112,11 @@ SELECTORS
|
|
|
93
112
|
alias, document, state (or disposition), openness, section,
|
|
94
113
|
text, body, evidence, conflicted, fm.<front-matter-key>
|
|
95
114
|
Operators: = != ^= $= *= ~= and [attr] for "is present"
|
|
96
|
-
~= is a
|
|
97
|
-
|
|
115
|
+
~= is a regular expression, matched by an automaton that cannot
|
|
116
|
+
backtrack: linear in the subject, whatever the pattern. It reads
|
|
117
|
+
the usual syntax minus backreferences and lookaround, which are
|
|
118
|
+
not regular - both are refused when the selector is read, with
|
|
119
|
+
the character pointed at
|
|
98
120
|
Relations: -kind-> <-kind- =kind=> <=kind= (= forms are transitive)
|
|
99
121
|
|
|
100
122
|
EXIT CODES
|
|
@@ -144,6 +166,10 @@ PROJECT RULES
|
|
|
144
166
|
with a built-in. A selector that does not parse, or a {2} the query can never
|
|
145
167
|
reach, is reported when the file is read rather than found missing later.
|
|
146
168
|
|
|
169
|
+
To see what one matches without copying its selector out of the file:
|
|
170
|
+
|
|
171
|
+
spec-graph query project:no-draft-dependency --verbose
|
|
172
|
+
|
|
147
173
|
ADOPTING THIS ON AN OLD REPOSITORY
|
|
148
174
|
Record what is already wrong, then report only what happens next:
|
|
149
175
|
|
|
@@ -163,7 +189,11 @@ EXAMPLES
|
|
|
163
189
|
spec-graph check --history "**/JOURNAL_*.md" # a log is not a specification
|
|
164
190
|
spec-graph check --baseline .spec-graph-baseline.json
|
|
165
191
|
spec-graph rules --explain # including this repository's own
|
|
192
|
+
spec-graph rules ghost-handover --explain # and why it exists
|
|
166
193
|
spec-graph check --rule project:no-draft-dependency=off
|
|
194
|
+
spec-graph query project:no-draft-dependency # what does that rule match?
|
|
195
|
+
spec-graph check --format markdown >> "$GITHUB_STEP_SUMMARY"
|
|
196
|
+
spec-graph diff base.json head.json --format markdown >> "$GITHUB_STEP_SUMMARY"
|
|
167
197
|
`;
|
|
168
198
|
const SEVERITIES = ['error', 'warn', 'info', 'off'];
|
|
169
199
|
/** Parses argv into options. Throws {@link UsageError} on anything malformed. */
|
|
@@ -172,7 +202,7 @@ export function parseArgs(argv, cwd) {
|
|
|
172
202
|
let command = 'check';
|
|
173
203
|
if (args.length > 0 && !(args[0].startsWith('-'))) {
|
|
174
204
|
const first = args[0];
|
|
175
|
-
if (first === 'check' || first === 'query' || first === 'graph' || first === 'rules') {
|
|
205
|
+
if (first === 'check' || first === 'query' || first === 'graph' || first === 'rules' || first === 'diff') {
|
|
176
206
|
command = first;
|
|
177
207
|
args.shift();
|
|
178
208
|
}
|
|
@@ -186,6 +216,7 @@ export function parseArgs(argv, cwd) {
|
|
|
186
216
|
let noConfig = false;
|
|
187
217
|
const severities = {};
|
|
188
218
|
let root = cwd;
|
|
219
|
+
let rootExplicit = false;
|
|
189
220
|
let format = 'human';
|
|
190
221
|
let graphFormat = 'dot';
|
|
191
222
|
let color = null;
|
|
@@ -255,6 +286,7 @@ export function parseArgs(argv, cwd) {
|
|
|
255
286
|
break;
|
|
256
287
|
case '--root':
|
|
257
288
|
root = next(arg, i);
|
|
289
|
+
rootExplicit = true;
|
|
258
290
|
i += 1;
|
|
259
291
|
break;
|
|
260
292
|
case '--ignore':
|
|
@@ -290,8 +322,8 @@ export function parseArgs(argv, cwd) {
|
|
|
290
322
|
break;
|
|
291
323
|
case '--format': {
|
|
292
324
|
const value = next(arg, i);
|
|
293
|
-
if (value !== 'human' && value !== 'json' && value !== 'sarif') {
|
|
294
|
-
throw new UsageError(`--format must be human, json or
|
|
325
|
+
if (value !== 'human' && value !== 'json' && value !== 'sarif' && value !== 'markdown') {
|
|
326
|
+
throw new UsageError(`--format must be human, json, sarif or markdown, got "${value}"`);
|
|
295
327
|
}
|
|
296
328
|
format = value;
|
|
297
329
|
i += 1;
|
|
@@ -343,10 +375,17 @@ export function parseArgs(argv, cwd) {
|
|
|
343
375
|
}
|
|
344
376
|
// SARIF is a report about findings, and only `check` produces those. Falling
|
|
345
377
|
// back to JSON would hand a pipeline something its uploader rejects with a
|
|
346
|
-
// message about a schema rather than about the command that was run.
|
|
378
|
+
// message about a schema rather than about the command that was run. Markdown
|
|
379
|
+
// is for a pull request, which reads a check or a diff.
|
|
347
380
|
if (format === 'sarif' && command !== 'check' && !help && !version) {
|
|
348
381
|
throw new UsageError(`--format sarif reports findings, so it belongs to check, not to ${command}`);
|
|
349
382
|
}
|
|
383
|
+
if (format === 'markdown' && command !== 'check' && command !== 'diff' && !help && !version) {
|
|
384
|
+
throw new UsageError(`--format markdown is a report for a pull request, so it belongs to check or diff, not to ${command}`);
|
|
385
|
+
}
|
|
386
|
+
if (command === 'diff' && patterns.length !== 2 && !help && !version) {
|
|
387
|
+
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)');
|
|
388
|
+
}
|
|
350
389
|
if (command === 'query' && selector === null && !help && !version) {
|
|
351
390
|
throw new UsageError('query needs a selector, for example:\n spec-graph query \'item[openness=open]\'');
|
|
352
391
|
}
|
|
@@ -354,6 +393,7 @@ export function parseArgs(argv, cwd) {
|
|
|
354
393
|
command,
|
|
355
394
|
patterns,
|
|
356
395
|
root,
|
|
396
|
+
rootExplicit,
|
|
357
397
|
ignore,
|
|
358
398
|
ignoreReferences,
|
|
359
399
|
families,
|
|
@@ -435,6 +475,10 @@ export async function main(io = {}) {
|
|
|
435
475
|
out(`${await readVersion()}\n`);
|
|
436
476
|
return EXIT_OK;
|
|
437
477
|
}
|
|
478
|
+
// A diff reads two files and nothing else: no configuration and no corpus, so
|
|
479
|
+
// it can compare exports from a checkout it is not running in.
|
|
480
|
+
if (options.command === 'diff')
|
|
481
|
+
return runDiff(options.patterns, cwd, options.format, out, err);
|
|
438
482
|
const color = options.color ?? shouldUseColor({ isTTY: io.isTTY, env });
|
|
439
483
|
const ascii = options.ascii ?? shouldUseAscii({ env });
|
|
440
484
|
// Configuration is what is true of the repository; a flag is somebody
|
|
@@ -444,43 +488,65 @@ export async function main(io = {}) {
|
|
|
444
488
|
//
|
|
445
489
|
// Read before `rules` prints anything, so that command lists the conventions
|
|
446
490
|
// this repository will actually check rather than the ones spec-graph ships.
|
|
447
|
-
const loaded = options.noConfig
|
|
491
|
+
const loaded = options.noConfig
|
|
492
|
+
? { config: {}, source: null, problems: [], root: options.root }
|
|
493
|
+
: options.rootExplicit
|
|
494
|
+
? { ...loadConfig(options.root), root: options.root }
|
|
495
|
+
: discoverConfig(options.root);
|
|
448
496
|
for (const problem of loaded.problems)
|
|
449
497
|
err(`spec-graph: ${problem}\n`);
|
|
450
498
|
const file = loaded.config;
|
|
451
499
|
const projectRules = file.rules ?? [];
|
|
500
|
+
const root = loaded.root;
|
|
501
|
+
// How far the root moved up. Everything a repository declares about itself is
|
|
502
|
+
// relative to the root; everything typed on the command line is relative to
|
|
503
|
+
// where it was typed, and this is what keeps those two readings apart.
|
|
504
|
+
const here = below(root, options.root);
|
|
452
505
|
// A `--rule` naming a project rule that does not exist is a flag that
|
|
453
506
|
// silently does nothing, and nothing in the output would distinguish that
|
|
454
507
|
// from a rule that ran and found none.
|
|
455
508
|
for (const id of Object.keys(options.severities)) {
|
|
456
509
|
if (!isProjectRule(id) || projectRules.some((rule) => rule.id === id))
|
|
457
510
|
continue;
|
|
458
|
-
|
|
459
|
-
const where = known.length > 0 ? `project rules here: ${known.join(', ')}` : 'this repository defines no project rules';
|
|
460
|
-
err(`spec-graph: unknown rule "${id}"\n ${where}\n`);
|
|
511
|
+
err(`spec-graph: unknown rule "${id}"\n ${knownProjectRules(projectRules)}\n`);
|
|
461
512
|
return EXIT_ERROR;
|
|
462
513
|
}
|
|
463
514
|
if (options.command === 'rules') {
|
|
464
|
-
|
|
515
|
+
// The one positional argument `rules` can take is a rule id: this command
|
|
516
|
+
// reads no files, so a pattern here would be a word with nowhere to go.
|
|
517
|
+
const wanted = options.patterns[0];
|
|
518
|
+
if (wanted !== undefined && !RULE_IDS.includes(wanted) && !projectRules.some((rule) => rule.id === wanted)) {
|
|
519
|
+
err(`spec-graph: unknown rule "${wanted}"\n known rules: ${RULE_IDS.join(', ')}\n ${knownProjectRules(projectRules)}\n`);
|
|
520
|
+
return EXIT_ERROR;
|
|
521
|
+
}
|
|
522
|
+
out(renderRules(options.verbose, projectRules, wanted ?? null, loaded.source));
|
|
465
523
|
return EXIT_OK;
|
|
466
524
|
}
|
|
467
|
-
const patterns = options.patterns.length > 0 ? options.patterns : (file.patterns ?? DEFAULT_PATTERNS);
|
|
525
|
+
const patterns = options.patterns.length > 0 ? options.patterns.map((pattern) => anchor(here, pattern)) : (file.patterns ?? DEFAULT_PATTERNS);
|
|
468
526
|
const severityOverrides = { ...(file.severities ?? {}), ...options.severities };
|
|
469
527
|
const { severities, escalated } = resolveStrict(severityOverrides, options.strict || (file.strict ?? false), projectRules);
|
|
470
528
|
const analyseOptions = {
|
|
471
|
-
root
|
|
529
|
+
root,
|
|
472
530
|
patterns,
|
|
473
|
-
|
|
531
|
+
// A bare name prunes a directory of that name at any depth, the way a
|
|
532
|
+
// .gitignore line does, so it means the same thing wherever it was typed.
|
|
533
|
+
// A path or a glob is matched against the repository-relative path, and
|
|
534
|
+
// leaving that one alone would make it silently match nothing.
|
|
535
|
+
ignore: [...(file.ignore ?? []), ...options.ignore.map((pattern) => anchorPath(here, pattern))],
|
|
474
536
|
ignoreReferences: [...(file.ignoreReferences ?? []), ...options.ignoreReferences],
|
|
475
537
|
families: [...(file.families ?? []), ...options.families],
|
|
476
538
|
ignoreFamilies: [...(file.ignoreFamilies ?? []), ...options.ignoreFamilies],
|
|
477
|
-
historyPatterns: [...(file.historyPatterns ?? []), ...options.historyPatterns],
|
|
539
|
+
historyPatterns: [...(file.historyPatterns ?? []), ...options.historyPatterns.map((pattern) => anchorPath(here, pattern))],
|
|
478
540
|
severities,
|
|
479
541
|
projectRules,
|
|
480
542
|
...(file.maxRelated !== undefined ? { maxRelated: file.maxRelated } : {}),
|
|
481
543
|
};
|
|
482
|
-
|
|
483
|
-
|
|
544
|
+
// Named the way the reader would have to type it, because a discovered
|
|
545
|
+
// configuration is often not the one in front of them.
|
|
546
|
+
if (options.verbose && loaded.source !== null) {
|
|
547
|
+
const depth = here === '' ? 0 : here.split('/').length;
|
|
548
|
+
out(`configuration: ${'../'.repeat(depth)}${loaded.source}\n`);
|
|
549
|
+
}
|
|
484
550
|
let result;
|
|
485
551
|
try {
|
|
486
552
|
result = await analyse(analyseOptions);
|
|
@@ -490,26 +556,49 @@ export async function main(io = {}) {
|
|
|
490
556
|
return EXIT_ERROR;
|
|
491
557
|
}
|
|
492
558
|
if (result.files.length === 0) {
|
|
493
|
-
err(`spec-graph: no specifications matched ${
|
|
559
|
+
err(`spec-graph: no specifications matched ${patterns.map((p) => `"${p}"`).join(', ')}\n looked under ${root}\n`);
|
|
494
560
|
return EXIT_ERROR;
|
|
495
561
|
}
|
|
496
562
|
switch (options.command) {
|
|
497
563
|
case 'graph':
|
|
498
|
-
out(formatGraph(result.graph, options.graphFormat, {
|
|
564
|
+
out(formatGraph(result.graph, options.graphFormat, {
|
|
565
|
+
documentsOnly: options.documentsOnly,
|
|
566
|
+
generator: { name: 'spec-graph', version: await readVersion() },
|
|
567
|
+
}));
|
|
499
568
|
return EXIT_OK;
|
|
500
569
|
case 'query': {
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
570
|
+
const selector = options.selector;
|
|
571
|
+
let queries;
|
|
572
|
+
// A registered rule is asked for by name, because by the time this runs
|
|
573
|
+
// it is already compiled and the alternative is copying its selector out
|
|
574
|
+
// of the configuration file by hand. The namespace cannot collide with
|
|
575
|
+
// the grammar: no selector begins with a word and a colon. See ADR-0016.
|
|
576
|
+
if (isProjectRule(selector)) {
|
|
577
|
+
const rule = projectRules.find((candidate) => candidate.id === selector);
|
|
578
|
+
if (rule === undefined) {
|
|
579
|
+
err(`spec-graph: unknown rule "${selector}"\n ${knownProjectRules(projectRules)}\n`);
|
|
509
580
|
return EXIT_ERROR;
|
|
510
581
|
}
|
|
511
|
-
|
|
582
|
+
queries = rule.queries;
|
|
583
|
+
if (options.verbose)
|
|
584
|
+
for (const source of rule.sources)
|
|
585
|
+
out(`${rule.id}: ${source}\n`);
|
|
586
|
+
}
|
|
587
|
+
else {
|
|
588
|
+
try {
|
|
589
|
+
queries = [parseQuery(selector)];
|
|
590
|
+
}
|
|
591
|
+
catch (error) {
|
|
592
|
+
if (error instanceof QueryError) {
|
|
593
|
+
err(renderQueryError(selector, error));
|
|
594
|
+
return EXIT_ERROR;
|
|
595
|
+
}
|
|
596
|
+
throw error;
|
|
597
|
+
}
|
|
512
598
|
}
|
|
599
|
+
const matches = union(result.graph, queries);
|
|
600
|
+
out(renderMatches(matches, options.format === 'json' ? 'json' : 'human', result.graph.nodes.size));
|
|
601
|
+
return matches.length > 0 ? EXIT_OK : EXIT_FAILED;
|
|
513
602
|
}
|
|
514
603
|
default: {
|
|
515
604
|
// Recording is not checking. It writes down what is wrong today so that
|
|
@@ -519,7 +608,7 @@ export async function main(io = {}) {
|
|
|
519
608
|
const text = formatBaseline(result.graph, result.diagnostics);
|
|
520
609
|
try {
|
|
521
610
|
const { writeFile } = await import('node:fs/promises');
|
|
522
|
-
await writeFile(underRoot(
|
|
611
|
+
await writeFile(underRoot(root, anchor(here, options.recordBaseline)), text, 'utf8');
|
|
523
612
|
}
|
|
524
613
|
catch (error) {
|
|
525
614
|
err(`spec-graph: cannot write ${options.recordBaseline}: ${error.message}\n`);
|
|
@@ -529,12 +618,12 @@ export async function main(io = {}) {
|
|
|
529
618
|
out(`recorded ${count(result.diagnostics.length, 'finding')} as ${count(entries, 'entry', 'entries')} in ${options.recordBaseline}\n`);
|
|
530
619
|
return EXIT_OK;
|
|
531
620
|
}
|
|
532
|
-
const source = options.baseline
|
|
621
|
+
const source = options.baseline === null ? (file.baseline ?? null) : anchor(here, options.baseline);
|
|
533
622
|
const ratchet = options.ratchet || file.ratchet === true;
|
|
534
623
|
let reported = result;
|
|
535
624
|
let note;
|
|
536
625
|
if (source !== null) {
|
|
537
|
-
const held = await readBaseline(underRoot(
|
|
626
|
+
const held = await readBaseline(underRoot(root, source), source);
|
|
538
627
|
for (const problem of held.problems)
|
|
539
628
|
err(`spec-graph: ${problem}\n`);
|
|
540
629
|
const outcome = applyBaseline(result.graph, result.diagnostics, held.baseline);
|
|
@@ -561,11 +650,14 @@ export async function main(io = {}) {
|
|
|
561
650
|
}
|
|
562
651
|
const looseBaseline = note !== undefined && note.ratchet && note.stale > 0;
|
|
563
652
|
const baselineNote = note === undefined ? {} : { baseline: note };
|
|
653
|
+
const reporterOptions = { verbose: options.verbose, max: options.max, escalated, ...baselineNote };
|
|
564
654
|
out(options.format === 'sarif'
|
|
565
655
|
? formatSarif(reported, reported.graph, { version: await readVersion(), escalated, projectRules })
|
|
566
656
|
: options.format === 'json'
|
|
567
657
|
? formatJson(reported, { escalated, ...baselineNote })
|
|
568
|
-
:
|
|
658
|
+
: options.format === 'markdown'
|
|
659
|
+
? formatMarkdown(reported, reporterOptions)
|
|
660
|
+
: `${formatReport(reported, { color, ascii, ...reporterOptions })}\n`);
|
|
569
661
|
if (!reported.ok || looseBaseline)
|
|
570
662
|
return EXIT_FAILED;
|
|
571
663
|
if (options.maxWarnings >= 0 && reported.summary.warnings > options.maxWarnings)
|
|
@@ -577,29 +669,92 @@ export async function main(io = {}) {
|
|
|
577
669
|
/* -------------------------------------------------------------------------- */
|
|
578
670
|
/* Rendering */
|
|
579
671
|
/* -------------------------------------------------------------------------- */
|
|
580
|
-
|
|
672
|
+
/**
|
|
673
|
+
* Where the working directory sits below the root, or `''` when it is the root.
|
|
674
|
+
*
|
|
675
|
+
* Discovery only ever walks upward, so the root is always a prefix of the
|
|
676
|
+
* directory the command was typed in, and this is a slice rather than path
|
|
677
|
+
* arithmetic.
|
|
678
|
+
*/
|
|
679
|
+
function below(root, from) {
|
|
680
|
+
const start = toPosix(from).replace(/[/]+$/, '');
|
|
681
|
+
return start.startsWith(`${root}/`) ? start.slice(root.length + 1) : '';
|
|
682
|
+
}
|
|
683
|
+
/**
|
|
684
|
+
* Re-anchors a path typed in `prefix` so it reads from the root.
|
|
685
|
+
*
|
|
686
|
+
* An absolute path is left alone: it was not relative to anywhere, so moving
|
|
687
|
+
* the root cannot change what it means.
|
|
688
|
+
*/
|
|
689
|
+
function anchor(prefix, value) {
|
|
690
|
+
if (prefix === '' || ABSOLUTE.test(value))
|
|
691
|
+
return value;
|
|
692
|
+
return value.startsWith('!') ? `!${prefix}/${value.slice(1)}` : `${prefix}/${value}`;
|
|
693
|
+
}
|
|
694
|
+
const ABSOLUTE = /^(?:[/\\]|[A-Za-z]:)/;
|
|
695
|
+
/** The same, for an ignore, where a bare name is a directory at any depth. */
|
|
696
|
+
function anchorPath(prefix, pattern) {
|
|
697
|
+
return isGlob(pattern) || pattern.includes('/') ? anchor(prefix, pattern) : pattern;
|
|
698
|
+
}
|
|
699
|
+
function knownProjectRules(projectRules) {
|
|
700
|
+
const known = projectRules.map((rule) => rule.id);
|
|
701
|
+
return known.length > 0 ? `project rules here: ${known.join(', ')}` : 'this repository defines no project rules';
|
|
702
|
+
}
|
|
703
|
+
/**
|
|
704
|
+
* Runs a list of selectors as one result set.
|
|
705
|
+
*
|
|
706
|
+
* A project rule is a union of its selectors, and a path that two of them both
|
|
707
|
+
* find is one path - the same dedupe `projectFindings` does, so that asking
|
|
708
|
+
* this command what a rule matches answers with the set `check` reports on.
|
|
709
|
+
*/
|
|
710
|
+
function union(graph, queries) {
|
|
711
|
+
if (queries.length === 1)
|
|
712
|
+
return execute(graph, queries[0]);
|
|
713
|
+
const seen = new Set();
|
|
714
|
+
const matches = [];
|
|
715
|
+
for (const spec of queries) {
|
|
716
|
+
for (const match of execute(graph, spec)) {
|
|
717
|
+
const key = match.nodes.map((node) => node.id).join('>');
|
|
718
|
+
if (seen.has(key))
|
|
719
|
+
continue;
|
|
720
|
+
seen.add(key);
|
|
721
|
+
matches.push(match);
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
return matches;
|
|
725
|
+
}
|
|
726
|
+
function renderRules(explain, projectRules = [], only = null, configSource = null) {
|
|
581
727
|
// A project rule is described by the selector it is, because that is what it
|
|
582
728
|
// is - its message is a template, and a template is not a description.
|
|
583
729
|
const rows = [
|
|
584
|
-
...RULE_IDS.map((id) =>
|
|
730
|
+
...RULE_IDS.map((id) => ({
|
|
585
731
|
id,
|
|
586
|
-
DEFAULT_SEVERITIES[id],
|
|
587
|
-
RULE_DESCRIPTIONS[id],
|
|
588
|
-
RULE_QUERIES[id],
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
rule.
|
|
593
|
-
rule.
|
|
594
|
-
rule.sources
|
|
595
|
-
|
|
732
|
+
severity: DEFAULT_SEVERITIES[id],
|
|
733
|
+
description: RULE_DESCRIPTIONS[id],
|
|
734
|
+
query: RULE_QUERIES[id],
|
|
735
|
+
decided: RULE_DECISIONS[id],
|
|
736
|
+
})),
|
|
737
|
+
...projectRules.map((rule) => ({
|
|
738
|
+
id: rule.id,
|
|
739
|
+
severity: rule.severity,
|
|
740
|
+
description: rule.sources[0],
|
|
741
|
+
query: rule.sources.slice(1).join(' | ') || undefined,
|
|
742
|
+
decided: configSource === null ? `rules.${rule.name}` : `${configSource}: rules.${rule.name}`,
|
|
743
|
+
})),
|
|
596
744
|
];
|
|
597
|
-
const
|
|
745
|
+
const shown = only === null ? rows : rows.filter((row) => row.id === only);
|
|
746
|
+
const width = Math.max(...shown.map((row) => row.id.length));
|
|
598
747
|
const lines = [];
|
|
599
|
-
for (const
|
|
600
|
-
lines.push(`${id.padEnd(width)} ${severity.padEnd(5)} ${description}`);
|
|
601
|
-
if (explain
|
|
602
|
-
|
|
748
|
+
for (const row of shown) {
|
|
749
|
+
lines.push(`${row.id.padEnd(width)} ${row.severity.padEnd(5)} ${row.description}`);
|
|
750
|
+
if (!explain)
|
|
751
|
+
continue;
|
|
752
|
+
if (row.query)
|
|
753
|
+
lines.push(`${' '.repeat(width)} ${row.query}`);
|
|
754
|
+
// Where the decision is written down, rather than a second copy of it. A
|
|
755
|
+
// reader who has just been told their document is inconsistent is entitled
|
|
756
|
+
// to know who decided that and why, and the ADR is the answer.
|
|
757
|
+
lines.push(`${' '.repeat(width)} ${row.decided}`);
|
|
603
758
|
}
|
|
604
759
|
return `${lines.join('\n')}\n`;
|
|
605
760
|
}
|
|
@@ -647,6 +802,41 @@ function renderQueryError(selector, error) {
|
|
|
647
802
|
const caret = `${' '.repeat(Math.max(0, error.offset))}^`;
|
|
648
803
|
return `spec-graph: ${error.message}\n ${selector}\n ${caret}\n`;
|
|
649
804
|
}
|
|
805
|
+
/**
|
|
806
|
+
* Compares two exports.
|
|
807
|
+
*
|
|
808
|
+
* Exit 0 whether anything changed or not: a diff describes, and `check` gates. A
|
|
809
|
+
* diff that failed on a removed relation would fail every legitimate
|
|
810
|
+
* supersession (ADR-0020).
|
|
811
|
+
*/
|
|
812
|
+
async function runDiff(paths, cwd, format, out, err) {
|
|
813
|
+
const { readFile } = await import('node:fs/promises');
|
|
814
|
+
const { resolve } = await import('node:path');
|
|
815
|
+
const sides = [];
|
|
816
|
+
for (const path of paths) {
|
|
817
|
+
let raw;
|
|
818
|
+
try {
|
|
819
|
+
raw = await readFile(resolve(cwd, path), 'utf8');
|
|
820
|
+
}
|
|
821
|
+
catch (error) {
|
|
822
|
+
err(`spec-graph: cannot read ${path}: ${error.message}\n`);
|
|
823
|
+
return EXIT_ERROR;
|
|
824
|
+
}
|
|
825
|
+
try {
|
|
826
|
+
sides.push(parseGraphExport(raw, path));
|
|
827
|
+
}
|
|
828
|
+
catch (error) {
|
|
829
|
+
if (!(error instanceof DiffInputError))
|
|
830
|
+
throw error;
|
|
831
|
+
err(`spec-graph: ${error.message}\n`);
|
|
832
|
+
return EXIT_ERROR;
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
const [before, after] = sides;
|
|
836
|
+
const diff = diffExports(before, after);
|
|
837
|
+
out(format === 'json' ? formatDiffJson(diff) : format === 'markdown' ? formatDiffMarkdown(diff) : formatDiffText(diff));
|
|
838
|
+
return EXIT_OK;
|
|
839
|
+
}
|
|
650
840
|
async function readVersion() {
|
|
651
841
|
try {
|
|
652
842
|
const { readFile } = await import('node:fs/promises');
|