@descent-vtt/spec-guard 0.2.0 → 0.3.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.
@@ -0,0 +1,241 @@
1
+ /**
2
+ * Comment classification.
3
+ *
4
+ * A documented codebase defeats a plain text search in the most ironic way
5
+ * available: the comment recording that a symbol was deleted is itself a match
6
+ * for that symbol. `@assert-absence symbol="LegacyThing"` then fails on
7
+ *
8
+ * // LegacyThing was removed in ADR-398
9
+ *
10
+ * which is the sentence proving the assertion true. So matches are classified
11
+ * and, by default, those inside comments do not count.
12
+ *
13
+ * The scanner tracks strings as well as comments, and it has to: a `//` inside
14
+ * a string literal is not a comment, and misreading one is the dangerous
15
+ * direction. Consider
16
+ *
17
+ * const url = "http://example.com"; const x = LegacyThing;
18
+ *
19
+ * Treating `//` in that URL as a comment start would hide a real use of
20
+ * LegacyThing and turn a violation into a silent pass. Every uncertainty here
21
+ * therefore resolves the same way: **when in doubt, it is code**. A match
22
+ * wrongly kept is a visible failure someone can argue with; a match wrongly
23
+ * dropped is a lie.
24
+ *
25
+ * Languages it does not recognise get no comment ranges at all, so their
26
+ * matches all count - and the result says so rather than implying the file was
27
+ * understood.
28
+ */
29
+ import path from 'node:path';
30
+ const QUOTES = [
31
+ { open: '"', close: '"', escape: true },
32
+ { open: "'", close: "'", escape: true },
33
+ ];
34
+ const C_LIKE = {
35
+ name: 'c-like',
36
+ line: ['//'],
37
+ block: [['/*', '*/']],
38
+ nested: false,
39
+ strings: QUOTES,
40
+ };
41
+ const JS_LIKE = {
42
+ ...C_LIKE,
43
+ name: 'javascript',
44
+ strings: [...QUOTES, { open: '`', close: '`', escape: true }],
45
+ };
46
+ const C_SHARP = {
47
+ ...C_LIKE,
48
+ name: 'c#',
49
+ // Verbatim strings come first so @" wins over ".
50
+ strings: [{ open: '@"', close: '"', escape: false }, ...QUOTES],
51
+ };
52
+ const RUST = {
53
+ name: 'rust',
54
+ line: ['//'],
55
+ block: [['/*', '*/']],
56
+ // Rust block comments nest, so /* /* */ */ is one comment, not one and a half.
57
+ nested: true,
58
+ strings: [
59
+ { open: 'r#"', close: '"#', escape: false },
60
+ { open: 'r"', close: '"', escape: false },
61
+ ...QUOTES,
62
+ ],
63
+ };
64
+ const GO = {
65
+ ...C_LIKE,
66
+ name: 'go',
67
+ strings: [...QUOTES, { open: '`', close: '`', escape: false }],
68
+ };
69
+ const HASH = {
70
+ name: 'hash',
71
+ line: ['#'],
72
+ block: [],
73
+ nested: false,
74
+ // Triple quotes first: a Python docstring is a string, not a comment, and
75
+ // treating it as code is the conservative reading.
76
+ strings: [
77
+ { open: '"""', close: '"""', escape: true },
78
+ { open: "'''", close: "'''", escape: true },
79
+ ...QUOTES,
80
+ ],
81
+ };
82
+ const SQL_LIKE = {
83
+ name: 'sql-like',
84
+ line: ['--'],
85
+ block: [['/*', '*/']],
86
+ nested: false,
87
+ strings: QUOTES,
88
+ };
89
+ const MARKUP = {
90
+ name: 'markup',
91
+ line: [],
92
+ block: [['<!--', '-->']],
93
+ nested: false,
94
+ strings: [],
95
+ };
96
+ /**
97
+ * Formats with no comments at all.
98
+ *
99
+ * Worth naming rather than leaving unknown: "this file has no comments" is
100
+ * something we know about JSON, and saying "I could not read it" instead would
101
+ * attach a caveat to every match in a package.json. Dialects that do allow
102
+ * comments in practice - tsconfig.json and its `//` lines - simply have them
103
+ * counted as code, which is the direction that fails loudly.
104
+ */
105
+ const NO_COMMENTS = {
106
+ name: 'none',
107
+ line: [],
108
+ block: [],
109
+ nested: false,
110
+ strings: [],
111
+ };
112
+ /** Extension to comment syntax. Anything absent is left unclassified. */
113
+ const BY_EXTENSION = new Map([
114
+ ...['.js', '.mjs', '.cjs', '.jsx', '.ts', '.mts', '.cts', '.tsx'].map((extension) => [extension, JS_LIKE]),
115
+ ...['.c', '.h', '.cc', '.cpp', '.cxx', '.hpp', '.java', '.kt', '.kts', '.scala', '.swift', '.dart', '.php', '.m', '.mm', '.zig'].map((extension) => [extension, C_LIKE]),
116
+ ...['.cs', '.csx'].map((extension) => [extension, C_SHARP]),
117
+ ...['.rs'].map((extension) => [extension, RUST]),
118
+ ...['.go'].map((extension) => [extension, GO]),
119
+ ...['.py', '.pyi', '.rb', '.sh', '.bash', '.zsh', '.yaml', '.yml', '.toml', '.tf', '.pl', '.r'].map((extension) => [extension, HASH]),
120
+ ...['.sql', '.lua', '.hs', '.elm'].map((extension) => [extension, SQL_LIKE]),
121
+ ...['.html', '.htm', '.xml', '.svg', '.vue', '.svelte', '.md', '.markdown'].map((extension) => [extension, MARKUP]),
122
+ // .jsonc is named for the comments it allows, so it gets the C-style reader.
123
+ ...['.jsonc'].map((extension) => [extension, C_LIKE]),
124
+ ...['.json', '.txt', '.csv', '.tsv', '.lock', '.log'].map((extension) => [extension, NO_COMMENTS]),
125
+ ]);
126
+ /** The comment syntax for a path, or null when the language is unknown. */
127
+ export function syntaxFor(filePath) {
128
+ return BY_EXTENSION.get(path.extname(filePath).toLowerCase()) ?? null;
129
+ }
130
+ /** Where the literal opened at `at` ends, or the end of the file. */
131
+ function endOfString(source, at, rule) {
132
+ let index = at + rule.open.length;
133
+ while (index < source.length) {
134
+ if (rule.escape && source[index] === '\\') {
135
+ index += 2;
136
+ continue;
137
+ }
138
+ if (source.startsWith(rule.close, index))
139
+ return index + rule.close.length;
140
+ index += 1;
141
+ }
142
+ return source.length;
143
+ }
144
+ /** Where the block comment opened at `at` ends, or the end of the file. */
145
+ function endOfBlock(source, at, pair, nested) {
146
+ const [open, close] = pair;
147
+ let index = at + open.length;
148
+ let depth = 1;
149
+ while (index < source.length && depth > 0) {
150
+ if (nested && source.startsWith(open, index)) {
151
+ depth += 1;
152
+ index += open.length;
153
+ continue;
154
+ }
155
+ if (source.startsWith(close, index)) {
156
+ depth -= 1;
157
+ index += close.length;
158
+ continue;
159
+ }
160
+ index += 1;
161
+ }
162
+ return index;
163
+ }
164
+ /**
165
+ * Finds every comment in the source.
166
+ *
167
+ * One pass, tracking strings so that comment markers inside them are ignored.
168
+ * An unterminated comment or string runs to the end of the file, which matches
169
+ * how a compiler would read it.
170
+ *
171
+ * The scan is written so that it cannot stand still: whatever the branches
172
+ * below decide, the loop advances by at least one character. That guarantee is
173
+ * the difference between a wrong answer and a hang, and this loop appends as it
174
+ * goes - standing still here would not spin, it would eat memory until the
175
+ * process died, on somebody's file, in somebody's CI.
176
+ */
177
+ export function commentRanges(source, syntax) {
178
+ const ranges = [];
179
+ let index = 0;
180
+ while (index < source.length) {
181
+ const start = index;
182
+ const startsWith = (token) => source.startsWith(token, start);
183
+ // First match wins, in this order: a literal hides comment markers inside
184
+ // it, and a line comment hides a block opener on the same line. Each lookup
185
+ // sits in the branch that needs it, so none of them runs speculatively.
186
+ const stringRule = syntax.strings.find((rule) => startsWith(rule.open));
187
+ if (stringRule) {
188
+ index = endOfString(source, start, stringRule);
189
+ }
190
+ else {
191
+ const lineToken = syntax.line.find((token) => startsWith(token));
192
+ if (lineToken !== undefined) {
193
+ const newline = source.indexOf('\n', start);
194
+ index = newline === -1 ? source.length : newline;
195
+ ranges.push([start, index]);
196
+ }
197
+ else {
198
+ const blockPair = syntax.block.find(([open]) => startsWith(open));
199
+ if (blockPair) {
200
+ index = endOfBlock(source, start, blockPair, syntax.nested);
201
+ ranges.push([start, index]);
202
+ }
203
+ }
204
+ }
205
+ // The single advance point, and the only place termination depends on.
206
+ if (index <= start)
207
+ index = start + 1;
208
+ }
209
+ return ranges;
210
+ }
211
+ const UNCLASSIFIED = {
212
+ classified: false,
213
+ syntax: null,
214
+ isComment: () => false,
215
+ };
216
+ export function createCommentMask(source, filePath) {
217
+ const syntax = syntaxFor(filePath);
218
+ if (!syntax)
219
+ return UNCLASSIFIED;
220
+ const ranges = commentRanges(source, syntax);
221
+ return {
222
+ classified: true,
223
+ syntax: syntax.name,
224
+ isComment(offset) {
225
+ let low = 0;
226
+ let high = ranges.length - 1;
227
+ while (low <= high) {
228
+ const mid = (low + high) >> 1;
229
+ const [start, end] = ranges[mid];
230
+ if (offset < start)
231
+ high = mid - 1;
232
+ else if (offset >= end)
233
+ low = mid + 1;
234
+ else
235
+ return true;
236
+ }
237
+ return false;
238
+ },
239
+ };
240
+ }
241
+ //# sourceMappingURL=comments.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"comments.js","sourceRoot":"","sources":["../src/comments.ts"],"names":[],"mappings":"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"}
package/dist/engine.d.ts CHANGED
@@ -14,6 +14,7 @@
14
14
  * flags are answered by a single pass. Measured on a 2,000 file / 5 MB tree,
15
15
  * eight assertions cost ~280ms as eight ripgrep passes and ~90ms as one.
16
16
  */
17
+ import { type CommentMask } from './comments.js';
17
18
  import type { EngineName, MatchLocation, SearchOptions, SearchResult } from './types.js';
18
19
  /** Files larger than this are skipped by both engines, keeping them in sync. */
19
20
  export declare const MAX_FILE_SIZE: number;
@@ -37,10 +38,27 @@ export interface Engine {
37
38
  }
38
39
  export type EnginePreference = 'auto' | 'ripgrep' | 'javascript';
39
40
  export declare function escapeRegExp(value: string): string;
41
+ /**
42
+ * Converts ripgrep's byte column into a character column.
43
+ *
44
+ * ripgrep counts bytes; the scanner counts characters, and so does every editor
45
+ * that a reader will paste the location into. On an ASCII line the two agree,
46
+ * which is why the difference went unnoticed - it only appears once a line
47
+ * holds a non-ASCII character before the match, and then the two engines report
48
+ * different columns for the same match.
49
+ */
50
+ export declare function byteColumnToCharacter(line: string, byteOffset: number): number;
51
+ /**
52
+ * Orders matches by path, then by line. ripgrep searches in parallel and emits
53
+ * files in no fixed order, so this is what makes snippet output stable.
54
+ */
55
+ export declare function sortLocations(locations: MatchLocation[]): MatchLocation[];
40
56
  /** Per-pattern accumulator used by both engines. */
41
57
  interface Tally {
42
58
  count: number;
43
59
  locations: MatchLocation[];
60
+ /** Matches skipped because they were inside a comment. */
61
+ commentCount: number;
44
62
  }
45
63
  /**
46
64
  * True when no two of these literals can ever match overlapping text, which is
@@ -109,7 +127,14 @@ export interface EnumerationBudget {
109
127
  */
110
128
  export declare function enumerateCandidates(request: SearchRequest, budget?: EnumerationBudget): Promise<Enumeration>;
111
129
  /** Counts matches in one file and records per-line snippets. */
112
- export declare function scanContent(content: string, relativePath: string, regexp: RegExp): Tally;
130
+ export declare function scanContent(content: string, relativePath: string, regexp: RegExp,
131
+ /**
132
+ * Resolved lazily, and only once the file has produced a match. Classifying
133
+ * every file in scope would mean a comment scan of the whole tree; this way
134
+ * the cost is proportional to matches, which for an architecture assertion is
135
+ * usually zero.
136
+ */
137
+ getMask?: () => CommentMask): Tally;
113
138
  /** An engine that is guaranteed to implement the batch API. */
114
139
  export type BatchEngine = Engine & Required<Pick<Engine, 'searchBatch'>> & {
115
140
  /** Scans a file list that the caller already enumerated. */
@@ -133,6 +158,9 @@ export declare const javascriptEngine: BatchEngine;
133
158
  * Windows avoids a spawn that would cost ten times the whole search.
134
159
  */
135
160
  export declare const SMALL_TREE_BUDGET: EnumerationBudget;
161
+ /** Exported so the code list can be asserted; EACCES and friends are not
162
+ * reproducible on demand from a real spawn. */
163
+ export declare function isMissingBinary(error: unknown): boolean;
136
164
  /** Resolves the engine to use, honouring an explicit preference. */
137
165
  export declare function resolveEngine(preference?: EnginePreference): Promise<Engine>;
138
166
  export type CachedEngine = Engine & {
@@ -1 +1 @@
1
- {"version":3,"file":"engine.d.ts","sourceRoot":"","sources":["../src/engine.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"engine.d.ts","sourceRoot":"","sources":["../src/engine.ts"],"names":[],"mappings":"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"}
package/dist/engine.js CHANGED
@@ -17,7 +17,8 @@
17
17
  import { spawn } from 'node:child_process';
18
18
  import { promises as fs } from 'node:fs';
19
19
  import path from 'node:path';
20
- import { createGlobMatcher, toPosix, walkFiles } from './glob.js';
20
+ import { createCommentMask } from './comments.js';
21
+ import { createExcludeMatcher, createGlobMatcher, toPosix, walkFiles } from './glob.js';
21
22
  /** Files larger than this are skipped by both engines, keeping them in sync. */
22
23
  export const MAX_FILE_SIZE = 20 * 1024 * 1024;
23
24
  /** Hard cap on collected locations; the reporter only ever shows a handful. */
@@ -31,11 +32,31 @@ function truncate(value) {
31
32
  const trimmed = value.replace(/\r?\n$/, '');
32
33
  return trimmed.length > MAX_SNIPPET_LENGTH ? `${trimmed.slice(0, MAX_SNIPPET_LENGTH)}…` : trimmed;
33
34
  }
34
- function sortLocations(locations) {
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
+ /**
52
+ * Orders matches by path, then by line. ripgrep searches in parallel and emits
53
+ * files in no fixed order, so this is what makes snippet output stable.
54
+ */
55
+ export function sortLocations(locations) {
35
56
  return locations.sort((a, b) => (a.file === b.file ? a.line - b.line : a.file < b.file ? -1 : 1));
36
57
  }
37
58
  function emptyTally() {
38
- return { count: 0, locations: [] };
59
+ return { count: 0, locations: [], commentCount: 0 };
39
60
  }
40
61
  /**
41
62
  * True when no two of these literals can ever match overlapping text, which is
@@ -75,6 +96,8 @@ function sharesOnePass(requests) {
75
96
  request.options.ignoreCase === first.options.ignoreCase &&
76
97
  request.options.globs.length === first.options.globs.length &&
77
98
  request.options.globs.every((glob, index) => glob === first.options.globs[index]) &&
99
+ request.options.excludeGlobs.length === first.options.excludeGlobs.length &&
100
+ request.options.excludeGlobs.every((glob, index) => glob === first.options.excludeGlobs[index]) &&
78
101
  request.options.excludeFiles === first.options.excludeFiles);
79
102
  }
80
103
  /** Runs requests through the batch API when the engine has one. */
@@ -131,6 +154,10 @@ export function buildRipgrepArgs(request, patterns = [request.symbol]) {
131
154
  args.push('--ignore-case');
132
155
  for (const glob of options.globs)
133
156
  args.push('--glob', glob);
157
+ // ripgrep reads a leading "!" as an exclusion, with gitignore semantics that
158
+ // createExcludeMatcher mirrors for the JavaScript engine.
159
+ for (const glob of options.excludeGlobs)
160
+ args.push('--glob', `!${glob}`);
134
161
  for (const pattern of patterns)
135
162
  args.push('--regexp', pattern);
136
163
  args.push('--');
@@ -147,6 +174,10 @@ class RipgrepEngine {
147
174
  this.binary = binary;
148
175
  }
149
176
  async search(request) {
177
+ if (request.options.ignoreComments) {
178
+ const [result] = await this.commentAware(request, [request.symbol], [request]);
179
+ return result;
180
+ }
150
181
  const tallies = await this.run(request, [request.symbol]);
151
182
  return this.toResult(tallies.get(request.symbol));
152
183
  }
@@ -155,6 +186,15 @@ class RipgrepEngine {
155
186
  if (!first)
156
187
  return [];
157
188
  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
+ }
158
198
  if (!shouldBatchPatterns(patterns, first.options)) {
159
199
  return Promise.all(requests.map((request) => this.search(request)));
160
200
  }
@@ -168,9 +208,63 @@ class RipgrepEngine {
168
208
  return Promise.all(requests.map((request) => this.search(request)));
169
209
  }
170
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);
220
+ return results.map((result) => ({ ...result, engine: this.name }));
221
+ }
222
+ /** Phase one: which files contain any of these patterns at all. */
223
+ filesWithMatches(request, patterns) {
224
+ 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, {
227
+ cwd: request.root,
228
+ windowsHide: true,
229
+ stdio: ['ignore', 'pipe', 'pipe'],
230
+ });
231
+ let stdout = '';
232
+ let stderr = '';
233
+ child.stdout.setEncoding('utf8');
234
+ child.stdout.on('data', (chunk) => (stdout += chunk));
235
+ child.stderr.setEncoding('utf8');
236
+ child.stderr.on('data', (chunk) => (stderr += chunk));
237
+ child.once('error', reject);
238
+ child.once('close', (code) => {
239
+ // 0 = matches, 1 = none, anything else is a real failure.
240
+ if (code !== 0 && code !== 1) {
241
+ reject(new Error(`ripgrep exited with code ${code}${stderr ? `: ${stderr.trim()}` : ''}`));
242
+ return;
243
+ }
244
+ const files = [];
245
+ for (const line of stdout.split('\n')) {
246
+ const trimmed = line.trim();
247
+ if (trimmed.length === 0)
248
+ continue;
249
+ const absolutePath = path.resolve(request.root, trimmed);
250
+ if (request.options.excludeFiles.has(absolutePath))
251
+ continue;
252
+ files.push({ absolutePath, relativePath: toPosix(path.relative(request.root, absolutePath)) });
253
+ }
254
+ files.sort((a, b) => (a.relativePath < b.relativePath ? -1 : 1));
255
+ resolve(files);
256
+ });
257
+ });
258
+ }
171
259
  toResult(tally) {
172
260
  const resolved = tally ?? emptyTally();
173
- return { count: resolved.count, matches: sortLocations(resolved.locations), engine: this.name };
261
+ return {
262
+ count: resolved.count,
263
+ commentMatches: resolved.commentCount,
264
+ unclassifiedFiles: 0,
265
+ matches: sortLocations(resolved.locations),
266
+ engine: this.name,
267
+ };
174
268
  }
175
269
  /** One ripgrep pass; returns a tally per pattern. */
176
270
  run(request, patterns) {
@@ -266,7 +360,8 @@ export function createRipgrepSink(request, patterns) {
266
360
  return;
267
361
  const file = toPosix(path.relative(request.root, absolute));
268
362
  const lineNumber = data.line_number ?? 0;
269
- const text_ = truncate(decodeText(data.lines));
363
+ const lineText = decodeText(data.lines);
364
+ const text_ = truncate(lineText);
270
365
  for (const submatch of submatches) {
271
366
  // With --fixed-strings and no --ignore-case the matched text is the
272
367
  // pattern verbatim, which is what makes attribution exact.
@@ -282,7 +377,13 @@ export function createRipgrepSink(request, patterns) {
282
377
  last.count += 1;
283
378
  }
284
379
  else if (tally.locations.length < MAX_COLLECTED_MATCHES) {
285
- tally.locations.push({ file, line: lineNumber, column: (submatch.start ?? 0) + 1, text: text_, count: 1 });
380
+ tally.locations.push({
381
+ file,
382
+ line: lineNumber,
383
+ column: byteColumnToCharacter(lineText, submatch.start ?? 0),
384
+ text: text_,
385
+ count: 1,
386
+ });
286
387
  }
287
388
  }
288
389
  },
@@ -338,6 +439,7 @@ class JavaScriptEngine {
338
439
  const concurrency = Math.min(16, Math.max(1, files.length));
339
440
  let cursor = 0;
340
441
  const perFile = new Map();
442
+ let unclassifiedFiles = 0;
341
443
  const worker = async () => {
342
444
  while (cursor < files.length) {
343
445
  const file = files[cursor++];
@@ -348,10 +450,19 @@ class JavaScriptEngine {
348
450
  if (!buffer || isProbablyBinary(buffer))
349
451
  continue;
350
452
  const content = buffer.toString('utf8');
453
+ let mask;
454
+ const getMask = first.options.ignoreComments
455
+ ? () => (mask ??= createCommentMask(content, file.relativePath))
456
+ : undefined;
351
457
  const scanned = new Map();
352
458
  for (const [pattern, regexp] of regexps) {
353
- scanned.set(pattern, scanContent(content, file.relativePath, regexp));
459
+ scanned.set(pattern, scanContent(content, file.relativePath, regexp, getMask));
354
460
  }
461
+ // A mask exists only if some pattern matched, since that is the only
462
+ // thing that calls getMask - so reaching here already means this file
463
+ // matched and its language was not understood.
464
+ if (mask && !mask.classified)
465
+ unclassifiedFiles += 1;
355
466
  perFile.set(file.relativePath, scanned);
356
467
  }
357
468
  };
@@ -364,6 +475,7 @@ class JavaScriptEngine {
364
475
  for (const [pattern, tally] of scanned) {
365
476
  const total = tallies.get(pattern);
366
477
  total.count += tally.count;
478
+ total.commentCount += tally.commentCount;
367
479
  for (const location of tally.locations) {
368
480
  if (total.locations.length < MAX_COLLECTED_MATCHES)
369
481
  total.locations.push(location);
@@ -372,7 +484,13 @@ class JavaScriptEngine {
372
484
  }
373
485
  return requests.map((request) => {
374
486
  const tally = tallies.get(request.symbol) ?? emptyTally();
375
- return { count: tally.count, matches: tally.locations, engine: this.name };
487
+ return {
488
+ count: tally.count,
489
+ commentMatches: tally.commentCount,
490
+ unclassifiedFiles,
491
+ matches: tally.locations,
492
+ engine: this.name,
493
+ };
376
494
  });
377
495
  }
378
496
  /** Every file a request should look at, sorted by relative path. */
@@ -390,6 +508,8 @@ class JavaScriptEngine {
390
508
  */
391
509
  export async function enumerateCandidates(request, budget) {
392
510
  const matcher = createGlobMatcher(request.options.globs);
511
+ const excluded = createExcludeMatcher(request.options.excludeGlobs);
512
+ const admits = (relativePath) => matcher(relativePath) && !excluded(relativePath);
393
513
  const found = new Map();
394
514
  let bytes = 0;
395
515
  let exceeded = false;
@@ -409,7 +529,7 @@ export async function enumerateCandidates(request, budget) {
409
529
  continue;
410
530
  if (stats.isFile()) {
411
531
  const relativePath = toPosix(path.relative(request.root, absoluteTarget));
412
- if (stats.size <= MAX_FILE_SIZE && matcher(relativePath)) {
532
+ if (stats.size <= MAX_FILE_SIZE && admits(relativePath)) {
413
533
  if (!admit(absoluteTarget, relativePath, stats.size))
414
534
  break outer;
415
535
  }
@@ -419,7 +539,7 @@ export async function enumerateCandidates(request, budget) {
419
539
  if (file.size > MAX_FILE_SIZE)
420
540
  continue;
421
541
  const relativePath = toPosix(path.relative(request.root, file.absolutePath));
422
- if (!matcher(relativePath))
542
+ if (!admits(relativePath))
423
543
  continue;
424
544
  if (!admit(file.absolutePath, relativePath, file.size))
425
545
  break outer;
@@ -431,9 +551,17 @@ export async function enumerateCandidates(request, budget) {
431
551
  return { files, exceeded, bytes };
432
552
  }
433
553
  /** Counts matches in one file and records per-line snippets. */
434
- export function scanContent(content, relativePath, regexp) {
554
+ export function scanContent(content, relativePath, regexp,
555
+ /**
556
+ * Resolved lazily, and only once the file has produced a match. Classifying
557
+ * every file in scope would mean a comment scan of the whole tree; this way
558
+ * the cost is proportional to matches, which for an architecture assertion is
559
+ * usually zero.
560
+ */
561
+ getMask) {
435
562
  regexp.lastIndex = 0;
436
563
  let count = 0;
564
+ let commentCount = 0;
437
565
  const byLine = new Map();
438
566
  // Lazily built line index: only paid for when the file actually matches.
439
567
  let lineStarts = null;
@@ -458,6 +586,12 @@ export function scanContent(content, relativePath, regexp) {
458
586
  };
459
587
  let match;
460
588
  while ((match = regexp.exec(content)) !== null) {
589
+ if (getMask?.().isComment(match.index)) {
590
+ commentCount += 1;
591
+ if (match[0].length === 0)
592
+ regexp.lastIndex += 1;
593
+ continue;
594
+ }
461
595
  count += 1;
462
596
  const { line, column } = lineOf(match.index);
463
597
  const existing = byLine.get(line);
@@ -478,7 +612,11 @@ export function scanContent(content, relativePath, regexp) {
478
612
  if (match[0].length === 0)
479
613
  regexp.lastIndex += 1;
480
614
  }
481
- return { count, locations: [...byLine.values()].sort((a, b) => a.line - b.line) };
615
+ // No sort: exec scans forward, so lines are discovered in increasing order and
616
+ // a Map preserves insertion order. Sorting here was dead code - mutation
617
+ // testing found it by reporting that neither reversing nor removing the
618
+ // comparator changed any result.
619
+ return { count, locations: [...byLine.values()], commentCount };
482
620
  }
483
621
  export const javascriptEngine = new JavaScriptEngine();
484
622
  /**
@@ -500,7 +638,9 @@ export const javascriptEngine = new JavaScriptEngine();
500
638
  export const SMALL_TREE_BUDGET = process.platform === 'win32' ? { maxFiles: 512, maxBytes: 1024 * 1024 } : { maxFiles: 32, maxBytes: 64 * 1024 };
501
639
  /** Spawn failures that mean "this binary is not installed", not "search failed". */
502
640
  const MISSING_BINARY_CODES = new Set(['ENOENT', 'EACCES', 'EPERM', 'EINVAL', 'UNKNOWN']);
503
- function isMissingBinary(error) {
641
+ /** Exported so the code list can be asserted; EACCES and friends are not
642
+ * reproducible on demand from a real spawn. */
643
+ export function isMissingBinary(error) {
504
644
  const code = error?.code;
505
645
  return typeof code === 'string' && MISSING_BINARY_CODES.has(code);
506
646
  }
@@ -528,6 +668,7 @@ function enumerationKey(request) {
528
668
  request.root,
529
669
  request.targets,
530
670
  request.options.globs,
671
+ request.options.excludeGlobs,
531
672
  excludeSetId(request.options.excludeFiles),
532
673
  ]);
533
674
  }
@@ -614,6 +755,7 @@ export function createCachedEngine(engine) {
614
755
  request.options.word,
615
756
  request.options.ignoreCase,
616
757
  request.options.globs,
758
+ request.options.excludeGlobs,
617
759
  [...request.options.excludeFiles].sort(),
618
760
  ]);
619
761
  const recordFallback = (error) => {