@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/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 and print the matching paths.
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-in diagnostics.
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 (default: cwd)
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 - the interchange format
64
- GitHub code scanning and editors already read
65
- (default: human)
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. Repeatable.
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 and per-file detail
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 sarif, got "${value}"`);
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
- if (!RULE_IDS.includes(id)) {
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 sarif reports findings, so it belongs to check, not to ${command}`);
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
- const loaded = options.noConfig ? { config: {}, source: null, problems: [] } : loadConfig(options.root);
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 patterns = options.patterns.length > 0 ? options.patterns : (file.patterns ?? DEFAULT_PATTERNS);
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: options.root,
509
+ root,
421
510
  patterns,
422
- ignore: [...(file.ignore ?? []), ...options.ignore],
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
- if (options.verbose && loaded.source !== null)
431
- out(`configuration: ${loaded.source}\n`);
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 ${options.patterns.length > 0 ? options.patterns.map((p) => `"${p}"`).join(', ') : 'the default patterns'}\n looked under ${options.root}\n`);
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
- try {
450
- const matches = execute(result.graph, parseQuery(options.selector));
451
- out(renderMatches(matches, options.format === 'json' ? 'json' : 'human', result.graph.nodes.size));
452
- return matches.length > 0 ? EXIT_OK : EXIT_FAILED;
453
- }
454
- catch (error) {
455
- if (error instanceof QueryError) {
456
- err(renderQueryError(options.selector, error));
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
- throw error;
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(options.root, options.recordBaseline), text, 'utf8');
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 ?? file.baseline ?? null;
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(options.root, source), source);
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 = { source, suppressed: outcome.suppressed, stale: outcome.stale.length, ratchet };
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. Never
493
- // in JSON, where the entries are already in the report and a stray line
494
- // on stdout is the difference between parsing and not.
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
- out(` paid: ${entry.rule} ${entry.document}${entry.subject === '' ? '' : ` "${entry.subject}"`}\n`);
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
- : `${formatReport(reported, { color, ascii, verbose: options.verbose, max: options.max, escalated, ...baselineNote })}\n`);
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
- function renderRules(explain) {
520
- const width = Math.max(...RULE_IDS.map((id) => id.length));
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 id of RULE_IDS) {
523
- lines.push(`${id.padEnd(width)} ${DEFAULT_SEVERITIES[id].padEnd(5)} ${RULE_DESCRIPTIONS[id]}`);
524
- const query = RULE_QUERIES[id];
525
- if (explain && query)
526
- lines.push(`${' '.repeat(width)} ${query}`);
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
  }