@descent-vtt/spec-graph 0.13.1 → 0.14.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
@@ -14,6 +14,7 @@ import { applyBaseline, EMPTY_BASELINE, formatBaseline, parseBaseline, } from '.
14
14
  import { discoverConfig, loadConfig } from './config.js';
15
15
  import { DiffInputError, diffExports, formatDiffJson, formatDiffMarkdown, formatDiffText, parseGraphExport, } from './diff.js';
16
16
  import { isGlob, rebasePattern, underRoot } from './glob.js';
17
+ import { isFamilyName } from './identity.js';
17
18
  import { analyse, DEFAULT_PATTERNS, withDiagnostics } from './runner.js';
18
19
  import { formatGithub, formatGitlab, formatGraph, formatJson, formatMarkdown, formatReport, formatSarif, shouldUseAscii, shouldUseColor, } from './report.js';
19
20
  import { DEFAULT_SEVERITIES, resolveStrict, RULE_DECISIONS, decisionUrl, RULE_DESCRIPTIONS, RULE_IDS, RULE_QUERIES, } from './rules.js';
@@ -60,16 +61,22 @@ OPTIONS
60
61
  and the directory holding it becomes the root - so a
61
62
  run from a subdirectory reports what a run from the
62
63
  top reports. Paths typed on the command line stay
63
- relative to where they were typed.
64
+ relative to where they were typed. It has to be a
65
+ directory that is there.
64
66
  --ignore <glob> Skip paths. Repeatable.
65
67
  --ignore-ref <glob> Do not report these reference targets when they fail
66
68
  to resolve, for repositories where [[...]] tags a
67
69
  concept rather than naming a file. Repeatable.
68
70
  Suppresses findings only, never edges.
69
71
  --family <name> Families a bare identifier in prose may name. When
70
- given, everything else stays prose. Repeatable.
72
+ given, everything else stays prose. Repeatable: one
73
+ name each time, the prefix its identifiers are
74
+ written with, ADR for ADR-0007. A document here has
75
+ to belong to one of them, or the check stops: a list
76
+ that names none allows nothing.
71
77
  --ignore-family <name> Families that are never citations - RFC when the repo
72
- cites RFC 2119 and keeps its own RFCs. Repeatable.
78
+ cites RFC 2119 and keeps its own RFCs. Repeatable,
79
+ one name each time.
73
80
  --history <glob> Files that log what was decided rather than deciding
74
81
  it - journals, changelogs, minutes. Their links are
75
82
  still checked; their obligations are not. Repeatable.
@@ -83,14 +90,15 @@ OPTIONS
83
90
  --ratchet Also fail when a baseline entry no longer occurs, so
84
91
  a paid-off exemption cannot outlive the defect.
85
92
  --no-config Ignore .spec-graph.json and the package.json key.
86
- --format <fmt> human, json, sarif, github, gitlab or markdown. sarif
87
- is the interchange format GitHub code scanning and
88
- editors already read, github the workflow commands a
89
- GitHub Actions log turns into annotations, and gitlab
90
- the Code Quality report a GitLab merge request reads,
91
- all three for check; markdown is a table for a
92
- pull-request comment or $GITHUB_STEP_SUMMARY, for
93
- check or diff (default: human)
93
+ --format <fmt> human, json, sarif, github, gitlab or markdown. json
94
+ is for check, query or diff. sarif is the interchange
95
+ format GitHub code scanning and editors already read,
96
+ github the workflow commands a GitHub Actions log
97
+ turns into annotations, and gitlab the Code Quality
98
+ report a GitLab merge request reads, all three for
99
+ check; markdown is a table for a pull-request comment
100
+ or $GITHUB_STEP_SUMMARY, for check or diff (default:
101
+ human)
94
102
  --graph-format <fmt> dot, mermaid or json (default: dot)
95
103
  --documents-only Leave items out of the exported graph
96
104
  --rule <id>=<severity> Override one rule: error, warn, info or off. A project
@@ -101,12 +109,32 @@ OPTIONS
101
109
  --strict Raise every warning to an error. An explicit --rule
102
110
  still wins, so --strict --rule x=warn exempts x.
103
111
  --color / --no-color Force colour on or off
104
- --ascii Use ASCII glyphs only
112
+ --ascii Use ASCII glyphs only. SPEC_GRAPH_ASCII asks for the
113
+ same, unless it is empty, 0 or false
105
114
  --verbose Include parse problems, per-file detail, and every
106
115
  reference --ignore-ref or --ignore-family silenced
116
+ --explain With rules: add each rule's selector and the ADR
117
+ that decided it
107
118
  -h, --help Show this help
108
119
  -v, --version Show the version
109
120
 
121
+ An option belongs to the commands that read it, and any other command
122
+ refuses it by name:
123
+
124
+ --root, --verbose check, query, graph, rules
125
+ --ignore, --history check, query, graph
126
+ --format check, query, diff
127
+ --graph-format, --documents-only graph
128
+ --explain rules
129
+ every other option check
130
+
131
+ --no-config, --no-color and --ascii are taken by every command: each is
132
+ true already of a command with nothing to read it for.
133
+
134
+ A value that begins with -- reads as an option, so the option before it is
135
+ refused as missing its value. A name that does begin so is written ./--name
136
+ for a file, [-]-name in a pattern and \\--name for a reference target.
137
+
110
138
  SELECTORS
111
139
  A selector is a path through the graph.
112
140
 
@@ -132,6 +160,11 @@ EXIT CODES
132
160
  1 findings at error severity (or over --max-warnings)
133
161
  2 the tool could not run
134
162
 
163
+ An input that is set and names nothing is a 2, named on stderr, and never
164
+ read as if it had not been given: an empty value, a family name no family
165
+ has, a list of families no document here belongs to, a --root that is no
166
+ directory, a second word after rules, an option of another command.
167
+
135
168
  CONFIGURATION
136
169
  Anything repeated on every run belongs in the repository rather than in the
137
170
  command. spec-graph reads the first of these that exists:
@@ -153,9 +186,10 @@ CONFIGURATION
153
186
  finding lists, 8 unless set, and 0 lists none.
154
187
 
155
188
  A problem in that file stops the run with exit 2, named on stderr: invalid
156
- JSON, an unknown key, a rule that does not compile. A configuration that did
157
- not load checks a different repository than the one configured, and would
158
- report that one as consistent. --no-config runs on defaults instead.
189
+ JSON, an unknown key, a rule that does not compile, a file that is there and
190
+ cannot be read. A configuration that did not load checks a different
191
+ repository than the one configured, and would report that one as consistent.
192
+ --no-config runs on defaults instead.
159
193
 
160
194
  PROJECT RULES
161
195
  A convention spec-graph never anticipated is a selector plus a sentence, and
@@ -213,6 +247,61 @@ EXAMPLES
213
247
  spec-graph diff base.json head.json --format markdown >> "$GITHUB_STEP_SUMMARY"
214
248
  `;
215
249
  const SEVERITIES = ['error', 'warn', 'info', 'off'];
250
+ /**
251
+ * The commands that read an option, or nothing for one every command takes.
252
+ *
253
+ * Any other command refuses the option, rather than run as if it were not
254
+ * there: `query --strict` taking the flag and doing nothing with it would
255
+ * tell someone their query was strict, `graph --record-baseline` wrote no
256
+ * file and said so nowhere, and `spec-graph --graph-format mermaid`, the
257
+ * command forgotten, wrote the check's report into what was meant to hold a
258
+ * graph. The three options about citations are a check's alone: they pass
259
+ * over findings and never an edge, so a graph or a query is the same with
260
+ * them as without.
261
+ *
262
+ * `--no-color`, `--ascii` and `--no-config` are taken by every command.
263
+ * Each is true already of what the commands that do not read it write - none
264
+ * of them colours or draws a glyph, and `diff` reads no configuration - and
265
+ * a script passes them to every command it runs. `--color` is not one of
266
+ * them: it would promise colour that only a check prints.
267
+ *
268
+ * A function and not a table, so that none of it runs when the module loads:
269
+ * a mutant of a table built at import pays for the whole suite (ADR-0007).
270
+ */
271
+ function readersOf(option) {
272
+ switch (option) {
273
+ case '--root':
274
+ case '--verbose':
275
+ return ['check', 'query', 'graph', 'rules'];
276
+ case '--ignore':
277
+ case '--history':
278
+ return ['check', 'query', 'graph'];
279
+ case '--format':
280
+ return ['check', 'query', 'diff'];
281
+ case '--graph-format':
282
+ case '--documents-only':
283
+ return ['graph'];
284
+ case '--explain':
285
+ return ['rules'];
286
+ case '--ignore-ref':
287
+ case '--family':
288
+ case '--ignore-family':
289
+ case '--baseline':
290
+ case '--record-baseline':
291
+ case '--ratchet':
292
+ case '--rule':
293
+ case '--strict':
294
+ case '--max':
295
+ case '--max-warnings':
296
+ case '--color':
297
+ return ['check'];
298
+ }
299
+ return undefined;
300
+ }
301
+ /** `a`, `a or b`, `a, b or c`: one name or more in a sentence, with the word that joins the last. */
302
+ function listed(names, joint) {
303
+ return names.length < 2 ? names[0] : `${names.slice(0, -1).join(', ')} ${joint} ${names[names.length - 1]}`;
304
+ }
216
305
  /** Parses argv into options. Throws {@link UsageError} on anything malformed. */
217
306
  export function parseArgs(argv, cwd) {
218
307
  const args = [...argv];
@@ -247,10 +336,42 @@ export function parseArgs(argv, cwd) {
247
336
  let selector = null;
248
337
  let help = false;
249
338
  let version = false;
339
+ // In the order they were given, so the first that does not belong is the
340
+ // one named. A set keeps that order, and starts from no literal: a list
341
+ // seeded with a word no option is called would refuse nothing more, which
342
+ // is a mutant no test can tell from the code.
343
+ const given = new Set();
250
344
  const next = (flag, index) => {
251
345
  const value = args[index + 1];
252
346
  if (value === undefined)
253
347
  throw new UsageError(`${flag} needs a value`);
348
+ // The option after one whose value was forgotten. Taken as the value,
349
+ // `--ignore --strict` ignored a directory called `--strict` and the check
350
+ // was not strict, and `--record-baseline --verbose` wrote a file of that
351
+ // name. A name that does begin with two dashes has another spelling
352
+ // wherever one can be meant: `./--x` for a file, `[-]-x` in a pattern,
353
+ // `\--x` for a reference target.
354
+ if (value.startsWith('--') || value === '-h' || value === '-v') {
355
+ throw new UsageError(`${flag} needs a value, and ${value} reads as an option`);
356
+ }
357
+ return value;
358
+ };
359
+ // A blank value is what an unset variable leaves in `--root "$DIR"`. A path
360
+ // made of it names the directory beside it or a file called " ", and the run
361
+ // then answered for a place nobody named.
362
+ const filled = (flag, index, what) => {
363
+ const value = next(flag, index);
364
+ if (value.trim() === '')
365
+ throw new UsageError(`${flag} expects ${what}, got "${value}"`);
366
+ return value;
367
+ };
368
+ // A family is taken once per option. A blank, `ADR,RFC` or the option typed
369
+ // after it is a name no family has: alone on the allowlist it left every
370
+ // citation as prose, and the check passed over the ones that dangle.
371
+ const family = (flag, index) => {
372
+ const value = next(flag, index);
373
+ if (!isFamilyName(value))
374
+ throw new UsageError(`${flag} expects one family's name, such as ADR, got "${value}"`);
254
375
  return value;
255
376
  };
256
377
  for (let i = 0; i < args.length; i += 1) {
@@ -266,6 +387,7 @@ export function parseArgs(argv, cwd) {
266
387
  patterns.push(arg);
267
388
  continue;
268
389
  }
390
+ given.add(arg);
269
391
  switch (arg) {
270
392
  case '-h':
271
393
  case '--help':
@@ -300,7 +422,7 @@ export function parseArgs(argv, cwd) {
300
422
  verbose = true;
301
423
  break;
302
424
  case '--root':
303
- root = next(arg, i);
425
+ root = filled(arg, i, 'a directory');
304
426
  rootExplicit = true;
305
427
  i += 1;
306
428
  break;
@@ -313,11 +435,11 @@ export function parseArgs(argv, cwd) {
313
435
  i += 1;
314
436
  break;
315
437
  case '--family':
316
- families.push(next(arg, i));
438
+ families.push(family(arg, i));
317
439
  i += 1;
318
440
  break;
319
441
  case '--ignore-family':
320
- ignoreFamilies.push(next(arg, i));
442
+ ignoreFamilies.push(family(arg, i));
321
443
  i += 1;
322
444
  break;
323
445
  case '--history':
@@ -325,11 +447,11 @@ export function parseArgs(argv, cwd) {
325
447
  i += 1;
326
448
  break;
327
449
  case '--baseline':
328
- baseline = next(arg, i);
450
+ baseline = filled(arg, i, 'a file');
329
451
  i += 1;
330
452
  break;
331
453
  case '--record-baseline':
332
- recordBaseline = next(arg, i);
454
+ recordBaseline = filled(arg, i, 'a file');
333
455
  i += 1;
334
456
  break;
335
457
  case '--no-config':
@@ -404,9 +526,35 @@ export function parseArgs(argv, cwd) {
404
526
  if (format === 'markdown' && command !== 'check' && command !== 'diff' && !help && !version) {
405
527
  throw new UsageError(`--format markdown is a report for a pull request, so it belongs to check or diff, not to ${command}`);
406
528
  }
529
+ // JSON is refused where the other reports are, and for the reason above:
530
+ // `graph --format json` wrote DOT and `rules --format json` a list, each
531
+ // with exit 0, to whatever was waiting for JSON.
532
+ if (format === 'json' && (command === 'graph' || command === 'rules') && !help && !version) {
533
+ const instead = command === 'graph' ? 'graph writes JSON with --graph-format json' : 'rules lists the rules as text';
534
+ throw new UsageError(`--format json belongs to check, query or diff, not to ${command}: ${instead}`);
535
+ }
536
+ if (!help && !version) {
537
+ for (const option of given) {
538
+ const readers = readersOf(option);
539
+ if (readers !== undefined && !readers.includes(command)) {
540
+ throw new UsageError(`${option} belongs to ${listed(readers, 'or')}, not to ${command}`);
541
+ }
542
+ }
543
+ }
544
+ // The second word given to `rules` was dropped, so `rules a b` listed `a`
545
+ // and said nothing of `b`.
546
+ if (command === 'rules' && patterns.length > 1 && !help && !version) {
547
+ throw new UsageError(`rules takes one rule id, got ${patterns.length}: ${patterns.join(', ')}`);
548
+ }
407
549
  if (command === 'diff' && patterns.length !== 2 && !help && !version) {
408
550
  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)');
409
551
  }
552
+ // An empty name resolved to the directory the run was started in, and the
553
+ // refusal was the read's: `cannot read : EISDIR`.
554
+ const blank = patterns.findIndex((path) => path.trim() === '');
555
+ if (command === 'diff' && blank !== -1 && !help && !version) {
556
+ throw new UsageError(`diff expects a graph export on each side, got "${patterns[blank]}" for the ${blank === 0 ? 'first' : 'second'}`);
557
+ }
410
558
  if (command === 'query' && selector === null && !help && !version) {
411
559
  throw new UsageError('query needs a selector, for example:\n spec-graph query \'item[openness=open]\'');
412
560
  }
@@ -540,6 +688,20 @@ async function run(io, err) {
540
688
  // it can compare exports from a checkout it is not running in.
541
689
  if (options.command === 'diff')
542
690
  return runDiff(options.patterns, cwd, options.format, out, err);
691
+ // A root that was named is a directory, for every command that reads one.
692
+ // `rules --root` with a path that is not there listed the built-in rules and
693
+ // exited 0, as if the repository declared none, and the others said only
694
+ // that no specification matched under it.
695
+ if (options.rootExplicit) {
696
+ const { stat } = await import('node:fs/promises');
697
+ // A path that cannot be reached reads as one that is not there: either
698
+ // way there is nothing under it to read.
699
+ const found = await stat(options.root).catch(() => null);
700
+ if (found === null || !found.isDirectory()) {
701
+ err(`spec-graph: --root "${options.root}" is not ${found === null ? 'there' : 'a directory'}\n`);
702
+ return EXIT_ERROR;
703
+ }
704
+ }
543
705
  const color = options.color ?? shouldUseColor({ isTTY: io.isTTY, env });
544
706
  const ascii = options.ascii ?? shouldUseAscii({ env });
545
707
  // Configuration is what is true of the repository; a flag is somebody
@@ -625,16 +787,19 @@ async function run(io, err) {
625
787
  const patterns = typed.patterns.length > 0 ? typed.patterns : (file.patterns ?? DEFAULT_PATTERNS);
626
788
  const severityOverrides = { ...(file.severities ?? {}), ...options.severities };
627
789
  const { severities, escalated } = resolveStrict(severityOverrides, options.strict || (file.strict ?? false), projectRules);
790
+ // The list the check is held against, once it has read the documents.
791
+ const families = [...(file.families ?? []), ...options.families];
628
792
  const analyseOptions = {
629
793
  root,
630
794
  patterns,
631
- // A stray entry in one of the empty fallbacks below changes a run only if
632
- // it matches a path, a target or a family some corpus has, so the mutants
633
- // that plant one survive every corpus but one built for them - except in
634
- // `families`, where any entry is an allowlist.
795
+ // A stray entry in one of the empty fallbacks here changes a run only if
796
+ // it matches a path or a target some corpus has, so the mutants that plant
797
+ // one survive every corpus but one built for them - except in the two
798
+ // lists of families, where what they plant is no family's name and is
799
+ // refused.
635
800
  ignore: [...(file.ignore ?? []), ...typed.ignore],
636
801
  ignoreReferences: [...(file.ignoreReferences ?? []), ...options.ignoreReferences],
637
- families: [...(file.families ?? []), ...options.families],
802
+ families,
638
803
  ignoreFamilies: [...(file.ignoreFamilies ?? []), ...options.ignoreFamilies],
639
804
  historyPatterns: [...(file.historyPatterns ?? []), ...typed.history],
640
805
  severities,
@@ -701,6 +866,30 @@ async function run(io, err) {
701
866
  return matches.length > 0 ? EXIT_OK : EXIT_FAILED;
702
867
  }
703
868
  default: {
869
+ // An allowlist is held against the corpus it was written for. One that
870
+ // names no family a document here belongs to allows nothing the corpus
871
+ // has: every citation is then prose, and the run that failed on a
872
+ // dangling one reported a consistent graph - for `--family ADRS` as it
873
+ // did for `--family ""`. Nothing was measured, which is not clean
874
+ // (spec-core's ADR-0005), so the run stops before it says anything. A
875
+ // list that names such a family beside one the corpus has is a family
876
+ // the repository means to start, or a typo in half of it: said, and
877
+ // nothing fails.
878
+ const allowed = [...new Set(families.map((family) => family.trim().toUpperCase()))];
879
+ const absent = allowed.filter((family) => !result.corpus.families.includes(family));
880
+ if (absent.length > 0) {
881
+ const lists = [];
882
+ if (options.families.length > 0)
883
+ lists.push('--family');
884
+ if (file.families !== undefined && file.families.length > 0)
885
+ lists.push(`"families" in ${loaded.source}`);
886
+ const here = `the documents here belong to ${result.corpus.families.length > 0 ? listed(result.corpus.families, 'and') : 'no family'}`;
887
+ if (absent.length === allowed.length) {
888
+ err(`spec-graph: no document here belongs to a family named by ${lists.join(' and ')} (${allowed.join(', ')}); ${here}\n name a family the documents belong to, or leave the list out\n`);
889
+ return EXIT_ERROR;
890
+ }
891
+ err(`spec-graph: no document here belongs to ${listed(absent, 'or')}, named by ${lists.join(' and ')}; ${here}\n`);
892
+ }
704
893
  // Recording is not checking. It writes down what is wrong today so that
705
894
  // tomorrow can be compared against it, and says nothing about whether
706
895
  // today is acceptable - so it reports what it wrote and exits clean.