@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/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 and print the matching paths.
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 (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.
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 - the interchange format
65
- GitHub code scanning and editors already read
66
- (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)
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 JavaScript regular expression, run once per node; ^= $=
97
- and *= cover most cases and cannot backtrack
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 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}"`);
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 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}`);
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 ? { config: {}, source: null, problems: [] } : loadConfig(options.root);
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
- 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`);
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
- out(renderRules(options.verbose, projectRules));
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: options.root,
509
+ root,
472
510
  patterns,
473
- 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))],
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
- if (options.verbose && loaded.source !== null)
483
- 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
+ }
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 ${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`);
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
- 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));
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
- 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
+ }
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(options.root, options.recordBaseline), text, 'utf8');
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 ?? file.baseline ?? null;
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(options.root, source), source);
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
- : `${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`);
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
- function renderRules(explain, projectRules = []) {
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
- ...projectRules.map((rule) => [
591
- rule.id,
592
- rule.severity,
593
- rule.sources[0],
594
- rule.sources.slice(1).join(' | ') || undefined,
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 width = Math.max(...rows.map(([id]) => id.length));
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 [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}`);
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
  }