@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/engine.js CHANGED
@@ -22,19 +22,72 @@ import { spawn } from 'node:child_process';
22
22
  import { promises as fs } from 'node:fs';
23
23
  import path from 'node:path';
24
24
  import { createCommentMask } from './comments.js';
25
- import { createExcludeMatcher, createGlobMatcher, toPosix, walkFiles } from './glob.js';
26
- import { isBinary, LedgerBuilder, MAX_LEDGER_ENTRIES, UNCERTAIN_REASONS, } from './scope.js';
25
+ import { createExcludeMatcher, createGlobMatcher, statOrNull, toPosix, walkFiles, } from './glob.js';
26
+ import { isBinary, LedgerBuilder, UNCERTAIN_REASONS } from './scope.js';
27
+ import { lineStarts, locate } from './text.js';
27
28
  /** Files larger than this are skipped by both engines, keeping them in sync. */
28
29
  export const MAX_FILE_SIZE = 20 * 1024 * 1024;
30
+ /**
31
+ * One rule about file size, in one place.
32
+ *
33
+ * It used to be written twice and spelled differently each time - `size <=
34
+ * MAX_FILE_SIZE` where a file was admitted and `size > MAX_FILE_SIZE` where one
35
+ * was skipped - so the boundary was defined by two expressions that had to be
36
+ * kept complementary by hand. ripgrep is given the same number as
37
+ * `--max-filesize`, which is also inclusive, so all three now agree by
38
+ * construction rather than by inspection.
39
+ */
40
+ export function withinSizeLimit(size) {
41
+ return size <= MAX_FILE_SIZE;
42
+ }
29
43
  /** Hard cap on collected locations; the reporter only ever shows a handful. */
30
- const MAX_COLLECTED_MATCHES = 500;
44
+ export const MAX_COLLECTED_MATCHES = 500;
31
45
  /** Longest line snippet echoed back to the terminal. */
32
46
  const MAX_SNIPPET_LENGTH = 200;
47
+ /** Most files read at once. Enough to keep a disk busy, few enough to be polite. */
48
+ export const MAX_CONCURRENT_READS = 16;
49
+ /** How many readers to start for a file list: never more than there are files. */
50
+ export function readConcurrency(fileCount) {
51
+ return Math.min(MAX_CONCURRENT_READS, fileCount);
52
+ }
53
+ /**
54
+ * The target list for a request that names none: the root itself.
55
+ *
56
+ * Shared by the walker and the ripgrep argv rather than written out at each,
57
+ * because the two must agree on what "no target" means - ripgrep given an empty
58
+ * path argument searches nothing at all, and a walk given one searches
59
+ * everything.
60
+ */
61
+ export const ROOT_TARGETS = ['.'];
62
+ /** The targets a request asks for, or the root when it asks for none. */
63
+ function targetsOf(request) {
64
+ return request.targets.length > 0 ? request.targets : ROOT_TARGETS;
65
+ }
66
+ /**
67
+ * Total ordering on POSIX paths, ties included.
68
+ *
69
+ * Three call sites used to inline `a < b ? -1 : 1`, which is correct only
70
+ * because no caller can produce two equal paths - and therefore had no
71
+ * behaviour to test at the tie. Stating the tie makes the function total, the
72
+ * ordering identical, and the comparison something a test can pin down.
73
+ */
74
+ export function comparePaths(a, b) {
75
+ return a < b ? -1 : a > b ? 1 : 0;
76
+ }
33
77
  export function escapeRegExp(value) {
34
78
  return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
35
79
  }
36
- function truncate(value) {
37
- const trimmed = value.replace(/\r?\n$/, '');
80
+ /**
81
+ * Trims a line to snippet length, and takes the line terminator off it.
82
+ *
83
+ * The terminator is a lone CR, not a newline: the caller slices up to the
84
+ * `\n`, so on a CRLF file the carriage return is the one byte that survives.
85
+ * This used to strip `\r?\n$`, which cannot match anything the caller passes -
86
+ * so every snippet from a Windows-authored file carried a CR into the report,
87
+ * where it returns the terminal cursor to column 0 and overwrites the line.
88
+ */
89
+ export function truncate(value) {
90
+ const trimmed = value.replace(/\r$/, '');
38
91
  return trimmed.length > MAX_SNIPPET_LENGTH ? `${trimmed.slice(0, MAX_SNIPPET_LENGTH)}…` : trimmed;
39
92
  }
40
93
  /**
@@ -42,34 +95,88 @@ function truncate(value) {
42
95
  * files in no fixed order, so this is what makes snippet output stable.
43
96
  */
44
97
  export function sortLocations(locations) {
45
- return locations.sort((a, b) => (a.file === b.file ? a.line - b.line : a.file < b.file ? -1 : 1));
98
+ return locations.sort((a, b) => (a.file === b.file ? a.line - b.line : comparePaths(a.file, b.file)));
46
99
  }
47
100
  function emptyTally() {
48
101
  return { count: 0, locations: [], commentCount: 0 };
49
102
  }
50
- /** Requests that may share one pass: same root, same targets, same options. */
51
- function sharesOnePass(requests) {
52
- const [first] = requests;
53
- /* c8 ignore next -- runSearches never passes an empty list here */
54
- if (!first)
55
- return false;
56
- return requests.every((request) => request.root === first.root &&
57
- request.targets.length === first.targets.length &&
58
- request.targets.every((target, index) => target === first.targets[index]) &&
59
- request.options.regex === first.options.regex &&
60
- request.options.word === first.options.word &&
61
- request.options.ignoreCase === first.options.ignoreCase &&
62
- request.options.globs.length === first.options.globs.length &&
63
- request.options.globs.every((glob, index) => glob === first.options.globs[index]) &&
64
- request.options.excludeGlobs.length === first.options.excludeGlobs.length &&
65
- request.options.excludeGlobs.every((glob, index) => glob === first.options.excludeGlobs[index]) &&
66
- request.options.excludeFiles === first.options.excludeFiles);
103
+ /* ------------------------------------------------------------ request keys */
104
+ /**
105
+ * When two requests are the same question, in three nested scopes.
106
+ *
107
+ * Three places need this and each used to answer it separately: whether two
108
+ * requests can share one walk, whether they can share one scan of that walk,
109
+ * and whether one result can be served from the cache instead of searched
110
+ * again. The lists drifted, as hand-maintained parallel lists do, and two of
111
+ * the gaps were live defects rather than missed cache hits:
112
+ *
113
+ * - the result cache ignored `ignoreComments`, so an assertion written
114
+ * `comments="include"` was answered with the comment-stripped count from
115
+ * the assertion above it, or the other way round depending on which ran
116
+ * first;
117
+ * - the grouping test ignored it too, so the same two assertions were merged
118
+ * into one pass and the second was scanned with the first's mask;
119
+ * - neither considered `scope`, which is one object per run today and would
120
+ * have done the same thing the day it stopped being one.
121
+ *
122
+ * Now each key is the one inside it plus exactly what that layer adds, so a new
123
+ * search option is either in `walkKey` or in `passKey` and cannot be in neither.
124
+ *
125
+ * The keys are conservative by construction: equal keys mean the same answer,
126
+ * while two spellings of one question merely cost a repeated search. That is
127
+ * the safe direction, and it is why there is no canonicalisation here - sorting
128
+ * the exclude set or memoising the result would buy cache hits and could not
129
+ * buy correctness.
130
+ */
131
+ function walkKey(request) {
132
+ const { options } = request;
133
+ return JSON.stringify([
134
+ request.root,
135
+ request.targets,
136
+ options.globs,
137
+ options.excludeGlobs,
138
+ [...options.excludeFiles],
139
+ [...options.scope.skippedDirectories],
140
+ ]);
141
+ }
142
+ /**
143
+ * One walk, plus everything that decides what counts as a match within it.
144
+ *
145
+ * Exported because the runner groups assertions before handing them here, and
146
+ * it used to do so against a list of its own that was missing four of these
147
+ * fields. Being too coarse there costs no correctness - `sharesOnePass` checks
148
+ * again and declines - but it did cost the batching: a group of five where two
149
+ * requests disagreed was refused as a whole and run as five separate searches,
150
+ * rather than as the three-and-two it actually was.
151
+ */
152
+ export function passKey(request) {
153
+ const { options } = request;
154
+ return JSON.stringify([walkKey(request), options.regex, options.word, options.ignoreCase, options.ignoreComments]);
155
+ }
156
+ /** One pass, plus the pattern it is looking for. */
157
+ function searchKey(request) {
158
+ return JSON.stringify([passKey(request), request.symbol]);
159
+ }
160
+ /**
161
+ * Requests that may share one pass: same walk, same matching semantics.
162
+ *
163
+ * Takes the first request rather than finding it, so there is no empty list to
164
+ * defend against here. `runSearches` has already answered that question, and
165
+ * answering it twice left a branch nothing could reach.
166
+ */
167
+ function sharesOnePass(first, requests) {
168
+ const key = passKey(first);
169
+ return requests.every((request) => passKey(request) === key);
67
170
  }
68
171
  /** Runs requests through the batch API when the engine has one. */
69
172
  export async function runSearches(engine, requests) {
70
- if (requests.length === 0)
173
+ const [first] = requests;
174
+ if (!first)
71
175
  return [];
72
- if (requests.length === 1 || !engine.searchBatch || !sharesOnePass(requests)) {
176
+ // No special case for a single request. Handing one request to `searchBatch`
177
+ // is what `search` does anyway in all three engines here, so the shortcut
178
+ // saved one key comparison and cost a branch that no result could depend on.
179
+ if (!engine.searchBatch || !sharesOnePass(first, requests)) {
73
180
  return Promise.all(requests.map((request) => engine.search(request)));
74
181
  }
75
182
  return engine.searchBatch(requests);
@@ -84,15 +191,13 @@ export function resetRipgrepProbe() {
84
191
  export function findRipgrep() {
85
192
  ripgrepProbe ??= new Promise((resolve) => {
86
193
  const binary = process.env.SPEC_GUARD_RG || 'rg';
87
- let child;
88
- try {
89
- child = spawn(binary, ['--version'], { stdio: 'ignore', windowsHide: true });
90
- }
91
- catch {
92
- /* c8 ignore next 3 -- spawn only throws synchronously on bad arguments */
93
- resolve(null);
94
- return;
95
- }
194
+ // No try/catch. `spawn` throws synchronously only for invalid arguments,
195
+ // and every argument here is a literal except the binary name, which comes
196
+ // from the environment and therefore cannot contain the NUL byte that is
197
+ // the only thing that would make a string argument invalid. The catch was
198
+ // unreachable, and an unreachable catch that resolves to "not installed"
199
+ // would have turned a real failure into a silent fallback if it ever ran.
200
+ const child = spawn(binary, ['--version'], { stdio: 'ignore', windowsHide: true });
96
201
  child.once('error', () => resolve(null));
97
202
  child.once('close', (code) => resolve(code === 0 ? binary : null));
98
203
  });
@@ -152,7 +257,7 @@ export function buildRipgrepArgs(request, patterns = [request.symbol]) {
152
257
  for (const pattern of patterns)
153
258
  args.push('--regexp', pattern);
154
259
  args.push('--');
155
- args.push(...(request.targets.length > 0 ? request.targets : ['.']));
260
+ args.push(...targetsOf(request));
156
261
  return args;
157
262
  }
158
263
  /**
@@ -206,37 +311,68 @@ class RipgrepEngine {
206
311
  windowsHide: true,
207
312
  stdio: ['ignore', 'pipe', 'pipe'],
208
313
  });
209
- let stdout = '';
210
- let stderr = '';
211
- child.stdout.setEncoding('utf8');
212
- child.stdout.on('data', (chunk) => (stdout += chunk));
213
- child.stderr.setEncoding('utf8');
214
- child.stderr.on('data', (chunk) => (stderr += chunk));
314
+ // Bytes, decoded once at the end, rather than a string built chunk by
315
+ // chunk. `setEncoding('utf8')` would also have been correct, but the
316
+ // failure it prevents is invisible when it is missing: a path containing
317
+ // a multi-byte character that straddles a chunk boundary decodes to two
318
+ // replacement characters, and only for files large enough and named
319
+ // awkwardly enough to land on the seam. Holding bytes until there are no
320
+ // more of them makes that unrepresentable rather than merely handled.
321
+ const stdout = [];
322
+ const stderr = [];
323
+ child.stdout.on('data', (chunk) => stdout.push(chunk));
324
+ child.stderr.on('data', (chunk) => stderr.push(chunk));
215
325
  child.once('error', reject);
216
326
  child.once('close', (code) => {
327
+ const errors = Buffer.concat(stderr).toString('utf8');
217
328
  // 0 = matches, 1 = none, anything else is a real failure.
218
329
  if (code !== 0 && code !== 1) {
219
- reject(new Error(`ripgrep exited with code ${code}${stderr ? `: ${stderr.trim()}` : ''}`));
330
+ reject(new Error(ripgrepFailureMessage(code, errors)));
220
331
  return;
221
332
  }
222
- const files = [];
223
- // --null separates paths with NUL, which is the only separator a file
224
- // name cannot contain. Splitting on newlines loses files whose names
225
- // contain one.
226
- for (const entry of stdout.split('\0')) {
227
- if (entry.length === 0)
228
- continue;
229
- const absolutePath = path.resolve(request.root, entry);
230
- if (request.options.excludeFiles.has(absolutePath))
231
- continue;
232
- files.push({ absolutePath, relativePath: toPosix(path.relative(request.root, absolutePath)) });
233
- }
234
- files.sort((a, b) => (a.relativePath < b.relativePath ? -1 : 1));
235
- resolve({ files, unreadable: parseRipgrepErrors(stderr) });
333
+ resolve({
334
+ files: parseRipgrepFiles(Buffer.concat(stdout).toString('utf8'), request.root, request.options.excludeFiles),
335
+ unreadable: parseRipgrepErrors(errors),
336
+ });
236
337
  });
237
338
  });
238
339
  }
239
340
  }
341
+ /**
342
+ * What a run says when ripgrep exits with a code that is neither 0 nor 1.
343
+ *
344
+ * Separate from the spawn because it is the only part a reader ever sees, and
345
+ * inside the promise the only way to produce one was to break a real
346
+ * subprocess - so the exact text, including whether the trailing newline every
347
+ * command writes ends up in the middle of the sentence, was never asserted.
348
+ */
349
+ export function ripgrepFailureMessage(code, errors) {
350
+ return `ripgrep exited with code ${code}${errors ? `: ${errors.trim()}` : ''}`;
351
+ }
352
+ /**
353
+ * Turns ripgrep's `--files-with-matches --null` output into candidate files.
354
+ *
355
+ * Split out from the spawn so that the parsing has somewhere to be tested from:
356
+ * inside the promise it could only be reached by running a real subprocess,
357
+ * which is why the separator handling, the exclusion filter and the ordering
358
+ * were all covered only incidentally, by whatever a real tree happened to
359
+ * contain.
360
+ */
361
+ export function parseRipgrepFiles(stdout, root, excludeFiles) {
362
+ const files = [];
363
+ // --null separates paths with NUL, which is the only separator a file name
364
+ // cannot contain. Splitting on newlines loses files whose names contain one.
365
+ for (const entry of stdout.split('\0')) {
366
+ // The list is NUL-*terminated*, so the last split is always empty.
367
+ if (entry.length === 0)
368
+ continue;
369
+ const absolutePath = path.resolve(root, entry);
370
+ if (excludeFiles.has(absolutePath))
371
+ continue;
372
+ files.push({ absolutePath, relativePath: toPosix(path.relative(root, absolutePath)) });
373
+ }
374
+ return files.sort((a, b) => comparePaths(a.relativePath, b.relativePath));
375
+ }
240
376
  /**
241
377
  * Pulls the paths out of ripgrep's stderr.
242
378
  *
@@ -310,9 +446,16 @@ class JavaScriptEngine {
310
446
  const patterns = [...new Set(requests.map((request) => request.symbol))];
311
447
  const regexps = new Map(patterns.map((pattern) => [pattern, buildJsRegExp(pattern, first.options)]));
312
448
  const tallies = new Map(patterns.map((pattern) => [pattern, emptyTally()]));
313
- const concurrency = Math.min(16, Math.max(1, files.length));
449
+ // No floor of 1: an empty file list starts no readers, and a loop that
450
+ // never runs produces the same empty tallies as one that runs once and
451
+ // finds nothing. The guard was defending against an outcome it shared.
452
+ const concurrency = readConcurrency(files.length);
314
453
  let cursor = 0;
315
454
  const perFile = new Map();
455
+ // Per pattern, how many matches each file holds. Only files that matched
456
+ // appear, so this is smaller than perFile, which already holds an entry for
457
+ // every file scanned - there is no new memory shape here.
458
+ const byFile = new Map(patterns.map((pattern) => [pattern, new Map()]));
316
459
  let unclassifiedFiles = 0;
317
460
  const worker = async () => {
318
461
  while (cursor < files.length) {
@@ -369,6 +512,8 @@ class JavaScriptEngine {
369
512
  const total = tallies.get(pattern);
370
513
  total.count += tally.count;
371
514
  total.commentCount += tally.commentCount;
515
+ if (tally.count > 0)
516
+ byFile.get(pattern).set(file.relativePath, tally.count);
372
517
  for (const location of tally.locations) {
373
518
  if (total.locations.length < MAX_COLLECTED_MATCHES)
374
519
  total.locations.push(location);
@@ -383,12 +528,22 @@ class JavaScriptEngine {
383
528
  commentMatches: tally.commentCount,
384
529
  unclassifiedFiles,
385
530
  matches: tally.locations,
531
+ fileCounts: byFile.get(request.symbol) ?? new Map(),
386
532
  scope,
387
533
  engine: this.name,
388
534
  };
389
535
  });
390
536
  }
391
537
  }
538
+ /**
539
+ * A budget that abandons the walk at the first file it finds.
540
+ *
541
+ * Used to answer one question - "is there anything here at all?" - without
542
+ * walking a tree to count files nobody asked about. The answer is the same
543
+ * whichever engine will do the searching, which is the point: an assertion that
544
+ * covers nothing has to be recognised identically by both.
545
+ */
546
+ export const ANY_FILE_PROBE = { maxFiles: 0, maxBytes: 0 };
392
547
  /**
393
548
  * Lists the files a request would search, sorted by relative path.
394
549
  *
@@ -397,12 +552,28 @@ class JavaScriptEngine {
397
552
  * work, so finding out a tree is "too big" costs a bounded number of stats
398
553
  * rather than a full traversal.
399
554
  */
400
- export async function enumerateCandidates(request, budget) {
555
+ export async function enumerateCandidates(request, budget,
556
+ /**
557
+ * Directory reader, forwarded to the walk. Exists for the same reason
558
+ * `walkFiles` takes one: the interesting behaviour here is what the
559
+ * enumeration does with a directory it *cannot* read, and there is no
560
+ * portable way to create one - Windows has no chmod, and a permission bit set
561
+ * by a test is a permission bit a failed test leaves behind.
562
+ */
563
+ readDirectory) {
401
564
  const matcher = createGlobMatcher(request.options.globs);
402
565
  const excluded = createExcludeMatcher(request.options.excludeGlobs);
403
- const admits = (relativePath) => matcher(relativePath) && !excluded(relativePath);
566
+ // excludeFiles is applied here rather than after the walk so that a budgeted
567
+ // enumeration counts only files it would really search. Filtering afterwards
568
+ // let an excluded spec file fill a one-file probe and make a populated
569
+ // directory look empty.
570
+ const admits = (absolutePath, relativePath) => matcher(relativePath) && !excluded(relativePath) && !request.options.excludeFiles.has(absolutePath);
404
571
  const found = new Map();
405
- const skipped = [];
572
+ // A LedgerBuilder rather than an array and a cap of its own: the sample cap
573
+ // is one rule, it is applied again downstream, and a second implementation of
574
+ // it here was reachable only by a tree with a hundred unreadable paths in it -
575
+ // which is to say, by nothing the suite could build.
576
+ const ledger = new LedgerBuilder();
406
577
  const note = (relativePath, reason) => {
407
578
  // Only gaps go in the ledger. `.git` and `node_modules` are configuration,
408
579
  // not news: they are the same on every run, they are documented, and
@@ -411,8 +582,7 @@ export async function enumerateCandidates(request, budget) {
411
582
  // ever asked about files it did not skip.
412
583
  if (!UNCERTAIN_REASONS.has(reason))
413
584
  return;
414
- if (skipped.length < MAX_LEDGER_ENTRIES)
415
- skipped.push({ path: relativePath, reason });
585
+ ledger.add(relativePath, reason);
416
586
  };
417
587
  let bytes = 0;
418
588
  let exceeded = false;
@@ -425,14 +595,14 @@ export async function enumerateCandidates(request, budget) {
425
595
  }
426
596
  return true;
427
597
  };
428
- outer: for (const target of request.targets.length > 0 ? request.targets : ['.']) {
598
+ outer: for (const target of targetsOf(request)) {
429
599
  const absoluteTarget = path.resolve(request.root, target);
430
- const stats = await fs.stat(absoluteTarget).catch(() => null);
600
+ const stats = await statOrNull(absoluteTarget);
431
601
  if (!stats)
432
602
  continue;
433
603
  if (stats.isFile()) {
434
604
  const relativePath = toPosix(path.relative(request.root, absoluteTarget));
435
- if (stats.size <= MAX_FILE_SIZE && admits(relativePath)) {
605
+ if (withinSizeLimit(stats.size) && admits(absoluteTarget, relativePath)) {
436
606
  if (!admit(absoluteTarget, relativePath, stats.size))
437
607
  break outer;
438
608
  }
@@ -442,21 +612,20 @@ export async function enumerateCandidates(request, budget) {
442
612
  const walkOptions = {
443
613
  scope: request.options.scope,
444
614
  onSkip: (relativePath, reason) => note(prefix ? `${prefix}/${relativePath}` : relativePath, reason),
615
+ ...(readDirectory ? { readDirectory } : {}),
445
616
  };
446
617
  for await (const file of walkFiles(absoluteTarget, walkOptions)) {
447
- if (file.size > MAX_FILE_SIZE)
618
+ if (!withinSizeLimit(file.size))
448
619
  continue;
449
620
  const relativePath = toPosix(path.relative(request.root, file.absolutePath));
450
- if (!admits(relativePath))
621
+ if (!admits(file.absolutePath, relativePath))
451
622
  continue;
452
623
  if (!admit(file.absolutePath, relativePath, file.size))
453
624
  break outer;
454
625
  }
455
626
  }
456
- for (const excluded of request.options.excludeFiles)
457
- found.delete(excluded);
458
- const files = [...found.values()].sort((a, b) => (a.relativePath < b.relativePath ? -1 : 1));
459
- return { files, exceeded, bytes, skipped };
627
+ const files = [...found.values()].sort((a, b) => comparePaths(a.relativePath, b.relativePath));
628
+ return { files, exceeded, bytes, skipped: ledger.build().skipped };
460
629
  }
461
630
  /** Counts matches in one file and records per-line snippets. */
462
631
  export function scanContent(content, relativePath, regexp,
@@ -472,26 +641,12 @@ getMask) {
472
641
  let commentCount = 0;
473
642
  const byLine = new Map();
474
643
  // Lazily built line index: only paid for when the file actually matches.
475
- let lineStarts = null;
476
- const lineOf = (index) => {
477
- if (!lineStarts) {
478
- lineStarts = [0];
479
- for (let i = 0; i < content.length; i++) {
480
- if (content[i] === '\n')
481
- lineStarts.push(i + 1);
482
- }
483
- }
484
- let low = 0;
485
- let high = lineStarts.length - 1;
486
- while (low < high) {
487
- const mid = (low + high + 1) >> 1;
488
- if (lineStarts[mid] <= index)
489
- low = mid;
490
- else
491
- high = mid - 1;
492
- }
493
- return { line: low + 1, column: index - lineStarts[low] + 1 };
494
- };
644
+ // The arithmetic itself belongs to text.ts, which every other reader of a
645
+ // source file already shares. This function used to carry its own copy of
646
+ // both halves, which is precisely the second chance to be off by one that
647
+ // module exists to remove.
648
+ let starts = null;
649
+ const lineOf = (index) => locate((starts ??= lineStarts(content)), index);
495
650
  let match;
496
651
  while ((match = regexp.exec(content)) !== null) {
497
652
  if (getMask?.().isComment(match.index)) {
@@ -542,43 +697,35 @@ export const javascriptEngine = new JavaScriptEngine();
542
697
  * The budget is set just below each crossover, so choosing JavaScript is never
543
698
  * the slower option by more than a few milliseconds, while a small tree on
544
699
  * Windows avoids a spawn that would cost ten times the whole search.
700
+ *
701
+ * Taking the platform as an argument rather than reading it: a decision that is
702
+ * explicitly about two platforms cannot be verified on one of them if the other
703
+ * branch is only reachable by being that other platform. Both are now asserted
704
+ * everywhere the suite runs.
545
705
  */
546
- export const SMALL_TREE_BUDGET = process.platform === 'win32' ? { maxFiles: 512, maxBytes: 1024 * 1024 } : { maxFiles: 32, maxBytes: 64 * 1024 };
547
- /** Spawn failures that mean "this binary is not installed", not "search failed". */
548
- const MISSING_BINARY_CODES = new Set(['ENOENT', 'EACCES', 'EPERM', 'EINVAL', 'UNKNOWN']);
549
- /** Exported so the code list can be asserted; EACCES and friends are not
550
- * reproducible on demand from a real spawn. */
551
- export function isMissingBinary(error) {
552
- const code = error?.code;
553
- return typeof code === 'string' && MISSING_BINARY_CODES.has(code);
706
+ export function smallTreeBudget(platform) {
707
+ return platform === 'win32' ? { maxFiles: 512, maxBytes: 1024 * 1024 } : { maxFiles: 32, maxBytes: 64 * 1024 };
554
708
  }
709
+ export const SMALL_TREE_BUDGET = smallTreeBudget(process.platform);
555
710
  /**
556
- * Identity tokens for exclude sets.
711
+ * Spawn failures that mean "this binary is not installed", not "search failed".
557
712
  *
558
- * The enumeration cache key has to account for `excludeFiles`, because
559
- * enumeration applies it - two groups over the same targets but with different
560
- * exclusions are different questions. Hashing the paths would be wasteful (a
561
- * run excludes every spec file), and the runner passes one set for the whole
562
- * run, so identity is both cheap and sufficient.
713
+ * Typed as a set of `unknown` so the lookup can be handed whatever an error
714
+ * object carried. The alternative - narrowing with `typeof code === 'string'`
715
+ * first - was a branch that could not change an answer, because a code that is
716
+ * not a string is not in the set either.
563
717
  */
564
- const excludeSetIds = new WeakMap();
565
- let nextExcludeSetId = 0;
566
- function excludeSetId(excludeFiles) {
567
- let id = excludeSetIds.get(excludeFiles);
568
- if (id === undefined) {
569
- id = nextExcludeSetId++;
570
- excludeSetIds.set(excludeFiles, id);
571
- }
572
- return id;
573
- }
574
- function enumerationKey(request) {
575
- return JSON.stringify([
576
- request.root,
577
- request.targets,
578
- request.options.globs,
579
- request.options.excludeGlobs,
580
- excludeSetId(request.options.excludeFiles),
581
- ]);
718
+ const MISSING_BINARY_CODES = new Set([
719
+ 'ENOENT',
720
+ 'EACCES',
721
+ 'EPERM',
722
+ 'EINVAL',
723
+ 'UNKNOWN',
724
+ ]);
725
+ /** Exported so the code list can be asserted; EACCES and friends are not
726
+ * reproducible on demand from a real spawn. */
727
+ export function isMissingBinary(error) {
728
+ return MISSING_BINARY_CODES.has(error?.code);
582
729
  }
583
730
  /**
584
731
  * Picks an engine per search group instead of once per run.
@@ -609,7 +756,7 @@ class AdaptiveEngine {
609
756
  if (!first)
610
757
  return [];
611
758
  if (!this.ripgrepMissing) {
612
- const key = enumerationKey(first);
759
+ const key = walkKey(first);
613
760
  let probe = this.enumerations.get(key);
614
761
  if (!probe) {
615
762
  probe = enumerateCandidates(first, SMALL_TREE_BUDGET);
@@ -619,7 +766,13 @@ class AdaptiveEngine {
619
766
  if (!enumeration.exceeded) {
620
767
  // Small tree: the walk already produced the file list, so scanning it
621
768
  // here costs less than starting a process.
622
- return javascriptEngine.searchFiles(enumeration.files, requests);
769
+ //
770
+ // `enumeration.skipped` is passed on, which it was not: the walk's own
771
+ // ledger was collected and then dropped, so a directory the walk could
772
+ // not read went unreported on any tree small enough to take this branch
773
+ // and was reported on any tree that was not. Two answers about the same
774
+ // repository, decided by its size - the drift ADR-0007 exists to stop.
775
+ return javascriptEngine.searchFiles(enumeration.files, requests, [], enumeration.skipped);
623
776
  }
624
777
  try {
625
778
  const results = await this.ripgrep.searchBatch(requests);
@@ -637,16 +790,18 @@ class AdaptiveEngine {
637
790
  }
638
791
  /** Resolves the engine to use, honouring an explicit preference. */
639
792
  export async function resolveEngine(preference = 'auto') {
793
+ // Every preference is compared, including the default. Leaving `auto` as the
794
+ // fall-through meant its name was never read, so the default could have said
795
+ // anything at all and every run would still have behaved identically.
640
796
  if (preference === 'javascript')
641
797
  return javascriptEngine;
642
- if (preference === 'ripgrep') {
643
- const binary = await findRipgrep();
644
- if (!binary) {
645
- throw new Error('ripgrep (rg) was requested with --engine rg but is not available on PATH.');
646
- }
647
- return new RipgrepEngine(binary);
798
+ if (preference === 'auto')
799
+ return new AdaptiveEngine();
800
+ const binary = await findRipgrep();
801
+ if (!binary) {
802
+ throw new Error('ripgrep (rg) was requested with --engine rg but is not available on PATH.');
648
803
  }
649
- return new AdaptiveEngine();
804
+ return new RipgrepEngine(binary);
650
805
  }
651
806
  /**
652
807
  * Wraps an engine with a de-duplicating cache plus an automatic fallback to the
@@ -655,17 +810,7 @@ export async function resolveEngine(preference = 'auto') {
655
810
  export function createCachedEngine(engine) {
656
811
  const cache = new Map();
657
812
  const fallbacks = [];
658
- const keyOf = (request) => JSON.stringify([
659
- request.root,
660
- request.symbol,
661
- request.targets,
662
- request.options.regex,
663
- request.options.word,
664
- request.options.ignoreCase,
665
- request.options.globs,
666
- request.options.excludeGlobs,
667
- [...request.options.excludeFiles].sort(),
668
- ]);
813
+ const keyOf = searchKey;
669
814
  const recordFallback = (error) => {
670
815
  fallbacks.push(error instanceof Error ? error.message : String(error));
671
816
  };
@@ -694,17 +839,19 @@ export function createCachedEngine(engine) {
694
839
  const keys = requests.map(keyOf);
695
840
  const missing = requests.filter((_, index) => !cache.has(keys[index]));
696
841
  const uncached = [...new Map(missing.map((request) => [keyOf(request), request])).values()];
697
- if (uncached.length > 0) {
698
- const pending = runSearches(engine, uncached).catch(async (error) => {
699
- if (engine === javascriptEngine)
700
- throw error;
701
- recordFallback(error);
702
- return runSearches(javascriptEngine, uncached);
703
- });
704
- uncached.forEach((request, index) => {
705
- cache.set(keyOf(request), pending.then((results) => results[index]));
706
- });
707
- }
842
+ // No `if (uncached.length > 0)` around this: `runSearches` returns an
843
+ // empty array without touching the engine, and a forEach over nothing
844
+ // does nothing. The guard could not change an outcome, which is why
845
+ // nothing could be written to hold it in place.
846
+ const pending = runSearches(engine, uncached).catch(async (error) => {
847
+ if (engine === javascriptEngine)
848
+ throw error;
849
+ recordFallback(error);
850
+ return runSearches(javascriptEngine, uncached);
851
+ });
852
+ uncached.forEach((request, index) => {
853
+ cache.set(keyOf(request), pending.then((results) => results[index]));
854
+ });
708
855
  return Promise.all(keys.map((key) => cache.get(key)));
709
856
  },
710
857
  };