@mnemonik/shared 6.48.0 → 6.51.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.
- package/dist/ast/astChunker.d.ts +124 -0
- package/dist/ast/astChunker.d.ts.map +1 -0
- package/dist/ast/astChunker.js +559 -0
- package/dist/ast/astChunker.js.map +1 -0
- package/dist/ast/grammars.d.ts +170 -0
- package/dist/ast/grammars.d.ts.map +1 -0
- package/dist/ast/grammars.js +411 -0
- package/dist/ast/grammars.js.map +1 -0
- package/dist/codeScanner.d.ts +106 -0
- package/dist/codeScanner.d.ts.map +1 -1
- package/dist/codeScanner.js +552 -62
- package/dist/codeScanner.js.map +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/package.json +3 -2
- package/queries/bash.tags.scm +7 -0
- package/queries/groovy.tags.scm +15 -0
- package/queries/powershell.tags.scm +14 -0
- package/scripts/vendor-grammars.ts +317 -0
- package/src/ast/astChunker.ts +661 -0
- package/src/ast/grammars.ts +490 -0
- package/src/codeScanner.ts +577 -63
- package/src/index.ts +25 -0
- package/wasm/bash.tags.scm +7 -0
- package/wasm/bash.wasm +0 -0
- package/wasm/c-sharp.tags.scm +23 -0
- package/wasm/c-sharp.wasm +0 -0
- package/wasm/c.tags.scm +9 -0
- package/wasm/c.wasm +0 -0
- package/wasm/cpp.tags.scm +15 -0
- package/wasm/cpp.wasm +0 -0
- package/wasm/elixir.tags.scm +54 -0
- package/wasm/elixir.wasm +0 -0
- package/wasm/go.tags.scm +42 -0
- package/wasm/go.wasm +0 -0
- package/wasm/groovy.tags.scm +15 -0
- package/wasm/groovy.wasm +0 -0
- package/wasm/java.tags.scm +20 -0
- package/wasm/java.wasm +0 -0
- package/wasm/javascript.tags.scm +99 -0
- package/wasm/javascript.wasm +0 -0
- package/wasm/php.tags.scm +40 -0
- package/wasm/php.wasm +0 -0
- package/wasm/powershell.tags.scm +14 -0
- package/wasm/powershell.wasm +0 -0
- package/wasm/python.tags.scm +14 -0
- package/wasm/python.wasm +0 -0
- package/wasm/ruby.tags.scm +64 -0
- package/wasm/ruby.wasm +0 -0
- package/wasm/rust.tags.scm +60 -0
- package/wasm/rust.wasm +0 -0
- package/wasm/scala.tags.scm +66 -0
- package/wasm/scala.wasm +0 -0
- package/wasm/solidity.tags.scm +43 -0
- package/wasm/solidity.wasm +0 -0
- package/wasm/tsx.wasm +0 -0
- package/wasm/typescript.tags.scm +23 -0
- package/wasm/typescript.wasm +0 -0
package/dist/codeScanner.js
CHANGED
|
@@ -5,9 +5,11 @@ import { readdir, readFile, stat, lstat, realpath } from 'fs/promises';
|
|
|
5
5
|
import { join, relative, extname, sep } from 'path';
|
|
6
6
|
import { createHash } from 'crypto';
|
|
7
7
|
import ignore from 'ignore';
|
|
8
|
-
import { debug as logDebug, warn as logWarn } from './logger.js';
|
|
8
|
+
import { debug as logDebug, info as logInfo, warn as logWarn } from './logger.js';
|
|
9
9
|
import { withTimeout } from './asyncUtils.js';
|
|
10
10
|
import { scrubSecrets } from './secretPatterns.js';
|
|
11
|
+
import { MAX_AST_PARSE_BYTES, chunkWithAst } from './ast/astChunker.js';
|
|
12
|
+
import { astArtifactReport, resolveAstLanguage } from './ast/grammars.js';
|
|
11
13
|
/**
|
|
12
14
|
* File operation timeout (5 seconds) to prevent hanging on slow/unresponsive filesystems
|
|
13
15
|
*/
|
|
@@ -150,6 +152,9 @@ const BUILT_IN_IGNORE_FILE_PATTERNS = [
|
|
|
150
152
|
* real, common SOURCE directory name (gRPC, auth libraries) and
|
|
151
153
|
* `shouldIgnore` cannot tell a file from a directory at its call sites —
|
|
152
154
|
* an unqualified rule would silently delete that subtree from the index.
|
|
155
|
+
* - `.tfvars` is excluded whole rather than per-basename: the credential file
|
|
156
|
+
* is conventionally `terraform.tfvars` but the name is free-form, and the
|
|
157
|
+
* variables that are NOT secret are visible in `.tf` anyway.
|
|
153
158
|
*/
|
|
154
159
|
const SECRET_FILE_PLACEHOLDER_BASENAMES = new Set(['.env.example']);
|
|
155
160
|
const SECRET_FILE_BASENAME_PATTERNS = [
|
|
@@ -158,6 +163,12 @@ const SECRET_FILE_BASENAME_PATTERNS = [
|
|
|
158
163
|
/^\.(?:npmrc|netrc|pgpass)$/i, // registry / ftp / postgres password files
|
|
159
164
|
/^kubeconfig$/i,
|
|
160
165
|
/\.(?:pem|key|p12|pfx|keystore|jks|kubeconfig)$/i,
|
|
166
|
+
// Terraform variable files: `terraform.tfvars` is the conventional home for
|
|
167
|
+
// provider credentials, and unlike the entries above nothing else was
|
|
168
|
+
// keeping it out — it is excluded here rather than merely left off
|
|
169
|
+
// `DEFAULT_INCLUDE_EXTENSIONS`, so a future widening of the allowlist cannot
|
|
170
|
+
// quietly opt a project into embedding its own cloud keys.
|
|
171
|
+
/\.tfvars(\.json)?$/i,
|
|
161
172
|
];
|
|
162
173
|
/**
|
|
163
174
|
* True when `relPath` names a file whose contents are credentials. Applied
|
|
@@ -184,38 +195,264 @@ export function isSecretFile(relPath) {
|
|
|
184
195
|
* list (e.g. `secrets/`). Matched hierarchically via the `ignore` package.
|
|
185
196
|
*/
|
|
186
197
|
const IGNORE_FILE_NAMES = ['.gitignore', '.mnemonikignore'];
|
|
198
|
+
/**
|
|
199
|
+
* Every file type the scanner will chunk, by extension. Matched
|
|
200
|
+
* case-INSENSITIVELY (see `isIncludedExtension`), so lowercase spellings here
|
|
201
|
+
* also cover `.R`, `.SQL`, `.PS1` and the uppercase `.C`/`.H` of older trees.
|
|
202
|
+
*
|
|
203
|
+
* Exported because this list IS the product's language coverage: a project
|
|
204
|
+
* written in something absent from it indexes zero code, and `code_search`
|
|
205
|
+
* reports that as "no indexed matches" rather than "I do not read this
|
|
206
|
+
* language" — invisible to the developer and to us. Coverage is a separate
|
|
207
|
+
* dial from parse quality: a language with no structured extractor falls
|
|
208
|
+
* through to `chunkRaw`, which is what Go, Java and C already get in
|
|
209
|
+
* production, and crude chunks beat no chunks by an enormous margin.
|
|
210
|
+
*
|
|
211
|
+
* Data formats (`.json`, `.yaml`, `.toml`, `.lock`) are deliberately absent:
|
|
212
|
+
* the ones that carry meaning are already collected verbatim by
|
|
213
|
+
* `AUTHORITY_FILE_MATCHERS`, and blanket-indexing the extension would pull in
|
|
214
|
+
* lockfiles and generated output. `.tfvars` is absent for a different reason —
|
|
215
|
+
* `terraform.tfvars` is a conventional home for provider credentials and
|
|
216
|
+
* `isSecretFile` now excludes it outright.
|
|
217
|
+
*/
|
|
218
|
+
export const DEFAULT_INCLUDE_EXTENSIONS = [
|
|
219
|
+
'.ts',
|
|
220
|
+
'.tsx',
|
|
221
|
+
'.js',
|
|
222
|
+
'.jsx',
|
|
223
|
+
'.py',
|
|
224
|
+
'.rs',
|
|
225
|
+
'.go',
|
|
226
|
+
'.java',
|
|
227
|
+
'.c',
|
|
228
|
+
'.cpp',
|
|
229
|
+
'.h',
|
|
230
|
+
'.cs',
|
|
231
|
+
'.rb',
|
|
232
|
+
'.php',
|
|
233
|
+
'.swift',
|
|
234
|
+
'.kt',
|
|
235
|
+
// Shell and Lua are the working languages of whole real projects —
|
|
236
|
+
// deployment tooling, container entrypoints, imapfilter/nginx/redis
|
|
237
|
+
// configuration. Omitting them meant such a project indexed ZERO code and
|
|
238
|
+
// code_search could not answer anything about it, while reporting that as
|
|
239
|
+
// "no indexed matches" rather than as missing coverage.
|
|
240
|
+
'.sh',
|
|
241
|
+
'.bash',
|
|
242
|
+
'.zsh',
|
|
243
|
+
'.lua',
|
|
244
|
+
'.md',
|
|
245
|
+
// ── Variants of languages already on the list. `.mjs`/`.cjs`/`.mts`/`.cts`/
|
|
246
|
+
// `.pyi` inherit their language string from a listed extension, so their
|
|
247
|
+
// absence was oversight rather than policy. The C++ spellings are a choice,
|
|
248
|
+
// not an inheritance: `.h` stays 'c' (C headers dominate, and reading a C++
|
|
249
|
+
// header as C is the safer default), while the unambiguously-C++ spellings
|
|
250
|
+
// resolve to 'cpp'.
|
|
251
|
+
'.mjs',
|
|
252
|
+
'.cjs',
|
|
253
|
+
'.mts',
|
|
254
|
+
'.cts',
|
|
255
|
+
'.pyi',
|
|
256
|
+
'.hpp',
|
|
257
|
+
'.hh',
|
|
258
|
+
'.hxx',
|
|
259
|
+
'.cc',
|
|
260
|
+
'.cxx',
|
|
261
|
+
'.kts',
|
|
262
|
+
// ── Languages the scanner could not read at all.
|
|
263
|
+
'.dart',
|
|
264
|
+
'.m',
|
|
265
|
+
'.mm',
|
|
266
|
+
'.scala',
|
|
267
|
+
'.sc',
|
|
268
|
+
'.ex',
|
|
269
|
+
'.exs',
|
|
270
|
+
'.erl',
|
|
271
|
+
'.hrl',
|
|
272
|
+
'.hs',
|
|
273
|
+
'.jl',
|
|
274
|
+
'.ml',
|
|
275
|
+
'.mli',
|
|
276
|
+
'.clj',
|
|
277
|
+
'.cljs',
|
|
278
|
+
'.cljc',
|
|
279
|
+
'.groovy',
|
|
280
|
+
'.gradle',
|
|
281
|
+
'.ps1',
|
|
282
|
+
'.psm1',
|
|
283
|
+
'.pl',
|
|
284
|
+
'.pm',
|
|
285
|
+
'.r',
|
|
286
|
+
'.sol',
|
|
287
|
+
'.zig',
|
|
288
|
+
'.vue',
|
|
289
|
+
'.svelte',
|
|
290
|
+
// ── Infrastructure and schema DSLs, where real logic lives and where "how
|
|
291
|
+
// is this deployed?" and "what does this table hold?" go unanswered today.
|
|
292
|
+
'.tf',
|
|
293
|
+
'.sql',
|
|
294
|
+
'.proto',
|
|
295
|
+
'.graphql',
|
|
296
|
+
'.gql',
|
|
297
|
+
'.cmake',
|
|
298
|
+
'.nix',
|
|
299
|
+
'.bzl',
|
|
300
|
+
];
|
|
301
|
+
/**
|
|
302
|
+
* Extension → language string, the value carried on every chunk and on the
|
|
303
|
+
* wire (`/scan/push` accepts any non-empty string up to 50 chars).
|
|
304
|
+
*
|
|
305
|
+
* A string no grammar claims is correct and expected: the AST layer resolves
|
|
306
|
+
* such a language to `null` and the heuristic chunker takes over. Keep this in
|
|
307
|
+
* sync with `DEFAULT_INCLUDE_EXTENSIONS` — an allowlisted extension that lands
|
|
308
|
+
* on 'unknown' still gets chunked, but nothing downstream can reason about it.
|
|
309
|
+
*
|
|
310
|
+
* Two extensions are genuinely ambiguous and are resolved rather than fudged:
|
|
311
|
+
* `.m` is Objective-C here, not MATLAB, because a repo carrying `.m` alongside
|
|
312
|
+
* `.h`/`.mm` is overwhelmingly an Apple-platform project; `.pl` is Perl, not
|
|
313
|
+
* Prolog, on the same frequency argument.
|
|
314
|
+
*/
|
|
315
|
+
const EXTENSION_LANGUAGES = {
|
|
316
|
+
'.ts': 'typescript',
|
|
317
|
+
'.tsx': 'typescript',
|
|
318
|
+
'.mts': 'typescript',
|
|
319
|
+
'.cts': 'typescript',
|
|
320
|
+
'.js': 'javascript',
|
|
321
|
+
'.jsx': 'javascript',
|
|
322
|
+
'.mjs': 'javascript',
|
|
323
|
+
'.cjs': 'javascript',
|
|
324
|
+
'.py': 'python',
|
|
325
|
+
'.pyi': 'python',
|
|
326
|
+
'.rs': 'rust',
|
|
327
|
+
'.go': 'go',
|
|
328
|
+
'.java': 'java',
|
|
329
|
+
'.c': 'c',
|
|
330
|
+
'.h': 'c',
|
|
331
|
+
'.cpp': 'cpp',
|
|
332
|
+
'.cc': 'cpp',
|
|
333
|
+
'.cxx': 'cpp',
|
|
334
|
+
'.hpp': 'cpp',
|
|
335
|
+
'.hh': 'cpp',
|
|
336
|
+
'.hxx': 'cpp',
|
|
337
|
+
'.cs': 'csharp',
|
|
338
|
+
'.rb': 'ruby',
|
|
339
|
+
'.php': 'php',
|
|
340
|
+
'.swift': 'swift',
|
|
341
|
+
'.kt': 'kotlin',
|
|
342
|
+
'.kts': 'kotlin',
|
|
343
|
+
'.sh': 'shell',
|
|
344
|
+
'.bash': 'shell',
|
|
345
|
+
'.zsh': 'shell',
|
|
346
|
+
'.lua': 'lua',
|
|
347
|
+
'.md': 'markdown',
|
|
348
|
+
'.dart': 'dart',
|
|
349
|
+
'.m': 'objc',
|
|
350
|
+
'.mm': 'objc',
|
|
351
|
+
'.scala': 'scala',
|
|
352
|
+
'.sc': 'scala',
|
|
353
|
+
'.ex': 'elixir',
|
|
354
|
+
'.exs': 'elixir',
|
|
355
|
+
'.erl': 'erlang',
|
|
356
|
+
'.hrl': 'erlang',
|
|
357
|
+
'.hs': 'haskell',
|
|
358
|
+
'.jl': 'julia',
|
|
359
|
+
'.ml': 'ocaml',
|
|
360
|
+
'.mli': 'ocaml',
|
|
361
|
+
'.clj': 'clojure',
|
|
362
|
+
'.cljs': 'clojure',
|
|
363
|
+
'.cljc': 'clojure',
|
|
364
|
+
'.groovy': 'groovy',
|
|
365
|
+
'.gradle': 'groovy',
|
|
366
|
+
'.ps1': 'powershell',
|
|
367
|
+
'.psm1': 'powershell',
|
|
368
|
+
'.pl': 'perl',
|
|
369
|
+
'.pm': 'perl',
|
|
370
|
+
'.r': 'r',
|
|
371
|
+
'.sol': 'solidity',
|
|
372
|
+
'.zig': 'zig',
|
|
373
|
+
'.vue': 'vue',
|
|
374
|
+
'.svelte': 'svelte',
|
|
375
|
+
'.tf': 'terraform',
|
|
376
|
+
'.sql': 'sql',
|
|
377
|
+
'.proto': 'protobuf',
|
|
378
|
+
'.graphql': 'graphql',
|
|
379
|
+
'.gql': 'graphql',
|
|
380
|
+
'.cmake': 'cmake',
|
|
381
|
+
'.nix': 'nix',
|
|
382
|
+
'.bzl': 'starlark',
|
|
383
|
+
};
|
|
384
|
+
/**
|
|
385
|
+
* Language string for a file path or a bare extension, `'unknown'` when the
|
|
386
|
+
* extension is unmapped. A free function rather than a method because callers
|
|
387
|
+
* that never scan anything (AST grammar resolution, server-side symbol
|
|
388
|
+
* preference) need the same answer without constructing a scanner.
|
|
389
|
+
*/
|
|
390
|
+
export function languageForExtension(filePathOrExt) {
|
|
391
|
+
// `extname` FIRST. A dotfile that carries a real extension ('.eslintrc.js',
|
|
392
|
+
// '.mocharc.cjs', '.prettierrc.ts') starts with '.' and contains no '/', so
|
|
393
|
+
// a bare-extension-first reading swallowed the whole name and answered
|
|
394
|
+
// 'unknown' — while the same file spelled 'src/.eslintrc.js' answered
|
|
395
|
+
// 'javascript'. Those extensions are allowlisted, so the files are indexed
|
|
396
|
+
// and reach the server with exactly the root-relative spelling that failed.
|
|
397
|
+
const fromPath = EXTENSION_LANGUAGES[extname(filePathOrExt).toLowerCase()];
|
|
398
|
+
if (fromPath !== undefined)
|
|
399
|
+
return fromPath;
|
|
400
|
+
// Fallback: the argument IS the extension ('.dart'), for which `extname`
|
|
401
|
+
// returns ''. Every key contains a leading dot and no separator, so a real
|
|
402
|
+
// path can never collide here.
|
|
403
|
+
return EXTENSION_LANGUAGES[filePathOrExt.toLowerCase()] ?? 'unknown';
|
|
404
|
+
}
|
|
405
|
+
/**
|
|
406
|
+
* THE definition of "this path is a SQL migration the schema_columns authority
|
|
407
|
+
* collects verbatim". One predicate, referenced by both halves of the deal —
|
|
408
|
+
* `AUTHORITY_FILE_MATCHERS` (collect it) and `isAuthorityOnlyPath` (therefore
|
|
409
|
+
* do not chunk it) — because two hand-written regexes drifted once already and
|
|
410
|
+
* the failure is silent in both directions.
|
|
411
|
+
*
|
|
412
|
+
* Root-anchored and CASE-SENSITIVE on purpose: it mirrors the server-side
|
|
413
|
+
* extractor, which does `listFiles('migrations/')` (LIKE 'migrations/%') then
|
|
414
|
+
* `endsWith('.sql')`, both case-sensitive. `Migrations/001.sql` (the .NET/EF
|
|
415
|
+
* Core spelling) and `migrations/002.SQL` are NOT collected, so they must not
|
|
416
|
+
* be suppressed from chunking either — that would index them nowhere. Same
|
|
417
|
+
* reason a nested `packages/x/migrations/y.sql` is left alone.
|
|
418
|
+
*/
|
|
419
|
+
const isMigrationSqlAuthorityPath = (posixRelPath) => /^migrations\/.*\.sql$/.test(posixRelPath);
|
|
420
|
+
/**
|
|
421
|
+
* Predicates for paths whose verbatim content is ALREADY shipped by
|
|
422
|
+
* `collectAuthorityFiles` and that carry no additional value as embedded code
|
|
423
|
+
* chunks. Checked at the extension gate rather than in `shouldIgnore`, because
|
|
424
|
+
* `shouldIgnore` also guards the authority walk and must keep letting these
|
|
425
|
+
* through.
|
|
426
|
+
*
|
|
427
|
+
* `migrations/**.sql` is the live case: adding `.sql` to the allowlist without
|
|
428
|
+
* this exclusion would dual-collect every migration — once verbatim, once
|
|
429
|
+
* chunked and embedded. On this repo alone that is 208 files of append-only
|
|
430
|
+
* DDL (140 forward, the rest rollback/manual), embedded to answer questions
|
|
431
|
+
* the authority path already answers exactly.
|
|
432
|
+
*
|
|
433
|
+
* INVARIANT: every predicate here must also appear in
|
|
434
|
+
* `AUTHORITY_FILE_MATCHERS`, so no path can be excluded from chunking unless
|
|
435
|
+
* the authority path definitely collects it.
|
|
436
|
+
*/
|
|
437
|
+
const AUTHORITY_ONLY_PATH_PREDICATES = [
|
|
438
|
+
isMigrationSqlAuthorityPath,
|
|
439
|
+
];
|
|
440
|
+
/**
|
|
441
|
+
* True when `relPath` is collected verbatim as authority content and must not
|
|
442
|
+
* additionally be chunked. Accepts OS-native or POSIX separators.
|
|
443
|
+
*/
|
|
444
|
+
export function isAuthorityOnlyPath(relPath) {
|
|
445
|
+
if (!relPath)
|
|
446
|
+
return false;
|
|
447
|
+
const posix = relPath.split(sep).join('/');
|
|
448
|
+
return AUTHORITY_ONLY_PATH_PREDICATES.some((matches) => matches(posix));
|
|
449
|
+
}
|
|
187
450
|
const DEFAULT_OPTIONS = {
|
|
188
451
|
maxChunkSize: 8000, // ~2000 tokens
|
|
189
452
|
minChunkSize: 100,
|
|
190
453
|
ignorePatterns: [...BUILT_IN_IGNORE_DIRS, ...BUILT_IN_IGNORE_FILE_PATTERNS],
|
|
191
|
-
includeExtensions: [
|
|
192
|
-
|
|
193
|
-
'.tsx',
|
|
194
|
-
'.js',
|
|
195
|
-
'.jsx',
|
|
196
|
-
'.py',
|
|
197
|
-
'.rs',
|
|
198
|
-
'.go',
|
|
199
|
-
'.java',
|
|
200
|
-
'.c',
|
|
201
|
-
'.cpp',
|
|
202
|
-
'.h',
|
|
203
|
-
'.cs',
|
|
204
|
-
'.rb',
|
|
205
|
-
'.php',
|
|
206
|
-
'.swift',
|
|
207
|
-
'.kt',
|
|
208
|
-
// Shell and Lua are the working languages of whole real projects —
|
|
209
|
-
// deployment tooling, container entrypoints, imapfilter/nginx/redis
|
|
210
|
-
// configuration. Omitting them meant such a project indexed ZERO code and
|
|
211
|
-
// code_search could not answer anything about it, while reporting that as
|
|
212
|
-
// "no indexed matches" rather than as missing coverage.
|
|
213
|
-
'.sh',
|
|
214
|
-
'.bash',
|
|
215
|
-
'.zsh',
|
|
216
|
-
'.lua',
|
|
217
|
-
'.md',
|
|
218
|
-
],
|
|
454
|
+
includeExtensions: [...DEFAULT_INCLUDE_EXTENSIONS],
|
|
455
|
+
maxAstParseBytes: MAX_AST_PARSE_BYTES,
|
|
219
456
|
};
|
|
220
457
|
/**
|
|
221
458
|
* Matchers for authority manifest / config / CI files whose verbatim content
|
|
@@ -237,8 +474,10 @@ export const AUTHORITY_FILE_MATCHERS = [
|
|
|
237
474
|
// SQL migrations: the schema_columns authority extractor reads every .sql
|
|
238
475
|
// under `migrations/` (listFiles('migrations/') -> LIKE 'migrations/%' then
|
|
239
476
|
// .endsWith('.sql')). Without collecting these, that authority is empty and
|
|
240
|
-
// every schema_table_enumeration claim falls to unverifiable.
|
|
241
|
-
|
|
477
|
+
// every schema_table_enumeration claim falls to unverifiable. Shared with
|
|
478
|
+
// `AUTHORITY_ONLY_PATH_PREDICATES` by reference, not by a copied regex, so
|
|
479
|
+
// collection and the chunking exclusion cannot disagree.
|
|
480
|
+
isMigrationSqlAuthorityPath,
|
|
242
481
|
];
|
|
243
482
|
/**
|
|
244
483
|
* Segment-anchored match for the `tests/fixtures/` ignore pattern above —
|
|
@@ -307,10 +546,90 @@ export async function isGitBoundary(dirPath) {
|
|
|
307
546
|
return false;
|
|
308
547
|
}
|
|
309
548
|
}
|
|
549
|
+
/**
|
|
550
|
+
* Latch for `logAstCapabilityOnce`. A promise, not a boolean: two concurrent
|
|
551
|
+
* callers must both wait on the same report rather than the second returning
|
|
552
|
+
* before the first has logged.
|
|
553
|
+
*/
|
|
554
|
+
let astCapabilityLog = null;
|
|
555
|
+
/**
|
|
556
|
+
* `reason:grammar` pairs already warned about, for the reasons that are
|
|
557
|
+
* process-global rather than file-specific.
|
|
558
|
+
*
|
|
559
|
+
* Only `grammar_unavailable` qualifies: `loadGrammar` caches its failure, so the
|
|
560
|
+
* answer is identical for every file of that language and warning per file
|
|
561
|
+
* printed 1,037 lines in one scan of this repo. `file_too_large` and
|
|
562
|
+
* `parse_failed` are properties of one file and stay per file.
|
|
563
|
+
*/
|
|
564
|
+
const reportedGrammarFallbacks = new Set();
|
|
565
|
+
/**
|
|
566
|
+
* Log which grammars this install ships — ONCE per process, at daemon start,
|
|
567
|
+
* never per file.
|
|
568
|
+
*
|
|
569
|
+
* A missing grammar silently degrades every file of that language to the
|
|
570
|
+
* heuristic chunker. Per-file warnings would say so 30,000 times and drown the
|
|
571
|
+
* log; saying nothing is how a half-broken install looks healthy. One startup
|
|
572
|
+
* line naming the vendored grammars, and a WARNING when an artifact is missing,
|
|
573
|
+
* is the whole contract.
|
|
574
|
+
*
|
|
575
|
+
* Deliberately a `statSync` of the artifacts (`astArtifactReport`) and not a
|
|
576
|
+
* load of them (`astCapabilityReport`). Loading all 18 to print this line costs
|
|
577
|
+
* ~690 ms and ~75 MB of RSS that is never returned — web-tree-sitter exposes no
|
|
578
|
+
* `Language.delete` — which is a permanent tax on a daemon watching a pure
|
|
579
|
+
* TypeScript repo, paid to pre-answer a question about seventeen languages it
|
|
580
|
+
* will never see. Grammars load lazily instead, on the first file of a language,
|
|
581
|
+
* and the two failure modes only a load can detect (`wasm_load_failed`,
|
|
582
|
+
* `query_compile_failed`) are warned there, once per language, by the
|
|
583
|
+
* `grammar_unavailable` branch in `chunkFile`.
|
|
584
|
+
*
|
|
585
|
+
* Cheap to call repeatedly: this latches, and the report instantiates nothing.
|
|
586
|
+
*/
|
|
587
|
+
export function logAstCapabilityOnce() {
|
|
588
|
+
astCapabilityLog ??= (async () => {
|
|
589
|
+
const report = astArtifactReport();
|
|
590
|
+
const detail = {
|
|
591
|
+
grammars: report.vendored.length,
|
|
592
|
+
languages: report.vendored.join(' '),
|
|
593
|
+
};
|
|
594
|
+
if (report.missing.length > 0) {
|
|
595
|
+
logWarn('AST chunking: some vendored grammar artifacts are missing', {
|
|
596
|
+
...detail,
|
|
597
|
+
missing: report.missing.map((m) => `${m.id}(${m.reason}: ${m.detail})`).join('; '),
|
|
598
|
+
});
|
|
599
|
+
}
|
|
600
|
+
else {
|
|
601
|
+
logInfo('AST chunking ready (grammars load lazily, per language)', detail);
|
|
602
|
+
}
|
|
603
|
+
})();
|
|
604
|
+
return astCapabilityLog;
|
|
605
|
+
}
|
|
310
606
|
export class CodeScanner {
|
|
311
607
|
options;
|
|
608
|
+
/**
|
|
609
|
+
* `includeExtensions` folded to lowercase for matching. The gate used to
|
|
610
|
+
* compare `extname()` verbatim while `detectLanguage` lowercased, so a
|
|
611
|
+
* project spelling its files the canonical way — `.R` for R, `.SQL`/`.PS1`
|
|
612
|
+
* on Windows, `.C`/`.H` in older C trees — indexed zero of them and nothing
|
|
613
|
+
* said why.
|
|
614
|
+
*/
|
|
615
|
+
includeExtensionSet;
|
|
312
616
|
constructor(options = {}) {
|
|
313
617
|
this.options = { ...DEFAULT_OPTIONS, ...options };
|
|
618
|
+
this.includeExtensionSet = new Set(this.options.includeExtensions.map((ext) => ext.toLowerCase()));
|
|
619
|
+
}
|
|
620
|
+
/**
|
|
621
|
+
* The chunkable-file gate, shared by every walker and by the explicit
|
|
622
|
+
* file-list path so all three agree on what exists. `relPath` is the path
|
|
623
|
+
* relative to the scan root (OS-native separators accepted).
|
|
624
|
+
*/
|
|
625
|
+
isChunkable(absOrRelPath, relPath) {
|
|
626
|
+
if (!this.includeExtensionSet.has(extname(absOrRelPath).toLowerCase()))
|
|
627
|
+
return false;
|
|
628
|
+
if (isAuthorityOnlyPath(relPath)) {
|
|
629
|
+
logDebug('Skipping chunking for authority-collected path', { relPath });
|
|
630
|
+
return false;
|
|
631
|
+
}
|
|
632
|
+
return true;
|
|
314
633
|
}
|
|
315
634
|
/**
|
|
316
635
|
* Read `.gitignore` + `.mnemonikignore` in `absDir` and compile them into one
|
|
@@ -538,7 +857,7 @@ export class CodeScanner {
|
|
|
538
857
|
else if (stats.isFile()) {
|
|
539
858
|
if (this.ignoredByStack(relativePath, false, localStack))
|
|
540
859
|
continue;
|
|
541
|
-
if (this.
|
|
860
|
+
if (this.isChunkable(fullPath, relativePath)) {
|
|
542
861
|
out.push(relativePath);
|
|
543
862
|
}
|
|
544
863
|
}
|
|
@@ -579,8 +898,7 @@ export class CodeScanner {
|
|
|
579
898
|
if (rootPath && (await this.insideNestedGitBoundary(filePath, rootPath))) {
|
|
580
899
|
continue;
|
|
581
900
|
}
|
|
582
|
-
|
|
583
|
-
if (this.options.includeExtensions.includes(ext)) {
|
|
901
|
+
if (this.isChunkable(filePath, fileRel)) {
|
|
584
902
|
const fileChunks = await this.parseFile(filePath, rootPath || filePath);
|
|
585
903
|
chunks.push(...fileChunks);
|
|
586
904
|
}
|
|
@@ -666,8 +984,7 @@ export class CodeScanner {
|
|
|
666
984
|
else if (stats.isFile()) {
|
|
667
985
|
if (this.ignoredByStack(relativePath, false, localStack))
|
|
668
986
|
continue;
|
|
669
|
-
|
|
670
|
-
if (this.options.includeExtensions.includes(ext)) {
|
|
987
|
+
if (this.isChunkable(fullPath, relativePath)) {
|
|
671
988
|
const fileChunks = await this.parseFile(fullPath, rootPath);
|
|
672
989
|
chunks.push(...fileChunks);
|
|
673
990
|
}
|
|
@@ -755,12 +1072,130 @@ export class CodeScanner {
|
|
|
755
1072
|
if (language === 'markdown') {
|
|
756
1073
|
return this.chunkMarkdown(content, relativePath, stats.size);
|
|
757
1074
|
}
|
|
758
|
-
const structuredChunks = this.extractStructuredChunks(content, language);
|
|
759
1075
|
const fileMetadata = {
|
|
760
1076
|
fileName: filePath.split('/').pop() || '',
|
|
761
1077
|
extension: extname(filePath),
|
|
762
1078
|
size: stats.size,
|
|
763
1079
|
};
|
|
1080
|
+
// AST first for the languages with a vendored grammar. `null` means "no
|
|
1081
|
+
// grammar for this" — the honest majority of the allowlist — and the
|
|
1082
|
+
// heuristic path below takes over with no log, because that is not a
|
|
1083
|
+
// degradation.
|
|
1084
|
+
const astLanguage = resolveAstLanguage(fileMetadata.extension, language);
|
|
1085
|
+
if (astLanguage) {
|
|
1086
|
+
const ast = await chunkWithAst(content, astLanguage, {
|
|
1087
|
+
maxParseBytes: this.options.maxAstParseBytes,
|
|
1088
|
+
});
|
|
1089
|
+
if ('chunks' in ast) {
|
|
1090
|
+
// A definition the chunker could not place reaches no chunk, so its
|
|
1091
|
+
// name reaches no index and a citation to it resolves as
|
|
1092
|
+
// `unresolved_symbol`. It cannot be emitted (two chunks over one line
|
|
1093
|
+
// range collide on the `filePath:startLine-endLine` staleness key), but
|
|
1094
|
+
// a scan that quietly loses symbols must not look like a healthy one.
|
|
1095
|
+
if (ast.droppedDefinitions > 0) {
|
|
1096
|
+
logWarn('AST chunker dropped definitions that share a line range', {
|
|
1097
|
+
filePath: relativePath,
|
|
1098
|
+
grammar: astLanguage,
|
|
1099
|
+
droppedDefinitions: ast.droppedDefinitions,
|
|
1100
|
+
});
|
|
1101
|
+
}
|
|
1102
|
+
// `errorNodes` is handed back precisely so this degradation is not
|
|
1103
|
+
// silent, and discarding it made it silent. One ERROR node can swallow
|
|
1104
|
+
// the rest of a file — `typeof import(...)` in a type argument does
|
|
1105
|
+
// exactly that to tree-sitter-typescript — after which no definition is
|
|
1106
|
+
// captured and the file arrives as one unnamed whole-file chunk. Naming
|
|
1107
|
+
// the file, the error count and how many definitions survived is what
|
|
1108
|
+
// makes that visible in a scan log.
|
|
1109
|
+
//
|
|
1110
|
+
// Deliberately NOT a fallback to the heuristic path: measured on the
|
|
1111
|
+
// real construct (tests/AgentInjectionDeliveryWiring.test.ts), the
|
|
1112
|
+
// heuristic path names nothing either (its name pattern needs a
|
|
1113
|
+
// leading `function`/`class`/`const`) AND covers less — it emitted 3
|
|
1114
|
+
// chunks starting at line 18 and dropped lines 1-17, because the
|
|
1115
|
+
// uncovered-region supplement only fires above 50 lines. Trading
|
|
1116
|
+
// total coverage for no gain is worse than one honest blob, so the
|
|
1117
|
+
// AST result stands and the log says so.
|
|
1118
|
+
if (ast.errorNodes > 0) {
|
|
1119
|
+
logWarn('AST parse errors; extents and symbol names degraded for this file', {
|
|
1120
|
+
filePath: relativePath,
|
|
1121
|
+
grammar: astLanguage,
|
|
1122
|
+
errorNodes: ast.errorNodes,
|
|
1123
|
+
definitions: ast.chunks.filter((chunk) => chunk.symbolKind).length,
|
|
1124
|
+
chunks: ast.chunks.length,
|
|
1125
|
+
});
|
|
1126
|
+
}
|
|
1127
|
+
// The wire cap (`scanChunkSchema` allows 500,000 chars) and the
|
|
1128
|
+
// embedding cap (8191 tokens per input) are both downstream of
|
|
1129
|
+
// `maxChunkSize`, and the AST chunker only bounds its uncovered spans:
|
|
1130
|
+
// a definition chunk is whatever the definition is. `boundAstChunks`
|
|
1131
|
+
// makes the cap unreachable by construction, or answers null when the
|
|
1132
|
+
// file cannot be bounded on line boundaries at all.
|
|
1133
|
+
const bounded = this.boundAstChunks(ast.chunks);
|
|
1134
|
+
if (bounded) {
|
|
1135
|
+
// Zero chunks here means the file has no non-whitespace line — the AST
|
|
1136
|
+
// chunker covers every other line by construction — so returning
|
|
1137
|
+
// nothing is the correct answer, not a lost file.
|
|
1138
|
+
return bounded.map(({ symbolName, symbolKind, signature, ...chunk }) => ({
|
|
1139
|
+
...chunk,
|
|
1140
|
+
filePath: relativePath,
|
|
1141
|
+
language,
|
|
1142
|
+
contentHash: this.hash(chunk.content),
|
|
1143
|
+
metadata: {
|
|
1144
|
+
...fileMetadata,
|
|
1145
|
+
...(signature ? { signature } : {}),
|
|
1146
|
+
...(symbolName ? { symbolName } : {}),
|
|
1147
|
+
...(symbolKind ? { symbolKind } : {}),
|
|
1148
|
+
},
|
|
1149
|
+
}));
|
|
1150
|
+
}
|
|
1151
|
+
// One line longer than `maxChunkSize` — a generated `*_pb2.py` carries
|
|
1152
|
+
// the whole serialized descriptor on one. Splitting it would have to
|
|
1153
|
+
// cut mid-line, and two chunks over one line range collide on the
|
|
1154
|
+
// `filePath:startLine-endLine` staleness key, so the whole file goes to
|
|
1155
|
+
// the heuristic chunker, which force-splits long lines by character
|
|
1156
|
+
// count. Loud, because a file that loses its symbol names must not look
|
|
1157
|
+
// like a healthy one.
|
|
1158
|
+
logWarn('AST chunk exceeds maxChunkSize on a single line; heuristic chunking instead', {
|
|
1159
|
+
filePath: relativePath,
|
|
1160
|
+
grammar: astLanguage,
|
|
1161
|
+
maxChunkSize: this.options.maxChunkSize,
|
|
1162
|
+
longestLine: content
|
|
1163
|
+
.split('\n')
|
|
1164
|
+
.reduce((longest, line) => Math.max(longest, line.length), 0),
|
|
1165
|
+
});
|
|
1166
|
+
}
|
|
1167
|
+
else if (ast.unsupported === 'grammar_unavailable') {
|
|
1168
|
+
// Process-global, not per file: `loadGrammar` caches its failures, so
|
|
1169
|
+
// every file of this language degrades for the same reason. Warned per
|
|
1170
|
+
// file, this printed 1,037 identical lines in one scan of this repo —
|
|
1171
|
+
// the log flood `logAstCapabilityOnce` exists to avoid. Once per
|
|
1172
|
+
// (reason, grammar); the file-specific reasons below stay per file.
|
|
1173
|
+
const key = `${ast.unsupported}:${astLanguage}`;
|
|
1174
|
+
if (!reportedGrammarFallbacks.has(key)) {
|
|
1175
|
+
reportedGrammarFallbacks.add(key);
|
|
1176
|
+
logWarn('AST chunking unavailable for a whole language; heuristic chunking instead', {
|
|
1177
|
+
grammar: astLanguage,
|
|
1178
|
+
reason: ast.unsupported,
|
|
1179
|
+
detail: ast.detail,
|
|
1180
|
+
firstFile: relativePath,
|
|
1181
|
+
note: 'logged once per grammar per process; every file of this language degrades',
|
|
1182
|
+
});
|
|
1183
|
+
}
|
|
1184
|
+
}
|
|
1185
|
+
else {
|
|
1186
|
+
// Not a silent no-op: name the file AND the reason, then degrade to the
|
|
1187
|
+
// heuristic path. A degraded scan indistinguishable from a healthy one
|
|
1188
|
+
// is the defect class this routing exists to avoid — and degrading to
|
|
1189
|
+
// zero chunks would delete the file from the index instead.
|
|
1190
|
+
logWarn('AST chunking unavailable; falling back to heuristic chunking', {
|
|
1191
|
+
filePath: relativePath,
|
|
1192
|
+
grammar: astLanguage,
|
|
1193
|
+
reason: ast.unsupported,
|
|
1194
|
+
detail: ast.detail,
|
|
1195
|
+
});
|
|
1196
|
+
}
|
|
1197
|
+
}
|
|
1198
|
+
const structuredChunks = this.extractStructuredChunks(content, language);
|
|
764
1199
|
if (structuredChunks.length > 0) {
|
|
765
1200
|
const mapped = structuredChunks.map(({ signature, symbolName, ...chunk }) => ({
|
|
766
1201
|
...chunk,
|
|
@@ -806,35 +1241,90 @@ export class CodeScanner {
|
|
|
806
1241
|
return [];
|
|
807
1242
|
}
|
|
808
1243
|
}
|
|
1244
|
+
/**
|
|
1245
|
+
* Hold every AST chunk at or under `maxChunkSize`, or answer `null` when this
|
|
1246
|
+
* file cannot be held there on line boundaries.
|
|
1247
|
+
*
|
|
1248
|
+
* The AST chunker bounds only the spans it invents (`MAX_RAW_SPAN_CHARS`); a
|
|
1249
|
+
* chunk that came from a definition is exactly as big as the definition, and a
|
|
1250
|
+
* span that is one enormous line is left whole on purpose. Both used to reach
|
|
1251
|
+
* the wire verbatim, where `scanChunkSchema` caps content at 500,000 chars and
|
|
1252
|
+
* rejects the WHOLE push if any chunk is over — so one generated file stopped
|
|
1253
|
+
* all scanning for that push — and where anything past 8191 tokens is only
|
|
1254
|
+
* partially embedded.
|
|
1255
|
+
*
|
|
1256
|
+
* The invariants, all of which the split preserves:
|
|
1257
|
+
* - content stays verbatim contiguous source: `lines[startLine-1..endLine-1]`
|
|
1258
|
+
* joined by '\n', never a join of disjoint regions and never an elision;
|
|
1259
|
+
* - total coverage is unchanged: the pieces tile the original range, in order,
|
|
1260
|
+
* with no gap and no overlap;
|
|
1261
|
+
* - the definition's identity rides on the piece that carries its signature —
|
|
1262
|
+
* the first one — and the continuations are plain `raw` spans, because a
|
|
1263
|
+
* continuation is not the definition and must not claim its name.
|
|
1264
|
+
*
|
|
1265
|
+
* `null` (the caller degrades the whole file to the heuristic chunker) is
|
|
1266
|
+
* reserved for the one shape a line-boundary split cannot fix: a single line
|
|
1267
|
+
* longer than the cap, as every generated `*_pb2.py` has. Cutting mid-line
|
|
1268
|
+
* would give two chunks the same `filePath:startLine-endLine` staleness key,
|
|
1269
|
+
* which is the collision the chunker refuses everywhere else.
|
|
1270
|
+
*/
|
|
1271
|
+
boundAstChunks(chunks) {
|
|
1272
|
+
const max = this.options.maxChunkSize;
|
|
1273
|
+
if (chunks.every((chunk) => chunk.content.length <= max))
|
|
1274
|
+
return [...chunks];
|
|
1275
|
+
const bounded = [];
|
|
1276
|
+
for (const chunk of chunks) {
|
|
1277
|
+
if (chunk.content.length <= max) {
|
|
1278
|
+
bounded.push(chunk);
|
|
1279
|
+
continue;
|
|
1280
|
+
}
|
|
1281
|
+
// `content` is exactly `startLine..endLine` joined by '\n', so this split
|
|
1282
|
+
// recovers the file's own lines for that range.
|
|
1283
|
+
const lines = chunk.content.split('\n');
|
|
1284
|
+
if (lines.some((line) => line.length > max))
|
|
1285
|
+
return null;
|
|
1286
|
+
/** `from`/`to` are 0-based offsets into `lines`, inclusive. */
|
|
1287
|
+
const emit = (from, to) => {
|
|
1288
|
+
const content = lines.slice(from, to + 1).join('\n');
|
|
1289
|
+
// A piece of nothing but blank lines is owed no chunk (the chunker's own
|
|
1290
|
+
// rule) and `scanChunkSchema` requires content.min(1) anyway.
|
|
1291
|
+
if (content.trim() === '')
|
|
1292
|
+
return;
|
|
1293
|
+
const isFirst = from === 0;
|
|
1294
|
+
bounded.push({
|
|
1295
|
+
content,
|
|
1296
|
+
startLine: chunk.startLine + from,
|
|
1297
|
+
endLine: chunk.startLine + to,
|
|
1298
|
+
chunkType: isFirst ? chunk.chunkType : 'raw',
|
|
1299
|
+
...(isFirst && chunk.symbolName ? { symbolName: chunk.symbolName } : {}),
|
|
1300
|
+
...(isFirst && chunk.symbolKind ? { symbolKind: chunk.symbolKind } : {}),
|
|
1301
|
+
...(isFirst && chunk.signature ? { signature: chunk.signature } : {}),
|
|
1302
|
+
});
|
|
1303
|
+
};
|
|
1304
|
+
let pieceStart = 0;
|
|
1305
|
+
let pieceLength = 0;
|
|
1306
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1307
|
+
const lineLength = (lines[i] ?? '').length;
|
|
1308
|
+
// The '\n' the join will put back is part of what has to fit.
|
|
1309
|
+
const withLine = i === pieceStart ? lineLength : pieceLength + 1 + lineLength;
|
|
1310
|
+
if (i > pieceStart && withLine > max) {
|
|
1311
|
+
emit(pieceStart, i - 1);
|
|
1312
|
+
pieceStart = i;
|
|
1313
|
+
pieceLength = lineLength;
|
|
1314
|
+
}
|
|
1315
|
+
else {
|
|
1316
|
+
pieceLength = withLine;
|
|
1317
|
+
}
|
|
1318
|
+
}
|
|
1319
|
+
emit(pieceStart, lines.length - 1);
|
|
1320
|
+
}
|
|
1321
|
+
return bounded;
|
|
1322
|
+
}
|
|
809
1323
|
/**
|
|
810
1324
|
* Detect language from file extension
|
|
811
1325
|
*/
|
|
812
1326
|
detectLanguage(filePath) {
|
|
813
|
-
|
|
814
|
-
const langMap = {
|
|
815
|
-
'.ts': 'typescript',
|
|
816
|
-
'.tsx': 'typescript',
|
|
817
|
-
'.js': 'javascript',
|
|
818
|
-
'.jsx': 'javascript',
|
|
819
|
-
'.py': 'python',
|
|
820
|
-
'.rs': 'rust',
|
|
821
|
-
'.go': 'go',
|
|
822
|
-
'.java': 'java',
|
|
823
|
-
'.c': 'c',
|
|
824
|
-
'.cpp': 'cpp',
|
|
825
|
-
'.h': 'c',
|
|
826
|
-
'.cs': 'csharp',
|
|
827
|
-
'.rb': 'ruby',
|
|
828
|
-
'.php': 'php',
|
|
829
|
-
'.swift': 'swift',
|
|
830
|
-
'.kt': 'kotlin',
|
|
831
|
-
'.sh': 'shell',
|
|
832
|
-
'.bash': 'shell',
|
|
833
|
-
'.zsh': 'shell',
|
|
834
|
-
'.lua': 'lua',
|
|
835
|
-
'.md': 'markdown',
|
|
836
|
-
};
|
|
837
|
-
return langMap[ext] || 'unknown';
|
|
1327
|
+
return languageForExtension(filePath);
|
|
838
1328
|
}
|
|
839
1329
|
/**
|
|
840
1330
|
* Chunk markdown files by headers
|