@descent-vtt/spec-graph 0.3.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +430 -5
- package/README.md +137 -7
- package/dist/baseline.d.ts +18 -4
- package/dist/baseline.js +14 -2
- package/dist/baseline.js.map +1 -1
- package/dist/cli.d.ts +12 -4
- package/dist/cli.js +267 -59
- package/dist/cli.js.map +1 -1
- package/dist/config.d.ts +45 -2
- package/dist/config.js +61 -2
- package/dist/config.js.map +1 -1
- package/dist/extract.d.ts +20 -11
- package/dist/extract.js +58 -44
- package/dist/extract.js.map +1 -1
- package/dist/identity.d.ts +10 -0
- package/dist/identity.js +37 -0
- package/dist/identity.js.map +1 -1
- package/dist/index.d.ts +13 -9
- package/dist/index.js +8 -6
- package/dist/index.js.map +1 -1
- package/dist/project-rules.d.ts +92 -0
- package/dist/project-rules.js +201 -0
- package/dist/project-rules.js.map +1 -0
- package/dist/regex.d.ts +64 -0
- package/dist/regex.js +708 -0
- package/dist/regex.js.map +1 -0
- package/dist/report.d.ts +34 -7
- package/dist/report.js +172 -5
- package/dist/report.js.map +1 -1
- package/dist/resolve.d.ts +8 -1
- package/dist/resolve.js +154 -30
- package/dist/resolve.js.map +1 -1
- package/dist/rules.d.ts +21 -5
- package/dist/rules.js +92 -5
- package/dist/rules.js.map +1 -1
- package/dist/runner.d.ts +5 -2
- package/dist/runner.js +1 -0
- package/dist/runner.js.map +1 -1
- package/dist/select.d.ts +19 -0
- package/dist/select.js +64 -14
- package/dist/select.js.map +1 -1
- package/dist/types.d.ts +36 -1
- package/dist/types.js +5 -0
- package/dist/types.js.map +1 -1
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -8,14 +8,16 @@
|
|
|
8
8
|
* Exit codes are the contract with CI: `0` clean, `1` findings, `2` the tool
|
|
9
9
|
* itself could not run. A usage mistake never masquerades as a passing build.
|
|
10
10
|
*/
|
|
11
|
-
import { applyBaseline, EMPTY_BASELINE, formatBaseline, parseBaseline } from './baseline.js';
|
|
12
|
-
import { loadConfig } from './config.js';
|
|
13
|
-
import { underRoot } from './glob.js';
|
|
11
|
+
import { applyBaseline, EMPTY_BASELINE, formatBaseline, parseBaseline, } from './baseline.js';
|
|
12
|
+
import { discoverConfig, loadConfig } from './config.js';
|
|
13
|
+
import { isGlob, underRoot } from './glob.js';
|
|
14
14
|
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';
|
|
15
|
+
import { formatGraph, formatJson, formatMarkdown, formatReport, formatSarif, shouldUseAscii, shouldUseColor, } from './report.js';
|
|
16
|
+
import { DEFAULT_SEVERITIES, resolveStrict, RULE_DECISIONS, RULE_DESCRIPTIONS, RULE_IDS, RULE_QUERIES, } from './rules.js';
|
|
17
|
+
import { toPosix } from './paths.js';
|
|
17
18
|
import { formatRef } from './source.js';
|
|
18
19
|
import { execute, parseQuery, QueryError, renderMatch } from './select.js';
|
|
20
|
+
import { isProjectRule } from './types.js';
|
|
19
21
|
export const EXIT_OK = 0;
|
|
20
22
|
export const EXIT_FAILED = 1;
|
|
21
23
|
export const EXIT_ERROR = 2;
|
|
@@ -29,18 +31,27 @@ export const HELP = `spec-graph - turn Markdown specifications into a verifiable
|
|
|
29
31
|
|
|
30
32
|
USAGE
|
|
31
33
|
spec-graph [check] [patterns...] [options]
|
|
32
|
-
spec-graph query <selector> [patterns...] [options]
|
|
34
|
+
spec-graph query <selector|project:rule> [patterns...] [options]
|
|
33
35
|
spec-graph graph [patterns...] [--graph-format dot|mermaid|json]
|
|
34
|
-
spec-graph rules [--explain]
|
|
36
|
+
spec-graph rules [rule-id] [--explain]
|
|
35
37
|
|
|
36
38
|
COMMANDS
|
|
37
39
|
check Validate the specification graph. The default.
|
|
38
|
-
query Run a selector
|
|
40
|
+
query Run a selector - or a registered project rule, by its id - and
|
|
41
|
+
print the matching paths.
|
|
39
42
|
graph Export the graph for Graphviz, Mermaid, or another tool.
|
|
40
|
-
rules List the built
|
|
43
|
+
rules List the diagnostics that will run, built in and project. Name one
|
|
44
|
+
to see only that one; --explain adds its selector and the ADR that
|
|
45
|
+
decided it.
|
|
41
46
|
|
|
42
47
|
OPTIONS
|
|
43
|
-
--root <dir> Directory the patterns resolve against
|
|
48
|
+
--root <dir> Directory the patterns resolve against. Without it,
|
|
49
|
+
.spec-graph.json is looked for in the working
|
|
50
|
+
directory and then upward as far as the repository,
|
|
51
|
+
and the directory holding it becomes the root - so a
|
|
52
|
+
run from a subdirectory reports what a run from the
|
|
53
|
+
top reports. Paths typed on the command line stay
|
|
54
|
+
relative to where they were typed.
|
|
44
55
|
--ignore <glob> Skip paths. Repeatable.
|
|
45
56
|
--ignore-ref <glob> Do not report these reference targets when they fail
|
|
46
57
|
to resolve, for repositories where [[...]] tags a
|
|
@@ -60,19 +71,23 @@ OPTIONS
|
|
|
60
71
|
--ratchet Also fail when a baseline entry no longer occurs, so
|
|
61
72
|
a paid-off exemption cannot outlive the defect.
|
|
62
73
|
--no-config Ignore .spec-graph.json and the package.json key.
|
|
63
|
-
--format <fmt> human, json, or sarif
|
|
64
|
-
GitHub code scanning and editors
|
|
65
|
-
|
|
74
|
+
--format <fmt> human, json, sarif or markdown. sarif is the
|
|
75
|
+
interchange format GitHub code scanning and editors
|
|
76
|
+
already read; markdown is a table for a pull-request
|
|
77
|
+
comment or $GITHUB_STEP_SUMMARY (default: human)
|
|
66
78
|
--graph-format <fmt> dot, mermaid or json (default: dot)
|
|
67
79
|
--documents-only Leave items out of the exported graph
|
|
68
|
-
--rule <id>=<severity> Override one rule: error, warn, info or off.
|
|
80
|
+
--rule <id>=<severity> Override one rule: error, warn, info or off. A project
|
|
81
|
+
rule is named in full: project:no-draft-dependency.
|
|
82
|
+
Repeatable.
|
|
69
83
|
--max <n> Show at most n findings (0 = no limit)
|
|
70
84
|
--max-warnings <n> Fail when warnings exceed n (default: no limit)
|
|
71
85
|
--strict Raise every warning to an error. An explicit --rule
|
|
72
86
|
still wins, so --strict --rule x=warn exempts x.
|
|
73
87
|
--color / --no-color Force colour on or off
|
|
74
88
|
--ascii Use ASCII glyphs only
|
|
75
|
-
--verbose Include parse problems
|
|
89
|
+
--verbose Include parse problems, per-file detail, and every
|
|
90
|
+
reference --ignore-ref or --ignore-family silenced
|
|
76
91
|
-h, --help Show this help
|
|
77
92
|
-v, --version Show the version
|
|
78
93
|
|
|
@@ -89,6 +104,11 @@ SELECTORS
|
|
|
89
104
|
alias, document, state (or disposition), openness, section,
|
|
90
105
|
text, body, evidence, conflicted, fm.<front-matter-key>
|
|
91
106
|
Operators: = != ^= $= *= ~= and [attr] for "is present"
|
|
107
|
+
~= is a regular expression, matched by an automaton that cannot
|
|
108
|
+
backtrack: linear in the subject, whatever the pattern. It reads
|
|
109
|
+
the usual syntax minus backreferences and lookaround, which are
|
|
110
|
+
not regular - both are refused when the selector is read, with
|
|
111
|
+
the character pointed at
|
|
92
112
|
Relations: -kind-> <-kind- =kind=> <=kind= (= forms are transitive)
|
|
93
113
|
|
|
94
114
|
EXIT CODES
|
|
@@ -114,6 +134,34 @@ CONFIGURATION
|
|
|
114
134
|
A flag always wins over the file, and list flags add to it rather than
|
|
115
135
|
replacing it.
|
|
116
136
|
|
|
137
|
+
PROJECT RULES
|
|
138
|
+
A convention spec-graph never anticipated is a selector plus a sentence, and
|
|
139
|
+
belongs in the same file. Every rule declared here runs beside the built-ins
|
|
140
|
+
and is reported, baselined, escalated by --strict and silenced by --rule in
|
|
141
|
+
exactly the same way.
|
|
142
|
+
|
|
143
|
+
{ "rules": {
|
|
144
|
+
"no-draft-dependency": {
|
|
145
|
+
"query": "document[phase=active] -depends-on-> document[phase=draft]",
|
|
146
|
+
"message": "{0} depends on {1}, which is still a draft",
|
|
147
|
+
"hint": "wait for {1} to be accepted, or drop it from {0.path}",
|
|
148
|
+
"severity": "error" } } }
|
|
149
|
+
|
|
150
|
+
query One selector, or a list of them read as a union.
|
|
151
|
+
message The headline. {0} is the first node on the path, {1} the next;
|
|
152
|
+
{1.phase} and {0.fm.owner} read any selector attribute.
|
|
153
|
+
hint The next action. Optional, and templated the same way.
|
|
154
|
+
severity error, warn, info or off. Defaults to warn - a rule a team has
|
|
155
|
+
just written has not yet earned the right to fail their build.
|
|
156
|
+
|
|
157
|
+
The id is the name with project: in front, which is why it can never collide
|
|
158
|
+
with a built-in. A selector that does not parse, or a {2} the query can never
|
|
159
|
+
reach, is reported when the file is read rather than found missing later.
|
|
160
|
+
|
|
161
|
+
To see what one matches without copying its selector out of the file:
|
|
162
|
+
|
|
163
|
+
spec-graph query project:no-draft-dependency --verbose
|
|
164
|
+
|
|
117
165
|
ADOPTING THIS ON AN OLD REPOSITORY
|
|
118
166
|
Record what is already wrong, then report only what happens next:
|
|
119
167
|
|
|
@@ -132,6 +180,11 @@ EXAMPLES
|
|
|
132
180
|
spec-graph graph --documents-only --graph-format mermaid > graph.mmd
|
|
133
181
|
spec-graph check --history "**/JOURNAL_*.md" # a log is not a specification
|
|
134
182
|
spec-graph check --baseline .spec-graph-baseline.json
|
|
183
|
+
spec-graph rules --explain # including this repository's own
|
|
184
|
+
spec-graph rules ghost-handover --explain # and why it exists
|
|
185
|
+
spec-graph check --rule project:no-draft-dependency=off
|
|
186
|
+
spec-graph query project:no-draft-dependency # what does that rule match?
|
|
187
|
+
spec-graph check --format markdown >> "$GITHUB_STEP_SUMMARY"
|
|
135
188
|
`;
|
|
136
189
|
const SEVERITIES = ['error', 'warn', 'info', 'off'];
|
|
137
190
|
/** Parses argv into options. Throws {@link UsageError} on anything malformed. */
|
|
@@ -154,6 +207,7 @@ export function parseArgs(argv, cwd) {
|
|
|
154
207
|
let noConfig = false;
|
|
155
208
|
const severities = {};
|
|
156
209
|
let root = cwd;
|
|
210
|
+
let rootExplicit = false;
|
|
157
211
|
let format = 'human';
|
|
158
212
|
let graphFormat = 'dot';
|
|
159
213
|
let color = null;
|
|
@@ -223,6 +277,7 @@ export function parseArgs(argv, cwd) {
|
|
|
223
277
|
break;
|
|
224
278
|
case '--root':
|
|
225
279
|
root = next(arg, i);
|
|
280
|
+
rootExplicit = true;
|
|
226
281
|
i += 1;
|
|
227
282
|
break;
|
|
228
283
|
case '--ignore':
|
|
@@ -258,8 +313,8 @@ export function parseArgs(argv, cwd) {
|
|
|
258
313
|
break;
|
|
259
314
|
case '--format': {
|
|
260
315
|
const value = next(arg, i);
|
|
261
|
-
if (value !== 'human' && value !== 'json' && value !== 'sarif') {
|
|
262
|
-
throw new UsageError(`--format must be human, json or
|
|
316
|
+
if (value !== 'human' && value !== 'json' && value !== 'sarif' && value !== 'markdown') {
|
|
317
|
+
throw new UsageError(`--format must be human, json, sarif or markdown, got "${value}"`);
|
|
263
318
|
}
|
|
264
319
|
format = value;
|
|
265
320
|
i += 1;
|
|
@@ -281,7 +336,11 @@ export function parseArgs(argv, cwd) {
|
|
|
281
336
|
throw new UsageError(`--rule expects <id>=<severity>, got "${value}"`);
|
|
282
337
|
const id = value.slice(0, equals);
|
|
283
338
|
const level = value.slice(equals + 1);
|
|
284
|
-
|
|
339
|
+
// A project rule is taken on its namespace here and checked for real
|
|
340
|
+
// once the configuration defining it has been read: arguments are
|
|
341
|
+
// parsed before any file is opened, and a flag has to be usable on that
|
|
342
|
+
// first pass whatever the repository turns out to declare.
|
|
343
|
+
if (!RULE_IDS.includes(id) && !isProjectRule(id)) {
|
|
285
344
|
throw new UsageError(`unknown rule "${id}"\n known rules: ${RULE_IDS.join(', ')}`);
|
|
286
345
|
}
|
|
287
346
|
if (!SEVERITIES.includes(level)) {
|
|
@@ -308,8 +367,8 @@ export function parseArgs(argv, cwd) {
|
|
|
308
367
|
// SARIF is a report about findings, and only `check` produces those. Falling
|
|
309
368
|
// back to JSON would hand a pipeline something its uploader rejects with a
|
|
310
369
|
// message about a schema rather than about the command that was run.
|
|
311
|
-
if (format === 'sarif' && command !== 'check' && !help && !version) {
|
|
312
|
-
throw new UsageError(`--format
|
|
370
|
+
if ((format === 'sarif' || format === 'markdown') && command !== 'check' && !help && !version) {
|
|
371
|
+
throw new UsageError(`--format ${format} reports findings, so it belongs to check, not to ${command}`);
|
|
313
372
|
}
|
|
314
373
|
if (command === 'query' && selector === null && !help && !version) {
|
|
315
374
|
throw new UsageError('query needs a selector, for example:\n spec-graph query \'item[openness=open]\'');
|
|
@@ -318,6 +377,7 @@ export function parseArgs(argv, cwd) {
|
|
|
318
377
|
command,
|
|
319
378
|
patterns,
|
|
320
379
|
root,
|
|
380
|
+
rootExplicit,
|
|
321
381
|
ignore,
|
|
322
382
|
ignoreReferences,
|
|
323
383
|
families,
|
|
@@ -401,34 +461,72 @@ export async function main(io = {}) {
|
|
|
401
461
|
}
|
|
402
462
|
const color = options.color ?? shouldUseColor({ isTTY: io.isTTY, env });
|
|
403
463
|
const ascii = options.ascii ?? shouldUseAscii({ env });
|
|
404
|
-
if (options.command === 'rules') {
|
|
405
|
-
out(renderRules(options.verbose));
|
|
406
|
-
return EXIT_OK;
|
|
407
|
-
}
|
|
408
464
|
// Configuration is what is true of the repository; a flag is somebody
|
|
409
465
|
// overriding it for one run. So a flag always wins, and lists add rather than
|
|
410
466
|
// replace - a `--ignore-ref` on the command line is one more exclusion, not a
|
|
411
467
|
// decision to throw away the ones the repository already declared.
|
|
412
|
-
|
|
468
|
+
//
|
|
469
|
+
// Read before `rules` prints anything, so that command lists the conventions
|
|
470
|
+
// this repository will actually check rather than the ones spec-graph ships.
|
|
471
|
+
const loaded = options.noConfig
|
|
472
|
+
? { config: {}, source: null, problems: [], root: options.root }
|
|
473
|
+
: options.rootExplicit
|
|
474
|
+
? { ...loadConfig(options.root), root: options.root }
|
|
475
|
+
: discoverConfig(options.root);
|
|
413
476
|
for (const problem of loaded.problems)
|
|
414
477
|
err(`spec-graph: ${problem}\n`);
|
|
415
478
|
const file = loaded.config;
|
|
416
|
-
const
|
|
479
|
+
const projectRules = file.rules ?? [];
|
|
480
|
+
const root = loaded.root;
|
|
481
|
+
// How far the root moved up. Everything a repository declares about itself is
|
|
482
|
+
// relative to the root; everything typed on the command line is relative to
|
|
483
|
+
// where it was typed, and this is what keeps those two readings apart.
|
|
484
|
+
const here = below(root, options.root);
|
|
485
|
+
// A `--rule` naming a project rule that does not exist is a flag that
|
|
486
|
+
// silently does nothing, and nothing in the output would distinguish that
|
|
487
|
+
// from a rule that ran and found none.
|
|
488
|
+
for (const id of Object.keys(options.severities)) {
|
|
489
|
+
if (!isProjectRule(id) || projectRules.some((rule) => rule.id === id))
|
|
490
|
+
continue;
|
|
491
|
+
err(`spec-graph: unknown rule "${id}"\n ${knownProjectRules(projectRules)}\n`);
|
|
492
|
+
return EXIT_ERROR;
|
|
493
|
+
}
|
|
494
|
+
if (options.command === 'rules') {
|
|
495
|
+
// The one positional argument `rules` can take is a rule id: this command
|
|
496
|
+
// reads no files, so a pattern here would be a word with nowhere to go.
|
|
497
|
+
const wanted = options.patterns[0];
|
|
498
|
+
if (wanted !== undefined && !RULE_IDS.includes(wanted) && !projectRules.some((rule) => rule.id === wanted)) {
|
|
499
|
+
err(`spec-graph: unknown rule "${wanted}"\n known rules: ${RULE_IDS.join(', ')}\n ${knownProjectRules(projectRules)}\n`);
|
|
500
|
+
return EXIT_ERROR;
|
|
501
|
+
}
|
|
502
|
+
out(renderRules(options.verbose, projectRules, wanted ?? null, loaded.source));
|
|
503
|
+
return EXIT_OK;
|
|
504
|
+
}
|
|
505
|
+
const patterns = options.patterns.length > 0 ? options.patterns.map((pattern) => anchor(here, pattern)) : (file.patterns ?? DEFAULT_PATTERNS);
|
|
417
506
|
const severityOverrides = { ...(file.severities ?? {}), ...options.severities };
|
|
418
|
-
const { severities, escalated } = resolveStrict(severityOverrides, options.strict || (file.strict ?? false));
|
|
507
|
+
const { severities, escalated } = resolveStrict(severityOverrides, options.strict || (file.strict ?? false), projectRules);
|
|
419
508
|
const analyseOptions = {
|
|
420
|
-
root
|
|
509
|
+
root,
|
|
421
510
|
patterns,
|
|
422
|
-
|
|
511
|
+
// A bare name prunes a directory of that name at any depth, the way a
|
|
512
|
+
// .gitignore line does, so it means the same thing wherever it was typed.
|
|
513
|
+
// A path or a glob is matched against the repository-relative path, and
|
|
514
|
+
// leaving that one alone would make it silently match nothing.
|
|
515
|
+
ignore: [...(file.ignore ?? []), ...options.ignore.map((pattern) => anchorPath(here, pattern))],
|
|
423
516
|
ignoreReferences: [...(file.ignoreReferences ?? []), ...options.ignoreReferences],
|
|
424
517
|
families: [...(file.families ?? []), ...options.families],
|
|
425
518
|
ignoreFamilies: [...(file.ignoreFamilies ?? []), ...options.ignoreFamilies],
|
|
426
|
-
historyPatterns: [...(file.historyPatterns ?? []), ...options.historyPatterns],
|
|
519
|
+
historyPatterns: [...(file.historyPatterns ?? []), ...options.historyPatterns.map((pattern) => anchorPath(here, pattern))],
|
|
427
520
|
severities,
|
|
521
|
+
projectRules,
|
|
428
522
|
...(file.maxRelated !== undefined ? { maxRelated: file.maxRelated } : {}),
|
|
429
523
|
};
|
|
430
|
-
|
|
431
|
-
|
|
524
|
+
// Named the way the reader would have to type it, because a discovered
|
|
525
|
+
// configuration is often not the one in front of them.
|
|
526
|
+
if (options.verbose && loaded.source !== null) {
|
|
527
|
+
const depth = here === '' ? 0 : here.split('/').length;
|
|
528
|
+
out(`configuration: ${'../'.repeat(depth)}${loaded.source}\n`);
|
|
529
|
+
}
|
|
432
530
|
let result;
|
|
433
531
|
try {
|
|
434
532
|
result = await analyse(analyseOptions);
|
|
@@ -438,7 +536,7 @@ export async function main(io = {}) {
|
|
|
438
536
|
return EXIT_ERROR;
|
|
439
537
|
}
|
|
440
538
|
if (result.files.length === 0) {
|
|
441
|
-
err(`spec-graph: no specifications matched ${
|
|
539
|
+
err(`spec-graph: no specifications matched ${patterns.map((p) => `"${p}"`).join(', ')}\n looked under ${root}\n`);
|
|
442
540
|
return EXIT_ERROR;
|
|
443
541
|
}
|
|
444
542
|
switch (options.command) {
|
|
@@ -446,18 +544,38 @@ export async function main(io = {}) {
|
|
|
446
544
|
out(formatGraph(result.graph, options.graphFormat, { documentsOnly: options.documentsOnly }));
|
|
447
545
|
return EXIT_OK;
|
|
448
546
|
case 'query': {
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
547
|
+
const selector = options.selector;
|
|
548
|
+
let queries;
|
|
549
|
+
// A registered rule is asked for by name, because by the time this runs
|
|
550
|
+
// it is already compiled and the alternative is copying its selector out
|
|
551
|
+
// of the configuration file by hand. The namespace cannot collide with
|
|
552
|
+
// the grammar: no selector begins with a word and a colon. See ADR-0016.
|
|
553
|
+
if (isProjectRule(selector)) {
|
|
554
|
+
const rule = projectRules.find((candidate) => candidate.id === selector);
|
|
555
|
+
if (rule === undefined) {
|
|
556
|
+
err(`spec-graph: unknown rule "${selector}"\n ${knownProjectRules(projectRules)}\n`);
|
|
457
557
|
return EXIT_ERROR;
|
|
458
558
|
}
|
|
459
|
-
|
|
559
|
+
queries = rule.queries;
|
|
560
|
+
if (options.verbose)
|
|
561
|
+
for (const source of rule.sources)
|
|
562
|
+
out(`${rule.id}: ${source}\n`);
|
|
563
|
+
}
|
|
564
|
+
else {
|
|
565
|
+
try {
|
|
566
|
+
queries = [parseQuery(selector)];
|
|
567
|
+
}
|
|
568
|
+
catch (error) {
|
|
569
|
+
if (error instanceof QueryError) {
|
|
570
|
+
err(renderQueryError(selector, error));
|
|
571
|
+
return EXIT_ERROR;
|
|
572
|
+
}
|
|
573
|
+
throw error;
|
|
574
|
+
}
|
|
460
575
|
}
|
|
576
|
+
const matches = union(result.graph, queries);
|
|
577
|
+
out(renderMatches(matches, options.format === 'json' ? 'json' : 'human', result.graph.nodes.size));
|
|
578
|
+
return matches.length > 0 ? EXIT_OK : EXIT_FAILED;
|
|
461
579
|
}
|
|
462
580
|
default: {
|
|
463
581
|
// Recording is not checking. It writes down what is wrong today so that
|
|
@@ -467,7 +585,7 @@ export async function main(io = {}) {
|
|
|
467
585
|
const text = formatBaseline(result.graph, result.diagnostics);
|
|
468
586
|
try {
|
|
469
587
|
const { writeFile } = await import('node:fs/promises');
|
|
470
|
-
await writeFile(underRoot(
|
|
588
|
+
await writeFile(underRoot(root, anchor(here, options.recordBaseline)), text, 'utf8');
|
|
471
589
|
}
|
|
472
590
|
catch (error) {
|
|
473
591
|
err(`spec-graph: cannot write ${options.recordBaseline}: ${error.message}\n`);
|
|
@@ -477,34 +595,46 @@ export async function main(io = {}) {
|
|
|
477
595
|
out(`recorded ${count(result.diagnostics.length, 'finding')} as ${count(entries, 'entry', 'entries')} in ${options.recordBaseline}\n`);
|
|
478
596
|
return EXIT_OK;
|
|
479
597
|
}
|
|
480
|
-
const source = options.baseline
|
|
598
|
+
const source = options.baseline === null ? (file.baseline ?? null) : anchor(here, options.baseline);
|
|
481
599
|
const ratchet = options.ratchet || file.ratchet === true;
|
|
482
600
|
let reported = result;
|
|
483
601
|
let note;
|
|
484
602
|
if (source !== null) {
|
|
485
|
-
const held = await readBaseline(underRoot(
|
|
603
|
+
const held = await readBaseline(underRoot(root, source), source);
|
|
486
604
|
for (const problem of held.problems)
|
|
487
605
|
err(`spec-graph: ${problem}\n`);
|
|
488
606
|
const outcome = applyBaseline(result.graph, result.diagnostics, held.baseline);
|
|
489
607
|
reported = withDiagnostics(result, outcome.kept);
|
|
490
|
-
note = {
|
|
608
|
+
note = {
|
|
609
|
+
source,
|
|
610
|
+
suppressed: outcome.suppressed,
|
|
611
|
+
stale: outcome.stale.length,
|
|
612
|
+
ratchet,
|
|
613
|
+
entries: outcome.stale,
|
|
614
|
+
};
|
|
491
615
|
// Listed rather than counted when the run turns on them: a number is
|
|
492
|
-
// enough to know the file has slack, and not enough to strike it.
|
|
493
|
-
//
|
|
494
|
-
//
|
|
616
|
+
// enough to know the file has slack, and not enough to strike it. On
|
|
617
|
+
// stdout only for a human - the structured formats carry the same rows
|
|
618
|
+
// inside the document, where a stray line is the difference between
|
|
619
|
+
// parsing and not.
|
|
495
620
|
if (options.format === 'human' && (options.verbose || (ratchet && outcome.stale.length > 0))) {
|
|
496
621
|
for (const entry of outcome.stale) {
|
|
497
|
-
|
|
622
|
+
const subject = entry.subject === '' ? '' : ` "${entry.subject}"`;
|
|
623
|
+
const why = entry.reason === 'gone' ? ` - ${entry.document} is not in this corpus` : '';
|
|
624
|
+
out(` ${entry.reason}: ${entry.rule} ${entry.document}${subject}${why}\n`);
|
|
498
625
|
}
|
|
499
626
|
}
|
|
500
627
|
}
|
|
501
628
|
const looseBaseline = note !== undefined && note.ratchet && note.stale > 0;
|
|
502
629
|
const baselineNote = note === undefined ? {} : { baseline: note };
|
|
630
|
+
const reporterOptions = { verbose: options.verbose, max: options.max, escalated, ...baselineNote };
|
|
503
631
|
out(options.format === 'sarif'
|
|
504
|
-
? formatSarif(reported, reported.graph, { version: await readVersion(), escalated })
|
|
632
|
+
? formatSarif(reported, reported.graph, { version: await readVersion(), escalated, projectRules })
|
|
505
633
|
: options.format === 'json'
|
|
506
634
|
? formatJson(reported, { escalated, ...baselineNote })
|
|
507
|
-
:
|
|
635
|
+
: options.format === 'markdown'
|
|
636
|
+
? formatMarkdown(reported, reporterOptions)
|
|
637
|
+
: `${formatReport(reported, { color, ascii, ...reporterOptions })}\n`);
|
|
508
638
|
if (!reported.ok || looseBaseline)
|
|
509
639
|
return EXIT_FAILED;
|
|
510
640
|
if (options.maxWarnings >= 0 && reported.summary.warnings > options.maxWarnings)
|
|
@@ -516,14 +646,92 @@ export async function main(io = {}) {
|
|
|
516
646
|
/* -------------------------------------------------------------------------- */
|
|
517
647
|
/* Rendering */
|
|
518
648
|
/* -------------------------------------------------------------------------- */
|
|
519
|
-
|
|
520
|
-
|
|
649
|
+
/**
|
|
650
|
+
* Where the working directory sits below the root, or `''` when it is the root.
|
|
651
|
+
*
|
|
652
|
+
* Discovery only ever walks upward, so the root is always a prefix of the
|
|
653
|
+
* directory the command was typed in, and this is a slice rather than path
|
|
654
|
+
* arithmetic.
|
|
655
|
+
*/
|
|
656
|
+
function below(root, from) {
|
|
657
|
+
const start = toPosix(from).replace(/[/]+$/, '');
|
|
658
|
+
return start.startsWith(`${root}/`) ? start.slice(root.length + 1) : '';
|
|
659
|
+
}
|
|
660
|
+
/**
|
|
661
|
+
* Re-anchors a path typed in `prefix` so it reads from the root.
|
|
662
|
+
*
|
|
663
|
+
* An absolute path is left alone: it was not relative to anywhere, so moving
|
|
664
|
+
* the root cannot change what it means.
|
|
665
|
+
*/
|
|
666
|
+
function anchor(prefix, value) {
|
|
667
|
+
if (prefix === '' || ABSOLUTE.test(value))
|
|
668
|
+
return value;
|
|
669
|
+
return value.startsWith('!') ? `!${prefix}/${value.slice(1)}` : `${prefix}/${value}`;
|
|
670
|
+
}
|
|
671
|
+
const ABSOLUTE = /^(?:[/\\]|[A-Za-z]:)/;
|
|
672
|
+
/** The same, for an ignore, where a bare name is a directory at any depth. */
|
|
673
|
+
function anchorPath(prefix, pattern) {
|
|
674
|
+
return isGlob(pattern) || pattern.includes('/') ? anchor(prefix, pattern) : pattern;
|
|
675
|
+
}
|
|
676
|
+
function knownProjectRules(projectRules) {
|
|
677
|
+
const known = projectRules.map((rule) => rule.id);
|
|
678
|
+
return known.length > 0 ? `project rules here: ${known.join(', ')}` : 'this repository defines no project rules';
|
|
679
|
+
}
|
|
680
|
+
/**
|
|
681
|
+
* Runs a list of selectors as one result set.
|
|
682
|
+
*
|
|
683
|
+
* A project rule is a union of its selectors, and a path that two of them both
|
|
684
|
+
* find is one path - the same dedupe `projectFindings` does, so that asking
|
|
685
|
+
* this command what a rule matches answers with the set `check` reports on.
|
|
686
|
+
*/
|
|
687
|
+
function union(graph, queries) {
|
|
688
|
+
if (queries.length === 1)
|
|
689
|
+
return execute(graph, queries[0]);
|
|
690
|
+
const seen = new Set();
|
|
691
|
+
const matches = [];
|
|
692
|
+
for (const spec of queries) {
|
|
693
|
+
for (const match of execute(graph, spec)) {
|
|
694
|
+
const key = match.nodes.map((node) => node.id).join('>');
|
|
695
|
+
if (seen.has(key))
|
|
696
|
+
continue;
|
|
697
|
+
seen.add(key);
|
|
698
|
+
matches.push(match);
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
return matches;
|
|
702
|
+
}
|
|
703
|
+
function renderRules(explain, projectRules = [], only = null, configSource = null) {
|
|
704
|
+
// A project rule is described by the selector it is, because that is what it
|
|
705
|
+
// is - its message is a template, and a template is not a description.
|
|
706
|
+
const rows = [
|
|
707
|
+
...RULE_IDS.map((id) => ({
|
|
708
|
+
id,
|
|
709
|
+
severity: DEFAULT_SEVERITIES[id],
|
|
710
|
+
description: RULE_DESCRIPTIONS[id],
|
|
711
|
+
query: RULE_QUERIES[id],
|
|
712
|
+
decided: RULE_DECISIONS[id],
|
|
713
|
+
})),
|
|
714
|
+
...projectRules.map((rule) => ({
|
|
715
|
+
id: rule.id,
|
|
716
|
+
severity: rule.severity,
|
|
717
|
+
description: rule.sources[0],
|
|
718
|
+
query: rule.sources.slice(1).join(' | ') || undefined,
|
|
719
|
+
decided: configSource === null ? `rules.${rule.name}` : `${configSource}: rules.${rule.name}`,
|
|
720
|
+
})),
|
|
721
|
+
];
|
|
722
|
+
const shown = only === null ? rows : rows.filter((row) => row.id === only);
|
|
723
|
+
const width = Math.max(...shown.map((row) => row.id.length));
|
|
521
724
|
const lines = [];
|
|
522
|
-
for (const
|
|
523
|
-
lines.push(`${id.padEnd(width)} ${
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
725
|
+
for (const row of shown) {
|
|
726
|
+
lines.push(`${row.id.padEnd(width)} ${row.severity.padEnd(5)} ${row.description}`);
|
|
727
|
+
if (!explain)
|
|
728
|
+
continue;
|
|
729
|
+
if (row.query)
|
|
730
|
+
lines.push(`${' '.repeat(width)} ${row.query}`);
|
|
731
|
+
// Where the decision is written down, rather than a second copy of it. A
|
|
732
|
+
// reader who has just been told their document is inconsistent is entitled
|
|
733
|
+
// to know who decided that and why, and the ADR is the answer.
|
|
734
|
+
lines.push(`${' '.repeat(width)} ${row.decided}`);
|
|
527
735
|
}
|
|
528
736
|
return `${lines.join('\n')}\n`;
|
|
529
737
|
}
|