@descent-vtt/spec-guard 0.4.0 → 0.5.1

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 (49) hide show
  1. package/CHANGELOG.md +283 -150
  2. package/README.md +868 -644
  3. package/dist/cli.d.ts +8 -1
  4. package/dist/cli.d.ts.map +1 -1
  5. package/dist/cli.js +40 -7
  6. package/dist/cli.js.map +1 -1
  7. package/dist/comments.d.ts +23 -4
  8. package/dist/comments.d.ts.map +1 -1
  9. package/dist/comments.js +75 -27
  10. package/dist/comments.js.map +1 -1
  11. package/dist/engine.d.ts +126 -6
  12. package/dist/engine.d.ts.map +1 -1
  13. package/dist/engine.js +301 -154
  14. package/dist/engine.js.map +1 -1
  15. package/dist/glob.d.ts +11 -1
  16. package/dist/glob.d.ts.map +1 -1
  17. package/dist/glob.js +20 -5
  18. package/dist/glob.js.map +1 -1
  19. package/dist/imports.d.ts +28 -4
  20. package/dist/imports.d.ts.map +1 -1
  21. package/dist/imports.js +46 -8
  22. package/dist/imports.js.map +1 -1
  23. package/dist/index.d.ts +11 -8
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.js +7 -5
  26. package/dist/index.js.map +1 -1
  27. package/dist/parser.d.ts +12 -1
  28. package/dist/parser.d.ts.map +1 -1
  29. package/dist/parser.js +39 -26
  30. package/dist/parser.js.map +1 -1
  31. package/dist/polyglot.d.ts +139 -0
  32. package/dist/polyglot.d.ts.map +1 -0
  33. package/dist/polyglot.js +548 -0
  34. package/dist/polyglot.js.map +1 -0
  35. package/dist/reporter.d.ts +41 -0
  36. package/dist/reporter.d.ts.map +1 -1
  37. package/dist/reporter.js +154 -1
  38. package/dist/reporter.js.map +1 -1
  39. package/dist/runner.d.ts +54 -2
  40. package/dist/runner.d.ts.map +1 -1
  41. package/dist/runner.js +321 -73
  42. package/dist/runner.js.map +1 -1
  43. package/dist/text.d.ts +34 -0
  44. package/dist/text.d.ts.map +1 -0
  45. package/dist/text.js +80 -0
  46. package/dist/text.js.map +1 -0
  47. package/dist/types.d.ts +76 -2
  48. package/dist/types.d.ts.map +1 -1
  49. package/package.json +73 -73
package/dist/runner.js CHANGED
@@ -7,13 +7,24 @@
7
7
  */
8
8
  import { promises as fs } from "node:fs";
9
9
  import path from "node:path";
10
- import { createCachedEngine, enumerateCandidates, resolveEngine, runSearches, } 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 { ANALYSABLE_EXTENSIONS, createImportIndex, resolveSpecifier, } from "./imports.js";
12
+ import { ANALYSABLE_EXTENSIONS, createImportIndex, resolveModule, } from "./imports.js";
13
13
  import { parseDirectives } from "./parser.js";
14
- import { createScope, DEFAULT_SCOPE, EMPTY_LEDGER, UNCERTAIN_REASONS, } from "./scope.js";
14
+ import { createScope, DEFAULT_SCOPE, EMPTY_LEDGER, } from "./scope.js";
15
15
  export const DEFAULT_MAX_SNIPPETS = 5;
16
16
  export const DEFAULT_CONCURRENCY = 8;
17
+ /**
18
+ * Milliseconds between two `performance.now()` marks.
19
+ *
20
+ * Seven results carry a duration and each used to subtract inline, which is
21
+ * seven chances to write the operands the wrong way round and report a negative
22
+ * time - and seven copies of one subtraction that nothing could pin down,
23
+ * because "a plausible number of milliseconds" is not an assertion.
24
+ */
25
+ export function elapsed(from, to = performance.now()) {
26
+ return to - from;
27
+ }
17
28
  const TRUE_VALUES = new Set(["true", "1", "yes", "on"]);
18
29
  const FALSE_VALUES = new Set(["false", "0", "no", "off"]);
19
30
  function parseBoolean(value, attribute) {
@@ -44,13 +55,19 @@ function parseCount(value, attribute) {
44
55
  function splitList(value) {
45
56
  if (value === undefined)
46
57
  return [];
47
- return value
48
- .split(/[,\s]+/)
49
- .map((item) => item.trim())
50
- .filter((item) => item.length > 0);
58
+ // Taking the items rather than splitting on the separators. Split needed a
59
+ // `+` on the separator class, a trim and a length filter, and any two of the
60
+ // three made the third unnecessary - so none of them could be tested. What is
61
+ // wanted is "the runs of non-separator characters", and that is one pattern.
62
+ return value.match(/[^,\s]+/g) ?? [];
51
63
  }
52
64
  /** Rejects absolute paths and any `..` escape out of the root. */
53
65
  function normalizeTarget(target, root, attribute) {
66
+ // The second test is not redundant with the first: `path.isAbsolute` follows
67
+ // the host platform, so a Windows drive path is absolute on Windows and an
68
+ // ordinary relative name on Linux. A spec naming `C:/repo/src` has to be
69
+ // refused on both, or the same document means two different things. It is
70
+ // anchored, because a colon partway along a path is just a character.
54
71
  if (path.isAbsolute(target) || /^[a-zA-Z]:[\\/]/.test(target)) {
55
72
  throw new Error(`Attribute "${attribute}" must be relative to --root, got "${target}".`);
56
73
  }
@@ -75,7 +92,6 @@ function describeBounds(bounds) {
75
92
  return `at least ${min} match${plural(min)}`;
76
93
  if (max !== undefined)
77
94
  return max === 0 ? "no matches" : `at most ${max} match${plural(max)}`;
78
- /* c8 ignore next */
79
95
  return "any number of matches";
80
96
  }
81
97
  /** Prose form used in the assertion description ("must appear at most 3 times"). */
@@ -91,10 +107,10 @@ function describeExpectation(bounds) {
91
107
  return `must appear at least ${times(min)}`;
92
108
  if (max === 0)
93
109
  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)}`;
110
+ // No branch for "neither bound": this is only ever called from
111
+ // `resolveDirective`, which refuses a directive that names none. Carrying a
112
+ // fourth case here meant carrying a string no run could print.
113
+ return `must appear at most ${times(max ?? 0)}`;
98
114
  }
99
115
  /** Prose for an import claim: "must not import", "must import at least 2 files". */
100
116
  function describeImportExpectation(bounds) {
@@ -109,17 +125,149 @@ function describeImportExpectation(bounds) {
109
125
  }
110
126
  if (min !== undefined)
111
127
  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)}`;
128
+ // As above: `resolveDirective` never produces an unbounded import claim.
129
+ return `must import from at most ${files(max ?? 0)}`;
116
130
  }
117
- function satisfies(count, bounds) {
118
- if (bounds.min !== undefined && count < bounds.min)
119
- return false;
120
- if (bounds.max !== undefined && count > bounds.max)
121
- return false;
122
- return true;
131
+ /**
132
+ * What a report says when an assertion covered nothing.
133
+ *
134
+ * The fourth silent false green, after the three ADR-0007 closed. A rule whose
135
+ * scope holds no files passes every time, forever, and reads in the report
136
+ * exactly like a rule that inspected a thousand files and found nothing. The
137
+ * usual causes are a `glob` that matches no extension in the tree, an `exclude`
138
+ * that swallowed the target, or a directory that has since been emptied.
139
+ *
140
+ * Deliberately no file count alongside it: under ripgrep spec-guard never
141
+ * enumerates the tree, so "inspected 412 files" would be a number only one of
142
+ * the two engines could produce. "Nothing at all" is the same answer in both,
143
+ * and it is the only part that changes an outcome.
144
+ */
145
+ const EMPTY_SCOPE_HINT = 'add allow-empty="true" if that is expected';
146
+ /**
147
+ * The one-line explanation under a result.
148
+ *
149
+ * Built from parts rather than written per branch so that a run with a
150
+ * baseline, an unreadable file and a stale entry says all three things instead
151
+ * of whichever one the branch order happened to reach first.
152
+ */
153
+ function describeOutcome(bounds, actual, extras) {
154
+ const parts = [`expected ${describeBounds(bounds)}, found ${actual}`];
155
+ if (extras.excluded > 0) {
156
+ parts.push(`${extras.excluded} more ${extras.excluded === 1 ? "is" : "are"} on the baseline`);
157
+ }
158
+ if (extras.gaps > 0) {
159
+ parts.push(`${extras.gaps} file${extras.gaps === 1 ? "" : "s"} could not be inspected`);
160
+ }
161
+ if (extras.stale.length > 0) {
162
+ // Naming the entries matters more than counting them: the fix is to delete
163
+ // exactly these lines from the spec, and a reader should not have to work
164
+ // out which.
165
+ const listed = extras.stale
166
+ .map((entry) => entry.found === 0
167
+ ? `${entry.path} (no longer matches)`
168
+ : `${entry.path} (declares ${entry.declared}, found ${entry.found})`)
169
+ .join(", ");
170
+ parts.push(`the baseline is out of date and must be pruned: ${listed}`);
171
+ }
172
+ return parts.join("; ");
173
+ }
174
+ /**
175
+ * Reads a `baseline="..."` attribute into entries.
176
+ *
177
+ * Each item is a path, optionally `path:count`; a bare path means one match.
178
+ * The separator is a colon because a path here is always relative and
179
+ * POSIX-shaped, so it cannot contain one - the same reasoning that lets
180
+ * `target` split on whitespace.
181
+ */
182
+ function parseBaseline(value, root) {
183
+ const entries = [];
184
+ const seen = new Set();
185
+ for (const item of splitList(value)) {
186
+ const separator = item.lastIndexOf(":");
187
+ const rawPath = separator === -1 ? item : item.slice(0, separator);
188
+ const rawCount = separator === -1 ? "1" : item.slice(separator + 1);
189
+ const declared = parseCount(rawCount, "baseline");
190
+ if (declared === 0) {
191
+ throw new Error(`Baseline entry "${item}" declares 0 matches. Remove the entry instead; a baseline lists violations that exist.`);
192
+ }
193
+ const normalized = normalizeTarget(rawPath, root, "baseline");
194
+ if (seen.has(normalized)) {
195
+ throw new Error(`Baseline lists "${normalized}" twice.`);
196
+ }
197
+ seen.add(normalized);
198
+ entries.push({ path: normalized, declared });
199
+ }
200
+ return entries;
201
+ }
202
+ /**
203
+ * Subtracts the declared debt from what was found.
204
+ *
205
+ * Two questions, and the ratchet needs both. Matches inside a baselined file
206
+ * stop counting *up to the declared number* - so a file allowed two violations
207
+ * that grows a third reports one, not three, and the failure points at the
208
+ * change rather than at the history. And a file that no longer has as many as
209
+ * it declares is a stale entry: the debt was paid and the ledger still claims
210
+ * it, which is a spec asserting something untrue about the code.
211
+ */
212
+ export function applyBaseline(entries, fileCounts) {
213
+ let excluded = 0;
214
+ const stale = [];
215
+ const declaredBy = new Map(entries.map((entry) => [entry.path, entry.declared]));
216
+ for (const entry of entries) {
217
+ const found = fileCounts.get(entry.path) ?? 0;
218
+ excluded += Math.min(found, entry.declared);
219
+ if (found < entry.declared)
220
+ stale.push({ path: entry.path, declared: entry.declared, found });
221
+ }
222
+ return {
223
+ excluded,
224
+ stale,
225
+ // Which files a failure should show. A file inside its allowance is not
226
+ // what went wrong, and printing it points the reader at the history
227
+ // instead of at the change - the first version of this did exactly that,
228
+ // and reported a new violation by quoting a twelve-year-old one. A file
229
+ // *over* its allowance still shows, because the excess is a real
230
+ // violation living in it.
231
+ shows: (file) => (fileCounts.get(file) ?? 0) > (declaredBy.get(file) ?? 0),
232
+ };
233
+ }
234
+ /**
235
+ * Per-run cache for "does this scope contain anything at all".
236
+ *
237
+ * The same shape as the import index, and for the same reason: a document with
238
+ * twenty assertions over `src/` asks this question twenty times and it has one
239
+ * answer. Without the cache the probe measured about 0.9 ms per assertion,
240
+ * which is small until a spec has two hundred of them.
241
+ */
242
+ export function createScopeProbe() {
243
+ const cache = new Map();
244
+ return (request) => {
245
+ const key = JSON.stringify([
246
+ request.root,
247
+ request.targets,
248
+ request.options.globs,
249
+ request.options.excludeGlobs,
250
+ ]);
251
+ let pending = cache.get(key);
252
+ if (!pending) {
253
+ // The walk abandons at the first file it finds, so proving a scope is
254
+ // populated costs a couple of directory reads rather than a traversal.
255
+ pending = enumerateCandidates(request, ANY_FILE_PROBE).then((enumeration) => enumeration.files.length > 0);
256
+ cache.set(key, pending);
257
+ }
258
+ return pending;
259
+ };
260
+ }
261
+ /**
262
+ * Whether a count is inside the bounds. An absent bound is no bound.
263
+ *
264
+ * Written as defaults rather than as two `!== undefined` guards. Those guards
265
+ * could not change an answer - `count < undefined` is false for every count, so
266
+ * the comparison already admitted everything - which meant two conditions in
267
+ * the middle of the pass/fail decision that no test could hold in place.
268
+ */
269
+ function satisfies(count, { min = 0, max = Number.POSITIVE_INFINITY }) {
270
+ return count >= min && count <= max;
123
271
  }
124
272
  /** Turns one directive into an executable assertion, or an error. */
125
273
  export function resolveDirective(directive, context) {
@@ -144,6 +292,10 @@ export function resolveDirective(directive, context) {
144
292
  files,
145
293
  bounds: { min: files.length, max: files.length },
146
294
  missingTargets: [],
295
+ // A file list is never empty here: resolution rejects that above.
296
+ allowEmpty: true,
297
+ baseline: [],
298
+ ratchet: "two-sided",
147
299
  },
148
300
  };
149
301
  }
@@ -153,12 +305,25 @@ export function resolveDirective(directive, context) {
153
305
  if (symbol === undefined || symbol.length === 0) {
154
306
  return fail(`@${kind} requires a non-empty ${subject}="..." attribute.`);
155
307
  }
308
+ const allowEmpty = parseBoolean(attributes["allow-empty"], "allow-empty");
309
+ // `baseline` and `ratchet` reach here only on the absence kinds: the parser
310
+ // rejects them elsewhere, because "these known files may violate" means
311
+ // nothing for an assertion that is not forbidding anything.
312
+ const baseline = parseBaseline(attributes["baseline"], context.root);
313
+ const rawRatchet = (attributes["ratchet"] ?? "two-sided").trim().toLowerCase();
314
+ if (rawRatchet !== "two-sided" && rawRatchet !== "one-way") {
315
+ return fail(`Attribute "ratchet" must be two-sided or one-way, got "${attributes["ratchet"]}".`);
316
+ }
317
+ if (baseline.length === 0 && attributes["ratchet"] !== undefined) {
318
+ return fail(`Attribute "ratchet" needs a baseline="..." to ratchet.`);
319
+ }
320
+ const ratchet = rawRatchet;
156
321
  const comments = (attributes["comments"] ?? "ignore").trim().toLowerCase();
157
322
  if (comments !== "ignore" && comments !== "include") {
158
323
  return fail(`Attribute "comments" must be ignore or include, got "${attributes["comments"]}".`);
159
324
  }
160
325
  const rawTargets = splitList(attributes["target"]);
161
- const targets = (rawTargets.length > 0 ? rawTargets : ["."]).map((target) => normalizeTarget(target, context.root, "target"));
326
+ const targets = (rawTargets.length > 0 ? rawTargets : ROOT_TARGETS).map((target) => normalizeTarget(target, context.root, "target"));
162
327
  const bounds = {};
163
328
  if (kind === "assert-absence" || kind === "assert-import-absence") {
164
329
  if (attributes["expected"] !== undefined &&
@@ -191,9 +356,10 @@ export function resolveDirective(directive, context) {
191
356
  bounds.min = parseCount(attributes["min"], "min");
192
357
  if (attributes["max"] !== undefined)
193
358
  bounds.max = parseCount(attributes["max"], "max");
194
- if (bounds.min !== undefined &&
195
- bounds.max !== undefined &&
196
- bounds.min > bounds.max) {
359
+ // Defaults rather than presence checks, for the same reason as
360
+ // `satisfies`: `min > undefined` is false whatever min is, so the two
361
+ // `!== undefined` guards this used to carry could not decide anything.
362
+ if ((bounds.min ?? 0) > (bounds.max ?? Number.POSITIVE_INFINITY)) {
197
363
  return fail(`min="${bounds.min}" is greater than max="${bounds.max}".`);
198
364
  }
199
365
  }
@@ -233,6 +399,9 @@ export function resolveDirective(directive, context) {
233
399
  },
234
400
  imports: { modules: splitList(symbol), includeTypes },
235
401
  missingTargets: [],
402
+ allowEmpty,
403
+ baseline,
404
+ ratchet,
236
405
  },
237
406
  };
238
407
  }
@@ -251,7 +420,10 @@ export function resolveDirective(directive, context) {
251
420
  new RegExp(symbol);
252
421
  }
253
422
  catch (error) {
254
- return fail(`Invalid regular expression: ${error instanceof Error ? error.message : String(error)}`);
423
+ // No prefix of our own. V8's message already begins "Invalid regular
424
+ // expression: /(/: ...", so adding one produced the phrase twice in a
425
+ // row and pushed the pattern itself off the useful part of the line.
426
+ return fail(error instanceof Error ? error.message : String(error));
255
427
  }
256
428
  }
257
429
  const scope = targets.join(", ");
@@ -270,6 +442,9 @@ export function resolveDirective(directive, context) {
270
442
  bounds,
271
443
  search,
272
444
  missingTargets: [],
445
+ allowEmpty,
446
+ baseline,
447
+ ratchet,
273
448
  },
274
449
  };
275
450
  }
@@ -300,6 +475,9 @@ async function prepareAssertion(assertion, options) {
300
475
  commentMatches: 0,
301
476
  unclassifiedFiles: 0,
302
477
  scope: EMPTY_LEDGER,
478
+ baselinedMatches: 0,
479
+ staleBaseline: [],
480
+ fileMatches: [],
303
481
  };
304
482
  if (assertion.kind === "assert-present") {
305
483
  const missing = [];
@@ -316,7 +494,7 @@ async function prepareAssertion(assertion, options) {
316
494
  ? `all ${assertion.files.length} referenced ${assertion.files.length === 1 ? "path exists" : "paths exist"}`
317
495
  : `missing: ${missing.join(", ")}`,
318
496
  matches: [],
319
- durationMs: performance.now() - startedAt,
497
+ durationMs: elapsed(startedAt),
320
498
  };
321
499
  }
322
500
  if (assertion.imports) {
@@ -339,43 +517,79 @@ async function prepareAssertion(assertion, options) {
339
517
  actual: 0,
340
518
  message: `target path${assertion.missingTargets.length === 1 ? " does" : "s do"} not exist: ${assertion.missingTargets.join(", ")}`,
341
519
  matches: [],
342
- durationMs: performance.now() - startedAt,
520
+ durationMs: elapsed(startedAt),
343
521
  };
344
522
  }
523
+ const request = {
524
+ root: options.root,
525
+ symbol: assertion.symbol,
526
+ targets: existingTargets,
527
+ options: assertion.search,
528
+ };
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
+ }
540
+ }
345
541
  return {
346
- request: {
347
- root: options.root,
348
- symbol: assertion.symbol,
349
- targets: existingTargets,
350
- options: assertion.search,
351
- },
542
+ request,
352
543
  finish: (search) => {
353
544
  // A file that could not be read, or whose bytes are not text but did
354
545
  // contain the symbol, is a hole in the answer rather than a detail of it.
355
546
  // The count is still reported, because it is still true of everything
356
547
  // that was read; --strict is for runs where "true of what we read" is not
357
548
  // good enough.
358
- const gaps = search.scope.skipped.filter((entry) => UNCERTAIN_REASONS.has(entry.reason));
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;
359
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;
360
560
  return {
361
561
  ...base,
362
- ok: satisfies(search.count, assertion.bounds) && !strictFailure,
363
- actual: search.count,
364
- message: strictFailure
365
- ? `expected ${describeBounds(assertion.bounds)}, found ${search.count}, and ${gaps.length} file${gaps.length === 1 ? "" : "s"} could not be inspected`
366
- : `expected ${describeBounds(assertion.bounds)}, found ${search.count}`,
367
- matches: search.matches.slice(0, options.maxSnippets),
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),
368
573
  // Carried as numbers, not prose, so the reporter can total them across a
369
574
  // run and JSON consumers can act on them.
370
575
  commentMatches: search.commentMatches,
371
576
  unclassifiedFiles: search.unclassifiedFiles,
372
577
  scope: search.scope,
373
578
  engine: search.engine,
374
- durationMs: performance.now() - startedAt,
579
+ durationMs: elapsed(startedAt),
375
580
  };
376
581
  },
377
582
  };
378
583
  }
584
+ /** How each kind of reference is spelled in a report snippet. */
585
+ const IMPORT_VERBS = {
586
+ import: "import",
587
+ export: "export",
588
+ require: "require",
589
+ "dynamic-import": "import",
590
+ use: "use",
591
+ using: "using",
592
+ };
379
593
  /**
380
594
  * Counts the files in scope that depend on the requested module.
381
595
  *
@@ -395,16 +609,33 @@ async function executeImportAssertion(assertion, options, base, warnings, starte
395
609
  if (assertion.missingTargets.length > 0) {
396
610
  warnings.push(`target path${assertion.missingTargets.length === 1 ? "" : "s"} not found: ${assertion.missingTargets.join(", ")}`);
397
611
  }
612
+ // No symbol: an import assertion never runs a text search, and the walk does
613
+ // not depend on one. This used to pass `symbol: ""`, an invented value that
614
+ // reached nothing and that no test could therefore be wrong about.
398
615
  const enumeration = await enumerateCandidates({
399
616
  root: options.root,
400
- symbol: "",
401
617
  targets: existingTargets,
402
618
  options: assertion.search,
403
619
  });
404
620
  const analysable = enumeration.files.filter((file) => ANALYSABLE_EXTENSIONS.has(path.posix.extname(file.relativePath)));
621
+ if (analysable.length === 0 && !options.allowEmptyScope && !assertion.allowEmpty) {
622
+ // For an import rule the bar is higher than "some file exists": a directory
623
+ // of YAML has nothing this can read, so a dependency claim about it is a
624
+ // claim about nothing.
625
+ return {
626
+ ...base,
627
+ ok: false,
628
+ actual: 0,
629
+ message: enumeration.files.length === 0
630
+ ? `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})`,
632
+ matches: [],
633
+ durationMs: elapsed(startedAt),
634
+ };
635
+ }
405
636
  const skipped = enumeration.files.length - analysable.length;
406
637
  if (skipped > 0) {
407
- warnings.push(`analysed ${analysable.length} of ${enumeration.files.length} files; ${skipped} are not JavaScript or TypeScript`);
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`);
408
639
  }
409
640
  const matchesModule = createExcludeMatcher(query.modules);
410
641
  const matches = [];
@@ -417,14 +648,18 @@ async function executeImportAssertion(assertion, options, base, warnings, starte
417
648
  const hit = analysis.references.find((reference) => {
418
649
  if (reference.typeOnly && !query.includeTypes)
419
650
  return false;
420
- return matchesModule(resolveSpecifier(reference.specifier, file.relativePath));
651
+ // Both forms are tried: the resolved one so `module="app/db/**"` works
652
+ // everywhere, and the raw one so a Python or C# author can write the
653
+ // dotted path they see in their own source and still be understood.
654
+ return (matchesModule(resolveModule(reference.specifier, file.relativePath)) ||
655
+ matchesModule(reference.specifier));
421
656
  });
422
657
  if (hit) {
423
658
  matches.push({
424
659
  file: file.relativePath,
425
660
  line: hit.line,
426
661
  column: hit.column,
427
- text: `${hit.kind === "export" ? "export" : "import"} ${hit.specifier}`,
662
+ text: `${IMPORT_VERBS[hit.kind]} ${hit.specifier}`,
428
663
  count: 1,
429
664
  });
430
665
  }
@@ -433,19 +668,32 @@ async function executeImportAssertion(assertion, options, base, warnings, starte
433
668
  warnings.push(`${unresolved.length} module reference${unresolved.length === 1 ? "" : "s"} could not be resolved statically`, ...unresolved.slice(0, options.maxSnippets).map((entry) => ` ${entry}`));
434
669
  }
435
670
  const missingFailure = !options.allowMissingTargets && assertion.missingTargets.length > 0;
671
+ // One reference per file, so the file counts are the matches themselves.
672
+ const fileCounts = new Map(matches.map((match) => [match.file, 1]));
673
+ const { excluded, stale, shows } = applyBaseline(assertion.baseline, fileCounts);
674
+ const staleFailure = assertion.ratchet === "two-sided" && stale.length > 0;
675
+ const actual = matches.length - excluded;
436
676
  const strictFailure = missingFailure || (options.strictTargets && unresolved.length > 0);
437
- const ok = satisfies(matches.length, assertion.bounds) && !strictFailure;
677
+ const ok = satisfies(actual, assertion.bounds) && !strictFailure && !staleFailure;
438
678
  return {
439
679
  ...base,
440
680
  ok,
441
- actual: matches.length,
681
+ actual,
442
682
  message: missingFailure
443
683
  ? `target path${assertion.missingTargets.length === 1 ? " does" : "s do"} not exist: ${assertion.missingTargets.join(", ")}`
444
- : strictFailure
445
- ? `expected ${describeBounds(assertion.bounds)}, found ${matches.length}, and ${unresolved.length} reference(s) could not be resolved`
446
- : `expected ${describeBounds(assertion.bounds)}, found ${matches.length}`,
447
- matches: matches.slice(0, options.maxSnippets),
448
- durationMs: performance.now() - startedAt,
684
+ : describeOutcome(assertion.bounds, actual, {
685
+ excluded,
686
+ stale: staleFailure ? stale : [],
687
+ gaps: 0,
688
+ }) +
689
+ (options.strictTargets && unresolved.length > 0
690
+ ? `; ${unresolved.length} reference${unresolved.length === 1 ? "" : "s"} could not be resolved`
691
+ : ""),
692
+ baselinedMatches: excluded,
693
+ staleBaseline: stale,
694
+ fileMatches: [...fileCounts].map(([file, count]) => ({ file, count })),
695
+ matches: matches.filter((match) => shows(match.file)).slice(0, options.maxSnippets),
696
+ durationMs: elapsed(startedAt),
449
697
  };
450
698
  }
451
699
  /** Executes a single resolved assertion. */
@@ -456,19 +704,14 @@ export async function executeAssertion(assertion, options) {
456
704
  return prepared.finish(await options.engine.search(prepared.request));
457
705
  }
458
706
  /**
459
- * Groups searches that can share a single pass over the tree: same targets,
460
- * same flags. This is where most of spec-guard's speed comes from - a spec with
461
- * twenty assertions over `src/` costs one ripgrep pass, not twenty.
707
+ * How many workers to run over the batches.
708
+ *
709
+ * At least one, so an empty run still terminates; never more than there are
710
+ * batches, so a spec with three groups does not start eight workers to find
711
+ * five of them nothing to do.
462
712
  */
463
- function groupKey(request) {
464
- const { options } = request;
465
- return JSON.stringify([
466
- request.targets,
467
- options.regex,
468
- options.word,
469
- options.ignoreCase,
470
- options.globs,
471
- ]);
713
+ export function batchConcurrency(requested, batches) {
714
+ return Math.max(1, Math.min(requested, batches));
472
715
  }
473
716
  /** Reads, parses and executes every directive found in the given spec files. */
474
717
  export async function runSpecGuard(options) {
@@ -477,6 +720,7 @@ export async function runSpecGuard(options) {
477
720
  const maxSnippets = options.maxSnippets ?? DEFAULT_MAX_SNIPPETS;
478
721
  const strictTargets = options.strictTargets ?? false;
479
722
  const allowMissingTargets = options.allowMissingTargets ?? false;
723
+ const allowEmptyScope = options.allowEmptyScope ?? false;
480
724
  const scope = createScope(options.defaultSkips ?? true);
481
725
  const specFiles = await expandSpecPatterns(options.patterns, root);
482
726
  const excludeFiles = new Set(options.includeSpecs ? [] : specFiles.map((file) => path.resolve(file)));
@@ -512,10 +756,12 @@ export async function runSpecGuard(options) {
512
756
  engine,
513
757
  allowMissingTargets,
514
758
  strictTargets,
759
+ allowEmptyScope,
515
760
  maxSnippets,
516
761
  imports: createImportIndex(),
762
+ hasFiles: createScopeProbe(),
517
763
  };
518
- const results = new Array(assertions.length);
764
+ const results = [];
519
765
  if (options.failFast) {
520
766
  // Fail-fast trades throughput for an early exit, so it runs unbatched.
521
767
  for (let index = 0; index < assertions.length; index++) {
@@ -535,7 +781,11 @@ export async function runSpecGuard(options) {
535
781
  results[index] = entry;
536
782
  return;
537
783
  }
538
- const key = groupKey(entry.request);
784
+ // The engine's own definition of "these can share one pass", rather than
785
+ // a second list here that had drifted four fields behind it. This is
786
+ // where most of spec-guard's speed comes from: a spec with twenty
787
+ // assertions over `src/` costs one ripgrep pass, not twenty.
788
+ const key = passKey(entry.request);
539
789
  const group = groups.get(key);
540
790
  if (group)
541
791
  group.push({ index, pending: entry });
@@ -543,7 +793,7 @@ export async function runSpecGuard(options) {
543
793
  groups.set(key, [{ index, pending: entry }]);
544
794
  });
545
795
  const batches = [...groups.values()];
546
- const concurrency = Math.max(1, Math.min(options.concurrency ?? DEFAULT_CONCURRENCY, batches.length || 1));
796
+ const concurrency = batchConcurrency(options.concurrency ?? DEFAULT_CONCURRENCY, batches.length);
547
797
  let cursor = 0;
548
798
  const worker = async () => {
549
799
  while (cursor < batches.length) {
@@ -563,15 +813,13 @@ export async function runSpecGuard(options) {
563
813
  // Errors arrive in two waves (parse, then resolve); readers expect file order.
564
814
  errors.sort((a, b) => a.location.relativeFile === b.location.relativeFile
565
815
  ? a.location.line - b.location.line
566
- : a.location.relativeFile < b.location.relativeFile
567
- ? -1
568
- : 1);
816
+ : comparePaths(a.location.relativeFile, b.location.relativeFile));
569
817
  const failed = results.filter((result) => !result.ok).length;
570
818
  return {
571
819
  ok: failed === 0 && errors.length === 0,
572
820
  root,
573
821
  engine: warnings.length > 0 ? "javascript" : engine.name,
574
- durationMs: performance.now() - startedAt,
822
+ durationMs: elapsed(startedAt),
575
823
  summary: {
576
824
  specs: specFiles.length,
577
825
  total: results.length,