@descent-vtt/spec-guard 0.3.0 → 0.5.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 (54) hide show
  1. package/CHANGELOG.md +125 -0
  2. package/README.md +292 -49
  3. package/dist/cli.d.ts +9 -1
  4. package/dist/cli.d.ts.map +1 -1
  5. package/dist/cli.js +46 -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 +40 -14
  10. package/dist/comments.js.map +1 -1
  11. package/dist/engine.d.ts +52 -49
  12. package/dist/engine.d.ts.map +1 -1
  13. package/dist/engine.js +185 -259
  14. package/dist/engine.js.map +1 -1
  15. package/dist/glob.d.ts +19 -6
  16. package/dist/glob.d.ts.map +1 -1
  17. package/dist/glob.js +14 -28
  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 +36 -4
  22. package/dist/imports.js.map +1 -1
  23. package/dist/index.d.ts +15 -10
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.js +9 -6
  26. package/dist/index.js.map +1 -1
  27. package/dist/parser.d.ts.map +1 -1
  28. package/dist/parser.js +28 -23
  29. package/dist/parser.js.map +1 -1
  30. package/dist/polyglot.d.ts +129 -0
  31. package/dist/polyglot.d.ts.map +1 -0
  32. package/dist/polyglot.js +544 -0
  33. package/dist/polyglot.js.map +1 -0
  34. package/dist/reporter.d.ts +41 -0
  35. package/dist/reporter.d.ts.map +1 -1
  36. package/dist/reporter.js +187 -4
  37. package/dist/reporter.js.map +1 -1
  38. package/dist/runner.d.ts +47 -3
  39. package/dist/runner.d.ts.map +1 -1
  40. package/dist/runner.js +371 -105
  41. package/dist/runner.js.map +1 -1
  42. package/dist/scope.d.ts +108 -0
  43. package/dist/scope.d.ts.map +1 -0
  44. package/dist/scope.js +115 -0
  45. package/dist/scope.js.map +1 -0
  46. package/dist/text.d.ts +26 -0
  47. package/dist/text.d.ts.map +1 -0
  48. package/dist/text.js +69 -0
  49. package/dist/text.js.map +1 -0
  50. package/dist/types.d.ts +87 -0
  51. package/dist/types.d.ts.map +1 -1
  52. package/dist/types.js +0 -6
  53. package/dist/types.js.map +1 -1
  54. package/package.json +1 -1
package/dist/engine.js CHANGED
@@ -1,24 +1,29 @@
1
1
  /**
2
2
  * Search engines.
3
3
  *
4
- * Primary: native `ripgrep`, consumed as newline-delimited JSON. ripgrep is
5
- * multi-threaded, gitignore-aware and binary-skipping, which is why spec-guard
6
- * reaches for it before doing anything clever itself.
4
+ * There is one set of semantics here, not two. The scanner reads files, counts
5
+ * matches, classifies comments, decides what is binary and keeps the ledger of
6
+ * what it could not inspect. ripgrep answers one question - which files under
7
+ * these targets contain this text at all - and everything downstream of that is
8
+ * the scanner's work, for both engines.
7
9
  *
8
- * Fallback: a pure-JS walker with identical assertion semantics, used when `rg`
9
- * is not on PATH (or when `--engine js` is passed). Nothing about spec-guard's
10
- * behaviour depends on ripgrep being installed - only its speed does.
10
+ * It was not always so, and the reason for the rewrite is worth keeping: the
11
+ * two used to be independent implementations, and they drifted. On a tree with
12
+ * eight copies of one symbol the scanner found two and ripgrep found four,
13
+ * because one honoured .gitignore and the other did not, one skipped a
14
+ * hardcoded list of directory names and the other did not, and they disagreed
15
+ * about binary files. Nothing in the output said so. See ADR-0007.
11
16
  *
12
17
  * Both engines expose a batch API. Scanning a tree costs the same whether you
13
18
  * look for one symbol or twenty, so assertions that share a target set and
14
- * flags are answered by a single pass. Measured on a 2,000 file / 5 MB tree,
15
- * eight assertions cost ~280ms as eight ripgrep passes and ~90ms as one.
19
+ * flags are answered by a single pass.
16
20
  */
17
21
  import { spawn } from 'node:child_process';
18
22
  import { promises as fs } from 'node:fs';
19
23
  import path from 'node:path';
20
24
  import { createCommentMask } from './comments.js';
21
25
  import { createExcludeMatcher, createGlobMatcher, toPosix, walkFiles } from './glob.js';
26
+ import { isBinary, LedgerBuilder, MAX_LEDGER_ENTRIES, UNCERTAIN_REASONS, } from './scope.js';
22
27
  /** Files larger than this are skipped by both engines, keeping them in sync. */
23
28
  export const MAX_FILE_SIZE = 20 * 1024 * 1024;
24
29
  /** Hard cap on collected locations; the reporter only ever shows a handful. */
@@ -32,22 +37,6 @@ function truncate(value) {
32
37
  const trimmed = value.replace(/\r?\n$/, '');
33
38
  return trimmed.length > MAX_SNIPPET_LENGTH ? `${trimmed.slice(0, MAX_SNIPPET_LENGTH)}…` : trimmed;
34
39
  }
35
- /**
36
- * Converts ripgrep's byte column into a character column.
37
- *
38
- * ripgrep counts bytes; the scanner counts characters, and so does every editor
39
- * that a reader will paste the location into. On an ASCII line the two agree,
40
- * which is why the difference went unnoticed - it only appears once a line
41
- * holds a non-ASCII character before the match, and then the two engines report
42
- * different columns for the same match.
43
- */
44
- export function byteColumnToCharacter(line, byteOffset) {
45
- // One byte per character, so the offset is already a character count. This
46
- // also covers the case where ripgrep gave us no line text to measure.
47
- if (line.length === Buffer.byteLength(line))
48
- return byteOffset + 1;
49
- return Buffer.from(line, 'utf8').subarray(0, byteOffset).toString('utf8').length + 1;
50
- }
51
40
  /**
52
41
  * Orders matches by path, then by line. ripgrep searches in parallel and emits
53
42
  * files in no fixed order, so this is what makes snippet output stable.
@@ -58,30 +47,6 @@ export function sortLocations(locations) {
58
47
  function emptyTally() {
59
48
  return { count: 0, locations: [], commentCount: 0 };
60
49
  }
61
- /**
62
- * True when no two of these literals can ever match overlapping text, which is
63
- * exactly when merging them into one ripgrep alternation is safe.
64
- *
65
- * Two ways an alternation loses a match that separate passes would find:
66
- * containment - ["Primary", "PrimaryButton"] on "PrimaryButton"; and
67
- * dovetailing - ["abc", "cd"] on "abcd", where the scan resumes past "cd".
68
- * Rejecting both leaves batching indistinguishable from separate passes.
69
- */
70
- export function canBatchLiterals(patterns) {
71
- for (const a of patterns) {
72
- for (const b of patterns) {
73
- if (a === b)
74
- continue;
75
- if (a.includes(b) || b.includes(a))
76
- return false;
77
- for (let offset = 1; offset < a.length; offset++) {
78
- if (b.startsWith(a.slice(offset)))
79
- return false;
80
- }
81
- }
82
- }
83
- return true;
84
- }
85
50
  /** Requests that may share one pass: same root, same targets, same options. */
86
51
  function sharesOnePass(requests) {
87
52
  const [first] = requests;
@@ -133,19 +98,43 @@ export function findRipgrep() {
133
98
  });
134
99
  return ripgrepProbe;
135
100
  }
136
- function decodeText(value) {
137
- if (typeof value?.text === 'string')
138
- return value.text;
139
- // ripgrep base64-encodes paths and lines that are not valid UTF-8.
140
- /* c8 ignore next 2 -- needs a non-UTF-8 path on disk to reach */
141
- if (typeof value?.bytes === 'string')
142
- return Buffer.from(value.bytes, 'base64').toString('utf8');
143
- return '';
144
- }
145
- /** Builds the argv for one ripgrep pass over `patterns`. */
101
+ /**
102
+ * Builds the argv that makes ripgrep walk exactly what the scanner walks.
103
+ *
104
+ * Almost every flag here switches off an opinion. ripgrep's defaults are
105
+ * excellent for a developer grepping their own checkout and wrong for a rule
106
+ * about a repository:
107
+ *
108
+ * --hidden `.github`, `.husky` and `.claude-rules` hold real code, and
109
+ * leaving them out let an absence assertion pass while the
110
+ * forbidden thing sat in a workflow file.
111
+ * --no-ignore .gitignore says what git should carry, not what a rule
112
+ * covers - and it applies only inside a git repository, so the
113
+ * same tree answered differently depending on whether a .git
114
+ * directory happened to exist above it.
115
+ *
116
+ * What remains is spec-guard's own scope policy, passed as exclusions so that
117
+ * both engines skip the same four names for the same reasons. Those come last
118
+ * because ripgrep lets a later glob override an earlier one, and a policy skip
119
+ * must not be undone by a user's `glob="*.ts"`.
120
+ */
146
121
  export function buildRipgrepArgs(request, patterns = [request.symbol]) {
147
122
  const { options } = request;
148
- const args = ['--json', '--no-config', '--no-messages', `--max-filesize=${MAX_FILE_SIZE}`];
123
+ const args = [
124
+ '--files-with-matches',
125
+ // The only separator a filename cannot contain.
126
+ '--null',
127
+ '--no-config',
128
+ '--hidden',
129
+ '--no-ignore',
130
+ // --text ripgrep treats a file named on the command line as text
131
+ // and a file it walked into as binary, so the same bytes
132
+ // were listed or not depending on how they were reached.
133
+ // The scanner decides what is binary, once, for both
134
+ // engines; ripgrep's job is only to say which files matched.
135
+ '--text',
136
+ `--max-filesize=${MAX_FILE_SIZE}`,
137
+ ];
149
138
  if (!options.regex)
150
139
  args.push('--fixed-strings');
151
140
  if (options.word)
@@ -158,15 +147,35 @@ export function buildRipgrepArgs(request, patterns = [request.symbol]) {
158
147
  // createExcludeMatcher mirrors for the JavaScript engine.
159
148
  for (const glob of options.excludeGlobs)
160
149
  args.push('--glob', `!${glob}`);
150
+ for (const name of options.scope.skippedDirectories.keys())
151
+ args.push('--glob', `!${name}/`);
161
152
  for (const pattern of patterns)
162
153
  args.push('--regexp', pattern);
163
154
  args.push('--');
164
155
  args.push(...(request.targets.length > 0 ? request.targets : ['.']));
165
156
  return args;
166
157
  }
167
- /** Signals that a batched pass could not be attributed and must be re-run. */
168
- class UnattributableBatch extends Error {
169
- }
158
+ /**
159
+ * ripgrep, used as a pre-filter rather than as a counter.
160
+ *
161
+ * The two engines used to be two implementations of the same semantics, and
162
+ * they drifted: ripgrep honoured .gitignore and the scanner did not, the
163
+ * scanner skipped a hardcoded list of directory names and ripgrep did not, and
164
+ * a binary file was searched by one and skipped by the other. Same tree, two
165
+ * answers, no warning.
166
+ *
167
+ * So ripgrep no longer decides anything. It answers one question - which files
168
+ * under these targets contain this text at all - and the scanner does the rest:
169
+ * counting, comment classification, binary handling, positions, the ledger.
170
+ * There is one implementation of the semantics, and ripgrep supplies the thing
171
+ * it is unmatched at, which is getting from thousands of files down to a
172
+ * handful very quickly.
173
+ *
174
+ * That also removes the batching problem. Attributing a match to the right
175
+ * pattern used to be delicate, because one ripgrep pass over several patterns
176
+ * cannot always say which one matched; as a pre-filter it does not need to,
177
+ * since including a file that turns out not to match is free.
178
+ */
170
179
  class RipgrepEngine {
171
180
  binary;
172
181
  name = 'ripgrep';
@@ -174,56 +183,25 @@ class RipgrepEngine {
174
183
  this.binary = binary;
175
184
  }
176
185
  async search(request) {
177
- if (request.options.ignoreComments) {
178
- const [result] = await this.commentAware(request, [request.symbol], [request]);
179
- return result;
180
- }
181
- const tallies = await this.run(request, [request.symbol]);
182
- return this.toResult(tallies.get(request.symbol));
186
+ const [result] = await this.searchBatch([request]);
187
+ return result;
183
188
  }
184
189
  async searchBatch(requests) {
185
190
  const [first] = requests;
191
+ /* c8 ignore next -- runSearches never passes an empty list */
186
192
  if (!first)
187
193
  return [];
188
194
  const patterns = [...new Set(requests.map((request) => request.symbol))];
189
- // Comment-aware counting needs the file's text, which ripgrep's match
190
- // stream does not carry. Rather than re-implement classification twice,
191
- // ripgrep is used for what it is unmatched at - telling us which handful of
192
- // files out of thousands contain the symbol at all - and those files are
193
- // then counted by the scanner, which already knows about comments. Both
194
- // engines therefore produce comment-aware counts through one code path.
195
- if (first.options.ignoreComments) {
196
- return this.commentAware(first, patterns, requests);
197
- }
198
- if (!shouldBatchPatterns(patterns, first.options)) {
199
- return Promise.all(requests.map((request) => this.search(request)));
200
- }
201
- try {
202
- const tallies = await this.run(first, patterns);
203
- return requests.map((request) => this.toResult(tallies.get(request.symbol)));
204
- }
205
- catch (error) {
206
- if (!(error instanceof UnattributableBatch))
207
- throw error;
208
- return Promise.all(requests.map((request) => this.search(request)));
209
- }
210
- }
211
- /**
212
- * Two-phase search, used whenever comments must be classified.
213
- *
214
- * The result still names ripgrep: it did the searching, and classification is
215
- * a post-step both engines share rather than a different engine.
216
- */
217
- async commentAware(request, patterns, requests) {
218
- const files = await this.filesWithMatches(request, patterns);
219
- const results = await javascriptEngine.searchFiles(files, requests);
195
+ const { files, unreadable } = await this.filesWithMatches(first, patterns);
196
+ const results = await javascriptEngine.searchFiles(files, requests, unreadable);
197
+ // The result names ripgrep because ripgrep is what searched the tree; the
198
+ // scanner is a shared post-step, not a different engine.
220
199
  return results.map((result) => ({ ...result, engine: this.name }));
221
200
  }
222
- /** Phase one: which files contain any of these patterns at all. */
201
+ /** Which files contain any of these patterns, and which could not be read. */
223
202
  filesWithMatches(request, patterns) {
224
203
  return new Promise((resolve, reject) => {
225
- const args = buildRipgrepArgs(request, patterns).map((argument) => argument === '--json' ? '--files-with-matches' : argument);
226
- const child = spawn(this.binary, args, {
204
+ const child = spawn(this.binary, buildRipgrepArgs(request, patterns), {
227
205
  cwd: request.root,
228
206
  windowsHide: true,
229
207
  stdio: ['ignore', 'pipe', 'pipe'],
@@ -242,153 +220,46 @@ class RipgrepEngine {
242
220
  return;
243
221
  }
244
222
  const files = [];
245
- for (const line of stdout.split('\n')) {
246
- const trimmed = line.trim();
247
- if (trimmed.length === 0)
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)
248
228
  continue;
249
- const absolutePath = path.resolve(request.root, trimmed);
229
+ const absolutePath = path.resolve(request.root, entry);
250
230
  if (request.options.excludeFiles.has(absolutePath))
251
231
  continue;
252
232
  files.push({ absolutePath, relativePath: toPosix(path.relative(request.root, absolutePath)) });
253
233
  }
254
234
  files.sort((a, b) => (a.relativePath < b.relativePath ? -1 : 1));
255
- resolve(files);
256
- });
257
- });
258
- }
259
- toResult(tally) {
260
- const resolved = tally ?? emptyTally();
261
- return {
262
- count: resolved.count,
263
- commentMatches: resolved.commentCount,
264
- unclassifiedFiles: 0,
265
- matches: sortLocations(resolved.locations),
266
- engine: this.name,
267
- };
268
- }
269
- /** One ripgrep pass; returns a tally per pattern. */
270
- run(request, patterns) {
271
- return new Promise((resolve, reject) => {
272
- const child = spawn(this.binary, buildRipgrepArgs(request, patterns), {
273
- cwd: request.root,
274
- windowsHide: true,
275
- stdio: ['ignore', 'pipe', 'pipe'],
276
- });
277
- const sink = createRipgrepSink(request, patterns);
278
- let pending = '';
279
- let stderr = '';
280
- child.stdout.setEncoding('utf8');
281
- child.stdout.on('data', (chunk) => {
282
- pending += chunk;
283
- let newline = pending.indexOf('\n');
284
- while (newline !== -1) {
285
- sink.line(pending.slice(0, newline));
286
- pending = pending.slice(newline + 1);
287
- newline = pending.indexOf('\n');
288
- }
289
- });
290
- child.stderr.setEncoding('utf8');
291
- child.stderr.on('data', (chunk) => {
292
- stderr += chunk;
293
- });
294
- child.once('error', reject);
295
- child.once('close', (code) => {
296
- sink.line(pending);
297
- if (sink.failure) {
298
- reject(sink.failure);
299
- return;
300
- }
301
- // 0 = matches, 1 = no matches, 2 = an actual failure.
302
- if (code !== 0 && code !== 1) {
303
- reject(new Error(`ripgrep exited with code ${code}${stderr ? `: ${stderr.trim()}` : ''}`));
304
- return;
305
- }
306
- resolve(sink.tallies);
235
+ resolve({ files, unreadable: parseRipgrepErrors(stderr) });
307
236
  });
308
237
  });
309
238
  }
310
239
  }
311
240
  /**
312
- * Whether a set of patterns may share one ripgrep pass.
241
+ * Pulls the paths out of ripgrep's stderr.
313
242
  *
314
- * Kept separate from the engine so the decision can be asserted directly: from
315
- * the outside a correct batch and a correct set of separate passes are
316
- * indistinguishable by design, which makes this the only place the rule is
317
- * observable.
318
- */
319
- export function shouldBatchPatterns(patterns, options) {
320
- if (patterns.length <= 1)
321
- return false;
322
- if (options.regex || options.ignoreCase)
323
- return false;
324
- return canBatchLiterals(patterns);
325
- }
326
- /**
327
- * Parses ripgrep's newline-delimited JSON into per-pattern tallies.
243
+ * `--no-messages` used to be passed, which threw these away: a file ripgrep
244
+ * could not open produced no match, no error and no difference from a file that
245
+ * was searched and found clean. ripgrep writes one line per failure, as
246
+ * `path: reason`, and the path is what the report needs.
328
247
  *
329
- * This is deliberately pure and separate from the subprocess: every interesting
330
- * case here - a base64 path, a match with no submatches, an excluded file, an
331
- * unattributable match - is trivial to exercise as data and nearly impossible
332
- * to provoke from a real ripgrep on demand.
248
+ * Anything that does not parse is returned as-is rather than dropped, on the
249
+ * principle that an unexplained line from a subprocess is still information.
333
250
  */
334
- export function createRipgrepSink(request, patterns) {
335
- const single = patterns.length === 1 ? patterns[0] : null;
336
- const tallies = new Map(patterns.map((pattern) => [pattern, emptyTally()]));
337
- const sink = {
338
- tallies,
339
- failure: null,
340
- line(text) {
341
- if (text.length === 0 || sink.failure)
342
- return;
343
- let event;
344
- try {
345
- event = JSON.parse(text);
346
- }
347
- catch {
348
- return;
349
- }
350
- if (event.type !== 'match' || !event.data)
351
- return;
352
- const data = event.data;
353
- const submatches = data.submatches ?? [];
354
- if (submatches.length === 0)
355
- return;
356
- // ripgrep echoes the path as given ("./src/a.ts" when the target is
357
- // "."); normalise so both engines report the same relative path.
358
- const absolute = path.resolve(request.root, decodeText(data.path));
359
- if (request.options.excludeFiles.has(absolute))
360
- return;
361
- const file = toPosix(path.relative(request.root, absolute));
362
- const lineNumber = data.line_number ?? 0;
363
- const lineText = decodeText(data.lines);
364
- const text_ = truncate(lineText);
365
- for (const submatch of submatches) {
366
- // With --fixed-strings and no --ignore-case the matched text is the
367
- // pattern verbatim, which is what makes attribution exact.
368
- const pattern = single ?? decodeText(submatch.match);
369
- const tally = tallies.get(pattern);
370
- if (!tally) {
371
- sink.failure = new UnattributableBatch(`ripgrep reported an unexpected match: ${pattern}`);
372
- return;
373
- }
374
- tally.count += 1;
375
- const last = tally.locations.at(-1);
376
- if (last && last.file === file && last.line === lineNumber) {
377
- last.count += 1;
378
- }
379
- else if (tally.locations.length < MAX_COLLECTED_MATCHES) {
380
- tally.locations.push({
381
- file,
382
- line: lineNumber,
383
- column: byteColumnToCharacter(lineText, submatch.start ?? 0),
384
- text: text_,
385
- count: 1,
386
- });
387
- }
388
- }
389
- },
390
- };
391
- return sink;
251
+ export function parseRipgrepErrors(stderr) {
252
+ const paths = [];
253
+ for (const raw of stderr.split(/\r?\n/)) {
254
+ const line = raw.trim();
255
+ if (line.length === 0)
256
+ continue;
257
+ // "rg: " prefixes ripgrep's own diagnostics rather than a file's.
258
+ const body = line.startsWith('rg: ') ? line.slice(4) : line;
259
+ const separator = body.indexOf(': ');
260
+ paths.push(separator === -1 ? body : body.slice(0, separator));
261
+ }
262
+ return paths;
392
263
  }
393
264
  /* --------------------------------------------------------------- javascript */
394
265
  /** Builds the matching RegExp shared by the JS engine. */
@@ -402,14 +273,6 @@ export function buildJsRegExp(symbol, options) {
402
273
  const source = options.word ? `(?<![\\p{L}\\p{N}_])(?:${escaped})(?![\\p{L}\\p{N}_])` : escaped;
403
274
  return new RegExp(source, `${flags}u`);
404
275
  }
405
- function isProbablyBinary(buffer) {
406
- const limit = Math.min(buffer.length, 8192);
407
- for (let index = 0; index < limit; index++) {
408
- if (buffer[index] === 0)
409
- return true;
410
- }
411
- return false;
412
- }
413
276
  class JavaScriptEngine {
414
277
  name = 'javascript';
415
278
  async search(request) {
@@ -425,20 +288,35 @@ class JavaScriptEngine {
425
288
  const [first] = requests;
426
289
  if (!first)
427
290
  return [];
428
- const files = await this.collectFiles(first);
429
- return this.searchFiles(files, requests);
291
+ const enumeration = await enumerateCandidates(first);
292
+ return this.searchFiles(enumeration.files, requests, [], enumeration.skipped);
430
293
  }
431
- /** Scans an already-enumerated file list. */
432
- async searchFiles(files, requests) {
294
+ /**
295
+ * Scans an already-enumerated file list.
296
+ *
297
+ * `unreadable` carries paths a caller already knows it could not open -
298
+ * ripgrep reports those on stderr, and they belong in the same ledger as the
299
+ * ones this scanner discovers for itself.
300
+ */
301
+ async searchFiles(files, requests, unreadable = [], skipped = []) {
433
302
  const [first] = requests;
434
303
  if (!first)
435
304
  return [];
305
+ const ledger = new LedgerBuilder();
306
+ for (const entry of skipped)
307
+ ledger.add(entry.path, entry.reason, entry.matches);
308
+ for (const entry of unreadable)
309
+ ledger.add(entry, 'unreadable');
436
310
  const patterns = [...new Set(requests.map((request) => request.symbol))];
437
311
  const regexps = new Map(patterns.map((pattern) => [pattern, buildJsRegExp(pattern, first.options)]));
438
312
  const tallies = new Map(patterns.map((pattern) => [pattern, emptyTally()]));
439
313
  const concurrency = Math.min(16, Math.max(1, files.length));
440
314
  let cursor = 0;
441
315
  const perFile = new Map();
316
+ // Per pattern, how many matches each file holds. Only files that matched
317
+ // appear, so this is smaller than perFile, which already holds an entry for
318
+ // every file scanned - there is no new memory shape here.
319
+ const byFile = new Map(patterns.map((pattern) => [pattern, new Map()]));
442
320
  let unclassifiedFiles = 0;
443
321
  const worker = async () => {
444
322
  while (cursor < files.length) {
@@ -447,8 +325,12 @@ class JavaScriptEngine {
447
325
  if (!file)
448
326
  return;
449
327
  const buffer = await fs.readFile(file.absolutePath).catch(() => null);
450
- if (!buffer || isProbablyBinary(buffer))
328
+ if (!buffer) {
329
+ // A file we cannot open might hold anything, so it is recorded rather
330
+ // than passed over as though it had been read and found clean.
331
+ ledger.add(file.relativePath, 'unreadable');
451
332
  continue;
333
+ }
452
334
  const content = buffer.toString('utf8');
453
335
  let mask;
454
336
  const getMask = first.options.ignoreComments
@@ -458,6 +340,21 @@ class JavaScriptEngine {
458
340
  for (const [pattern, regexp] of regexps) {
459
341
  scanned.set(pattern, scanContent(content, file.relativePath, regexp, getMask));
460
342
  }
343
+ if (isBinary(buffer)) {
344
+ // Searched, but not counted. Skipping binary files silently was a way
345
+ // to pass an assertion by never looking; searching them and saying
346
+ // what was found leaves the decision with the reader.
347
+ //
348
+ // Only a binary file that *did* contain the symbol goes in the
349
+ // ledger. One that did not is not a gap - it was read, searched and
350
+ // found clean - and leaving it out is also what keeps the two engines
351
+ // reporting the same thing, since ripgrep only ever hands the scanner
352
+ // files that matched.
353
+ const found = [...scanned.values()].reduce((total, tally) => total + tally.count, 0);
354
+ if (found > 0)
355
+ ledger.add(file.relativePath, 'binary', found);
356
+ continue;
357
+ }
461
358
  // A mask exists only if some pattern matched, since that is the only
462
359
  // thing that calls getMask - so reaching here already means this file
463
360
  // matched and its language was not understood.
@@ -476,12 +373,15 @@ class JavaScriptEngine {
476
373
  const total = tallies.get(pattern);
477
374
  total.count += tally.count;
478
375
  total.commentCount += tally.commentCount;
376
+ if (tally.count > 0)
377
+ byFile.get(pattern).set(file.relativePath, tally.count);
479
378
  for (const location of tally.locations) {
480
379
  if (total.locations.length < MAX_COLLECTED_MATCHES)
481
380
  total.locations.push(location);
482
381
  }
483
382
  }
484
383
  }
384
+ const scope = ledger.build();
485
385
  return requests.map((request) => {
486
386
  const tally = tallies.get(request.symbol) ?? emptyTally();
487
387
  return {
@@ -489,15 +389,22 @@ class JavaScriptEngine {
489
389
  commentMatches: tally.commentCount,
490
390
  unclassifiedFiles,
491
391
  matches: tally.locations,
392
+ fileCounts: byFile.get(request.symbol) ?? new Map(),
393
+ scope,
492
394
  engine: this.name,
493
395
  };
494
396
  });
495
397
  }
496
- /** Every file a request should look at, sorted by relative path. */
497
- async collectFiles(request) {
498
- return (await enumerateCandidates(request)).files;
499
- }
500
398
  }
399
+ /**
400
+ * A budget that abandons the walk at the first file it finds.
401
+ *
402
+ * Used to answer one question - "is there anything here at all?" - without
403
+ * walking a tree to count files nobody asked about. The answer is the same
404
+ * whichever engine will do the searching, which is the point: an assertion that
405
+ * covers nothing has to be recognised identically by both.
406
+ */
407
+ export const ANY_FILE_PROBE = { maxFiles: 0, maxBytes: 0 };
501
408
  /**
502
409
  * Lists the files a request would search, sorted by relative path.
503
410
  *
@@ -509,8 +416,24 @@ class JavaScriptEngine {
509
416
  export async function enumerateCandidates(request, budget) {
510
417
  const matcher = createGlobMatcher(request.options.globs);
511
418
  const excluded = createExcludeMatcher(request.options.excludeGlobs);
512
- const admits = (relativePath) => matcher(relativePath) && !excluded(relativePath);
419
+ // excludeFiles is applied here rather than after the walk so that a budgeted
420
+ // enumeration counts only files it would really search. Filtering afterwards
421
+ // let an excluded spec file fill a one-file probe and make a populated
422
+ // directory look empty.
423
+ const admits = (absolutePath, relativePath) => matcher(relativePath) && !excluded(relativePath) && !request.options.excludeFiles.has(absolutePath);
513
424
  const found = new Map();
425
+ const skipped = [];
426
+ const note = (relativePath, reason) => {
427
+ // Only gaps go in the ledger. `.git` and `node_modules` are configuration,
428
+ // not news: they are the same on every run, they are documented, and
429
+ // reporting them each time would bury the entries that do mean something.
430
+ // It also keeps the two engines' ledgers identical, since ripgrep is only
431
+ // ever asked about files it did not skip.
432
+ if (!UNCERTAIN_REASONS.has(reason))
433
+ return;
434
+ if (skipped.length < MAX_LEDGER_ENTRIES)
435
+ skipped.push({ path: relativePath, reason });
436
+ };
514
437
  let bytes = 0;
515
438
  let exceeded = false;
516
439
  const admit = (absolutePath, relativePath, size) => {
@@ -529,26 +452,29 @@ export async function enumerateCandidates(request, budget) {
529
452
  continue;
530
453
  if (stats.isFile()) {
531
454
  const relativePath = toPosix(path.relative(request.root, absoluteTarget));
532
- if (stats.size <= MAX_FILE_SIZE && admits(relativePath)) {
455
+ if (stats.size <= MAX_FILE_SIZE && admits(absoluteTarget, relativePath)) {
533
456
  if (!admit(absoluteTarget, relativePath, stats.size))
534
457
  break outer;
535
458
  }
536
459
  continue;
537
460
  }
538
- for await (const file of walkFiles(absoluteTarget)) {
461
+ const prefix = toPosix(path.relative(request.root, absoluteTarget));
462
+ const walkOptions = {
463
+ scope: request.options.scope,
464
+ onSkip: (relativePath, reason) => note(prefix ? `${prefix}/${relativePath}` : relativePath, reason),
465
+ };
466
+ for await (const file of walkFiles(absoluteTarget, walkOptions)) {
539
467
  if (file.size > MAX_FILE_SIZE)
540
468
  continue;
541
469
  const relativePath = toPosix(path.relative(request.root, file.absolutePath));
542
- if (!admits(relativePath))
470
+ if (!admits(file.absolutePath, relativePath))
543
471
  continue;
544
472
  if (!admit(file.absolutePath, relativePath, file.size))
545
473
  break outer;
546
474
  }
547
475
  }
548
- for (const excluded of request.options.excludeFiles)
549
- found.delete(excluded);
550
476
  const files = [...found.values()].sort((a, b) => (a.relativePath < b.relativePath ? -1 : 1));
551
- return { files, exceeded, bytes };
477
+ return { files, exceeded, bytes, skipped };
552
478
  }
553
479
  /** Counts matches in one file and records per-line snippets. */
554
480
  export function scanContent(content, relativePath, regexp,