@descent-vtt/spec-guard 0.5.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.
Files changed (54) hide show
  1. package/CHANGELOG.md +162 -0
  2. package/README.md +201 -14
  3. package/dist/cli.d.ts +2 -1
  4. package/dist/cli.d.ts.map +1 -1
  5. package/dist/cli.js +10 -0
  6. package/dist/cli.js.map +1 -1
  7. package/dist/comments.d.ts.map +1 -1
  8. package/dist/comments.js +41 -19
  9. package/dist/comments.js.map +1 -1
  10. package/dist/engine.d.ts +117 -6
  11. package/dist/engine.d.ts.map +1 -1
  12. package/dist/engine.js +279 -150
  13. package/dist/engine.js.map +1 -1
  14. package/dist/glob.d.ts +11 -1
  15. package/dist/glob.d.ts.map +1 -1
  16. package/dist/glob.js +20 -5
  17. package/dist/glob.js.map +1 -1
  18. package/dist/graph.d.ts +124 -0
  19. package/dist/graph.d.ts.map +1 -0
  20. package/dist/graph.js +241 -0
  21. package/dist/graph.js.map +1 -0
  22. package/dist/imports.d.ts.map +1 -1
  23. package/dist/imports.js +10 -4
  24. package/dist/imports.js.map +1 -1
  25. package/dist/index.d.ts +6 -2
  26. package/dist/index.d.ts.map +1 -1
  27. package/dist/index.js +3 -1
  28. package/dist/index.js.map +1 -1
  29. package/dist/layers.d.ts +72 -0
  30. package/dist/layers.d.ts.map +1 -0
  31. package/dist/layers.js +102 -0
  32. package/dist/layers.js.map +1 -0
  33. package/dist/parser.d.ts +36 -1
  34. package/dist/parser.d.ts.map +1 -1
  35. package/dist/parser.js +189 -4
  36. package/dist/parser.js.map +1 -1
  37. package/dist/polyglot.d.ts +11 -1
  38. package/dist/polyglot.d.ts.map +1 -1
  39. package/dist/polyglot.js +21 -17
  40. package/dist/polyglot.js.map +1 -1
  41. package/dist/reporter.d.ts.map +1 -1
  42. package/dist/reporter.js +62 -5
  43. package/dist/reporter.js.map +1 -1
  44. package/dist/runner.d.ts +25 -0
  45. package/dist/runner.d.ts.map +1 -1
  46. package/dist/runner.js +318 -93
  47. package/dist/runner.js.map +1 -1
  48. package/dist/text.d.ts +9 -1
  49. package/dist/text.d.ts.map +1 -1
  50. package/dist/text.js +29 -18
  51. package/dist/text.js.map +1 -1
  52. package/dist/types.d.ts +60 -2
  53. package/dist/types.d.ts.map +1 -1
  54. package/package.json +1 -1
package/dist/runner.js CHANGED
@@ -7,13 +7,26 @@
7
7
  */
8
8
  import { promises as fs } from "node:fs";
9
9
  import path from "node:path";
10
- import { createCachedEngine, enumerateCandidates, resolveEngine, runSearches, ANY_FILE_PROBE, } from "./engine.js";
10
+ import { comparePaths, createCachedEngine, enumerateCandidates, passKey, resolveEngine, runSearches, ANY_FILE_PROBE, ROOT_TARGETS, } from "./engine.js";
11
11
  import { createExcludeMatcher, expandSpecPatterns, toPosix } from "./glob.js";
12
+ import { buildGraph, cyclicComponents, edgeKey, isGraphFile, witness, } from "./graph.js";
12
13
  import { ANALYSABLE_EXTENSIONS, createImportIndex, resolveModule, } from "./imports.js";
14
+ import { checkLayers } from "./layers.js";
13
15
  import { parseDirectives } from "./parser.js";
14
- import { createScope, DEFAULT_SCOPE, EMPTY_LEDGER, UNCERTAIN_REASONS, } from "./scope.js";
16
+ import { createScope, DEFAULT_SCOPE, EMPTY_LEDGER, } from "./scope.js";
15
17
  export const DEFAULT_MAX_SNIPPETS = 5;
16
18
  export const DEFAULT_CONCURRENCY = 8;
19
+ /**
20
+ * Milliseconds between two `performance.now()` marks.
21
+ *
22
+ * Seven results carry a duration and each used to subtract inline, which is
23
+ * seven chances to write the operands the wrong way round and report a negative
24
+ * time - and seven copies of one subtraction that nothing could pin down,
25
+ * because "a plausible number of milliseconds" is not an assertion.
26
+ */
27
+ export function elapsed(from, to = performance.now()) {
28
+ return to - from;
29
+ }
17
30
  const TRUE_VALUES = new Set(["true", "1", "yes", "on"]);
18
31
  const FALSE_VALUES = new Set(["false", "0", "no", "off"]);
19
32
  function parseBoolean(value, attribute) {
@@ -44,13 +57,19 @@ function parseCount(value, attribute) {
44
57
  function splitList(value) {
45
58
  if (value === undefined)
46
59
  return [];
47
- return value
48
- .split(/[,\s]+/)
49
- .map((item) => item.trim())
50
- .filter((item) => item.length > 0);
60
+ // Taking the items rather than splitting on the separators. Split needed a
61
+ // `+` on the separator class, a trim and a length filter, and any two of the
62
+ // three made the third unnecessary - so none of them could be tested. What is
63
+ // wanted is "the runs of non-separator characters", and that is one pattern.
64
+ return value.match(/[^,\s]+/g) ?? [];
51
65
  }
52
66
  /** Rejects absolute paths and any `..` escape out of the root. */
53
67
  function normalizeTarget(target, root, attribute) {
68
+ // The second test is not redundant with the first: `path.isAbsolute` follows
69
+ // the host platform, so a Windows drive path is absolute on Windows and an
70
+ // ordinary relative name on Linux. A spec naming `C:/repo/src` has to be
71
+ // refused on both, or the same document means two different things. It is
72
+ // anchored, because a colon partway along a path is just a character.
54
73
  if (path.isAbsolute(target) || /^[a-zA-Z]:[\\/]/.test(target)) {
55
74
  throw new Error(`Attribute "${attribute}" must be relative to --root, got "${target}".`);
56
75
  }
@@ -61,22 +80,22 @@ function normalizeTarget(target, root, attribute) {
61
80
  }
62
81
  return toPosix(relative) || ".";
63
82
  }
64
- function plural(count) {
65
- return count === 1 ? "" : "es";
66
- }
67
- function describeBounds(bounds) {
83
+ const MATCHES = { one: "match", many: "matches" };
84
+ const CYCLES = { one: "import cycle", many: "import cycles" };
85
+ const VIOLATING_FILES = { one: "violating file", many: "violating files" };
86
+ function describeBounds(bounds, unit = MATCHES) {
68
87
  const { min, max } = bounds;
88
+ const counted = (count) => `${count} ${count === 1 ? unit.one : unit.many}`;
69
89
  if (min !== undefined && max !== undefined) {
70
90
  return min === max
71
- ? `exactly ${min} match${plural(min)}`
72
- : `between ${min} and ${max} matches`;
91
+ ? `exactly ${counted(min)}`
92
+ : `between ${min} and ${max} ${unit.many}`;
73
93
  }
74
94
  if (min !== undefined)
75
- return `at least ${min} match${plural(min)}`;
95
+ return `at least ${counted(min)}`;
76
96
  if (max !== undefined)
77
- return max === 0 ? "no matches" : `at most ${max} match${plural(max)}`;
78
- /* c8 ignore next */
79
- return "any number of matches";
97
+ return max === 0 ? `no ${unit.many}` : `at most ${counted(max)}`;
98
+ return `any number of ${unit.many}`;
80
99
  }
81
100
  /** Prose form used in the assertion description ("must appear at most 3 times"). */
82
101
  function describeExpectation(bounds) {
@@ -91,10 +110,10 @@ function describeExpectation(bounds) {
91
110
  return `must appear at least ${times(min)}`;
92
111
  if (max === 0)
93
112
  return "must not appear";
94
- /* c8 ignore next */
95
- return max === undefined
96
- ? "may appear any number of times"
97
- : `must appear at most ${times(max)}`;
113
+ // No branch for "neither bound": this is only ever called from
114
+ // `resolveDirective`, which refuses a directive that names none. Carrying a
115
+ // fourth case here meant carrying a string no run could print.
116
+ return `must appear at most ${times(max ?? 0)}`;
98
117
  }
99
118
  /** Prose for an import claim: "must not import", "must import at least 2 files". */
100
119
  function describeImportExpectation(bounds) {
@@ -109,10 +128,8 @@ function describeImportExpectation(bounds) {
109
128
  }
110
129
  if (min !== undefined)
111
130
  return `must import from at least ${files(min)}`;
112
- /* c8 ignore next */
113
- return max === undefined
114
- ? "may import"
115
- : `must import from at most ${files(max)}`;
131
+ // As above: `resolveDirective` never produces an unbounded import claim.
132
+ return `must import from at most ${files(max ?? 0)}`;
116
133
  }
117
134
  /**
118
135
  * What a report says when an assertion covered nothing.
@@ -136,8 +153,8 @@ const EMPTY_SCOPE_HINT = 'add allow-empty="true" if that is expected';
136
153
  * baseline, an unreadable file and a stale entry says all three things instead
137
154
  * of whichever one the branch order happened to reach first.
138
155
  */
139
- function describeOutcome(bounds, actual, extras) {
140
- const parts = [`expected ${describeBounds(bounds)}, found ${actual}`];
156
+ function describeOutcome(bounds, actual, extras, unit = MATCHES) {
157
+ const parts = [`expected ${describeBounds(bounds, unit)}, found ${actual}`];
141
158
  if (extras.excluded > 0) {
142
159
  parts.push(`${extras.excluded} more ${extras.excluded === 1 ? "is" : "are"} on the baseline`);
143
160
  }
@@ -244,12 +261,16 @@ export function createScopeProbe() {
244
261
  return pending;
245
262
  };
246
263
  }
247
- function satisfies(count, bounds) {
248
- if (bounds.min !== undefined && count < bounds.min)
249
- return false;
250
- if (bounds.max !== undefined && count > bounds.max)
251
- return false;
252
- return true;
264
+ /**
265
+ * Whether a count is inside the bounds. An absent bound is no bound.
266
+ *
267
+ * Written as defaults rather than as two `!== undefined` guards. Those guards
268
+ * could not change an answer - `count < undefined` is false for every count, so
269
+ * the comparison already admitted everything - which meant two conditions in
270
+ * the middle of the pass/fail decision that no test could hold in place.
271
+ */
272
+ function satisfies(count, { min = 0, max = Number.POSITIVE_INFINITY }) {
273
+ return count >= min && count <= max;
253
274
  }
254
275
  /** Turns one directive into an executable assertion, or an error. */
255
276
  export function resolveDirective(directive, context) {
@@ -281,10 +302,14 @@ export function resolveDirective(directive, context) {
281
302
  },
282
303
  };
283
304
  }
284
- const isImportKind = kind === "assert-import-absence" || kind === "assert-import-count";
285
- const subject = isImportKind ? "module" : "symbol";
286
- const symbol = attributes[subject];
287
- if (symbol === undefined || symbol.length === 0) {
305
+ const perModule = kind === "assert-import-absence" || kind === "assert-import-count";
306
+ // The two directives of ADR-0011. A cycle is a claim about the graph
307
+ // itself and names no subject; layers name theirs in `order`.
308
+ const graphKind = kind === "assert-import-cycle" || kind === "assert-layers";
309
+ const isImportKind = perModule || graphKind;
310
+ const subject = kind === "assert-layers" ? "order" : perModule ? "module" : "symbol";
311
+ const symbol = attributes[subject] ?? "";
312
+ if (kind !== "assert-import-cycle" && symbol.length === 0) {
288
313
  return fail(`@${kind} requires a non-empty ${subject}="..." attribute.`);
289
314
  }
290
315
  const allowEmpty = parseBoolean(attributes["allow-empty"], "allow-empty");
@@ -305,9 +330,9 @@ export function resolveDirective(directive, context) {
305
330
  return fail(`Attribute "comments" must be ignore or include, got "${attributes["comments"]}".`);
306
331
  }
307
332
  const rawTargets = splitList(attributes["target"]);
308
- const targets = (rawTargets.length > 0 ? rawTargets : ["."]).map((target) => normalizeTarget(target, context.root, "target"));
333
+ const targets = (rawTargets.length > 0 ? rawTargets : ROOT_TARGETS).map((target) => normalizeTarget(target, context.root, "target"));
309
334
  const bounds = {};
310
- if (kind === "assert-absence" || kind === "assert-import-absence") {
335
+ if (kind === "assert-absence" || kind === "assert-import-absence" || graphKind) {
311
336
  if (attributes["expected"] !== undefined &&
312
337
  attributes["max"] !== undefined) {
313
338
  return fail(`@${kind} accepts either expected="..." or max="...", not both.`);
@@ -338,9 +363,10 @@ export function resolveDirective(directive, context) {
338
363
  bounds.min = parseCount(attributes["min"], "min");
339
364
  if (attributes["max"] !== undefined)
340
365
  bounds.max = parseCount(attributes["max"], "max");
341
- if (bounds.min !== undefined &&
342
- bounds.max !== undefined &&
343
- bounds.min > bounds.max) {
366
+ // Defaults rather than presence checks, for the same reason as
367
+ // `satisfies`: `min > undefined` is false whatever min is, so the two
368
+ // `!== undefined` guards this used to carry could not decide anything.
369
+ if ((bounds.min ?? 0) > (bounds.max ?? Number.POSITIVE_INFINITY)) {
344
370
  return fail(`min="${bounds.min}" is greater than max="${bounds.max}".`);
345
371
  }
346
372
  }
@@ -355,13 +381,30 @@ export function resolveDirective(directive, context) {
355
381
  if (!["include", "ignore"].includes((attributes["types"] ?? "include").trim().toLowerCase())) {
356
382
  return fail(`Attribute "types" must be include or ignore, got "${attributes["types"]}".`);
357
383
  }
384
+ const layers = splitList(symbol);
385
+ if (kind === "assert-layers") {
386
+ if (layers.length < 2) {
387
+ return fail(`@assert-layers needs at least two layers in order="...", got ${layers.length}.`);
388
+ }
389
+ const repeated = layers.find((layer, at) => layers.indexOf(layer) !== at);
390
+ if (repeated !== undefined) {
391
+ return fail(`Layer "${repeated}" is listed twice in order="...".`);
392
+ }
393
+ }
394
+ const typesNote = includeTypes ? "" : " (type-only imports ignored)";
395
+ const description = kind === "assert-import-cycle"
396
+ ? `${scope} must have ${describeBounds(bounds, CYCLES)}${typesNote}${except}`
397
+ : kind === "assert-layers"
398
+ ? `${scope} must keep its layers in order, ${layers.join(" < ")}${bounds.max === 0 ? "" : `, with ${describeBounds(bounds, VIOLATING_FILES)}`}${typesNote}${except}`
399
+ : `${scope} ${describeImportExpectation(bounds)} "${symbol}"${except}`;
358
400
  return {
359
401
  assertion: {
360
402
  kind,
361
403
  location,
362
- description: `${scope} ${describeImportExpectation(bounds)} "${symbol}"${except}`,
404
+ description,
363
405
  reason,
364
- symbol,
406
+ // Only the per-module kinds have a subject a report can quote.
407
+ symbol: perModule ? symbol : undefined,
365
408
  targets,
366
409
  files: [],
367
410
  bounds,
@@ -378,7 +421,8 @@ export function resolveDirective(directive, context) {
378
421
  scope: context.scope ?? DEFAULT_SCOPE,
379
422
  excludeFiles: context.excludeFiles,
380
423
  },
381
- imports: { modules: splitList(symbol), includeTypes },
424
+ imports: { modules: perModule ? splitList(symbol) : [], includeTypes },
425
+ ...(kind === "assert-layers" ? { layers } : {}),
382
426
  missingTargets: [],
383
427
  allowEmpty,
384
428
  baseline,
@@ -401,7 +445,10 @@ export function resolveDirective(directive, context) {
401
445
  new RegExp(symbol);
402
446
  }
403
447
  catch (error) {
404
- return fail(`Invalid regular expression: ${error instanceof Error ? error.message : String(error)}`);
448
+ // No prefix of our own. V8's message already begins "Invalid regular
449
+ // expression: /(/: ...", so adding one produced the phrase twice in a
450
+ // row and pushed the pattern itself off the useful part of the line.
451
+ return fail(error instanceof Error ? error.message : String(error));
405
452
  }
406
453
  }
407
454
  const scope = targets.join(", ");
@@ -472,7 +519,7 @@ async function prepareAssertion(assertion, options) {
472
519
  ? `all ${assertion.files.length} referenced ${assertion.files.length === 1 ? "path exists" : "paths exist"}`
473
520
  : `missing: ${missing.join(", ")}`,
474
521
  matches: [],
475
- durationMs: performance.now() - startedAt,
522
+ durationMs: elapsed(startedAt),
476
523
  };
477
524
  }
478
525
  if (assertion.imports) {
@@ -495,7 +542,7 @@ async function prepareAssertion(assertion, options) {
495
542
  actual: 0,
496
543
  message: `target path${assertion.missingTargets.length === 1 ? " does" : "s do"} not exist: ${assertion.missingTargets.join(", ")}`,
497
544
  matches: [],
498
- durationMs: performance.now() - startedAt,
545
+ durationMs: elapsed(startedAt),
499
546
  };
500
547
  }
501
548
  const request = {
@@ -512,7 +559,7 @@ async function prepareAssertion(assertion, options) {
512
559
  actual: 0,
513
560
  message: `no files were inspected, so this assertion verified nothing (${EMPTY_SCOPE_HINT})`,
514
561
  matches: [],
515
- durationMs: performance.now() - startedAt,
562
+ durationMs: elapsed(startedAt),
516
563
  };
517
564
  }
518
565
  }
@@ -524,7 +571,13 @@ async function prepareAssertion(assertion, options) {
524
571
  // The count is still reported, because it is still true of everything
525
572
  // that was read; --strict is for runs where "true of what we read" is not
526
573
  // good enough.
527
- const gaps = search.scope.skipped.filter((entry) => UNCERTAIN_REASONS.has(entry.reason));
574
+ //
575
+ // No filter for uncertainty here any more. The ledger only ever holds
576
+ // gaps: the walk drops a policy skip before recording it, and the only
577
+ // other reasons the scanner adds are "unreadable" and "binary". Filtering
578
+ // again was a second statement of an invariant that is enforced where it
579
+ // is created, and being a no-op, nothing could tell it from its absence.
580
+ const gaps = search.scope.skipped;
528
581
  const strictFailure = options.strictTargets && gaps.length > 0;
529
582
  const { excluded, stale, shows } = applyBaseline(assertion.baseline, search.fileCounts);
530
583
  const staleFailure = assertion.ratchet === "two-sided" && stale.length > 0;
@@ -548,7 +601,7 @@ async function prepareAssertion(assertion, options) {
548
601
  unclassifiedFiles: search.unclassifiedFiles,
549
602
  scope: search.scope,
550
603
  engine: search.engine,
551
- durationMs: performance.now() - startedAt,
604
+ durationMs: elapsed(startedAt),
552
605
  };
553
606
  },
554
607
  };
@@ -581,13 +634,21 @@ async function executeImportAssertion(assertion, options, base, warnings, starte
581
634
  if (assertion.missingTargets.length > 0) {
582
635
  warnings.push(`target path${assertion.missingTargets.length === 1 ? "" : "s"} not found: ${assertion.missingTargets.join(", ")}`);
583
636
  }
637
+ // No symbol: an import assertion never runs a text search, and the walk does
638
+ // not depend on one. This used to pass `symbol: ""`, an invented value that
639
+ // reached nothing and that no test could therefore be wrong about.
584
640
  const enumeration = await enumerateCandidates({
585
641
  root: options.root,
586
- symbol: "",
587
642
  targets: existingTargets,
588
643
  options: assertion.search,
589
644
  });
590
- const analysable = enumeration.files.filter((file) => ANALYSABLE_EXTENSIONS.has(path.posix.extname(file.relativePath)));
645
+ // A cycle needs files the resolver can name, which today means JavaScript
646
+ // and TypeScript; every other import rule reads any language spec-guard
647
+ // tokenizes. ADR-0011 has the reasons, language by language.
648
+ const cycles = assertion.kind === "assert-import-cycle";
649
+ const analysable = enumeration.files.filter((file) => cycles
650
+ ? isGraphFile(file.relativePath)
651
+ : ANALYSABLE_EXTENSIONS.has(path.posix.extname(file.relativePath)));
591
652
  if (analysable.length === 0 && !options.allowEmptyScope && !assertion.allowEmpty) {
592
653
  // For an import rule the bar is higher than "some file exists": a directory
593
654
  // of YAML has nothing this can read, so a dependency claim about it is a
@@ -598,35 +659,60 @@ async function executeImportAssertion(assertion, options, base, warnings, starte
598
659
  actual: 0,
599
660
  message: enumeration.files.length === 0
600
661
  ? `no files were inspected, so this assertion verified nothing (${EMPTY_SCOPE_HINT})`
601
- : `none of the ${enumeration.files.length} files here are in a language whose imports spec-guard can read, so this assertion verified nothing (${EMPTY_SCOPE_HINT})`,
662
+ : cycles
663
+ ? `none of the ${enumeration.files.length} files here are JavaScript or TypeScript, so there is no import graph to check and this assertion verified nothing (${EMPTY_SCOPE_HINT})`
664
+ : `none of the ${enumeration.files.length} files here are in a language whose imports spec-guard can read, so this assertion verified nothing (${EMPTY_SCOPE_HINT})`,
602
665
  matches: [],
603
- durationMs: performance.now() - startedAt,
666
+ durationMs: elapsed(startedAt),
604
667
  };
605
668
  }
606
669
  const skipped = enumeration.files.length - analysable.length;
607
670
  if (skipped > 0) {
608
- warnings.push(`analysed ${analysable.length} of ${enumeration.files.length} files; ${skipped} ${skipped === 1 ? "is" : "are"} in a language whose imports spec-guard cannot read`);
671
+ warnings.push(cycles
672
+ ? `placed ${analysable.length} of ${enumeration.files.length} files in the import graph; ${skipped} ${skipped === 1 ? "is" : "are"} not JavaScript or TypeScript`
673
+ : `analysed ${analysable.length} of ${enumeration.files.length} files; ${skipped} ${skipped === 1 ? "is" : "are"} in a language whose imports spec-guard cannot read`);
609
674
  }
610
- const matchesModule = createExcludeMatcher(query.modules);
611
- const matches = [];
675
+ const analysed = [];
612
676
  const unresolved = [];
613
677
  for (const file of analysable) {
614
678
  const analysis = await options.imports.analyze(file.absolutePath, file.relativePath);
615
679
  for (const note of analysis.notes) {
616
680
  unresolved.push(`${note.file}:${note.line} ${note.detail}`);
617
681
  }
618
- const hit = analysis.references.find((reference) => {
682
+ analysed.push({ file: file.relativePath, references: analysis.references });
683
+ }
684
+ if (unresolved.length > 0) {
685
+ warnings.push(`${unresolved.length} module reference${unresolved.length === 1 ? "" : "s"} could not be resolved statically`, ...unresolved.slice(0, options.maxSnippets).map((entry) => ` ${entry}`));
686
+ }
687
+ const read = {
688
+ analysed,
689
+ walked: enumeration.files.map((file) => file.relativePath),
690
+ targets: existingTargets,
691
+ unresolved: unresolved.length,
692
+ missing: !options.allowMissingTargets && assertion.missingTargets.length > 0
693
+ ? `target path${assertion.missingTargets.length === 1 ? " does" : "s do"} not exist: ${assertion.missingTargets.join(", ")}`
694
+ : null,
695
+ };
696
+ if (cycles)
697
+ return finishCycles(assertion, options, base, warnings, startedAt, read);
698
+ if (assertion.layers) {
699
+ return finishLayers(assertion, assertion.layers, options, base, warnings, startedAt, read);
700
+ }
701
+ const matchesModule = createExcludeMatcher(query.modules);
702
+ const matches = [];
703
+ for (const { file, references } of analysed) {
704
+ const hit = references.find((reference) => {
619
705
  if (reference.typeOnly && !query.includeTypes)
620
706
  return false;
621
707
  // Both forms are tried: the resolved one so `module="app/db/**"` works
622
708
  // everywhere, and the raw one so a Python or C# author can write the
623
709
  // dotted path they see in their own source and still be understood.
624
- return (matchesModule(resolveModule(reference.specifier, file.relativePath)) ||
710
+ return (matchesModule(resolveModule(reference.specifier, file)) ||
625
711
  matchesModule(reference.specifier));
626
712
  });
627
713
  if (hit) {
628
714
  matches.push({
629
- file: file.relativePath,
715
+ file,
630
716
  line: hit.line,
631
717
  column: hit.column,
632
718
  text: `${IMPORT_VERBS[hit.kind]} ${hit.specifier}`,
@@ -634,36 +720,149 @@ async function executeImportAssertion(assertion, options, base, warnings, starte
634
720
  });
635
721
  }
636
722
  }
637
- if (unresolved.length > 0) {
638
- warnings.push(`${unresolved.length} module reference${unresolved.length === 1 ? "" : "s"} could not be resolved statically`, ...unresolved.slice(0, options.maxSnippets).map((entry) => ` ${entry}`));
639
- }
640
- const missingFailure = !options.allowMissingTargets && assertion.missingTargets.length > 0;
641
723
  // One reference per file, so the file counts are the matches themselves.
642
724
  const fileCounts = new Map(matches.map((match) => [match.file, 1]));
643
725
  const { excluded, stale, shows } = applyBaseline(assertion.baseline, fileCounts);
644
726
  const staleFailure = assertion.ratchet === "two-sided" && stale.length > 0;
645
727
  const actual = matches.length - excluded;
646
- const strictFailure = missingFailure || (options.strictTargets && unresolved.length > 0);
728
+ const strictFailure = read.missing !== null || (options.strictTargets && unresolved.length > 0);
647
729
  const ok = satisfies(actual, assertion.bounds) && !strictFailure && !staleFailure;
648
730
  return {
649
731
  ...base,
650
732
  ok,
651
733
  actual,
652
- message: missingFailure
653
- ? `target path${assertion.missingTargets.length === 1 ? " does" : "s do"} not exist: ${assertion.missingTargets.join(", ")}`
654
- : describeOutcome(assertion.bounds, actual, {
734
+ message: read.missing ??
735
+ describeOutcome(assertion.bounds, actual, {
655
736
  excluded,
656
737
  stale: staleFailure ? stale : [],
657
738
  gaps: 0,
658
- }) +
659
- (options.strictTargets && unresolved.length > 0
660
- ? `; ${unresolved.length} reference${unresolved.length === 1 ? "" : "s"} could not be resolved`
661
- : ""),
739
+ }) + strictSuffix(options, unresolved.length),
662
740
  baselinedMatches: excluded,
663
741
  staleBaseline: stale,
664
742
  fileMatches: [...fileCounts].map(([file, count]) => ({ file, count })),
665
743
  matches: matches.filter((match) => shows(match.file)).slice(0, options.maxSnippets),
666
- durationMs: performance.now() - startedAt,
744
+ durationMs: elapsed(startedAt),
745
+ };
746
+ }
747
+ /** What `--strict` adds to a message when references went unresolved. */
748
+ function strictSuffix(options, unresolved) {
749
+ return options.strictTargets && unresolved > 0
750
+ ? `; ${unresolved} reference${unresolved === 1 ? "" : "s"} could not be resolved`
751
+ : "";
752
+ }
753
+ /**
754
+ * Counts the import cycles in scope - components, not simple cycles.
755
+ *
756
+ * Each is shown as one concrete loop with the line of every import on it, at
757
+ * the location of the first. The count does not move when someone adds a
758
+ * second route around a loop that already exists, which is what makes it a
759
+ * number a spec can hold. See ADR-0011.
760
+ */
761
+ function finishCycles(assertion, options, base, warnings, startedAt, read) {
762
+ const query = assertion.imports;
763
+ const scope = {
764
+ nodes: new Set(read.analysed.map((entry) => entry.file)),
765
+ walked: new Set(read.walked),
766
+ excluded: createExcludeMatcher(assertion.search.excludeGlobs),
767
+ covers: (relativePath) => relativePath !== ".." &&
768
+ !relativePath.startsWith("../") &&
769
+ read.targets.some((target) => target === "." || relativePath === target || relativePath.startsWith(`${target}/`)),
770
+ };
771
+ const graph = buildGraph(read.analysed, scope, query.includeTypes);
772
+ const loops = cyclicComponents(graph).map((component) => witness(component, graph.successors));
773
+ if (graph.unresolved.length > 0) {
774
+ const count = graph.unresolved.length;
775
+ warnings.push(`${count} import${count === 1 ? "" : "s"} could not be resolved to a file, so ${count === 1 ? "its edge is" : "their edges are"} missing from the graph`, ...graph.unresolved
776
+ .slice(0, options.maxSnippets)
777
+ .map(({ file, reference }) => ` ${file}:${reference.line} ${reference.specifier}`));
778
+ }
779
+ const gaps = read.unresolved + graph.unresolved.length;
780
+ const actual = loops.length;
781
+ const ok = satisfies(actual, assertion.bounds) &&
782
+ read.missing === null &&
783
+ !(options.strictTargets && gaps > 0);
784
+ const matches = loops.map((loop) => {
785
+ const hop = (at) => graph.via.get(edgeKey(loop[at], loop[at + 1]));
786
+ return {
787
+ file: loop[0],
788
+ line: hop(0).line,
789
+ column: hop(0).column,
790
+ // Every file on the loop with the line that leaves it, so the imports to
791
+ // change can be read off without opening each file to find them.
792
+ text: loop.map((file, at) => (at < loop.length - 1 ? `${file}:${hop(at).line}` : file)).join(" -> "),
793
+ count: 1,
794
+ };
795
+ });
796
+ return {
797
+ ...base,
798
+ ok,
799
+ actual,
800
+ message: read.missing ??
801
+ describeOutcome(assertion.bounds, actual, { excluded: 0, stale: [], gaps: 0 }, CYCLES) +
802
+ strictSuffix(options, gaps),
803
+ matches: matches.slice(0, options.maxSnippets),
804
+ durationMs: elapsed(startedAt),
805
+ };
806
+ }
807
+ /**
808
+ * Checks each file's imports against the layer order.
809
+ *
810
+ * Three things fail besides a violation, because each is a rule that would
811
+ * otherwise pass while checking less than it says: a layer that matches no
812
+ * file, a file two layers claim, and a missing target. See ADR-0011.
813
+ */
814
+ function finishLayers(assertion, order, options, base, warnings, startedAt, read) {
815
+ const query = assertion.imports;
816
+ const report = checkLayers(read.analysed, order, query.includeTypes);
817
+ const quoted = (layers) => layers.map((layer) => `"${order[layer]}"`).join(" and ");
818
+ const empty = order.flatMap((_, layer) => (report.members[layer] === 0 ? [layer] : []));
819
+ const emptyAllowed = options.allowEmptyScope || assertion.allowEmpty;
820
+ const one = empty.length === 1;
821
+ const unmatched = `${one ? "layer" : "layers"} ${quoted(empty)} ${one ? "matches" : "match"} no file in scope`;
822
+ if (report.unassigned.length > 0) {
823
+ warnings.push(`${report.unassigned.length} of ${read.analysed.length} files ${report.unassigned.length === 1 ? "belongs" : "belong"} to no layer, so nothing here constrains ${report.unassigned.length === 1 ? "it" : "them"}`);
824
+ }
825
+ if (empty.length > 0 && emptyAllowed)
826
+ warnings.push(unmatched);
827
+ const fileCounts = new Map(report.violations.map((violation) => [violation.file, 1]));
828
+ const { excluded, stale, shows } = applyBaseline(assertion.baseline, fileCounts);
829
+ const staleFailure = assertion.ratchet === "two-sided" && stale.length > 0;
830
+ const actual = report.violations.length - excluded;
831
+ const emptyFailure = empty.length > 0 && !emptyAllowed;
832
+ const ok = satisfies(actual, assertion.bounds) &&
833
+ read.missing === null &&
834
+ !staleFailure &&
835
+ !emptyFailure &&
836
+ report.ambiguous.length === 0 &&
837
+ !(options.strictTargets && read.unresolved > 0);
838
+ const message = read.missing ??
839
+ (emptyFailure
840
+ ? `${unmatched}, so nothing is held to ${one ? "it" : "them"} (${EMPTY_SCOPE_HINT})`
841
+ : report.ambiguous.length > 0
842
+ ? `${report.ambiguous
843
+ .slice(0, options.maxSnippets)
844
+ .map(({ file, layers }) => `${file} is in both ${quoted(layers)}`)
845
+ .join("; ")}; a file in two layers has no single rule to follow`
846
+ : describeOutcome(assertion.bounds, actual, { excluded, stale: staleFailure ? stale : [], gaps: 0 }, VIOLATING_FILES) + strictSuffix(options, read.unresolved));
847
+ return {
848
+ ...base,
849
+ ok,
850
+ actual,
851
+ message,
852
+ baselinedMatches: excluded,
853
+ staleBaseline: stale,
854
+ fileMatches: [...fileCounts].map(([file, count]) => ({ file, count })),
855
+ matches: report.violations
856
+ .filter((violation) => shows(violation.file))
857
+ .slice(0, options.maxSnippets)
858
+ .map((violation) => ({
859
+ file: violation.file,
860
+ line: violation.reference.line,
861
+ column: violation.reference.column,
862
+ text: `${order[violation.from]} -> ${order[violation.to]}: ${IMPORT_VERBS[violation.reference.kind]} ${violation.reference.specifier}`,
863
+ count: 1,
864
+ })),
865
+ durationMs: elapsed(startedAt),
667
866
  };
668
867
  }
669
868
  /** Executes a single resolved assertion. */
@@ -674,19 +873,14 @@ export async function executeAssertion(assertion, options) {
674
873
  return prepared.finish(await options.engine.search(prepared.request));
675
874
  }
676
875
  /**
677
- * Groups searches that can share a single pass over the tree: same targets,
678
- * same flags. This is where most of spec-guard's speed comes from - a spec with
679
- * twenty assertions over `src/` costs one ripgrep pass, not twenty.
876
+ * How many workers to run over the batches.
877
+ *
878
+ * At least one, so an empty run still terminates; never more than there are
879
+ * batches, so a spec with three groups does not start eight workers to find
880
+ * five of them nothing to do.
680
881
  */
681
- function groupKey(request) {
682
- const { options } = request;
683
- return JSON.stringify([
684
- request.targets,
685
- options.regex,
686
- options.word,
687
- options.ignoreCase,
688
- options.globs,
689
- ]);
882
+ export function batchConcurrency(requested, batches) {
883
+ return Math.max(1, Math.min(requested, batches));
690
884
  }
691
885
  /** Reads, parses and executes every directive found in the given spec files. */
692
886
  export async function runSpecGuard(options) {
@@ -700,7 +894,10 @@ export async function runSpecGuard(options) {
700
894
  const specFiles = await expandSpecPatterns(options.patterns, root);
701
895
  const excludeFiles = new Set(options.includeSpecs ? [] : specFiles.map((file) => path.resolve(file)));
702
896
  const directives = [];
897
+ /** Parsed, validated, and then not run: see ADR-0010. */
898
+ const withheld = [];
703
899
  const errors = [];
900
+ const inactiveSpecs = [];
704
901
  for (const file of specFiles) {
705
902
  const relativeFile = toPosix(path.relative(root, file)) || toPosix(file);
706
903
  const source = await fs.readFile(file, "utf8").catch((error) => {
@@ -714,8 +911,23 @@ export async function runSpecGuard(options) {
714
911
  if (source === null)
715
912
  continue;
716
913
  const parsed = parseDirectives(source, { file, relativeFile });
717
- directives.push(...parsed.directives);
718
914
  errors.push(...parsed.errors);
915
+ const status = parsed.status;
916
+ if (status && !status.active && !(options.ignoreStatus ?? false)) {
917
+ withheld.push(...parsed.directives);
918
+ // Recorded even when it held no directives. "docs/adr/0011.md is a
919
+ // draft" is worth saying to someone wondering why their new rule has no
920
+ // effect, and a report that only mentions the documents it happened to
921
+ // find directives in cannot answer that.
922
+ inactiveSpecs.push({
923
+ file: relativeFile,
924
+ status: status.value,
925
+ label: status.label,
926
+ directives: parsed.directives.length,
927
+ });
928
+ continue;
929
+ }
930
+ directives.push(...parsed.directives);
719
931
  }
720
932
  const assertions = [];
721
933
  for (const directive of directives) {
@@ -725,6 +937,15 @@ export async function runSpecGuard(options) {
725
937
  else
726
938
  assertions.push(resolved.assertion);
727
939
  }
940
+ // Not in force is not the same as not checked. A withheld directive is still
941
+ // held to being well-formed, so a draft's typo is found on the day it is
942
+ // written rather than on the day the ADR is accepted - which is the day
943
+ // everyone has already agreed the rule is right and stopped looking at it.
944
+ for (const directive of withheld) {
945
+ const resolved = resolveDirective(directive, { root, excludeFiles, scope });
946
+ if ("error" in resolved)
947
+ errors.push(resolved.error);
948
+ }
728
949
  const engine = createCachedEngine(await resolveEngine(options.engine ?? "auto"));
729
950
  const executeOptions = {
730
951
  root,
@@ -736,7 +957,7 @@ export async function runSpecGuard(options) {
736
957
  imports: createImportIndex(),
737
958
  hasFiles: createScopeProbe(),
738
959
  };
739
- const results = new Array(assertions.length);
960
+ const results = [];
740
961
  if (options.failFast) {
741
962
  // Fail-fast trades throughput for an early exit, so it runs unbatched.
742
963
  for (let index = 0; index < assertions.length; index++) {
@@ -756,7 +977,11 @@ export async function runSpecGuard(options) {
756
977
  results[index] = entry;
757
978
  return;
758
979
  }
759
- const key = groupKey(entry.request);
980
+ // The engine's own definition of "these can share one pass", rather than
981
+ // a second list here that had drifted four fields behind it. This is
982
+ // where most of spec-guard's speed comes from: a spec with twenty
983
+ // assertions over `src/` costs one ripgrep pass, not twenty.
984
+ const key = passKey(entry.request);
760
985
  const group = groups.get(key);
761
986
  if (group)
762
987
  group.push({ index, pending: entry });
@@ -764,7 +989,7 @@ export async function runSpecGuard(options) {
764
989
  groups.set(key, [{ index, pending: entry }]);
765
990
  });
766
991
  const batches = [...groups.values()];
767
- const concurrency = Math.max(1, Math.min(options.concurrency ?? DEFAULT_CONCURRENCY, batches.length || 1));
992
+ const concurrency = batchConcurrency(options.concurrency ?? DEFAULT_CONCURRENCY, batches.length);
768
993
  let cursor = 0;
769
994
  const worker = async () => {
770
995
  while (cursor < batches.length) {
@@ -784,25 +1009,25 @@ export async function runSpecGuard(options) {
784
1009
  // Errors arrive in two waves (parse, then resolve); readers expect file order.
785
1010
  errors.sort((a, b) => a.location.relativeFile === b.location.relativeFile
786
1011
  ? a.location.line - b.location.line
787
- : a.location.relativeFile < b.location.relativeFile
788
- ? -1
789
- : 1);
1012
+ : comparePaths(a.location.relativeFile, b.location.relativeFile));
790
1013
  const failed = results.filter((result) => !result.ok).length;
791
1014
  return {
792
1015
  ok: failed === 0 && errors.length === 0,
793
1016
  root,
794
1017
  engine: warnings.length > 0 ? "javascript" : engine.name,
795
- durationMs: performance.now() - startedAt,
1018
+ durationMs: elapsed(startedAt),
796
1019
  summary: {
797
1020
  specs: specFiles.length,
798
1021
  total: results.length,
799
1022
  passed: results.length - failed,
800
1023
  failed,
801
1024
  skipped: assertions.length - results.length,
1025
+ inactive: withheld.length,
802
1026
  },
803
1027
  results,
804
1028
  errors,
805
1029
  warnings,
1030
+ inactiveSpecs,
806
1031
  specFiles: specFiles.map((file) => toPosix(path.relative(root, file)) || toPosix(file)),
807
1032
  };
808
1033
  }