@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/dist/cli.js CHANGED
@@ -1,19 +1,22 @@
1
1
  /**
2
2
  * The command line.
3
3
  *
4
- * Four verbs, because there are four questions worth asking of a specification
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`), and what will you check for me (`rules`).
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 { underRoot } from './glob.js';
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 and print the matching paths.
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 (default: cwd)
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 - the interchange format
65
- GitHub code scanning and editors already read
66
- (default: human)
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 JavaScript regular expression, run once per node; ^= $=
97
- and *= cover most cases and cannot backtrack
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 sarif, got "${value}"`);
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 ? { config: {}, source: null, problems: [] } : loadConfig(options.root);
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
- const known = projectRules.map((rule) => rule.id);
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
- out(renderRules(options.verbose, projectRules));
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: options.root,
529
+ root,
472
530
  patterns,
473
- ignore: [...(file.ignore ?? []), ...options.ignore],
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
- if (options.verbose && loaded.source !== null)
483
- out(`configuration: ${loaded.source}\n`);
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 ${options.patterns.length > 0 ? options.patterns.map((p) => `"${p}"`).join(', ') : 'the default patterns'}\n looked under ${options.root}\n`);
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, { documentsOnly: options.documentsOnly }));
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
- try {
502
- const matches = execute(result.graph, parseQuery(options.selector));
503
- out(renderMatches(matches, options.format === 'json' ? 'json' : 'human', result.graph.nodes.size));
504
- return matches.length > 0 ? EXIT_OK : EXIT_FAILED;
505
- }
506
- catch (error) {
507
- if (error instanceof QueryError) {
508
- err(renderQueryError(options.selector, error));
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
- throw error;
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(options.root, options.recordBaseline), text, 'utf8');
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 ?? file.baseline ?? null;
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(options.root, source), source);
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
- : `${formatReport(reported, { color, ascii, verbose: options.verbose, max: options.max, escalated, ...baselineNote })}\n`);
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
- function renderRules(explain, projectRules = []) {
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
- ...projectRules.map((rule) => [
591
- rule.id,
592
- rule.severity,
593
- rule.sources[0],
594
- rule.sources.slice(1).join(' | ') || undefined,
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 width = Math.max(...rows.map(([id]) => id.length));
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 [id, severity, description, query] of rows) {
600
- lines.push(`${id.padEnd(width)} ${severity.padEnd(5)} ${description}`);
601
- if (explain && query)
602
- lines.push(`${' '.repeat(width)} ${query}`);
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');