@descent-vtt/spec-guard 0.6.0 → 0.8.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 (48) hide show
  1. package/CHANGELOG.md +132 -8
  2. package/README.md +191 -9
  3. package/dist/cli.d.ts +17 -2
  4. package/dist/cli.d.ts.map +1 -1
  5. package/dist/cli.js +127 -7
  6. package/dist/cli.js.map +1 -1
  7. package/dist/glob.d.ts +25 -2
  8. package/dist/glob.d.ts.map +1 -1
  9. package/dist/glob.js +26 -9
  10. package/dist/glob.js.map +1 -1
  11. package/dist/index.d.ts +17 -7
  12. package/dist/index.d.ts.map +1 -1
  13. package/dist/index.js +8 -3
  14. package/dist/index.js.map +1 -1
  15. package/dist/mcp.d.ts +117 -0
  16. package/dist/mcp.d.ts.map +1 -0
  17. package/dist/mcp.js +545 -0
  18. package/dist/mcp.js.map +1 -0
  19. package/dist/parser.d.ts +25 -0
  20. package/dist/parser.d.ts.map +1 -1
  21. package/dist/parser.js +71 -15
  22. package/dist/parser.js.map +1 -1
  23. package/dist/query.d.ts +108 -0
  24. package/dist/query.d.ts.map +1 -0
  25. package/dist/query.js +211 -0
  26. package/dist/query.js.map +1 -0
  27. package/dist/reporter.d.ts.map +1 -1
  28. package/dist/reporter.js +13 -4
  29. package/dist/reporter.js.map +1 -1
  30. package/dist/rules.d.ts +119 -0
  31. package/dist/rules.d.ts.map +1 -0
  32. package/dist/rules.js +197 -0
  33. package/dist/rules.js.map +1 -0
  34. package/dist/runner.d.ts +19 -0
  35. package/dist/runner.d.ts.map +1 -1
  36. package/dist/runner.js +289 -97
  37. package/dist/runner.js.map +1 -1
  38. package/dist/specs.d.ts +44 -0
  39. package/dist/specs.d.ts.map +1 -0
  40. package/dist/specs.js +62 -0
  41. package/dist/specs.js.map +1 -0
  42. package/dist/structure.d.ts +104 -0
  43. package/dist/structure.d.ts.map +1 -0
  44. package/dist/structure.js +253 -0
  45. package/dist/structure.js.map +1 -0
  46. package/dist/types.d.ts +33 -3
  47. package/dist/types.d.ts.map +1 -1
  48. package/package.json +1 -1
package/dist/runner.js CHANGED
@@ -8,12 +8,13 @@
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
12
  import { buildGraph, cyclicComponents, edgeKey, isGraphFile, witness, } from "./graph.js";
13
13
  import { ANALYSABLE_EXTENSIONS, createImportIndex, resolveModule, } from "./imports.js";
14
14
  import { checkLayers } from "./layers.js";
15
- import { parseDirectives } from "./parser.js";
15
+ import { readSpecs, specPath } from "./specs.js";
16
16
  import { createScope, DEFAULT_SCOPE, EMPTY_LEDGER, } from "./scope.js";
17
+ import { checkStructure, createTreeIndex, partnerTemplateIssue, requiredEntryIssue, } from "./structure.js";
17
18
  export const DEFAULT_MAX_SNIPPETS = 5;
18
19
  export const DEFAULT_CONCURRENCY = 8;
19
20
  /**
@@ -307,9 +308,23 @@ export function resolveDirective(directive, context) {
307
308
  // itself and names no subject; layers name theirs in `order`.
308
309
  const graphKind = kind === "assert-import-cycle" || kind === "assert-layers";
309
310
  const isImportKind = perModule || graphKind;
310
- const subject = kind === "assert-layers" ? "order" : perModule ? "module" : "symbol";
311
+ // ADR-0013: a structure rule names its subject by naming its claim, and
312
+ // names exactly one. Two claims in one directive would count two different
313
+ // things in one number.
314
+ const structureKind = kind === "assert-structure";
315
+ const claims = STRUCTURE_CLAIMS.filter((claim) => attributes[claim] !== undefined);
316
+ if (structureKind && claims.length !== 1) {
317
+ return fail(claims.length === 0
318
+ ? '@assert-structure requires one of pattern="...", required="..." or partner="...".'
319
+ : `@assert-structure makes one claim per directive, got ${claims.join(" and ")}.`);
320
+ }
321
+ const subject = structureKind
322
+ ? claims[0]
323
+ : kind === "assert-layers" ? "order" : perModule ? "module" : "symbol";
311
324
  const symbol = attributes[subject] ?? "";
312
- if (kind !== "assert-import-cycle" && symbol.length === 0) {
325
+ // A list attribute is empty when it lists nothing, whatever its length; a
326
+ // symbol of one space is a symbol.
327
+ if (kind !== "assert-import-cycle" && (structureKind ? splitList(symbol).length : symbol.length) === 0) {
313
328
  return fail(`@${kind} requires a non-empty ${subject}="..." attribute.`);
314
329
  }
315
330
  const allowEmpty = parseBoolean(attributes["allow-empty"], "allow-empty");
@@ -332,7 +347,7 @@ export function resolveDirective(directive, context) {
332
347
  const rawTargets = splitList(attributes["target"]);
333
348
  const targets = (rawTargets.length > 0 ? rawTargets : ROOT_TARGETS).map((target) => normalizeTarget(target, context.root, "target"));
334
349
  const bounds = {};
335
- if (kind === "assert-absence" || kind === "assert-import-absence" || graphKind) {
350
+ if (kind === "assert-absence" || kind === "assert-import-absence" || graphKind || structureKind) {
336
351
  if (attributes["expected"] !== undefined &&
337
352
  attributes["max"] !== undefined) {
338
353
  return fail(`@${kind} accepts either expected="..." or max="...", not both.`);
@@ -371,6 +386,16 @@ export function resolveDirective(directive, context) {
371
386
  }
372
387
  }
373
388
  }
389
+ if (structureKind) {
390
+ return resolveStructure(directive, subject, splitList(symbol), {
391
+ targets,
392
+ bounds,
393
+ allowEmpty,
394
+ baseline,
395
+ ratchet,
396
+ scope: context.scope ?? DEFAULT_SCOPE,
397
+ });
398
+ }
374
399
  if (isImportKind) {
375
400
  const scope = targets.join(", ");
376
401
  const excludeGlobs = splitList(attributes["exclude"]);
@@ -477,6 +502,90 @@ export function resolveDirective(directive, context) {
477
502
  return fail(error instanceof Error ? error.message : String(error));
478
503
  }
479
504
  }
505
+ const STRUCTURE_CLAIMS = ["pattern", "required", "partner"];
506
+ /** What each structure claim counts. */
507
+ const STRUCTURE_UNITS = {
508
+ pattern: { one: "misnamed file", many: "misnamed files" },
509
+ required: { one: "directory missing an entry", many: "directories missing an entry" },
510
+ partner: { one: "file without a partner", many: "files without a partner" },
511
+ };
512
+ /**
513
+ * The rest of an `@assert-structure` directive, once what every rule shares is
514
+ * read. Throws for a directive that cannot mean what it says, which
515
+ * `resolveDirective` reports as the directive's error.
516
+ */
517
+ function resolveStructure(directive, claim, values, shared) {
518
+ const { attributes } = directive;
519
+ const required = claim === "required";
520
+ const globs = splitList(attributes["glob"]);
521
+ const excludeGlobs = splitList(attributes["exclude"]);
522
+ // Two attributes that would otherwise be read and ignored, which is a rule
523
+ // saying something it does not check.
524
+ if (attributes["dirs"] !== undefined && !required) {
525
+ throw new Error(`Attribute "dirs" chooses the directories of required="...", and this directive claims ${claim}="...".`);
526
+ }
527
+ if (required && globs.length > 0) {
528
+ throw new Error(`Attribute "glob" chooses files, and required="..." is about directories; dirs="..." chooses those.`);
529
+ }
530
+ const dirs = attributes["dirs"]?.trim().replace(/^\.\//, "").replace(/\/+$/, "");
531
+ if (dirs === "")
532
+ throw new Error(`Attribute "dirs" must not be empty.`);
533
+ const issue = values
534
+ .map((value) => (required ? requiredEntryIssue(value) : claim === "partner" ? partnerTemplateIssue(value) : null))
535
+ .find((found) => found !== null);
536
+ if (issue)
537
+ throw new Error(issue);
538
+ const scope = shared.targets.join(", ");
539
+ const matching = globs.length > 0 ? ` matching ${globs.join(", ")}` : "";
540
+ const claimed = claim === "pattern"
541
+ ? `files in ${scope}${matching} must be named ${values.join(" or ")}`
542
+ : claim === "partner"
543
+ ? `files in ${scope}${matching} must each have a partner ${values.join(" or ")}`
544
+ : dirs === undefined
545
+ ? `${scope} must contain ${values.join(", ")}`
546
+ : `directories matching ${dirs} under ${scope} must contain ${values.join(", ")}`;
547
+ const allowance = shared.bounds.max === 0 ? "" : `, with ${describeBounds(shared.bounds, STRUCTURE_UNITS[claim])}`;
548
+ const except = excludeGlobs.length > 0 ? ` (excluding ${excludeGlobs.join(", ")})` : "";
549
+ return {
550
+ assertion: {
551
+ kind: directive.kind,
552
+ location: directive.location,
553
+ description: `${claimed}${allowance}${except}`,
554
+ reason: attributes["reason"],
555
+ targets: shared.targets,
556
+ files: [],
557
+ bounds: shared.bounds,
558
+ search: {
559
+ regex: false,
560
+ word: false,
561
+ ignoreCase: false,
562
+ globs,
563
+ excludeGlobs,
564
+ // Nothing is read, so there is no comment to leave out.
565
+ ignoreComments: false,
566
+ scope: shared.scope,
567
+ // The spec files are in scope. Every other rule leaves them out so a
568
+ // text rule does not find its own directive, and a structure rule reads
569
+ // no text - while a rule about what the ADRs are called is about them.
570
+ excludeFiles: new Set(),
571
+ },
572
+ structure: dirs === undefined ? { claim, values } : { claim, values, dirs },
573
+ missingTargets: [],
574
+ allowEmpty: shared.allowEmpty,
575
+ baseline: shared.baseline,
576
+ ratchet: shared.ratchet,
577
+ },
578
+ };
579
+ }
580
+ /**
581
+ * The spec files a search must not count, as absolute paths.
582
+ *
583
+ * A spec names the symbol it forbids, so without this every absence rule would
584
+ * find itself. Empty under `--include-specs`.
585
+ */
586
+ export function specExclusions(specFiles, includeSpecs) {
587
+ return new Set(includeSpecs ? undefined : specFiles.map((file) => path.resolve(file)));
588
+ }
480
589
  async function pathExists(candidate) {
481
590
  return (await fs.stat(candidate).catch(() => null)) !== null;
482
591
  }
@@ -496,6 +605,7 @@ async function prepareAssertion(assertion, options) {
496
605
  targets: assertion.targets,
497
606
  files: assertion.files,
498
607
  bounds: assertion.bounds,
608
+ claim: assertion.structure?.claim,
499
609
  warnings,
500
610
  commentMatches: 0,
501
611
  unclassifiedFiles: 0,
@@ -525,6 +635,9 @@ async function prepareAssertion(assertion, options) {
525
635
  if (assertion.imports) {
526
636
  return executeImportAssertion(assertion, options, base, warnings, startedAt);
527
637
  }
638
+ if (assertion.structure) {
639
+ return executeStructureAssertion(assertion, assertion.structure, options, base, warnings, startedAt);
640
+ }
528
641
  const existingTargets = [];
529
642
  for (const target of assertion.targets) {
530
643
  if (await pathExists(path.resolve(options.root, target)))
@@ -532,15 +645,15 @@ async function prepareAssertion(assertion, options) {
532
645
  else
533
646
  assertion.missingTargets.push(target);
534
647
  }
535
- if (assertion.missingTargets.length > 0) {
536
- warnings.push(`target path${assertion.missingTargets.length === 1 ? "" : "s"} not found: ${assertion.missingTargets.join(", ")}`);
537
- }
648
+ const notFound = missingTargets(assertion.missingTargets);
649
+ if (assertion.missingTargets.length > 0)
650
+ warnings.push(notFound.warning);
538
651
  if (!options.allowMissingTargets && assertion.missingTargets.length > 0) {
539
652
  return {
540
653
  ...base,
541
654
  ok: false,
542
655
  actual: 0,
543
- message: `target path${assertion.missingTargets.length === 1 ? " does" : "s do"} not exist: ${assertion.missingTargets.join(", ")}`,
656
+ message: notFound.failure,
544
657
  matches: [],
545
658
  durationMs: elapsed(startedAt),
546
659
  };
@@ -551,61 +664,138 @@ async function prepareAssertion(assertion, options) {
551
664
  targets: existingTargets,
552
665
  options: assertion.search,
553
666
  };
554
- if (!options.allowEmptyScope && !assertion.allowEmpty && !(await options.hasFiles(request))) {
555
- {
556
- return {
557
- ...base,
558
- ok: false,
559
- actual: 0,
560
- message: `no files were inspected, so this assertion verified nothing (${EMPTY_SCOPE_HINT})`,
561
- matches: [],
562
- durationMs: elapsed(startedAt),
563
- };
564
- }
667
+ // Every target missing, and that tolerated, leaves nothing to search - and
668
+ // must not be handed to the engine as an empty target list, which the engine
669
+ // reads, by contract, as the root. It used to be: a rule on a deleted
670
+ // `src/auth` searched the whole repository, where `min="1"` could pass on a
671
+ // test file. Found by holding `spec-guard query` to the walk; see ADR-0012.
672
+ const nothingLeft = existingTargets.length === 0;
673
+ if (!options.allowEmptyScope && !assertion.allowEmpty && (nothingLeft || !(await options.hasFiles(request)))) {
674
+ return {
675
+ ...base,
676
+ ok: false,
677
+ actual: 0,
678
+ message: `no files were inspected, so this assertion verified nothing (${EMPTY_SCOPE_HINT})`,
679
+ matches: [],
680
+ durationMs: elapsed(startedAt),
681
+ };
565
682
  }
683
+ const finish = (search) => {
684
+ // A file that could not be read, or whose bytes are not text but did
685
+ // contain the symbol, is a hole in the answer rather than a detail of it.
686
+ // The count is still reported, because it is still true of everything
687
+ // that was read; --strict is for runs where "true of what we read" is not
688
+ // good enough.
689
+ //
690
+ // No filter for uncertainty here any more. The ledger only ever holds
691
+ // gaps: the walk drops a policy skip before recording it, and the only
692
+ // other reasons the scanner adds are "unreadable" and "binary". Filtering
693
+ // again was a second statement of an invariant that is enforced where it
694
+ // is created, and being a no-op, nothing could tell it from its absence.
695
+ const gaps = search.scope.skipped;
696
+ const strictFailure = options.strictTargets && gaps.length > 0;
697
+ const { excluded, stale, shows } = applyBaseline(assertion.baseline, search.fileCounts);
698
+ const staleFailure = assertion.ratchet === "two-sided" && stale.length > 0;
699
+ const actual = search.count - excluded;
700
+ return {
701
+ ...base,
702
+ ok: satisfies(actual, assertion.bounds) && !strictFailure && !staleFailure,
703
+ actual,
704
+ message: describeOutcome(assertion.bounds, actual, {
705
+ excluded,
706
+ stale: staleFailure ? stale : [],
707
+ gaps: strictFailure ? gaps.length : 0,
708
+ }),
709
+ baselinedMatches: excluded,
710
+ staleBaseline: stale,
711
+ fileMatches: [...search.fileCounts].map(([file, count]) => ({ file, count })),
712
+ matches: search.matches.filter((match) => shows(match.file)).slice(0, options.maxSnippets),
713
+ // Carried as numbers, not prose, so the reporter can total them across a
714
+ // run and JSON consumers can act on them.
715
+ commentMatches: search.commentMatches,
716
+ unclassifiedFiles: search.unclassifiedFiles,
717
+ scope: search.scope,
718
+ engine: search.engine,
719
+ durationMs: elapsed(startedAt),
720
+ };
721
+ };
722
+ return nothingLeft ? finish(NOTHING_SEARCHED) : { request, finish };
723
+ }
724
+ /**
725
+ * What a report says about targets that are not there: a warning always, and a
726
+ * failure unless `--allow-missing-targets` tolerates it. One place for the
727
+ * words, since every kind of rule that has a target says them.
728
+ */
729
+ function missingTargets(missing) {
730
+ const one = missing.length === 1;
566
731
  return {
567
- request,
568
- finish: (search) => {
569
- // A file that could not be read, or whose bytes are not text but did
570
- // contain the symbol, is a hole in the answer rather than a detail of it.
571
- // The count is still reported, because it is still true of everything
572
- // that was read; --strict is for runs where "true of what we read" is not
573
- // good enough.
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;
581
- const strictFailure = options.strictTargets && gaps.length > 0;
582
- const { excluded, stale, shows } = applyBaseline(assertion.baseline, search.fileCounts);
583
- const staleFailure = assertion.ratchet === "two-sided" && stale.length > 0;
584
- const actual = search.count - excluded;
585
- return {
586
- ...base,
587
- ok: satisfies(actual, assertion.bounds) && !strictFailure && !staleFailure,
588
- actual,
589
- message: describeOutcome(assertion.bounds, actual, {
590
- excluded,
591
- stale: staleFailure ? stale : [],
592
- gaps: strictFailure ? gaps.length : 0,
593
- }),
594
- baselinedMatches: excluded,
595
- staleBaseline: stale,
596
- fileMatches: [...search.fileCounts].map(([file, count]) => ({ file, count })),
597
- matches: search.matches.filter((match) => shows(match.file)).slice(0, options.maxSnippets),
598
- // Carried as numbers, not prose, so the reporter can total them across a
599
- // run and JSON consumers can act on them.
600
- commentMatches: search.commentMatches,
601
- unclassifiedFiles: search.unclassifiedFiles,
602
- scope: search.scope,
603
- engine: search.engine,
604
- durationMs: elapsed(startedAt),
605
- };
606
- },
732
+ warning: `target path${one ? "" : "s"} not found: ${missing.join(", ")}`,
733
+ failure: `target path${one ? " does" : "s do"} not exist: ${missing.join(", ")}`,
607
734
  };
608
735
  }
736
+ /**
737
+ * Holds a structure rule to the tree, by name. ADR-0013.
738
+ *
739
+ * Four things fail before any count is read, because each is a rule that would
740
+ * otherwise pass without checking what it says: a missing target, a `required`
741
+ * target that is a file, a partner template that names the file itself, and a
742
+ * scope holding nothing.
743
+ */
744
+ async function executeStructureAssertion(assertion, query, options, base, warnings, startedAt) {
745
+ const search = assertion.search;
746
+ const check = await checkStructure(query, {
747
+ targets: assertion.targets,
748
+ globs: search.globs,
749
+ excludeGlobs: search.excludeGlobs,
750
+ scope: search.scope,
751
+ }, options.tree);
752
+ assertion.missingTargets.push(...check.missing);
753
+ const notFound = missingTargets(check.missing);
754
+ if (check.missing.length > 0)
755
+ warnings.push(notFound.warning);
756
+ const files = check.notDirectories;
757
+ const self = check.selfPartner;
758
+ const failure = !options.allowMissingTargets && check.missing.length > 0
759
+ ? notFound.failure
760
+ : files.length > 0
761
+ ? `required="..." is about directories, and ${files.length === 1 ? "this target is a file" : "these targets are files"}: ${files.join(", ")}`
762
+ : self !== undefined
763
+ ? `partner template ${self.template} names ${self.file} itself, so every file would be its own partner`
764
+ : check.inspected === 0 && !options.allowEmptyScope && !assertion.allowEmpty
765
+ ? `no ${query.claim === "required" ? "directories were selected" : "files were inspected"}, so this assertion verified nothing (${EMPTY_SCOPE_HINT})`
766
+ : null;
767
+ const fileCounts = new Map(check.violations.map((violation) => [violation.path, 1]));
768
+ const { excluded, stale, shows } = applyBaseline(assertion.baseline, fileCounts);
769
+ const staleFailure = assertion.ratchet === "two-sided" && stale.length > 0;
770
+ const gaps = check.scope.skipped.length;
771
+ const strictFailure = options.strictTargets && gaps > 0;
772
+ const actual = check.violations.length - excluded;
773
+ return {
774
+ ...base,
775
+ ok: failure === null && satisfies(actual, assertion.bounds) && !staleFailure && !strictFailure,
776
+ actual,
777
+ message: failure ??
778
+ describeOutcome(assertion.bounds, actual, { excluded, stale: staleFailure ? stale : [], gaps: strictFailure ? gaps : 0 }, STRUCTURE_UNITS[query.claim]),
779
+ baselinedMatches: excluded,
780
+ staleBaseline: stale,
781
+ fileMatches: [...fileCounts].map(([file, count]) => ({ file, count })),
782
+ // No line and no column: what is wrong is the path itself.
783
+ matches: check.violations
784
+ .filter((violation) => shows(violation.path))
785
+ .slice(0, options.maxSnippets)
786
+ .map((violation) => ({ file: violation.path, line: 0, column: 0, text: violation.text, count: 1 })),
787
+ scope: check.scope,
788
+ durationMs: elapsed(startedAt),
789
+ };
790
+ }
791
+ const NOTHING_SEARCHED = {
792
+ count: 0,
793
+ commentMatches: 0,
794
+ unclassifiedFiles: 0,
795
+ matches: [],
796
+ fileCounts: new Map(),
797
+ scope: EMPTY_LEDGER,
798
+ };
609
799
  /** How each kind of reference is spelled in a report snippet. */
610
800
  const IMPORT_VERBS = {
611
801
  import: "import",
@@ -631,17 +821,28 @@ async function executeImportAssertion(assertion, options, base, warnings, starte
631
821
  else
632
822
  assertion.missingTargets.push(target);
633
823
  }
634
- if (assertion.missingTargets.length > 0) {
635
- warnings.push(`target path${assertion.missingTargets.length === 1 ? "" : "s"} not found: ${assertion.missingTargets.join(", ")}`);
636
- }
824
+ const notFound = missingTargets(assertion.missingTargets);
825
+ if (assertion.missingTargets.length > 0)
826
+ warnings.push(notFound.warning);
637
827
  // No symbol: an import assertion never runs a text search, and the walk does
638
828
  // not depend on one. This used to pass `symbol: ""`, an invented value that
639
829
  // reached nothing and that no test could therefore be wrong about.
640
- const enumeration = await enumerateCandidates({
641
- root: options.root,
642
- targets: existingTargets,
643
- options: assertion.search,
644
- });
830
+ //
831
+ // And no walk at all when every target is missing: an empty target list is
832
+ // the root to the engine, as the text rules above found out.
833
+ const enumeration = existingTargets.length === 0
834
+ ? { files: [] }
835
+ : await enumerateCandidates({
836
+ root: options.root,
837
+ targets: existingTargets,
838
+ options: assertion.search,
839
+ });
840
+ // Why a missing target fails the assertion, or null when it does not. Decided
841
+ // before scope, so a rule whose only target is gone says so rather than that
842
+ // its scope is empty - which is true, and not the thing to fix.
843
+ const missing = !options.allowMissingTargets && assertion.missingTargets.length > 0
844
+ ? notFound.failure
845
+ : null;
645
846
  // A cycle needs files the resolver can name, which today means JavaScript
646
847
  // and TypeScript; every other import rule reads any language spec-guard
647
848
  // tokenizes. ADR-0011 has the reasons, language by language.
@@ -649,7 +850,7 @@ async function executeImportAssertion(assertion, options, base, warnings, starte
649
850
  const analysable = enumeration.files.filter((file) => cycles
650
851
  ? isGraphFile(file.relativePath)
651
852
  : ANALYSABLE_EXTENSIONS.has(path.posix.extname(file.relativePath)));
652
- if (analysable.length === 0 && !options.allowEmptyScope && !assertion.allowEmpty) {
853
+ if (analysable.length === 0 && missing === null && !options.allowEmptyScope && !assertion.allowEmpty) {
653
854
  // For an import rule the bar is higher than "some file exists": a directory
654
855
  // of YAML has nothing this can read, so a dependency claim about it is a
655
856
  // claim about nothing.
@@ -689,9 +890,7 @@ async function executeImportAssertion(assertion, options, base, warnings, starte
689
890
  walked: enumeration.files.map((file) => file.relativePath),
690
891
  targets: existingTargets,
691
892
  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,
893
+ missing,
695
894
  };
696
895
  if (cycles)
697
896
  return finishCycles(assertion, options, base, warnings, startedAt, read);
@@ -891,50 +1090,42 @@ export async function runSpecGuard(options) {
891
1090
  const allowMissingTargets = options.allowMissingTargets ?? false;
892
1091
  const allowEmptyScope = options.allowEmptyScope ?? false;
893
1092
  const scope = createScope(options.defaultSkips ?? true);
894
- const specFiles = await expandSpecPatterns(options.patterns, root);
895
- const excludeFiles = new Set(options.includeSpecs ? [] : specFiles.map((file) => path.resolve(file)));
1093
+ const specs = await readSpecs(options.patterns, root);
1094
+ const specFiles = specs.files;
1095
+ const excludeFiles = specExclusions(specFiles, options.includeSpecs ?? false);
896
1096
  const directives = [];
897
1097
  /** Parsed, validated, and then not run: see ADR-0010. */
898
1098
  const withheld = [];
899
- const errors = [];
1099
+ const errors = [...specs.errors];
900
1100
  const inactiveSpecs = [];
901
- for (const file of specFiles) {
902
- const relativeFile = toPosix(path.relative(root, file)) || toPosix(file);
903
- const source = await fs.readFile(file, "utf8").catch((error) => {
904
- errors.push({
905
- location: { file, relativeFile, line: 1, column: 1 },
906
- raw: "",
907
- message: `Unable to read spec file: ${error instanceof Error ? error.message : String(error)}`,
908
- });
909
- return null;
910
- });
911
- if (source === null)
912
- continue;
913
- const parsed = parseDirectives(source, { file, relativeFile });
914
- errors.push(...parsed.errors);
915
- const status = parsed.status;
916
- if (status && !status.active && !(options.ignoreStatus ?? false)) {
917
- withheld.push(...parsed.directives);
1101
+ for (const document of specs.documents) {
1102
+ if (!document.inForce && !(options.ignoreStatus ?? false)) {
1103
+ // A document is only ever out of force because of a status it declares.
1104
+ const status = document.status;
1105
+ withheld.push(...document.directives);
918
1106
  // Recorded even when it held no directives. "docs/adr/0011.md is a
919
1107
  // draft" is worth saying to someone wondering why their new rule has no
920
1108
  // effect, and a report that only mentions the documents it happened to
921
1109
  // find directives in cannot answer that.
922
1110
  inactiveSpecs.push({
923
- file: relativeFile,
1111
+ file: document.relativeFile,
924
1112
  status: status.value,
925
1113
  label: status.label,
926
- directives: parsed.directives.length,
1114
+ directives: document.directives.length,
927
1115
  });
928
1116
  continue;
929
1117
  }
930
- directives.push(...parsed.directives);
1118
+ directives.push(...document.directives);
931
1119
  }
932
1120
  const assertions = [];
933
1121
  for (const directive of directives) {
934
1122
  const resolved = resolveDirective(directive, { root, excludeFiles, scope });
935
1123
  if ("error" in resolved)
936
1124
  errors.push(resolved.error);
937
- else
1125
+ // Selection happens after resolution, so a directive that is not selected
1126
+ // is still held to being well-formed - the same bargain ADR-0010 strikes
1127
+ // for a document that is not in force.
1128
+ else if (options.select?.(resolved.assertion) ?? true)
938
1129
  assertions.push(resolved.assertion);
939
1130
  }
940
1131
  // Not in force is not the same as not checked. A withheld directive is still
@@ -956,6 +1147,7 @@ export async function runSpecGuard(options) {
956
1147
  maxSnippets,
957
1148
  imports: createImportIndex(),
958
1149
  hasFiles: createScopeProbe(),
1150
+ tree: createTreeIndex(root),
959
1151
  };
960
1152
  const results = [];
961
1153
  if (options.failFast) {
@@ -1028,7 +1220,7 @@ export async function runSpecGuard(options) {
1028
1220
  errors,
1029
1221
  warnings,
1030
1222
  inactiveSpecs,
1031
- specFiles: specFiles.map((file) => toPosix(path.relative(root, file)) || toPosix(file)),
1223
+ specFiles: specFiles.map((file) => specPath(root, file)),
1032
1224
  };
1033
1225
  }
1034
1226
  //# sourceMappingURL=runner.js.map