@descent-vtt/spec-guard 0.7.0 → 0.9.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 (76) hide show
  1. package/CHANGELOG.md +147 -0
  2. package/README.md +224 -27
  3. package/dist/cli.d.ts +37 -2
  4. package/dist/cli.d.ts.map +1 -1
  5. package/dist/cli.js +275 -59
  6. package/dist/cli.js.map +1 -1
  7. package/dist/config.d.ts +66 -0
  8. package/dist/config.d.ts.map +1 -0
  9. package/dist/config.js +165 -0
  10. package/dist/config.js.map +1 -0
  11. package/dist/engine.d.ts +22 -8
  12. package/dist/engine.d.ts.map +1 -1
  13. package/dist/engine.js +118 -67
  14. package/dist/engine.js.map +1 -1
  15. package/dist/facts.d.ts +106 -0
  16. package/dist/facts.d.ts.map +1 -0
  17. package/dist/facts.js +188 -0
  18. package/dist/facts.js.map +1 -0
  19. package/dist/glob.d.ts +17 -21
  20. package/dist/glob.d.ts.map +1 -1
  21. package/dist/glob.js +13 -24
  22. package/dist/glob.js.map +1 -1
  23. package/dist/imports.d.ts +6 -1
  24. package/dist/imports.d.ts.map +1 -1
  25. package/dist/imports.js +9 -5
  26. package/dist/imports.js.map +1 -1
  27. package/dist/index.d.ts +20 -8
  28. package/dist/index.d.ts.map +1 -1
  29. package/dist/index.js +11 -5
  30. package/dist/index.js.map +1 -1
  31. package/dist/io.d.ts +51 -0
  32. package/dist/io.d.ts.map +1 -0
  33. package/dist/io.js +44 -0
  34. package/dist/io.js.map +1 -0
  35. package/dist/mcp.d.ts +11 -0
  36. package/dist/mcp.d.ts.map +1 -1
  37. package/dist/mcp.js +23 -18
  38. package/dist/mcp.js.map +1 -1
  39. package/dist/memo.d.ts +53 -0
  40. package/dist/memo.d.ts.map +1 -0
  41. package/dist/memo.js +67 -0
  42. package/dist/memo.js.map +1 -0
  43. package/dist/parser.d.ts.map +1 -1
  44. package/dist/parser.js +17 -0
  45. package/dist/parser.js.map +1 -1
  46. package/dist/query.d.ts +5 -2
  47. package/dist/query.d.ts.map +1 -1
  48. package/dist/query.js +12 -3
  49. package/dist/query.js.map +1 -1
  50. package/dist/reporter.d.ts +10 -0
  51. package/dist/reporter.d.ts.map +1 -1
  52. package/dist/reporter.js +33 -4
  53. package/dist/reporter.js.map +1 -1
  54. package/dist/rules.d.ts +12 -1
  55. package/dist/rules.d.ts.map +1 -1
  56. package/dist/rules.js +45 -1
  57. package/dist/rules.js.map +1 -1
  58. package/dist/runner.d.ts +42 -2
  59. package/dist/runner.d.ts.map +1 -1
  60. package/dist/runner.js +261 -76
  61. package/dist/runner.js.map +1 -1
  62. package/dist/specs.d.ts +3 -1
  63. package/dist/specs.d.ts.map +1 -1
  64. package/dist/specs.js +6 -5
  65. package/dist/specs.js.map +1 -1
  66. package/dist/structure.d.ts +104 -0
  67. package/dist/structure.d.ts.map +1 -0
  68. package/dist/structure.js +254 -0
  69. package/dist/structure.js.map +1 -0
  70. package/dist/types.d.ts +50 -5
  71. package/dist/types.d.ts.map +1 -1
  72. package/dist/watch.d.ts +125 -0
  73. package/dist/watch.d.ts.map +1 -0
  74. package/dist/watch.js +321 -0
  75. package/dist/watch.js.map +1 -0
  76. package/package.json +8 -2
package/dist/runner.js CHANGED
@@ -5,15 +5,16 @@
5
5
  * number, an unknown boolean or a path escaping the root is an error, never a
6
6
  * silently-passing assertion. A spec that lies is worse than no spec at all.
7
7
  */
8
- import { promises as fs } from "node:fs";
9
8
  import path from "node:path";
10
9
  import { comparePaths, createCachedEngine, enumerateCandidates, passKey, resolveEngine, runSearches, ANY_FILE_PROBE, ROOT_TARGETS, } from "./engine.js";
11
10
  import { createExcludeMatcher, toPosix } from "./glob.js";
11
+ import { nodeIo } from "./io.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
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
  /**
@@ -242,7 +243,7 @@ export function applyBaseline(entries, fileCounts) {
242
243
  * answer. Without the cache the probe measured about 0.9 ms per assertion,
243
244
  * which is small until a spec has two hundred of them.
244
245
  */
245
- export function createScopeProbe() {
246
+ export function createScopeProbe(io = nodeIo) {
246
247
  const cache = new Map();
247
248
  return (request) => {
248
249
  const key = JSON.stringify([
@@ -255,7 +256,7 @@ export function createScopeProbe() {
255
256
  if (!pending) {
256
257
  // The walk abandons at the first file it finds, so proving a scope is
257
258
  // populated costs a couple of directory reads rather than a traversal.
258
- pending = enumerateCandidates(request, ANY_FILE_PROBE).then((enumeration) => enumeration.files.length > 0);
259
+ pending = enumerateCandidates(request, ANY_FILE_PROBE, io).then((enumeration) => enumeration.files.length > 0);
259
260
  cache.set(key, pending);
260
261
  }
261
262
  return pending;
@@ -294,7 +295,6 @@ export function resolveDirective(directive, context) {
294
295
  targets: [],
295
296
  files,
296
297
  bounds: { min: files.length, max: files.length },
297
- missingTargets: [],
298
298
  // A file list is never empty here: resolution rejects that above.
299
299
  allowEmpty: true,
300
300
  baseline: [],
@@ -307,9 +307,23 @@ export function resolveDirective(directive, context) {
307
307
  // itself and names no subject; layers name theirs in `order`.
308
308
  const graphKind = kind === "assert-import-cycle" || kind === "assert-layers";
309
309
  const isImportKind = perModule || graphKind;
310
- const subject = kind === "assert-layers" ? "order" : perModule ? "module" : "symbol";
310
+ // ADR-0013: a structure rule names its subject by naming its claim, and
311
+ // names exactly one. Two claims in one directive would count two different
312
+ // things in one number.
313
+ const structureKind = kind === "assert-structure";
314
+ const claims = STRUCTURE_CLAIMS.filter((claim) => attributes[claim] !== undefined);
315
+ if (structureKind && claims.length !== 1) {
316
+ return fail(claims.length === 0
317
+ ? '@assert-structure requires one of pattern="...", required="..." or partner="...".'
318
+ : `@assert-structure makes one claim per directive, got ${claims.join(" and ")}.`);
319
+ }
320
+ const subject = structureKind
321
+ ? claims[0]
322
+ : kind === "assert-layers" ? "order" : perModule ? "module" : "symbol";
311
323
  const symbol = attributes[subject] ?? "";
312
- if (kind !== "assert-import-cycle" && symbol.length === 0) {
324
+ // A list attribute is empty when it lists nothing, whatever its length; a
325
+ // symbol of one space is a symbol.
326
+ if (kind !== "assert-import-cycle" && (structureKind ? splitList(symbol).length : symbol.length) === 0) {
313
327
  return fail(`@${kind} requires a non-empty ${subject}="..." attribute.`);
314
328
  }
315
329
  const allowEmpty = parseBoolean(attributes["allow-empty"], "allow-empty");
@@ -332,7 +346,7 @@ export function resolveDirective(directive, context) {
332
346
  const rawTargets = splitList(attributes["target"]);
333
347
  const targets = (rawTargets.length > 0 ? rawTargets : ROOT_TARGETS).map((target) => normalizeTarget(target, context.root, "target"));
334
348
  const bounds = {};
335
- if (kind === "assert-absence" || kind === "assert-import-absence" || graphKind) {
349
+ if (kind === "assert-absence" || kind === "assert-import-absence" || graphKind || structureKind) {
336
350
  if (attributes["expected"] !== undefined &&
337
351
  attributes["max"] !== undefined) {
338
352
  return fail(`@${kind} accepts either expected="..." or max="...", not both.`);
@@ -371,6 +385,16 @@ export function resolveDirective(directive, context) {
371
385
  }
372
386
  }
373
387
  }
388
+ if (structureKind) {
389
+ return resolveStructure(directive, subject, splitList(symbol), {
390
+ targets,
391
+ bounds,
392
+ allowEmpty,
393
+ baseline,
394
+ ratchet,
395
+ scope: context.scope ?? DEFAULT_SCOPE,
396
+ });
397
+ }
374
398
  if (isImportKind) {
375
399
  const scope = targets.join(", ");
376
400
  const excludeGlobs = splitList(attributes["exclude"]);
@@ -423,7 +447,6 @@ export function resolveDirective(directive, context) {
423
447
  },
424
448
  imports: { modules: perModule ? splitList(symbol) : [], includeTypes },
425
449
  ...(kind === "assert-layers" ? { layers } : {}),
426
- missingTargets: [],
427
450
  allowEmpty,
428
451
  baseline,
429
452
  ratchet,
@@ -466,7 +489,6 @@ export function resolveDirective(directive, context) {
466
489
  files: [],
467
490
  bounds,
468
491
  search,
469
- missingTargets: [],
470
492
  allowEmpty,
471
493
  baseline,
472
494
  ratchet,
@@ -477,6 +499,80 @@ export function resolveDirective(directive, context) {
477
499
  return fail(error instanceof Error ? error.message : String(error));
478
500
  }
479
501
  }
502
+ const STRUCTURE_CLAIMS = ["pattern", "required", "partner"];
503
+ /** What each structure claim counts. */
504
+ const STRUCTURE_UNITS = {
505
+ pattern: { one: "misnamed file", many: "misnamed files" },
506
+ required: { one: "directory missing an entry", many: "directories missing an entry" },
507
+ partner: { one: "file without a partner", many: "files without a partner" },
508
+ };
509
+ /**
510
+ * The rest of an `@assert-structure` directive, once what every rule shares is
511
+ * read. Throws for a directive that cannot mean what it says, which
512
+ * `resolveDirective` reports as the directive's error.
513
+ */
514
+ function resolveStructure(directive, claim, values, shared) {
515
+ const { attributes } = directive;
516
+ const required = claim === "required";
517
+ const globs = splitList(attributes["glob"]);
518
+ const excludeGlobs = splitList(attributes["exclude"]);
519
+ // Two attributes that would otherwise be read and ignored, which is a rule
520
+ // saying something it does not check.
521
+ if (attributes["dirs"] !== undefined && !required) {
522
+ throw new Error(`Attribute "dirs" chooses the directories of required="...", and this directive claims ${claim}="...".`);
523
+ }
524
+ if (required && globs.length > 0) {
525
+ throw new Error(`Attribute "glob" chooses files, and required="..." is about directories; dirs="..." chooses those.`);
526
+ }
527
+ const dirs = attributes["dirs"]?.trim().replace(/^\.\//, "").replace(/\/+$/, "");
528
+ if (dirs === "")
529
+ throw new Error(`Attribute "dirs" must not be empty.`);
530
+ const issue = values
531
+ .map((value) => (required ? requiredEntryIssue(value) : claim === "partner" ? partnerTemplateIssue(value) : null))
532
+ .find((found) => found !== null);
533
+ if (issue)
534
+ throw new Error(issue);
535
+ const scope = shared.targets.join(", ");
536
+ const matching = globs.length > 0 ? ` matching ${globs.join(", ")}` : "";
537
+ const claimed = claim === "pattern"
538
+ ? `files in ${scope}${matching} must be named ${values.join(" or ")}`
539
+ : claim === "partner"
540
+ ? `files in ${scope}${matching} must each have a partner ${values.join(" or ")}`
541
+ : dirs === undefined
542
+ ? `${scope} must contain ${values.join(", ")}`
543
+ : `directories matching ${dirs} under ${scope} must contain ${values.join(", ")}`;
544
+ const allowance = shared.bounds.max === 0 ? "" : `, with ${describeBounds(shared.bounds, STRUCTURE_UNITS[claim])}`;
545
+ const except = excludeGlobs.length > 0 ? ` (excluding ${excludeGlobs.join(", ")})` : "";
546
+ return {
547
+ assertion: {
548
+ kind: directive.kind,
549
+ location: directive.location,
550
+ description: `${claimed}${allowance}${except}`,
551
+ reason: attributes["reason"],
552
+ targets: shared.targets,
553
+ files: [],
554
+ bounds: shared.bounds,
555
+ search: {
556
+ regex: false,
557
+ word: false,
558
+ ignoreCase: false,
559
+ globs,
560
+ excludeGlobs,
561
+ // Nothing is read, so there is no comment to leave out.
562
+ ignoreComments: false,
563
+ scope: shared.scope,
564
+ // The spec files are in scope. Every other rule leaves them out so a
565
+ // text rule does not find its own directive, and a structure rule reads
566
+ // no text - while a rule about what the ADRs are called is about them.
567
+ excludeFiles: new Set(),
568
+ },
569
+ structure: dirs === undefined ? { claim, values } : { claim, values, dirs },
570
+ allowEmpty: shared.allowEmpty,
571
+ baseline: shared.baseline,
572
+ ratchet: shared.ratchet,
573
+ },
574
+ };
575
+ }
480
576
  /**
481
577
  * The spec files a search must not count, as absolute paths.
482
578
  *
@@ -486,8 +582,23 @@ export function resolveDirective(directive, context) {
486
582
  export function specExclusions(specFiles, includeSpecs) {
487
583
  return new Set(includeSpecs ? undefined : specFiles.map((file) => path.resolve(file)));
488
584
  }
489
- async function pathExists(candidate) {
490
- return (await fs.stat(candidate).catch(() => null)) !== null;
585
+ async function pathExists(io, candidate) {
586
+ return (await io.stat(candidate)) !== null;
587
+ }
588
+ /**
589
+ * The targets that exist and the ones that do not, in the order written.
590
+ *
591
+ * Returned rather than recorded on the assertion, which is what execution used
592
+ * to do: the second execution of a rule then named each missing target twice.
593
+ * A watch session executes its rules again and again (ADR-0014).
594
+ */
595
+ async function partitionTargets(targets, options) {
596
+ const existing = [];
597
+ const missing = [];
598
+ for (const target of targets) {
599
+ (await pathExists(options.io, path.resolve(options.root, target)) ? existing : missing).push(target);
600
+ }
601
+ return { existing, missing };
491
602
  }
492
603
  /**
493
604
  * Resolves everything about an assertion that does not need a search, and
@@ -505,6 +616,7 @@ async function prepareAssertion(assertion, options) {
505
616
  targets: assertion.targets,
506
617
  files: assertion.files,
507
618
  bounds: assertion.bounds,
619
+ claim: assertion.structure?.claim,
508
620
  warnings,
509
621
  commentMatches: 0,
510
622
  unclassifiedFiles: 0,
@@ -516,7 +628,7 @@ async function prepareAssertion(assertion, options) {
516
628
  if (assertion.kind === "assert-present") {
517
629
  const missing = [];
518
630
  for (const file of assertion.files) {
519
- if (!(await pathExists(path.resolve(options.root, file))))
631
+ if (!(await pathExists(options.io, path.resolve(options.root, file))))
520
632
  missing.push(file);
521
633
  }
522
634
  const actual = assertion.files.length - missing.length;
@@ -534,22 +646,19 @@ async function prepareAssertion(assertion, options) {
534
646
  if (assertion.imports) {
535
647
  return executeImportAssertion(assertion, options, base, warnings, startedAt);
536
648
  }
537
- const existingTargets = [];
538
- for (const target of assertion.targets) {
539
- if (await pathExists(path.resolve(options.root, target)))
540
- existingTargets.push(target);
541
- else
542
- assertion.missingTargets.push(target);
649
+ if (assertion.structure) {
650
+ return executeStructureAssertion(assertion, assertion.structure, options, base, warnings, startedAt);
543
651
  }
544
- if (assertion.missingTargets.length > 0) {
545
- warnings.push(`target path${assertion.missingTargets.length === 1 ? "" : "s"} not found: ${assertion.missingTargets.join(", ")}`);
546
- }
547
- if (!options.allowMissingTargets && assertion.missingTargets.length > 0) {
652
+ const { existing: existingTargets, missing } = await partitionTargets(assertion.targets, options);
653
+ const notFound = missingTargets(missing);
654
+ if (missing.length > 0)
655
+ warnings.push(notFound.warning);
656
+ if (!options.allowMissingTargets && missing.length > 0) {
548
657
  return {
549
658
  ...base,
550
659
  ok: false,
551
660
  actual: 0,
552
- message: `target path${assertion.missingTargets.length === 1 ? " does" : "s do"} not exist: ${assertion.missingTargets.join(", ")}`,
661
+ message: notFound.failure,
553
662
  matches: [],
554
663
  durationMs: elapsed(startedAt),
555
664
  };
@@ -617,6 +726,72 @@ async function prepareAssertion(assertion, options) {
617
726
  };
618
727
  return nothingLeft ? finish(NOTHING_SEARCHED) : { request, finish };
619
728
  }
729
+ /**
730
+ * What a report says about targets that are not there: a warning always, and a
731
+ * failure unless `--allow-missing-targets` tolerates it. One place for the
732
+ * words, since every kind of rule that has a target says them.
733
+ */
734
+ function missingTargets(missing) {
735
+ const one = missing.length === 1;
736
+ return {
737
+ warning: `target path${one ? "" : "s"} not found: ${missing.join(", ")}`,
738
+ failure: `target path${one ? " does" : "s do"} not exist: ${missing.join(", ")}`,
739
+ };
740
+ }
741
+ /**
742
+ * Holds a structure rule to the tree, by name. ADR-0013.
743
+ *
744
+ * Four things fail before any count is read, because each is a rule that would
745
+ * otherwise pass without checking what it says: a missing target, a `required`
746
+ * target that is a file, a partner template that names the file itself, and a
747
+ * scope holding nothing.
748
+ */
749
+ async function executeStructureAssertion(assertion, query, options, base, warnings, startedAt) {
750
+ const search = assertion.search;
751
+ const check = await checkStructure(query, {
752
+ targets: assertion.targets,
753
+ globs: search.globs,
754
+ excludeGlobs: search.excludeGlobs,
755
+ scope: search.scope,
756
+ }, options.tree);
757
+ const notFound = missingTargets(check.missing);
758
+ if (check.missing.length > 0)
759
+ warnings.push(notFound.warning);
760
+ const files = check.notDirectories;
761
+ const self = check.selfPartner;
762
+ const failure = !options.allowMissingTargets && check.missing.length > 0
763
+ ? notFound.failure
764
+ : files.length > 0
765
+ ? `required="..." is about directories, and ${files.length === 1 ? "this target is a file" : "these targets are files"}: ${files.join(", ")}`
766
+ : self !== undefined
767
+ ? `partner template ${self.template} names ${self.file} itself, so every file would be its own partner`
768
+ : check.inspected === 0 && !options.allowEmptyScope && !assertion.allowEmpty
769
+ ? `no ${query.claim === "required" ? "directories were selected" : "files were inspected"}, so this assertion verified nothing (${EMPTY_SCOPE_HINT})`
770
+ : null;
771
+ const fileCounts = new Map(check.violations.map((violation) => [violation.path, 1]));
772
+ const { excluded, stale, shows } = applyBaseline(assertion.baseline, fileCounts);
773
+ const staleFailure = assertion.ratchet === "two-sided" && stale.length > 0;
774
+ const gaps = check.scope.skipped.length;
775
+ const strictFailure = options.strictTargets && gaps > 0;
776
+ const actual = check.violations.length - excluded;
777
+ return {
778
+ ...base,
779
+ ok: failure === null && satisfies(actual, assertion.bounds) && !staleFailure && !strictFailure,
780
+ actual,
781
+ message: failure ??
782
+ describeOutcome(assertion.bounds, actual, { excluded, stale: staleFailure ? stale : [], gaps: strictFailure ? gaps : 0 }, STRUCTURE_UNITS[query.claim]),
783
+ baselinedMatches: excluded,
784
+ staleBaseline: stale,
785
+ fileMatches: [...fileCounts].map(([file, count]) => ({ file, count })),
786
+ // No line and no column: what is wrong is the path itself.
787
+ matches: check.violations
788
+ .filter((violation) => shows(violation.path))
789
+ .slice(0, options.maxSnippets)
790
+ .map((violation) => ({ file: violation.path, line: 0, column: 0, text: violation.text, count: 1 })),
791
+ scope: check.scope,
792
+ durationMs: elapsed(startedAt),
793
+ };
794
+ }
620
795
  const NOTHING_SEARCHED = {
621
796
  count: 0,
622
797
  commentMatches: 0,
@@ -643,16 +818,10 @@ const IMPORT_VERBS = {
643
818
  */
644
819
  async function executeImportAssertion(assertion, options, base, warnings, startedAt) {
645
820
  const query = assertion.imports;
646
- const existingTargets = [];
647
- for (const target of assertion.targets) {
648
- if (await pathExists(path.resolve(options.root, target)))
649
- existingTargets.push(target);
650
- else
651
- assertion.missingTargets.push(target);
652
- }
653
- if (assertion.missingTargets.length > 0) {
654
- warnings.push(`target path${assertion.missingTargets.length === 1 ? "" : "s"} not found: ${assertion.missingTargets.join(", ")}`);
655
- }
821
+ const { existing: existingTargets, missing: absent } = await partitionTargets(assertion.targets, options);
822
+ const notFound = missingTargets(absent);
823
+ if (absent.length > 0)
824
+ warnings.push(notFound.warning);
656
825
  // No symbol: an import assertion never runs a text search, and the walk does
657
826
  // not depend on one. This used to pass `symbol: ""`, an invented value that
658
827
  // reached nothing and that no test could therefore be wrong about.
@@ -665,12 +834,12 @@ async function executeImportAssertion(assertion, options, base, warnings, starte
665
834
  root: options.root,
666
835
  targets: existingTargets,
667
836
  options: assertion.search,
668
- });
837
+ }, undefined, options.io);
669
838
  // Why a missing target fails the assertion, or null when it does not. Decided
670
839
  // before scope, so a rule whose only target is gone says so rather than that
671
840
  // 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(", ")}`
841
+ const missing = !options.allowMissingTargets && absent.length > 0
842
+ ? notFound.failure
674
843
  : null;
675
844
  // A cycle needs files the resolver can name, which today means JavaScript
676
845
  // and TypeScript; every other import rule reads any language spec-guard
@@ -910,18 +1079,15 @@ export async function executeAssertion(assertion, options) {
910
1079
  export function batchConcurrency(requested, batches) {
911
1080
  return Math.max(1, Math.min(requested, batches));
912
1081
  }
913
- /** Reads, parses and executes every directive found in the given spec files. */
914
- export async function runSpecGuard(options) {
915
- const startedAt = performance.now();
916
- const root = path.resolve(options.root ?? process.cwd());
917
- const maxSnippets = options.maxSnippets ?? DEFAULT_MAX_SNIPPETS;
918
- const strictTargets = options.strictTargets ?? false;
919
- const allowMissingTargets = options.allowMissingTargets ?? false;
920
- const allowEmptyScope = options.allowEmptyScope ?? false;
1082
+ /**
1083
+ * Resolves what a set of specs asks for, reading nothing.
1084
+ *
1085
+ * The first half of `runSpecGuard`, moved out so that a watch session plans
1086
+ * each run the way a run does rather than by a copy of it (ADR-0014).
1087
+ */
1088
+ export function planRun(specs, root, options) {
921
1089
  const scope = createScope(options.defaultSkips ?? true);
922
- const specs = await readSpecs(options.patterns, root);
923
- const specFiles = specs.files;
924
- const excludeFiles = specExclusions(specFiles, options.includeSpecs ?? false);
1090
+ const excludeFiles = specExclusions(specs.files, options.includeSpecs ?? false);
925
1091
  const directives = [];
926
1092
  /** Parsed, validated, and then not run: see ADR-0010. */
927
1093
  const withheld = [];
@@ -966,16 +1132,59 @@ export async function runSpecGuard(options) {
966
1132
  if ("error" in resolved)
967
1133
  errors.push(resolved.error);
968
1134
  }
1135
+ return { root, specFiles: specs.files, assertions, withheld: withheld.length, errors, inactiveSpecs };
1136
+ }
1137
+ /**
1138
+ * A run's report, from its plan and the results of the rules it executed.
1139
+ *
1140
+ * The last part of `runSpecGuard`, shared with a watch session for the same
1141
+ * reason as `planRun`.
1142
+ */
1143
+ export function reportRun(plan, results, engine, startedAt) {
1144
+ const warnings = engine.fallbacks.map((message) => `ripgrep failed, fell back to the JavaScript engine (${message})`);
1145
+ // Errors arrive in two waves (parse, then resolve); readers expect file order.
1146
+ const errors = [...plan.errors].sort((a, b) => a.location.relativeFile === b.location.relativeFile
1147
+ ? a.location.line - b.location.line
1148
+ : comparePaths(a.location.relativeFile, b.location.relativeFile));
1149
+ const failed = results.filter((result) => !result.ok).length;
1150
+ return {
1151
+ ok: failed === 0 && errors.length === 0,
1152
+ root: plan.root,
1153
+ engine: warnings.length > 0 ? "javascript" : engine.name,
1154
+ durationMs: elapsed(startedAt),
1155
+ summary: {
1156
+ specs: plan.specFiles.length,
1157
+ total: results.length,
1158
+ passed: results.length - failed,
1159
+ failed,
1160
+ skipped: plan.assertions.length - results.length,
1161
+ inactive: plan.withheld,
1162
+ },
1163
+ results,
1164
+ errors,
1165
+ warnings,
1166
+ inactiveSpecs: plan.inactiveSpecs,
1167
+ specFiles: plan.specFiles.map((file) => specPath(plan.root, file)),
1168
+ };
1169
+ }
1170
+ /** Reads, parses and executes every directive found in the given spec files. */
1171
+ export async function runSpecGuard(options) {
1172
+ const startedAt = performance.now();
1173
+ const root = path.resolve(options.root ?? process.cwd());
1174
+ const plan = planRun(await readSpecs(options.patterns, root), root, options);
1175
+ const assertions = plan.assertions;
969
1176
  const engine = createCachedEngine(await resolveEngine(options.engine ?? "auto"));
970
1177
  const executeOptions = {
971
1178
  root,
1179
+ io: nodeIo,
972
1180
  engine,
973
- allowMissingTargets,
974
- strictTargets,
975
- allowEmptyScope,
976
- maxSnippets,
1181
+ allowMissingTargets: options.allowMissingTargets ?? false,
1182
+ strictTargets: options.strictTargets ?? false,
1183
+ allowEmptyScope: options.allowEmptyScope ?? false,
1184
+ maxSnippets: options.maxSnippets ?? DEFAULT_MAX_SNIPPETS,
977
1185
  imports: createImportIndex(),
978
1186
  hasFiles: createScopeProbe(),
1187
+ tree: createTreeIndex(root),
979
1188
  };
980
1189
  const results = [];
981
1190
  if (options.failFast) {
@@ -1025,30 +1234,6 @@ export async function runSpecGuard(options) {
1025
1234
  };
1026
1235
  await Promise.all(Array.from({ length: concurrency }, worker));
1027
1236
  }
1028
- const warnings = engine.fallbacks.map((message) => `ripgrep failed, fell back to the JavaScript engine (${message})`);
1029
- // Errors arrive in two waves (parse, then resolve); readers expect file order.
1030
- errors.sort((a, b) => a.location.relativeFile === b.location.relativeFile
1031
- ? a.location.line - b.location.line
1032
- : comparePaths(a.location.relativeFile, b.location.relativeFile));
1033
- const failed = results.filter((result) => !result.ok).length;
1034
- return {
1035
- ok: failed === 0 && errors.length === 0,
1036
- root,
1037
- engine: warnings.length > 0 ? "javascript" : engine.name,
1038
- durationMs: elapsed(startedAt),
1039
- summary: {
1040
- specs: specFiles.length,
1041
- total: results.length,
1042
- passed: results.length - failed,
1043
- failed,
1044
- skipped: assertions.length - results.length,
1045
- inactive: withheld.length,
1046
- },
1047
- results,
1048
- errors,
1049
- warnings,
1050
- inactiveSpecs,
1051
- specFiles: specFiles.map((file) => specPath(root, file)),
1052
- };
1237
+ return reportRun(plan, results, engine, startedAt);
1053
1238
  }
1054
1239
  //# sourceMappingURL=runner.js.map