@descent-vtt/spec-guard 0.5.1 → 0.7.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 (52) hide show
  1. package/CHANGELOG.md +444 -283
  2. package/README.md +1094 -868
  3. package/dist/cli.d.ts +18 -2
  4. package/dist/cli.d.ts.map +1 -1
  5. package/dist/cli.js +136 -7
  6. package/dist/cli.js.map +1 -1
  7. package/dist/glob.d.ts +17 -2
  8. package/dist/glob.d.ts.map +1 -1
  9. package/dist/glob.js +25 -9
  10. package/dist/glob.js.map +1 -1
  11. package/dist/graph.d.ts +124 -0
  12. package/dist/graph.d.ts.map +1 -0
  13. package/dist/graph.js +241 -0
  14. package/dist/graph.js.map +1 -0
  15. package/dist/index.d.ts +19 -7
  16. package/dist/index.d.ts.map +1 -1
  17. package/dist/index.js +9 -3
  18. package/dist/index.js.map +1 -1
  19. package/dist/layers.d.ts +72 -0
  20. package/dist/layers.d.ts.map +1 -0
  21. package/dist/layers.js +102 -0
  22. package/dist/layers.js.map +1 -0
  23. package/dist/mcp.d.ts +117 -0
  24. package/dist/mcp.d.ts.map +1 -0
  25. package/dist/mcp.js +544 -0
  26. package/dist/mcp.js.map +1 -0
  27. package/dist/parser.d.ts +50 -1
  28. package/dist/parser.d.ts.map +1 -1
  29. package/dist/parser.js +232 -16
  30. package/dist/parser.js.map +1 -1
  31. package/dist/query.d.ts +108 -0
  32. package/dist/query.d.ts.map +1 -0
  33. package/dist/query.js +206 -0
  34. package/dist/query.js.map +1 -0
  35. package/dist/reporter.d.ts.map +1 -1
  36. package/dist/reporter.js +62 -5
  37. package/dist/reporter.js.map +1 -1
  38. package/dist/rules.d.ts +108 -0
  39. package/dist/rules.d.ts.map +1 -0
  40. package/dist/rules.js +153 -0
  41. package/dist/rules.js.map +1 -0
  42. package/dist/runner.d.ts +24 -0
  43. package/dist/runner.d.ts.map +1 -1
  44. package/dist/runner.js +335 -117
  45. package/dist/runner.js.map +1 -1
  46. package/dist/specs.d.ts +44 -0
  47. package/dist/specs.d.ts.map +1 -0
  48. package/dist/specs.js +62 -0
  49. package/dist/specs.js.map +1 -0
  50. package/dist/types.d.ts +60 -2
  51. package/dist/types.d.ts.map +1 -1
  52. package/package.json +73 -73
package/dist/runner.js CHANGED
@@ -8,9 +8,11 @@
8
8
  import { promises as fs } from "node:fs";
9
9
  import path from "node:path";
10
10
  import { comparePaths, createCachedEngine, enumerateCandidates, passKey, resolveEngine, runSearches, ANY_FILE_PROBE, ROOT_TARGETS, } from "./engine.js";
11
- import { createExcludeMatcher, expandSpecPatterns, toPosix } from "./glob.js";
11
+ import { createExcludeMatcher, toPosix } from "./glob.js";
12
+ import { buildGraph, cyclicComponents, edgeKey, isGraphFile, witness, } from "./graph.js";
12
13
  import { ANALYSABLE_EXTENSIONS, createImportIndex, resolveModule, } from "./imports.js";
13
- import { parseDirectives } from "./parser.js";
14
+ import { checkLayers } from "./layers.js";
15
+ import { readSpecs, specPath } from "./specs.js";
14
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;
@@ -78,21 +80,22 @@ function normalizeTarget(target, root, attribute) {
78
80
  }
79
81
  return toPosix(relative) || ".";
80
82
  }
81
- function plural(count) {
82
- return count === 1 ? "" : "es";
83
- }
84
- 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) {
85
87
  const { min, max } = bounds;
88
+ const counted = (count) => `${count} ${count === 1 ? unit.one : unit.many}`;
86
89
  if (min !== undefined && max !== undefined) {
87
90
  return min === max
88
- ? `exactly ${min} match${plural(min)}`
89
- : `between ${min} and ${max} matches`;
91
+ ? `exactly ${counted(min)}`
92
+ : `between ${min} and ${max} ${unit.many}`;
90
93
  }
91
94
  if (min !== undefined)
92
- return `at least ${min} match${plural(min)}`;
95
+ return `at least ${counted(min)}`;
93
96
  if (max !== undefined)
94
- return max === 0 ? "no matches" : `at most ${max} match${plural(max)}`;
95
- return "any number of matches";
97
+ return max === 0 ? `no ${unit.many}` : `at most ${counted(max)}`;
98
+ return `any number of ${unit.many}`;
96
99
  }
97
100
  /** Prose form used in the assertion description ("must appear at most 3 times"). */
98
101
  function describeExpectation(bounds) {
@@ -150,8 +153,8 @@ const EMPTY_SCOPE_HINT = 'add allow-empty="true" if that is expected';
150
153
  * baseline, an unreadable file and a stale entry says all three things instead
151
154
  * of whichever one the branch order happened to reach first.
152
155
  */
153
- function describeOutcome(bounds, actual, extras) {
154
- const parts = [`expected ${describeBounds(bounds)}, found ${actual}`];
156
+ function describeOutcome(bounds, actual, extras, unit = MATCHES) {
157
+ const parts = [`expected ${describeBounds(bounds, unit)}, found ${actual}`];
155
158
  if (extras.excluded > 0) {
156
159
  parts.push(`${extras.excluded} more ${extras.excluded === 1 ? "is" : "are"} on the baseline`);
157
160
  }
@@ -299,10 +302,14 @@ export function resolveDirective(directive, context) {
299
302
  },
300
303
  };
301
304
  }
302
- const isImportKind = kind === "assert-import-absence" || kind === "assert-import-count";
303
- const subject = isImportKind ? "module" : "symbol";
304
- const symbol = attributes[subject];
305
- 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) {
306
313
  return fail(`@${kind} requires a non-empty ${subject}="..." attribute.`);
307
314
  }
308
315
  const allowEmpty = parseBoolean(attributes["allow-empty"], "allow-empty");
@@ -325,7 +332,7 @@ export function resolveDirective(directive, context) {
325
332
  const rawTargets = splitList(attributes["target"]);
326
333
  const targets = (rawTargets.length > 0 ? rawTargets : ROOT_TARGETS).map((target) => normalizeTarget(target, context.root, "target"));
327
334
  const bounds = {};
328
- if (kind === "assert-absence" || kind === "assert-import-absence") {
335
+ if (kind === "assert-absence" || kind === "assert-import-absence" || graphKind) {
329
336
  if (attributes["expected"] !== undefined &&
330
337
  attributes["max"] !== undefined) {
331
338
  return fail(`@${kind} accepts either expected="..." or max="...", not both.`);
@@ -374,13 +381,30 @@ export function resolveDirective(directive, context) {
374
381
  if (!["include", "ignore"].includes((attributes["types"] ?? "include").trim().toLowerCase())) {
375
382
  return fail(`Attribute "types" must be include or ignore, got "${attributes["types"]}".`);
376
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}`;
377
400
  return {
378
401
  assertion: {
379
402
  kind,
380
403
  location,
381
- description: `${scope} ${describeImportExpectation(bounds)} "${symbol}"${except}`,
404
+ description,
382
405
  reason,
383
- symbol,
406
+ // Only the per-module kinds have a subject a report can quote.
407
+ symbol: perModule ? symbol : undefined,
384
408
  targets,
385
409
  files: [],
386
410
  bounds,
@@ -397,7 +421,8 @@ export function resolveDirective(directive, context) {
397
421
  scope: context.scope ?? DEFAULT_SCOPE,
398
422
  excludeFiles: context.excludeFiles,
399
423
  },
400
- imports: { modules: splitList(symbol), includeTypes },
424
+ imports: { modules: perModule ? splitList(symbol) : [], includeTypes },
425
+ ...(kind === "assert-layers" ? { layers } : {}),
401
426
  missingTargets: [],
402
427
  allowEmpty,
403
428
  baseline,
@@ -452,6 +477,15 @@ export function resolveDirective(directive, context) {
452
477
  return fail(error instanceof Error ? error.message : String(error));
453
478
  }
454
479
  }
480
+ /**
481
+ * The spec files a search must not count, as absolute paths.
482
+ *
483
+ * A spec names the symbol it forbids, so without this every absence rule would
484
+ * find itself. Empty under `--include-specs`.
485
+ */
486
+ export function specExclusions(specFiles, includeSpecs) {
487
+ return new Set(includeSpecs ? undefined : specFiles.map((file) => path.resolve(file)));
488
+ }
455
489
  async function pathExists(candidate) {
456
490
  return (await fs.stat(candidate).catch(() => null)) !== null;
457
491
  }
@@ -526,61 +560,71 @@ async function prepareAssertion(assertion, options) {
526
560
  targets: existingTargets,
527
561
  options: assertion.search,
528
562
  };
529
- if (!options.allowEmptyScope && !assertion.allowEmpty && !(await options.hasFiles(request))) {
530
- {
531
- return {
532
- ...base,
533
- ok: false,
534
- actual: 0,
535
- message: `no files were inspected, so this assertion verified nothing (${EMPTY_SCOPE_HINT})`,
536
- matches: [],
537
- durationMs: elapsed(startedAt),
538
- };
539
- }
563
+ // Every target missing, and that tolerated, leaves nothing to search - and
564
+ // must not be handed to the engine as an empty target list, which the engine
565
+ // reads, by contract, as the root. It used to be: a rule on a deleted
566
+ // `src/auth` searched the whole repository, where `min="1"` could pass on a
567
+ // test file. Found by holding `spec-guard query` to the walk; see ADR-0012.
568
+ const nothingLeft = existingTargets.length === 0;
569
+ if (!options.allowEmptyScope && !assertion.allowEmpty && (nothingLeft || !(await options.hasFiles(request)))) {
570
+ return {
571
+ ...base,
572
+ ok: false,
573
+ actual: 0,
574
+ message: `no files were inspected, so this assertion verified nothing (${EMPTY_SCOPE_HINT})`,
575
+ matches: [],
576
+ durationMs: elapsed(startedAt),
577
+ };
540
578
  }
541
- return {
542
- request,
543
- finish: (search) => {
544
- // A file that could not be read, or whose bytes are not text but did
545
- // contain the symbol, is a hole in the answer rather than a detail of it.
546
- // The count is still reported, because it is still true of everything
547
- // that was read; --strict is for runs where "true of what we read" is not
548
- // good enough.
549
- //
550
- // No filter for uncertainty here any more. The ledger only ever holds
551
- // gaps: the walk drops a policy skip before recording it, and the only
552
- // other reasons the scanner adds are "unreadable" and "binary". Filtering
553
- // again was a second statement of an invariant that is enforced where it
554
- // is created, and being a no-op, nothing could tell it from its absence.
555
- const gaps = search.scope.skipped;
556
- const strictFailure = options.strictTargets && gaps.length > 0;
557
- const { excluded, stale, shows } = applyBaseline(assertion.baseline, search.fileCounts);
558
- const staleFailure = assertion.ratchet === "two-sided" && stale.length > 0;
559
- const actual = search.count - excluded;
560
- return {
561
- ...base,
562
- ok: satisfies(actual, assertion.bounds) && !strictFailure && !staleFailure,
563
- actual,
564
- message: describeOutcome(assertion.bounds, actual, {
565
- excluded,
566
- stale: staleFailure ? stale : [],
567
- gaps: strictFailure ? gaps.length : 0,
568
- }),
569
- baselinedMatches: excluded,
570
- staleBaseline: stale,
571
- fileMatches: [...search.fileCounts].map(([file, count]) => ({ file, count })),
572
- matches: search.matches.filter((match) => shows(match.file)).slice(0, options.maxSnippets),
573
- // Carried as numbers, not prose, so the reporter can total them across a
574
- // run and JSON consumers can act on them.
575
- commentMatches: search.commentMatches,
576
- unclassifiedFiles: search.unclassifiedFiles,
577
- scope: search.scope,
578
- engine: search.engine,
579
- durationMs: elapsed(startedAt),
580
- };
581
- },
579
+ const finish = (search) => {
580
+ // A file that could not be read, or whose bytes are not text but did
581
+ // contain the symbol, is a hole in the answer rather than a detail of it.
582
+ // The count is still reported, because it is still true of everything
583
+ // that was read; --strict is for runs where "true of what we read" is not
584
+ // good enough.
585
+ //
586
+ // No filter for uncertainty here any more. The ledger only ever holds
587
+ // gaps: the walk drops a policy skip before recording it, and the only
588
+ // other reasons the scanner adds are "unreadable" and "binary". Filtering
589
+ // again was a second statement of an invariant that is enforced where it
590
+ // is created, and being a no-op, nothing could tell it from its absence.
591
+ const gaps = search.scope.skipped;
592
+ const strictFailure = options.strictTargets && gaps.length > 0;
593
+ const { excluded, stale, shows } = applyBaseline(assertion.baseline, search.fileCounts);
594
+ const staleFailure = assertion.ratchet === "two-sided" && stale.length > 0;
595
+ const actual = search.count - excluded;
596
+ return {
597
+ ...base,
598
+ ok: satisfies(actual, assertion.bounds) && !strictFailure && !staleFailure,
599
+ actual,
600
+ message: describeOutcome(assertion.bounds, actual, {
601
+ excluded,
602
+ stale: staleFailure ? stale : [],
603
+ gaps: strictFailure ? gaps.length : 0,
604
+ }),
605
+ baselinedMatches: excluded,
606
+ staleBaseline: stale,
607
+ fileMatches: [...search.fileCounts].map(([file, count]) => ({ file, count })),
608
+ matches: search.matches.filter((match) => shows(match.file)).slice(0, options.maxSnippets),
609
+ // Carried as numbers, not prose, so the reporter can total them across a
610
+ // run and JSON consumers can act on them.
611
+ commentMatches: search.commentMatches,
612
+ unclassifiedFiles: search.unclassifiedFiles,
613
+ scope: search.scope,
614
+ engine: search.engine,
615
+ durationMs: elapsed(startedAt),
616
+ };
582
617
  };
618
+ return nothingLeft ? finish(NOTHING_SEARCHED) : { request, finish };
583
619
  }
620
+ const NOTHING_SEARCHED = {
621
+ count: 0,
622
+ commentMatches: 0,
623
+ unclassifiedFiles: 0,
624
+ matches: [],
625
+ fileCounts: new Map(),
626
+ scope: EMPTY_LEDGER,
627
+ };
584
628
  /** How each kind of reference is spelled in a report snippet. */
585
629
  const IMPORT_VERBS = {
586
630
  import: "import",
@@ -612,13 +656,30 @@ async function executeImportAssertion(assertion, options, base, warnings, starte
612
656
  // No symbol: an import assertion never runs a text search, and the walk does
613
657
  // not depend on one. This used to pass `symbol: ""`, an invented value that
614
658
  // reached nothing and that no test could therefore be wrong about.
615
- const enumeration = await enumerateCandidates({
616
- root: options.root,
617
- targets: existingTargets,
618
- options: assertion.search,
619
- });
620
- const analysable = enumeration.files.filter((file) => ANALYSABLE_EXTENSIONS.has(path.posix.extname(file.relativePath)));
621
- if (analysable.length === 0 && !options.allowEmptyScope && !assertion.allowEmpty) {
659
+ //
660
+ // And no walk at all when every target is missing: an empty target list is
661
+ // the root to the engine, as the text rules above found out.
662
+ const enumeration = existingTargets.length === 0
663
+ ? { files: [] }
664
+ : await enumerateCandidates({
665
+ root: options.root,
666
+ targets: existingTargets,
667
+ options: assertion.search,
668
+ });
669
+ // Why a missing target fails the assertion, or null when it does not. Decided
670
+ // before scope, so a rule whose only target is gone says so rather than that
671
+ // its scope is empty - which is true, and not the thing to fix.
672
+ const missing = !options.allowMissingTargets && assertion.missingTargets.length > 0
673
+ ? `target path${assertion.missingTargets.length === 1 ? " does" : "s do"} not exist: ${assertion.missingTargets.join(", ")}`
674
+ : null;
675
+ // A cycle needs files the resolver can name, which today means JavaScript
676
+ // and TypeScript; every other import rule reads any language spec-guard
677
+ // tokenizes. ADR-0011 has the reasons, language by language.
678
+ const cycles = assertion.kind === "assert-import-cycle";
679
+ const analysable = enumeration.files.filter((file) => cycles
680
+ ? isGraphFile(file.relativePath)
681
+ : ANALYSABLE_EXTENSIONS.has(path.posix.extname(file.relativePath)));
682
+ if (analysable.length === 0 && missing === null && !options.allowEmptyScope && !assertion.allowEmpty) {
622
683
  // For an import rule the bar is higher than "some file exists": a directory
623
684
  // of YAML has nothing this can read, so a dependency claim about it is a
624
685
  // claim about nothing.
@@ -628,35 +689,58 @@ async function executeImportAssertion(assertion, options, base, warnings, starte
628
689
  actual: 0,
629
690
  message: enumeration.files.length === 0
630
691
  ? `no files were inspected, so this assertion verified nothing (${EMPTY_SCOPE_HINT})`
631
- : `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})`,
692
+ : cycles
693
+ ? `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})`
694
+ : `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})`,
632
695
  matches: [],
633
696
  durationMs: elapsed(startedAt),
634
697
  };
635
698
  }
636
699
  const skipped = enumeration.files.length - analysable.length;
637
700
  if (skipped > 0) {
638
- warnings.push(`analysed ${analysable.length} of ${enumeration.files.length} files; ${skipped} ${skipped === 1 ? "is" : "are"} in a language whose imports spec-guard cannot read`);
701
+ warnings.push(cycles
702
+ ? `placed ${analysable.length} of ${enumeration.files.length} files in the import graph; ${skipped} ${skipped === 1 ? "is" : "are"} not JavaScript or TypeScript`
703
+ : `analysed ${analysable.length} of ${enumeration.files.length} files; ${skipped} ${skipped === 1 ? "is" : "are"} in a language whose imports spec-guard cannot read`);
639
704
  }
640
- const matchesModule = createExcludeMatcher(query.modules);
641
- const matches = [];
705
+ const analysed = [];
642
706
  const unresolved = [];
643
707
  for (const file of analysable) {
644
708
  const analysis = await options.imports.analyze(file.absolutePath, file.relativePath);
645
709
  for (const note of analysis.notes) {
646
710
  unresolved.push(`${note.file}:${note.line} ${note.detail}`);
647
711
  }
648
- const hit = analysis.references.find((reference) => {
712
+ analysed.push({ file: file.relativePath, references: analysis.references });
713
+ }
714
+ if (unresolved.length > 0) {
715
+ warnings.push(`${unresolved.length} module reference${unresolved.length === 1 ? "" : "s"} could not be resolved statically`, ...unresolved.slice(0, options.maxSnippets).map((entry) => ` ${entry}`));
716
+ }
717
+ const read = {
718
+ analysed,
719
+ walked: enumeration.files.map((file) => file.relativePath),
720
+ targets: existingTargets,
721
+ unresolved: unresolved.length,
722
+ missing,
723
+ };
724
+ if (cycles)
725
+ return finishCycles(assertion, options, base, warnings, startedAt, read);
726
+ if (assertion.layers) {
727
+ return finishLayers(assertion, assertion.layers, options, base, warnings, startedAt, read);
728
+ }
729
+ const matchesModule = createExcludeMatcher(query.modules);
730
+ const matches = [];
731
+ for (const { file, references } of analysed) {
732
+ const hit = references.find((reference) => {
649
733
  if (reference.typeOnly && !query.includeTypes)
650
734
  return false;
651
735
  // Both forms are tried: the resolved one so `module="app/db/**"` works
652
736
  // everywhere, and the raw one so a Python or C# author can write the
653
737
  // dotted path they see in their own source and still be understood.
654
- return (matchesModule(resolveModule(reference.specifier, file.relativePath)) ||
738
+ return (matchesModule(resolveModule(reference.specifier, file)) ||
655
739
  matchesModule(reference.specifier));
656
740
  });
657
741
  if (hit) {
658
742
  matches.push({
659
- file: file.relativePath,
743
+ file,
660
744
  line: hit.line,
661
745
  column: hit.column,
662
746
  text: `${IMPORT_VERBS[hit.kind]} ${hit.specifier}`,
@@ -664,31 +748,23 @@ async function executeImportAssertion(assertion, options, base, warnings, starte
664
748
  });
665
749
  }
666
750
  }
667
- if (unresolved.length > 0) {
668
- warnings.push(`${unresolved.length} module reference${unresolved.length === 1 ? "" : "s"} could not be resolved statically`, ...unresolved.slice(0, options.maxSnippets).map((entry) => ` ${entry}`));
669
- }
670
- const missingFailure = !options.allowMissingTargets && assertion.missingTargets.length > 0;
671
751
  // One reference per file, so the file counts are the matches themselves.
672
752
  const fileCounts = new Map(matches.map((match) => [match.file, 1]));
673
753
  const { excluded, stale, shows } = applyBaseline(assertion.baseline, fileCounts);
674
754
  const staleFailure = assertion.ratchet === "two-sided" && stale.length > 0;
675
755
  const actual = matches.length - excluded;
676
- const strictFailure = missingFailure || (options.strictTargets && unresolved.length > 0);
756
+ const strictFailure = read.missing !== null || (options.strictTargets && unresolved.length > 0);
677
757
  const ok = satisfies(actual, assertion.bounds) && !strictFailure && !staleFailure;
678
758
  return {
679
759
  ...base,
680
760
  ok,
681
761
  actual,
682
- message: missingFailure
683
- ? `target path${assertion.missingTargets.length === 1 ? " does" : "s do"} not exist: ${assertion.missingTargets.join(", ")}`
684
- : describeOutcome(assertion.bounds, actual, {
762
+ message: read.missing ??
763
+ describeOutcome(assertion.bounds, actual, {
685
764
  excluded,
686
765
  stale: staleFailure ? stale : [],
687
766
  gaps: 0,
688
- }) +
689
- (options.strictTargets && unresolved.length > 0
690
- ? `; ${unresolved.length} reference${unresolved.length === 1 ? "" : "s"} could not be resolved`
691
- : ""),
767
+ }) + strictSuffix(options, unresolved.length),
692
768
  baselinedMatches: excluded,
693
769
  staleBaseline: stale,
694
770
  fileMatches: [...fileCounts].map(([file, count]) => ({ file, count })),
@@ -696,6 +772,127 @@ async function executeImportAssertion(assertion, options, base, warnings, starte
696
772
  durationMs: elapsed(startedAt),
697
773
  };
698
774
  }
775
+ /** What `--strict` adds to a message when references went unresolved. */
776
+ function strictSuffix(options, unresolved) {
777
+ return options.strictTargets && unresolved > 0
778
+ ? `; ${unresolved} reference${unresolved === 1 ? "" : "s"} could not be resolved`
779
+ : "";
780
+ }
781
+ /**
782
+ * Counts the import cycles in scope - components, not simple cycles.
783
+ *
784
+ * Each is shown as one concrete loop with the line of every import on it, at
785
+ * the location of the first. The count does not move when someone adds a
786
+ * second route around a loop that already exists, which is what makes it a
787
+ * number a spec can hold. See ADR-0011.
788
+ */
789
+ function finishCycles(assertion, options, base, warnings, startedAt, read) {
790
+ const query = assertion.imports;
791
+ const scope = {
792
+ nodes: new Set(read.analysed.map((entry) => entry.file)),
793
+ walked: new Set(read.walked),
794
+ excluded: createExcludeMatcher(assertion.search.excludeGlobs),
795
+ covers: (relativePath) => relativePath !== ".." &&
796
+ !relativePath.startsWith("../") &&
797
+ read.targets.some((target) => target === "." || relativePath === target || relativePath.startsWith(`${target}/`)),
798
+ };
799
+ const graph = buildGraph(read.analysed, scope, query.includeTypes);
800
+ const loops = cyclicComponents(graph).map((component) => witness(component, graph.successors));
801
+ if (graph.unresolved.length > 0) {
802
+ const count = graph.unresolved.length;
803
+ 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
804
+ .slice(0, options.maxSnippets)
805
+ .map(({ file, reference }) => ` ${file}:${reference.line} ${reference.specifier}`));
806
+ }
807
+ const gaps = read.unresolved + graph.unresolved.length;
808
+ const actual = loops.length;
809
+ const ok = satisfies(actual, assertion.bounds) &&
810
+ read.missing === null &&
811
+ !(options.strictTargets && gaps > 0);
812
+ const matches = loops.map((loop) => {
813
+ const hop = (at) => graph.via.get(edgeKey(loop[at], loop[at + 1]));
814
+ return {
815
+ file: loop[0],
816
+ line: hop(0).line,
817
+ column: hop(0).column,
818
+ // Every file on the loop with the line that leaves it, so the imports to
819
+ // change can be read off without opening each file to find them.
820
+ text: loop.map((file, at) => (at < loop.length - 1 ? `${file}:${hop(at).line}` : file)).join(" -> "),
821
+ count: 1,
822
+ };
823
+ });
824
+ return {
825
+ ...base,
826
+ ok,
827
+ actual,
828
+ message: read.missing ??
829
+ describeOutcome(assertion.bounds, actual, { excluded: 0, stale: [], gaps: 0 }, CYCLES) +
830
+ strictSuffix(options, gaps),
831
+ matches: matches.slice(0, options.maxSnippets),
832
+ durationMs: elapsed(startedAt),
833
+ };
834
+ }
835
+ /**
836
+ * Checks each file's imports against the layer order.
837
+ *
838
+ * Three things fail besides a violation, because each is a rule that would
839
+ * otherwise pass while checking less than it says: a layer that matches no
840
+ * file, a file two layers claim, and a missing target. See ADR-0011.
841
+ */
842
+ function finishLayers(assertion, order, options, base, warnings, startedAt, read) {
843
+ const query = assertion.imports;
844
+ const report = checkLayers(read.analysed, order, query.includeTypes);
845
+ const quoted = (layers) => layers.map((layer) => `"${order[layer]}"`).join(" and ");
846
+ const empty = order.flatMap((_, layer) => (report.members[layer] === 0 ? [layer] : []));
847
+ const emptyAllowed = options.allowEmptyScope || assertion.allowEmpty;
848
+ const one = empty.length === 1;
849
+ const unmatched = `${one ? "layer" : "layers"} ${quoted(empty)} ${one ? "matches" : "match"} no file in scope`;
850
+ if (report.unassigned.length > 0) {
851
+ 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"}`);
852
+ }
853
+ if (empty.length > 0 && emptyAllowed)
854
+ warnings.push(unmatched);
855
+ const fileCounts = new Map(report.violations.map((violation) => [violation.file, 1]));
856
+ const { excluded, stale, shows } = applyBaseline(assertion.baseline, fileCounts);
857
+ const staleFailure = assertion.ratchet === "two-sided" && stale.length > 0;
858
+ const actual = report.violations.length - excluded;
859
+ const emptyFailure = empty.length > 0 && !emptyAllowed;
860
+ const ok = satisfies(actual, assertion.bounds) &&
861
+ read.missing === null &&
862
+ !staleFailure &&
863
+ !emptyFailure &&
864
+ report.ambiguous.length === 0 &&
865
+ !(options.strictTargets && read.unresolved > 0);
866
+ const message = read.missing ??
867
+ (emptyFailure
868
+ ? `${unmatched}, so nothing is held to ${one ? "it" : "them"} (${EMPTY_SCOPE_HINT})`
869
+ : report.ambiguous.length > 0
870
+ ? `${report.ambiguous
871
+ .slice(0, options.maxSnippets)
872
+ .map(({ file, layers }) => `${file} is in both ${quoted(layers)}`)
873
+ .join("; ")}; a file in two layers has no single rule to follow`
874
+ : describeOutcome(assertion.bounds, actual, { excluded, stale: staleFailure ? stale : [], gaps: 0 }, VIOLATING_FILES) + strictSuffix(options, read.unresolved));
875
+ return {
876
+ ...base,
877
+ ok,
878
+ actual,
879
+ message,
880
+ baselinedMatches: excluded,
881
+ staleBaseline: stale,
882
+ fileMatches: [...fileCounts].map(([file, count]) => ({ file, count })),
883
+ matches: report.violations
884
+ .filter((violation) => shows(violation.file))
885
+ .slice(0, options.maxSnippets)
886
+ .map((violation) => ({
887
+ file: violation.file,
888
+ line: violation.reference.line,
889
+ column: violation.reference.column,
890
+ text: `${order[violation.from]} -> ${order[violation.to]}: ${IMPORT_VERBS[violation.reference.kind]} ${violation.reference.specifier}`,
891
+ count: 1,
892
+ })),
893
+ durationMs: elapsed(startedAt),
894
+ };
895
+ }
699
896
  /** Executes a single resolved assertion. */
700
897
  export async function executeAssertion(assertion, options) {
701
898
  const prepared = await prepareAssertion(assertion, options);
@@ -722,34 +919,53 @@ export async function runSpecGuard(options) {
722
919
  const allowMissingTargets = options.allowMissingTargets ?? false;
723
920
  const allowEmptyScope = options.allowEmptyScope ?? false;
724
921
  const scope = createScope(options.defaultSkips ?? true);
725
- const specFiles = await expandSpecPatterns(options.patterns, root);
726
- const excludeFiles = new Set(options.includeSpecs ? [] : specFiles.map((file) => path.resolve(file)));
922
+ const specs = await readSpecs(options.patterns, root);
923
+ const specFiles = specs.files;
924
+ const excludeFiles = specExclusions(specFiles, options.includeSpecs ?? false);
727
925
  const directives = [];
728
- const errors = [];
729
- for (const file of specFiles) {
730
- const relativeFile = toPosix(path.relative(root, file)) || toPosix(file);
731
- const source = await fs.readFile(file, "utf8").catch((error) => {
732
- errors.push({
733
- location: { file, relativeFile, line: 1, column: 1 },
734
- raw: "",
735
- message: `Unable to read spec file: ${error instanceof Error ? error.message : String(error)}`,
926
+ /** Parsed, validated, and then not run: see ADR-0010. */
927
+ const withheld = [];
928
+ const errors = [...specs.errors];
929
+ const inactiveSpecs = [];
930
+ for (const document of specs.documents) {
931
+ if (!document.inForce && !(options.ignoreStatus ?? false)) {
932
+ // A document is only ever out of force because of a status it declares.
933
+ const status = document.status;
934
+ withheld.push(...document.directives);
935
+ // Recorded even when it held no directives. "docs/adr/0011.md is a
936
+ // draft" is worth saying to someone wondering why their new rule has no
937
+ // effect, and a report that only mentions the documents it happened to
938
+ // find directives in cannot answer that.
939
+ inactiveSpecs.push({
940
+ file: document.relativeFile,
941
+ status: status.value,
942
+ label: status.label,
943
+ directives: document.directives.length,
736
944
  });
737
- return null;
738
- });
739
- if (source === null)
740
945
  continue;
741
- const parsed = parseDirectives(source, { file, relativeFile });
742
- directives.push(...parsed.directives);
743
- errors.push(...parsed.errors);
946
+ }
947
+ directives.push(...document.directives);
744
948
  }
745
949
  const assertions = [];
746
950
  for (const directive of directives) {
747
951
  const resolved = resolveDirective(directive, { root, excludeFiles, scope });
748
952
  if ("error" in resolved)
749
953
  errors.push(resolved.error);
750
- else
954
+ // Selection happens after resolution, so a directive that is not selected
955
+ // is still held to being well-formed - the same bargain ADR-0010 strikes
956
+ // for a document that is not in force.
957
+ else if (options.select?.(resolved.assertion) ?? true)
751
958
  assertions.push(resolved.assertion);
752
959
  }
960
+ // Not in force is not the same as not checked. A withheld directive is still
961
+ // held to being well-formed, so a draft's typo is found on the day it is
962
+ // written rather than on the day the ADR is accepted - which is the day
963
+ // everyone has already agreed the rule is right and stopped looking at it.
964
+ for (const directive of withheld) {
965
+ const resolved = resolveDirective(directive, { root, excludeFiles, scope });
966
+ if ("error" in resolved)
967
+ errors.push(resolved.error);
968
+ }
753
969
  const engine = createCachedEngine(await resolveEngine(options.engine ?? "auto"));
754
970
  const executeOptions = {
755
971
  root,
@@ -826,11 +1042,13 @@ export async function runSpecGuard(options) {
826
1042
  passed: results.length - failed,
827
1043
  failed,
828
1044
  skipped: assertions.length - results.length,
1045
+ inactive: withheld.length,
829
1046
  },
830
1047
  results,
831
1048
  errors,
832
1049
  warnings,
833
- specFiles: specFiles.map((file) => toPosix(path.relative(root, file)) || toPosix(file)),
1050
+ inactiveSpecs,
1051
+ specFiles: specFiles.map((file) => specPath(root, file)),
834
1052
  };
835
1053
  }
836
1054
  //# sourceMappingURL=runner.js.map