@descent-vtt/spec-graph 0.4.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 +252 -0
- package/README.md +55 -6
- package/dist/cli.d.ts +10 -2
- package/dist/cli.js +192 -60
- 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/extract.d.ts +20 -0
- package/dist/extract.js +39 -6
- package/dist/extract.js.map +1 -1
- package/dist/index.d.ts +8 -6
- package/dist/index.js +6 -5
- package/dist/index.js.map +1 -1
- 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 +19 -0
- package/dist/report.js +109 -0
- package/dist/report.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/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -9,11 +9,12 @@
|
|
|
9
9
|
* itself could not run. A usage mistake never masquerades as a passing build.
|
|
10
10
|
*/
|
|
11
11
|
import { applyBaseline, EMPTY_BASELINE, formatBaseline, parseBaseline, } from './baseline.js';
|
|
12
|
-
import { loadConfig } from './config.js';
|
|
13
|
-
import { underRoot } from './glob.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';
|
|
19
20
|
import { isProjectRule } from './types.js';
|
|
@@ -30,18 +31,27 @@ export const HELP = `spec-graph - turn Markdown specifications into a verifiable
|
|
|
30
31
|
|
|
31
32
|
USAGE
|
|
32
33
|
spec-graph [check] [patterns...] [options]
|
|
33
|
-
spec-graph query <selector> [patterns...] [options]
|
|
34
|
+
spec-graph query <selector|project:rule> [patterns...] [options]
|
|
34
35
|
spec-graph graph [patterns...] [--graph-format dot|mermaid|json]
|
|
35
|
-
spec-graph rules [--explain]
|
|
36
|
+
spec-graph rules [rule-id] [--explain]
|
|
36
37
|
|
|
37
38
|
COMMANDS
|
|
38
39
|
check Validate the specification graph. The default.
|
|
39
|
-
query Run a selector
|
|
40
|
+
query Run a selector - or a registered project rule, by its id - and
|
|
41
|
+
print the matching paths.
|
|
40
42
|
graph Export the graph for Graphviz, Mermaid, or another tool.
|
|
41
|
-
rules List the diagnostics that will run, built in and project.
|
|
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.
|
|
42
46
|
|
|
43
47
|
OPTIONS
|
|
44
|
-
--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.
|
|
45
55
|
--ignore <glob> Skip paths. Repeatable.
|
|
46
56
|
--ignore-ref <glob> Do not report these reference targets when they fail
|
|
47
57
|
to resolve, for repositories where [[...]] tags a
|
|
@@ -61,9 +71,10 @@ OPTIONS
|
|
|
61
71
|
--ratchet Also fail when a baseline entry no longer occurs, so
|
|
62
72
|
a paid-off exemption cannot outlive the defect.
|
|
63
73
|
--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
|
-
|
|
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)
|
|
67
78
|
--graph-format <fmt> dot, mermaid or json (default: dot)
|
|
68
79
|
--documents-only Leave items out of the exported graph
|
|
69
80
|
--rule <id>=<severity> Override one rule: error, warn, info or off. A project
|
|
@@ -93,8 +104,11 @@ SELECTORS
|
|
|
93
104
|
alias, document, state (or disposition), openness, section,
|
|
94
105
|
text, body, evidence, conflicted, fm.<front-matter-key>
|
|
95
106
|
Operators: = != ^= $= *= ~= and [attr] for "is present"
|
|
96
|
-
~= is a
|
|
97
|
-
|
|
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
|
|
98
112
|
Relations: -kind-> <-kind- =kind=> <=kind= (= forms are transitive)
|
|
99
113
|
|
|
100
114
|
EXIT CODES
|
|
@@ -144,6 +158,10 @@ PROJECT RULES
|
|
|
144
158
|
with a built-in. A selector that does not parse, or a {2} the query can never
|
|
145
159
|
reach, is reported when the file is read rather than found missing later.
|
|
146
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
|
+
|
|
147
165
|
ADOPTING THIS ON AN OLD REPOSITORY
|
|
148
166
|
Record what is already wrong, then report only what happens next:
|
|
149
167
|
|
|
@@ -163,7 +181,10 @@ EXAMPLES
|
|
|
163
181
|
spec-graph check --history "**/JOURNAL_*.md" # a log is not a specification
|
|
164
182
|
spec-graph check --baseline .spec-graph-baseline.json
|
|
165
183
|
spec-graph rules --explain # including this repository's own
|
|
184
|
+
spec-graph rules ghost-handover --explain # and why it exists
|
|
166
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"
|
|
167
188
|
`;
|
|
168
189
|
const SEVERITIES = ['error', 'warn', 'info', 'off'];
|
|
169
190
|
/** Parses argv into options. Throws {@link UsageError} on anything malformed. */
|
|
@@ -186,6 +207,7 @@ export function parseArgs(argv, cwd) {
|
|
|
186
207
|
let noConfig = false;
|
|
187
208
|
const severities = {};
|
|
188
209
|
let root = cwd;
|
|
210
|
+
let rootExplicit = false;
|
|
189
211
|
let format = 'human';
|
|
190
212
|
let graphFormat = 'dot';
|
|
191
213
|
let color = null;
|
|
@@ -255,6 +277,7 @@ export function parseArgs(argv, cwd) {
|
|
|
255
277
|
break;
|
|
256
278
|
case '--root':
|
|
257
279
|
root = next(arg, i);
|
|
280
|
+
rootExplicit = true;
|
|
258
281
|
i += 1;
|
|
259
282
|
break;
|
|
260
283
|
case '--ignore':
|
|
@@ -290,8 +313,8 @@ export function parseArgs(argv, cwd) {
|
|
|
290
313
|
break;
|
|
291
314
|
case '--format': {
|
|
292
315
|
const value = next(arg, i);
|
|
293
|
-
if (value !== 'human' && value !== 'json' && value !== 'sarif') {
|
|
294
|
-
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}"`);
|
|
295
318
|
}
|
|
296
319
|
format = value;
|
|
297
320
|
i += 1;
|
|
@@ -344,8 +367,8 @@ export function parseArgs(argv, cwd) {
|
|
|
344
367
|
// SARIF is a report about findings, and only `check` produces those. Falling
|
|
345
368
|
// back to JSON would hand a pipeline something its uploader rejects with a
|
|
346
369
|
// message about a schema rather than about the command that was run.
|
|
347
|
-
if (format === 'sarif' && command !== 'check' && !help && !version) {
|
|
348
|
-
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}`);
|
|
349
372
|
}
|
|
350
373
|
if (command === 'query' && selector === null && !help && !version) {
|
|
351
374
|
throw new UsageError('query needs a selector, for example:\n spec-graph query \'item[openness=open]\'');
|
|
@@ -354,6 +377,7 @@ export function parseArgs(argv, cwd) {
|
|
|
354
377
|
command,
|
|
355
378
|
patterns,
|
|
356
379
|
root,
|
|
380
|
+
rootExplicit,
|
|
357
381
|
ignore,
|
|
358
382
|
ignoreReferences,
|
|
359
383
|
families,
|
|
@@ -444,43 +468,65 @@ export async function main(io = {}) {
|
|
|
444
468
|
//
|
|
445
469
|
// Read before `rules` prints anything, so that command lists the conventions
|
|
446
470
|
// this repository will actually check rather than the ones spec-graph ships.
|
|
447
|
-
const loaded = options.noConfig
|
|
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);
|
|
448
476
|
for (const problem of loaded.problems)
|
|
449
477
|
err(`spec-graph: ${problem}\n`);
|
|
450
478
|
const file = loaded.config;
|
|
451
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);
|
|
452
485
|
// A `--rule` naming a project rule that does not exist is a flag that
|
|
453
486
|
// silently does nothing, and nothing in the output would distinguish that
|
|
454
487
|
// from a rule that ran and found none.
|
|
455
488
|
for (const id of Object.keys(options.severities)) {
|
|
456
489
|
if (!isProjectRule(id) || projectRules.some((rule) => rule.id === id))
|
|
457
490
|
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`);
|
|
491
|
+
err(`spec-graph: unknown rule "${id}"\n ${knownProjectRules(projectRules)}\n`);
|
|
461
492
|
return EXIT_ERROR;
|
|
462
493
|
}
|
|
463
494
|
if (options.command === 'rules') {
|
|
464
|
-
|
|
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));
|
|
465
503
|
return EXIT_OK;
|
|
466
504
|
}
|
|
467
|
-
const patterns = options.patterns.length > 0 ? options.patterns : (file.patterns ?? DEFAULT_PATTERNS);
|
|
505
|
+
const patterns = options.patterns.length > 0 ? options.patterns.map((pattern) => anchor(here, pattern)) : (file.patterns ?? DEFAULT_PATTERNS);
|
|
468
506
|
const severityOverrides = { ...(file.severities ?? {}), ...options.severities };
|
|
469
507
|
const { severities, escalated } = resolveStrict(severityOverrides, options.strict || (file.strict ?? false), projectRules);
|
|
470
508
|
const analyseOptions = {
|
|
471
|
-
root
|
|
509
|
+
root,
|
|
472
510
|
patterns,
|
|
473
|
-
|
|
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))],
|
|
474
516
|
ignoreReferences: [...(file.ignoreReferences ?? []), ...options.ignoreReferences],
|
|
475
517
|
families: [...(file.families ?? []), ...options.families],
|
|
476
518
|
ignoreFamilies: [...(file.ignoreFamilies ?? []), ...options.ignoreFamilies],
|
|
477
|
-
historyPatterns: [...(file.historyPatterns ?? []), ...options.historyPatterns],
|
|
519
|
+
historyPatterns: [...(file.historyPatterns ?? []), ...options.historyPatterns.map((pattern) => anchorPath(here, pattern))],
|
|
478
520
|
severities,
|
|
479
521
|
projectRules,
|
|
480
522
|
...(file.maxRelated !== undefined ? { maxRelated: file.maxRelated } : {}),
|
|
481
523
|
};
|
|
482
|
-
|
|
483
|
-
|
|
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
|
+
}
|
|
484
530
|
let result;
|
|
485
531
|
try {
|
|
486
532
|
result = await analyse(analyseOptions);
|
|
@@ -490,7 +536,7 @@ export async function main(io = {}) {
|
|
|
490
536
|
return EXIT_ERROR;
|
|
491
537
|
}
|
|
492
538
|
if (result.files.length === 0) {
|
|
493
|
-
err(`spec-graph: no specifications matched ${
|
|
539
|
+
err(`spec-graph: no specifications matched ${patterns.map((p) => `"${p}"`).join(', ')}\n looked under ${root}\n`);
|
|
494
540
|
return EXIT_ERROR;
|
|
495
541
|
}
|
|
496
542
|
switch (options.command) {
|
|
@@ -498,18 +544,38 @@ export async function main(io = {}) {
|
|
|
498
544
|
out(formatGraph(result.graph, options.graphFormat, { documentsOnly: options.documentsOnly }));
|
|
499
545
|
return EXIT_OK;
|
|
500
546
|
case 'query': {
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
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`);
|
|
509
557
|
return EXIT_ERROR;
|
|
510
558
|
}
|
|
511
|
-
|
|
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
|
+
}
|
|
512
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;
|
|
513
579
|
}
|
|
514
580
|
default: {
|
|
515
581
|
// Recording is not checking. It writes down what is wrong today so that
|
|
@@ -519,7 +585,7 @@ export async function main(io = {}) {
|
|
|
519
585
|
const text = formatBaseline(result.graph, result.diagnostics);
|
|
520
586
|
try {
|
|
521
587
|
const { writeFile } = await import('node:fs/promises');
|
|
522
|
-
await writeFile(underRoot(
|
|
588
|
+
await writeFile(underRoot(root, anchor(here, options.recordBaseline)), text, 'utf8');
|
|
523
589
|
}
|
|
524
590
|
catch (error) {
|
|
525
591
|
err(`spec-graph: cannot write ${options.recordBaseline}: ${error.message}\n`);
|
|
@@ -529,12 +595,12 @@ export async function main(io = {}) {
|
|
|
529
595
|
out(`recorded ${count(result.diagnostics.length, 'finding')} as ${count(entries, 'entry', 'entries')} in ${options.recordBaseline}\n`);
|
|
530
596
|
return EXIT_OK;
|
|
531
597
|
}
|
|
532
|
-
const source = options.baseline
|
|
598
|
+
const source = options.baseline === null ? (file.baseline ?? null) : anchor(here, options.baseline);
|
|
533
599
|
const ratchet = options.ratchet || file.ratchet === true;
|
|
534
600
|
let reported = result;
|
|
535
601
|
let note;
|
|
536
602
|
if (source !== null) {
|
|
537
|
-
const held = await readBaseline(underRoot(
|
|
603
|
+
const held = await readBaseline(underRoot(root, source), source);
|
|
538
604
|
for (const problem of held.problems)
|
|
539
605
|
err(`spec-graph: ${problem}\n`);
|
|
540
606
|
const outcome = applyBaseline(result.graph, result.diagnostics, held.baseline);
|
|
@@ -561,11 +627,14 @@ export async function main(io = {}) {
|
|
|
561
627
|
}
|
|
562
628
|
const looseBaseline = note !== undefined && note.ratchet && note.stale > 0;
|
|
563
629
|
const baselineNote = note === undefined ? {} : { baseline: note };
|
|
630
|
+
const reporterOptions = { verbose: options.verbose, max: options.max, escalated, ...baselineNote };
|
|
564
631
|
out(options.format === 'sarif'
|
|
565
632
|
? formatSarif(reported, reported.graph, { version: await readVersion(), escalated, projectRules })
|
|
566
633
|
: options.format === 'json'
|
|
567
634
|
? formatJson(reported, { escalated, ...baselineNote })
|
|
568
|
-
:
|
|
635
|
+
: options.format === 'markdown'
|
|
636
|
+
? formatMarkdown(reported, reporterOptions)
|
|
637
|
+
: `${formatReport(reported, { color, ascii, ...reporterOptions })}\n`);
|
|
569
638
|
if (!reported.ok || looseBaseline)
|
|
570
639
|
return EXIT_FAILED;
|
|
571
640
|
if (options.maxWarnings >= 0 && reported.summary.warnings > options.maxWarnings)
|
|
@@ -577,29 +646,92 @@ export async function main(io = {}) {
|
|
|
577
646
|
/* -------------------------------------------------------------------------- */
|
|
578
647
|
/* Rendering */
|
|
579
648
|
/* -------------------------------------------------------------------------- */
|
|
580
|
-
|
|
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) {
|
|
581
704
|
// A project rule is described by the selector it is, because that is what it
|
|
582
705
|
// is - its message is a template, and a template is not a description.
|
|
583
706
|
const rows = [
|
|
584
|
-
...RULE_IDS.map((id) =>
|
|
707
|
+
...RULE_IDS.map((id) => ({
|
|
585
708
|
id,
|
|
586
|
-
DEFAULT_SEVERITIES[id],
|
|
587
|
-
RULE_DESCRIPTIONS[id],
|
|
588
|
-
RULE_QUERIES[id],
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
rule.
|
|
593
|
-
rule.
|
|
594
|
-
rule.sources
|
|
595
|
-
|
|
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
|
+
})),
|
|
596
721
|
];
|
|
597
|
-
const
|
|
722
|
+
const shown = only === null ? rows : rows.filter((row) => row.id === only);
|
|
723
|
+
const width = Math.max(...shown.map((row) => row.id.length));
|
|
598
724
|
const lines = [];
|
|
599
|
-
for (const
|
|
600
|
-
lines.push(`${id.padEnd(width)} ${severity.padEnd(5)} ${description}`);
|
|
601
|
-
if (explain
|
|
602
|
-
|
|
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}`);
|
|
603
735
|
}
|
|
604
736
|
return `${lines.join('\n')}\n`;
|
|
605
737
|
}
|