@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.
Files changed (60) hide show
  1. package/dist/ast/astChunker.d.ts +124 -0
  2. package/dist/ast/astChunker.d.ts.map +1 -0
  3. package/dist/ast/astChunker.js +559 -0
  4. package/dist/ast/astChunker.js.map +1 -0
  5. package/dist/ast/grammars.d.ts +170 -0
  6. package/dist/ast/grammars.d.ts.map +1 -0
  7. package/dist/ast/grammars.js +411 -0
  8. package/dist/ast/grammars.js.map +1 -0
  9. package/dist/codeScanner.d.ts +106 -0
  10. package/dist/codeScanner.d.ts.map +1 -1
  11. package/dist/codeScanner.js +552 -62
  12. package/dist/codeScanner.js.map +1 -1
  13. package/dist/index.d.ts +3 -1
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +3 -1
  16. package/dist/index.js.map +1 -1
  17. package/package.json +3 -2
  18. package/queries/bash.tags.scm +7 -0
  19. package/queries/groovy.tags.scm +15 -0
  20. package/queries/powershell.tags.scm +14 -0
  21. package/scripts/vendor-grammars.ts +317 -0
  22. package/src/ast/astChunker.ts +661 -0
  23. package/src/ast/grammars.ts +490 -0
  24. package/src/codeScanner.ts +577 -63
  25. package/src/index.ts +25 -0
  26. package/wasm/bash.tags.scm +7 -0
  27. package/wasm/bash.wasm +0 -0
  28. package/wasm/c-sharp.tags.scm +23 -0
  29. package/wasm/c-sharp.wasm +0 -0
  30. package/wasm/c.tags.scm +9 -0
  31. package/wasm/c.wasm +0 -0
  32. package/wasm/cpp.tags.scm +15 -0
  33. package/wasm/cpp.wasm +0 -0
  34. package/wasm/elixir.tags.scm +54 -0
  35. package/wasm/elixir.wasm +0 -0
  36. package/wasm/go.tags.scm +42 -0
  37. package/wasm/go.wasm +0 -0
  38. package/wasm/groovy.tags.scm +15 -0
  39. package/wasm/groovy.wasm +0 -0
  40. package/wasm/java.tags.scm +20 -0
  41. package/wasm/java.wasm +0 -0
  42. package/wasm/javascript.tags.scm +99 -0
  43. package/wasm/javascript.wasm +0 -0
  44. package/wasm/php.tags.scm +40 -0
  45. package/wasm/php.wasm +0 -0
  46. package/wasm/powershell.tags.scm +14 -0
  47. package/wasm/powershell.wasm +0 -0
  48. package/wasm/python.tags.scm +14 -0
  49. package/wasm/python.wasm +0 -0
  50. package/wasm/ruby.tags.scm +64 -0
  51. package/wasm/ruby.wasm +0 -0
  52. package/wasm/rust.tags.scm +60 -0
  53. package/wasm/rust.wasm +0 -0
  54. package/wasm/scala.tags.scm +66 -0
  55. package/wasm/scala.wasm +0 -0
  56. package/wasm/solidity.tags.scm +43 -0
  57. package/wasm/solidity.wasm +0 -0
  58. package/wasm/tsx.wasm +0 -0
  59. package/wasm/typescript.tags.scm +23 -0
  60. package/wasm/typescript.wasm +0 -0
@@ -6,9 +6,11 @@ import { readdir, readFile, stat, lstat, realpath } from 'fs/promises';
6
6
  import { join, relative, extname, sep } from 'path';
7
7
  import { createHash } from 'crypto';
8
8
  import ignore, { type Ignore } from 'ignore';
9
- import { debug as logDebug, warn as logWarn } from './logger.js';
9
+ import { debug as logDebug, info as logInfo, warn as logWarn } from './logger.js';
10
10
  import { withTimeout } from './asyncUtils.js';
11
11
  import { scrubSecrets } from './secretPatterns.js';
12
+ import { MAX_AST_PARSE_BYTES, chunkWithAst, type AstChunk } from './ast/astChunker.js';
13
+ import { astArtifactReport, resolveAstLanguage } from './ast/grammars.js';
12
14
 
13
15
  /**
14
16
  * File operation timeout (5 seconds) to prevent hanging on slow/unresponsive filesystems
@@ -51,6 +53,11 @@ export interface CodeChunk {
51
53
  size: number;
52
54
  signature?: string; // Function/class signature (e.g. "function foo(bar: string): number")
53
55
  symbolName?: string; // Symbol name (e.g. "foo")
56
+ // The AST chunker's finer classification, verbatim from the tag capture
57
+ // ('method', 'interface', 'struct', ...). `chunkType` above is the coarse
58
+ // wire enum and cannot express it; only the AST path sets this, so its
59
+ // absence is exactly the signal "this chunk came from the heuristic path".
60
+ symbolKind?: string;
54
61
  };
55
62
  }
56
63
 
@@ -59,6 +66,13 @@ export interface ScanOptions {
59
66
  minChunkSize?: number;
60
67
  ignorePatterns?: string[];
61
68
  includeExtensions?: string[];
69
+ /**
70
+ * Byte ceiling above which a file is chunked heuristically instead of parsed.
71
+ * Defaults to `MAX_AST_PARSE_BYTES`; exposed so a caller (and the routing
72
+ * test) can drive the degradation path through production code rather than a
73
+ * mock.
74
+ */
75
+ maxAstParseBytes?: number;
62
76
  }
63
77
 
64
78
  /**
@@ -192,6 +206,9 @@ const BUILT_IN_IGNORE_FILE_PATTERNS: readonly string[] = [
192
206
  * real, common SOURCE directory name (gRPC, auth libraries) and
193
207
  * `shouldIgnore` cannot tell a file from a directory at its call sites —
194
208
  * an unqualified rule would silently delete that subtree from the index.
209
+ * - `.tfvars` is excluded whole rather than per-basename: the credential file
210
+ * is conventionally `terraform.tfvars` but the name is free-form, and the
211
+ * variables that are NOT secret are visible in `.tf` anyway.
195
212
  */
196
213
  const SECRET_FILE_PLACEHOLDER_BASENAMES: ReadonlySet<string> = new Set(['.env.example']);
197
214
 
@@ -201,6 +218,12 @@ const SECRET_FILE_BASENAME_PATTERNS: readonly RegExp[] = [
201
218
  /^\.(?:npmrc|netrc|pgpass)$/i, // registry / ftp / postgres password files
202
219
  /^kubeconfig$/i,
203
220
  /\.(?:pem|key|p12|pfx|keystore|jks|kubeconfig)$/i,
221
+ // Terraform variable files: `terraform.tfvars` is the conventional home for
222
+ // provider credentials, and unlike the entries above nothing else was
223
+ // keeping it out — it is excluded here rather than merely left off
224
+ // `DEFAULT_INCLUDE_EXTENSIONS`, so a future widening of the allowlist cannot
225
+ // quietly opt a project into embedding its own cloud keys.
226
+ /\.tfvars(\.json)?$/i,
204
227
  ];
205
228
 
206
229
  /**
@@ -227,38 +250,272 @@ export function isSecretFile(relPath: string): boolean {
227
250
  */
228
251
  const IGNORE_FILE_NAMES = ['.gitignore', '.mnemonikignore'] as const;
229
252
 
253
+ /**
254
+ * Every file type the scanner will chunk, by extension. Matched
255
+ * case-INSENSITIVELY (see `isIncludedExtension`), so lowercase spellings here
256
+ * also cover `.R`, `.SQL`, `.PS1` and the uppercase `.C`/`.H` of older trees.
257
+ *
258
+ * Exported because this list IS the product's language coverage: a project
259
+ * written in something absent from it indexes zero code, and `code_search`
260
+ * reports that as "no indexed matches" rather than "I do not read this
261
+ * language" — invisible to the developer and to us. Coverage is a separate
262
+ * dial from parse quality: a language with no structured extractor falls
263
+ * through to `chunkRaw`, which is what Go, Java and C already get in
264
+ * production, and crude chunks beat no chunks by an enormous margin.
265
+ *
266
+ * Data formats (`.json`, `.yaml`, `.toml`, `.lock`) are deliberately absent:
267
+ * the ones that carry meaning are already collected verbatim by
268
+ * `AUTHORITY_FILE_MATCHERS`, and blanket-indexing the extension would pull in
269
+ * lockfiles and generated output. `.tfvars` is absent for a different reason —
270
+ * `terraform.tfvars` is a conventional home for provider credentials and
271
+ * `isSecretFile` now excludes it outright.
272
+ */
273
+ export const DEFAULT_INCLUDE_EXTENSIONS: readonly string[] = [
274
+ '.ts',
275
+ '.tsx',
276
+ '.js',
277
+ '.jsx',
278
+ '.py',
279
+ '.rs',
280
+ '.go',
281
+ '.java',
282
+ '.c',
283
+ '.cpp',
284
+ '.h',
285
+ '.cs',
286
+ '.rb',
287
+ '.php',
288
+ '.swift',
289
+ '.kt',
290
+ // Shell and Lua are the working languages of whole real projects —
291
+ // deployment tooling, container entrypoints, imapfilter/nginx/redis
292
+ // configuration. Omitting them meant such a project indexed ZERO code and
293
+ // code_search could not answer anything about it, while reporting that as
294
+ // "no indexed matches" rather than as missing coverage.
295
+ '.sh',
296
+ '.bash',
297
+ '.zsh',
298
+ '.lua',
299
+ '.md',
300
+
301
+ // ── Variants of languages already on the list. `.mjs`/`.cjs`/`.mts`/`.cts`/
302
+ // `.pyi` inherit their language string from a listed extension, so their
303
+ // absence was oversight rather than policy. The C++ spellings are a choice,
304
+ // not an inheritance: `.h` stays 'c' (C headers dominate, and reading a C++
305
+ // header as C is the safer default), while the unambiguously-C++ spellings
306
+ // resolve to 'cpp'.
307
+ '.mjs',
308
+ '.cjs',
309
+ '.mts',
310
+ '.cts',
311
+ '.pyi',
312
+ '.hpp',
313
+ '.hh',
314
+ '.hxx',
315
+ '.cc',
316
+ '.cxx',
317
+ '.kts',
318
+
319
+ // ── Languages the scanner could not read at all.
320
+ '.dart',
321
+ '.m',
322
+ '.mm',
323
+ '.scala',
324
+ '.sc',
325
+ '.ex',
326
+ '.exs',
327
+ '.erl',
328
+ '.hrl',
329
+ '.hs',
330
+ '.jl',
331
+ '.ml',
332
+ '.mli',
333
+ '.clj',
334
+ '.cljs',
335
+ '.cljc',
336
+ '.groovy',
337
+ '.gradle',
338
+ '.ps1',
339
+ '.psm1',
340
+ '.pl',
341
+ '.pm',
342
+ '.r',
343
+ '.sol',
344
+ '.zig',
345
+ '.vue',
346
+ '.svelte',
347
+
348
+ // ── Infrastructure and schema DSLs, where real logic lives and where "how
349
+ // is this deployed?" and "what does this table hold?" go unanswered today.
350
+ '.tf',
351
+ '.sql',
352
+ '.proto',
353
+ '.graphql',
354
+ '.gql',
355
+ '.cmake',
356
+ '.nix',
357
+ '.bzl',
358
+ ];
359
+
360
+ /**
361
+ * Extension → language string, the value carried on every chunk and on the
362
+ * wire (`/scan/push` accepts any non-empty string up to 50 chars).
363
+ *
364
+ * A string no grammar claims is correct and expected: the AST layer resolves
365
+ * such a language to `null` and the heuristic chunker takes over. Keep this in
366
+ * sync with `DEFAULT_INCLUDE_EXTENSIONS` — an allowlisted extension that lands
367
+ * on 'unknown' still gets chunked, but nothing downstream can reason about it.
368
+ *
369
+ * Two extensions are genuinely ambiguous and are resolved rather than fudged:
370
+ * `.m` is Objective-C here, not MATLAB, because a repo carrying `.m` alongside
371
+ * `.h`/`.mm` is overwhelmingly an Apple-platform project; `.pl` is Perl, not
372
+ * Prolog, on the same frequency argument.
373
+ */
374
+ const EXTENSION_LANGUAGES: Readonly<Record<string, string>> = {
375
+ '.ts': 'typescript',
376
+ '.tsx': 'typescript',
377
+ '.mts': 'typescript',
378
+ '.cts': 'typescript',
379
+ '.js': 'javascript',
380
+ '.jsx': 'javascript',
381
+ '.mjs': 'javascript',
382
+ '.cjs': 'javascript',
383
+ '.py': 'python',
384
+ '.pyi': 'python',
385
+ '.rs': 'rust',
386
+ '.go': 'go',
387
+ '.java': 'java',
388
+ '.c': 'c',
389
+ '.h': 'c',
390
+ '.cpp': 'cpp',
391
+ '.cc': 'cpp',
392
+ '.cxx': 'cpp',
393
+ '.hpp': 'cpp',
394
+ '.hh': 'cpp',
395
+ '.hxx': 'cpp',
396
+ '.cs': 'csharp',
397
+ '.rb': 'ruby',
398
+ '.php': 'php',
399
+ '.swift': 'swift',
400
+ '.kt': 'kotlin',
401
+ '.kts': 'kotlin',
402
+ '.sh': 'shell',
403
+ '.bash': 'shell',
404
+ '.zsh': 'shell',
405
+ '.lua': 'lua',
406
+ '.md': 'markdown',
407
+ '.dart': 'dart',
408
+ '.m': 'objc',
409
+ '.mm': 'objc',
410
+ '.scala': 'scala',
411
+ '.sc': 'scala',
412
+ '.ex': 'elixir',
413
+ '.exs': 'elixir',
414
+ '.erl': 'erlang',
415
+ '.hrl': 'erlang',
416
+ '.hs': 'haskell',
417
+ '.jl': 'julia',
418
+ '.ml': 'ocaml',
419
+ '.mli': 'ocaml',
420
+ '.clj': 'clojure',
421
+ '.cljs': 'clojure',
422
+ '.cljc': 'clojure',
423
+ '.groovy': 'groovy',
424
+ '.gradle': 'groovy',
425
+ '.ps1': 'powershell',
426
+ '.psm1': 'powershell',
427
+ '.pl': 'perl',
428
+ '.pm': 'perl',
429
+ '.r': 'r',
430
+ '.sol': 'solidity',
431
+ '.zig': 'zig',
432
+ '.vue': 'vue',
433
+ '.svelte': 'svelte',
434
+ '.tf': 'terraform',
435
+ '.sql': 'sql',
436
+ '.proto': 'protobuf',
437
+ '.graphql': 'graphql',
438
+ '.gql': 'graphql',
439
+ '.cmake': 'cmake',
440
+ '.nix': 'nix',
441
+ '.bzl': 'starlark',
442
+ };
443
+
444
+ /**
445
+ * Language string for a file path or a bare extension, `'unknown'` when the
446
+ * extension is unmapped. A free function rather than a method because callers
447
+ * that never scan anything (AST grammar resolution, server-side symbol
448
+ * preference) need the same answer without constructing a scanner.
449
+ */
450
+ export function languageForExtension(filePathOrExt: string): string {
451
+ // `extname` FIRST. A dotfile that carries a real extension ('.eslintrc.js',
452
+ // '.mocharc.cjs', '.prettierrc.ts') starts with '.' and contains no '/', so
453
+ // a bare-extension-first reading swallowed the whole name and answered
454
+ // 'unknown' — while the same file spelled 'src/.eslintrc.js' answered
455
+ // 'javascript'. Those extensions are allowlisted, so the files are indexed
456
+ // and reach the server with exactly the root-relative spelling that failed.
457
+ const fromPath = EXTENSION_LANGUAGES[extname(filePathOrExt).toLowerCase()];
458
+ if (fromPath !== undefined) return fromPath;
459
+ // Fallback: the argument IS the extension ('.dart'), for which `extname`
460
+ // returns ''. Every key contains a leading dot and no separator, so a real
461
+ // path can never collide here.
462
+ return EXTENSION_LANGUAGES[filePathOrExt.toLowerCase()] ?? 'unknown';
463
+ }
464
+
465
+ /**
466
+ * THE definition of "this path is a SQL migration the schema_columns authority
467
+ * collects verbatim". One predicate, referenced by both halves of the deal —
468
+ * `AUTHORITY_FILE_MATCHERS` (collect it) and `isAuthorityOnlyPath` (therefore
469
+ * do not chunk it) — because two hand-written regexes drifted once already and
470
+ * the failure is silent in both directions.
471
+ *
472
+ * Root-anchored and CASE-SENSITIVE on purpose: it mirrors the server-side
473
+ * extractor, which does `listFiles('migrations/')` (LIKE 'migrations/%') then
474
+ * `endsWith('.sql')`, both case-sensitive. `Migrations/001.sql` (the .NET/EF
475
+ * Core spelling) and `migrations/002.SQL` are NOT collected, so they must not
476
+ * be suppressed from chunking either — that would index them nowhere. Same
477
+ * reason a nested `packages/x/migrations/y.sql` is left alone.
478
+ */
479
+ const isMigrationSqlAuthorityPath = (posixRelPath: string): boolean =>
480
+ /^migrations\/.*\.sql$/.test(posixRelPath);
481
+
482
+ /**
483
+ * Predicates for paths whose verbatim content is ALREADY shipped by
484
+ * `collectAuthorityFiles` and that carry no additional value as embedded code
485
+ * chunks. Checked at the extension gate rather than in `shouldIgnore`, because
486
+ * `shouldIgnore` also guards the authority walk and must keep letting these
487
+ * through.
488
+ *
489
+ * `migrations/**.sql` is the live case: adding `.sql` to the allowlist without
490
+ * this exclusion would dual-collect every migration — once verbatim, once
491
+ * chunked and embedded. On this repo alone that is 208 files of append-only
492
+ * DDL (140 forward, the rest rollback/manual), embedded to answer questions
493
+ * the authority path already answers exactly.
494
+ *
495
+ * INVARIANT: every predicate here must also appear in
496
+ * `AUTHORITY_FILE_MATCHERS`, so no path can be excluded from chunking unless
497
+ * the authority path definitely collects it.
498
+ */
499
+ const AUTHORITY_ONLY_PATH_PREDICATES: readonly ((posixRelPath: string) => boolean)[] = [
500
+ isMigrationSqlAuthorityPath,
501
+ ];
502
+
503
+ /**
504
+ * True when `relPath` is collected verbatim as authority content and must not
505
+ * additionally be chunked. Accepts OS-native or POSIX separators.
506
+ */
507
+ export function isAuthorityOnlyPath(relPath: string): boolean {
508
+ if (!relPath) return false;
509
+ const posix = relPath.split(sep).join('/');
510
+ return AUTHORITY_ONLY_PATH_PREDICATES.some((matches) => matches(posix));
511
+ }
512
+
230
513
  const DEFAULT_OPTIONS: Required<ScanOptions> = {
231
514
  maxChunkSize: 8000, // ~2000 tokens
232
515
  minChunkSize: 100,
233
516
  ignorePatterns: [...BUILT_IN_IGNORE_DIRS, ...BUILT_IN_IGNORE_FILE_PATTERNS],
234
- includeExtensions: [
235
- '.ts',
236
- '.tsx',
237
- '.js',
238
- '.jsx',
239
- '.py',
240
- '.rs',
241
- '.go',
242
- '.java',
243
- '.c',
244
- '.cpp',
245
- '.h',
246
- '.cs',
247
- '.rb',
248
- '.php',
249
- '.swift',
250
- '.kt',
251
- // Shell and Lua are the working languages of whole real projects —
252
- // deployment tooling, container entrypoints, imapfilter/nginx/redis
253
- // configuration. Omitting them meant such a project indexed ZERO code and
254
- // code_search could not answer anything about it, while reporting that as
255
- // "no indexed matches" rather than as missing coverage.
256
- '.sh',
257
- '.bash',
258
- '.zsh',
259
- '.lua',
260
- '.md',
261
- ],
517
+ includeExtensions: [...DEFAULT_INCLUDE_EXTENSIONS],
518
+ maxAstParseBytes: MAX_AST_PARSE_BYTES,
262
519
  };
263
520
 
264
521
  /**
@@ -281,8 +538,10 @@ export const AUTHORITY_FILE_MATCHERS: Array<(relPath: string) => boolean> = [
281
538
  // SQL migrations: the schema_columns authority extractor reads every .sql
282
539
  // under `migrations/` (listFiles('migrations/') -> LIKE 'migrations/%' then
283
540
  // .endsWith('.sql')). Without collecting these, that authority is empty and
284
- // every schema_table_enumeration claim falls to unverifiable.
285
- (p) => /^migrations\/.*\.sql$/.test(p),
541
+ // every schema_table_enumeration claim falls to unverifiable. Shared with
542
+ // `AUTHORITY_ONLY_PATH_PREDICATES` by reference, not by a copied regex, so
543
+ // collection and the chunking exclusion cannot disagree.
544
+ isMigrationSqlAuthorityPath,
286
545
  ];
287
546
 
288
547
  /**
@@ -369,11 +628,96 @@ interface IgnoreLayer {
369
628
  }
370
629
  type IgnoreStack = IgnoreLayer[];
371
630
 
631
+ /**
632
+ * Latch for `logAstCapabilityOnce`. A promise, not a boolean: two concurrent
633
+ * callers must both wait on the same report rather than the second returning
634
+ * before the first has logged.
635
+ */
636
+ let astCapabilityLog: Promise<void> | null = null;
637
+
638
+ /**
639
+ * `reason:grammar` pairs already warned about, for the reasons that are
640
+ * process-global rather than file-specific.
641
+ *
642
+ * Only `grammar_unavailable` qualifies: `loadGrammar` caches its failure, so the
643
+ * answer is identical for every file of that language and warning per file
644
+ * printed 1,037 lines in one scan of this repo. `file_too_large` and
645
+ * `parse_failed` are properties of one file and stay per file.
646
+ */
647
+ const reportedGrammarFallbacks = new Set<string>();
648
+
649
+ /**
650
+ * Log which grammars this install ships — ONCE per process, at daemon start,
651
+ * never per file.
652
+ *
653
+ * A missing grammar silently degrades every file of that language to the
654
+ * heuristic chunker. Per-file warnings would say so 30,000 times and drown the
655
+ * log; saying nothing is how a half-broken install looks healthy. One startup
656
+ * line naming the vendored grammars, and a WARNING when an artifact is missing,
657
+ * is the whole contract.
658
+ *
659
+ * Deliberately a `statSync` of the artifacts (`astArtifactReport`) and not a
660
+ * load of them (`astCapabilityReport`). Loading all 18 to print this line costs
661
+ * ~690 ms and ~75 MB of RSS that is never returned — web-tree-sitter exposes no
662
+ * `Language.delete` — which is a permanent tax on a daemon watching a pure
663
+ * TypeScript repo, paid to pre-answer a question about seventeen languages it
664
+ * will never see. Grammars load lazily instead, on the first file of a language,
665
+ * and the two failure modes only a load can detect (`wasm_load_failed`,
666
+ * `query_compile_failed`) are warned there, once per language, by the
667
+ * `grammar_unavailable` branch in `chunkFile`.
668
+ *
669
+ * Cheap to call repeatedly: this latches, and the report instantiates nothing.
670
+ */
671
+ export function logAstCapabilityOnce(): Promise<void> {
672
+ astCapabilityLog ??= (async () => {
673
+ const report = astArtifactReport();
674
+ const detail = {
675
+ grammars: report.vendored.length,
676
+ languages: report.vendored.join(' '),
677
+ };
678
+ if (report.missing.length > 0) {
679
+ logWarn('AST chunking: some vendored grammar artifacts are missing', {
680
+ ...detail,
681
+ missing: report.missing.map((m) => `${m.id}(${m.reason}: ${m.detail})`).join('; '),
682
+ });
683
+ } else {
684
+ logInfo('AST chunking ready (grammars load lazily, per language)', detail);
685
+ }
686
+ })();
687
+ return astCapabilityLog;
688
+ }
689
+
372
690
  export class CodeScanner {
373
691
  private options: Required<ScanOptions>;
374
692
 
693
+ /**
694
+ * `includeExtensions` folded to lowercase for matching. The gate used to
695
+ * compare `extname()` verbatim while `detectLanguage` lowercased, so a
696
+ * project spelling its files the canonical way — `.R` for R, `.SQL`/`.PS1`
697
+ * on Windows, `.C`/`.H` in older C trees — indexed zero of them and nothing
698
+ * said why.
699
+ */
700
+ private readonly includeExtensionSet: ReadonlySet<string>;
701
+
375
702
  constructor(options: ScanOptions = {}) {
376
703
  this.options = { ...DEFAULT_OPTIONS, ...options };
704
+ this.includeExtensionSet = new Set(
705
+ this.options.includeExtensions.map((ext) => ext.toLowerCase())
706
+ );
707
+ }
708
+
709
+ /**
710
+ * The chunkable-file gate, shared by every walker and by the explicit
711
+ * file-list path so all three agree on what exists. `relPath` is the path
712
+ * relative to the scan root (OS-native separators accepted).
713
+ */
714
+ private isChunkable(absOrRelPath: string, relPath: string): boolean {
715
+ if (!this.includeExtensionSet.has(extname(absOrRelPath).toLowerCase())) return false;
716
+ if (isAuthorityOnlyPath(relPath)) {
717
+ logDebug('Skipping chunking for authority-collected path', { relPath });
718
+ return false;
719
+ }
720
+ return true;
377
721
  }
378
722
 
379
723
  /**
@@ -625,7 +969,7 @@ export class CodeScanner {
625
969
  await this.traversePaths(fullPath, rootPath, out, depth + 1, walk, localStack);
626
970
  } else if (stats.isFile()) {
627
971
  if (this.ignoredByStack(relativePath, false, localStack)) continue;
628
- if (this.options.includeExtensions.includes(extname(fullPath))) {
972
+ if (this.isChunkable(fullPath, relativePath)) {
629
973
  out.push(relativePath);
630
974
  }
631
975
  }
@@ -668,8 +1012,7 @@ export class CodeScanner {
668
1012
  continue;
669
1013
  }
670
1014
 
671
- const ext = extname(filePath);
672
- if (this.options.includeExtensions.includes(ext)) {
1015
+ if (this.isChunkable(filePath, fileRel)) {
673
1016
  const fileChunks = await this.parseFile(filePath, rootPath || filePath);
674
1017
  chunks.push(...fileChunks);
675
1018
  }
@@ -775,8 +1118,7 @@ export class CodeScanner {
775
1118
  await this.traverseDirectory(fullPath, rootPath, chunks, depth + 1, walk, localStack);
776
1119
  } else if (stats.isFile()) {
777
1120
  if (this.ignoredByStack(relativePath, false, localStack)) continue;
778
- const ext = extname(fullPath);
779
- if (this.options.includeExtensions.includes(ext)) {
1121
+ if (this.isChunkable(fullPath, relativePath)) {
780
1122
  const fileChunks = await this.parseFile(fullPath, rootPath);
781
1123
  chunks.push(...fileChunks);
782
1124
  }
@@ -876,14 +1218,131 @@ export class CodeScanner {
876
1218
  return this.chunkMarkdown(content, relativePath, stats.size);
877
1219
  }
878
1220
 
879
- const structuredChunks = this.extractStructuredChunks(content, language);
880
-
881
1221
  const fileMetadata = {
882
1222
  fileName: filePath.split('/').pop() || '',
883
1223
  extension: extname(filePath),
884
1224
  size: stats.size,
885
1225
  };
886
1226
 
1227
+ // AST first for the languages with a vendored grammar. `null` means "no
1228
+ // grammar for this" — the honest majority of the allowlist — and the
1229
+ // heuristic path below takes over with no log, because that is not a
1230
+ // degradation.
1231
+ const astLanguage = resolveAstLanguage(fileMetadata.extension, language);
1232
+ if (astLanguage) {
1233
+ const ast = await chunkWithAst(content, astLanguage, {
1234
+ maxParseBytes: this.options.maxAstParseBytes,
1235
+ });
1236
+ if ('chunks' in ast) {
1237
+ // A definition the chunker could not place reaches no chunk, so its
1238
+ // name reaches no index and a citation to it resolves as
1239
+ // `unresolved_symbol`. It cannot be emitted (two chunks over one line
1240
+ // range collide on the `filePath:startLine-endLine` staleness key), but
1241
+ // a scan that quietly loses symbols must not look like a healthy one.
1242
+ if (ast.droppedDefinitions > 0) {
1243
+ logWarn('AST chunker dropped definitions that share a line range', {
1244
+ filePath: relativePath,
1245
+ grammar: astLanguage,
1246
+ droppedDefinitions: ast.droppedDefinitions,
1247
+ });
1248
+ }
1249
+ // `errorNodes` is handed back precisely so this degradation is not
1250
+ // silent, and discarding it made it silent. One ERROR node can swallow
1251
+ // the rest of a file — `typeof import(...)` in a type argument does
1252
+ // exactly that to tree-sitter-typescript — after which no definition is
1253
+ // captured and the file arrives as one unnamed whole-file chunk. Naming
1254
+ // the file, the error count and how many definitions survived is what
1255
+ // makes that visible in a scan log.
1256
+ //
1257
+ // Deliberately NOT a fallback to the heuristic path: measured on the
1258
+ // real construct (tests/AgentInjectionDeliveryWiring.test.ts), the
1259
+ // heuristic path names nothing either (its name pattern needs a
1260
+ // leading `function`/`class`/`const`) AND covers less — it emitted 3
1261
+ // chunks starting at line 18 and dropped lines 1-17, because the
1262
+ // uncovered-region supplement only fires above 50 lines. Trading
1263
+ // total coverage for no gain is worse than one honest blob, so the
1264
+ // AST result stands and the log says so.
1265
+ if (ast.errorNodes > 0) {
1266
+ logWarn('AST parse errors; extents and symbol names degraded for this file', {
1267
+ filePath: relativePath,
1268
+ grammar: astLanguage,
1269
+ errorNodes: ast.errorNodes,
1270
+ definitions: ast.chunks.filter((chunk) => chunk.symbolKind).length,
1271
+ chunks: ast.chunks.length,
1272
+ });
1273
+ }
1274
+ // The wire cap (`scanChunkSchema` allows 500,000 chars) and the
1275
+ // embedding cap (8191 tokens per input) are both downstream of
1276
+ // `maxChunkSize`, and the AST chunker only bounds its uncovered spans:
1277
+ // a definition chunk is whatever the definition is. `boundAstChunks`
1278
+ // makes the cap unreachable by construction, or answers null when the
1279
+ // file cannot be bounded on line boundaries at all.
1280
+ const bounded = this.boundAstChunks(ast.chunks);
1281
+ if (bounded) {
1282
+ // Zero chunks here means the file has no non-whitespace line — the AST
1283
+ // chunker covers every other line by construction — so returning
1284
+ // nothing is the correct answer, not a lost file.
1285
+ return bounded.map(({ symbolName, symbolKind, signature, ...chunk }) => ({
1286
+ ...chunk,
1287
+ filePath: relativePath,
1288
+ language,
1289
+ contentHash: this.hash(chunk.content),
1290
+ metadata: {
1291
+ ...fileMetadata,
1292
+ ...(signature ? { signature } : {}),
1293
+ ...(symbolName ? { symbolName } : {}),
1294
+ ...(symbolKind ? { symbolKind } : {}),
1295
+ },
1296
+ }));
1297
+ }
1298
+ // One line longer than `maxChunkSize` — a generated `*_pb2.py` carries
1299
+ // the whole serialized descriptor on one. Splitting it would have to
1300
+ // cut mid-line, and two chunks over one line range collide on the
1301
+ // `filePath:startLine-endLine` staleness key, so the whole file goes to
1302
+ // the heuristic chunker, which force-splits long lines by character
1303
+ // count. Loud, because a file that loses its symbol names must not look
1304
+ // like a healthy one.
1305
+ logWarn('AST chunk exceeds maxChunkSize on a single line; heuristic chunking instead', {
1306
+ filePath: relativePath,
1307
+ grammar: astLanguage,
1308
+ maxChunkSize: this.options.maxChunkSize,
1309
+ longestLine: content
1310
+ .split('\n')
1311
+ .reduce((longest, line) => Math.max(longest, line.length), 0),
1312
+ });
1313
+ } else if (ast.unsupported === 'grammar_unavailable') {
1314
+ // Process-global, not per file: `loadGrammar` caches its failures, so
1315
+ // every file of this language degrades for the same reason. Warned per
1316
+ // file, this printed 1,037 identical lines in one scan of this repo —
1317
+ // the log flood `logAstCapabilityOnce` exists to avoid. Once per
1318
+ // (reason, grammar); the file-specific reasons below stay per file.
1319
+ const key = `${ast.unsupported}:${astLanguage}`;
1320
+ if (!reportedGrammarFallbacks.has(key)) {
1321
+ reportedGrammarFallbacks.add(key);
1322
+ logWarn('AST chunking unavailable for a whole language; heuristic chunking instead', {
1323
+ grammar: astLanguage,
1324
+ reason: ast.unsupported,
1325
+ detail: ast.detail,
1326
+ firstFile: relativePath,
1327
+ note: 'logged once per grammar per process; every file of this language degrades',
1328
+ });
1329
+ }
1330
+ } else {
1331
+ // Not a silent no-op: name the file AND the reason, then degrade to the
1332
+ // heuristic path. A degraded scan indistinguishable from a healthy one
1333
+ // is the defect class this routing exists to avoid — and degrading to
1334
+ // zero chunks would delete the file from the index instead.
1335
+ logWarn('AST chunking unavailable; falling back to heuristic chunking', {
1336
+ filePath: relativePath,
1337
+ grammar: astLanguage,
1338
+ reason: ast.unsupported,
1339
+ detail: ast.detail,
1340
+ });
1341
+ }
1342
+ }
1343
+
1344
+ const structuredChunks = this.extractStructuredChunks(content, language);
1345
+
887
1346
  if (structuredChunks.length > 0) {
888
1347
  const mapped = structuredChunks.map(({ signature, symbolName, ...chunk }) => ({
889
1348
  ...chunk,
@@ -933,35 +1392,90 @@ export class CodeScanner {
933
1392
  }
934
1393
  }
935
1394
 
1395
+ /**
1396
+ * Hold every AST chunk at or under `maxChunkSize`, or answer `null` when this
1397
+ * file cannot be held there on line boundaries.
1398
+ *
1399
+ * The AST chunker bounds only the spans it invents (`MAX_RAW_SPAN_CHARS`); a
1400
+ * chunk that came from a definition is exactly as big as the definition, and a
1401
+ * span that is one enormous line is left whole on purpose. Both used to reach
1402
+ * the wire verbatim, where `scanChunkSchema` caps content at 500,000 chars and
1403
+ * rejects the WHOLE push if any chunk is over — so one generated file stopped
1404
+ * all scanning for that push — and where anything past 8191 tokens is only
1405
+ * partially embedded.
1406
+ *
1407
+ * The invariants, all of which the split preserves:
1408
+ * - content stays verbatim contiguous source: `lines[startLine-1..endLine-1]`
1409
+ * joined by '\n', never a join of disjoint regions and never an elision;
1410
+ * - total coverage is unchanged: the pieces tile the original range, in order,
1411
+ * with no gap and no overlap;
1412
+ * - the definition's identity rides on the piece that carries its signature —
1413
+ * the first one — and the continuations are plain `raw` spans, because a
1414
+ * continuation is not the definition and must not claim its name.
1415
+ *
1416
+ * `null` (the caller degrades the whole file to the heuristic chunker) is
1417
+ * reserved for the one shape a line-boundary split cannot fix: a single line
1418
+ * longer than the cap, as every generated `*_pb2.py` has. Cutting mid-line
1419
+ * would give two chunks the same `filePath:startLine-endLine` staleness key,
1420
+ * which is the collision the chunker refuses everywhere else.
1421
+ */
1422
+ private boundAstChunks(chunks: readonly AstChunk[]): AstChunk[] | null {
1423
+ const max = this.options.maxChunkSize;
1424
+ if (chunks.every((chunk) => chunk.content.length <= max)) return [...chunks];
1425
+
1426
+ const bounded: AstChunk[] = [];
1427
+ for (const chunk of chunks) {
1428
+ if (chunk.content.length <= max) {
1429
+ bounded.push(chunk);
1430
+ continue;
1431
+ }
1432
+ // `content` is exactly `startLine..endLine` joined by '\n', so this split
1433
+ // recovers the file's own lines for that range.
1434
+ const lines = chunk.content.split('\n');
1435
+ if (lines.some((line) => line.length > max)) return null;
1436
+
1437
+ /** `from`/`to` are 0-based offsets into `lines`, inclusive. */
1438
+ const emit = (from: number, to: number): void => {
1439
+ const content = lines.slice(from, to + 1).join('\n');
1440
+ // A piece of nothing but blank lines is owed no chunk (the chunker's own
1441
+ // rule) and `scanChunkSchema` requires content.min(1) anyway.
1442
+ if (content.trim() === '') return;
1443
+ const isFirst = from === 0;
1444
+ bounded.push({
1445
+ content,
1446
+ startLine: chunk.startLine + from,
1447
+ endLine: chunk.startLine + to,
1448
+ chunkType: isFirst ? chunk.chunkType : 'raw',
1449
+ ...(isFirst && chunk.symbolName ? { symbolName: chunk.symbolName } : {}),
1450
+ ...(isFirst && chunk.symbolKind ? { symbolKind: chunk.symbolKind } : {}),
1451
+ ...(isFirst && chunk.signature ? { signature: chunk.signature } : {}),
1452
+ });
1453
+ };
1454
+
1455
+ let pieceStart = 0;
1456
+ let pieceLength = 0;
1457
+ for (let i = 0; i < lines.length; i++) {
1458
+ const lineLength = (lines[i] ?? '').length;
1459
+ // The '\n' the join will put back is part of what has to fit.
1460
+ const withLine = i === pieceStart ? lineLength : pieceLength + 1 + lineLength;
1461
+ if (i > pieceStart && withLine > max) {
1462
+ emit(pieceStart, i - 1);
1463
+ pieceStart = i;
1464
+ pieceLength = lineLength;
1465
+ } else {
1466
+ pieceLength = withLine;
1467
+ }
1468
+ }
1469
+ emit(pieceStart, lines.length - 1);
1470
+ }
1471
+ return bounded;
1472
+ }
1473
+
936
1474
  /**
937
1475
  * Detect language from file extension
938
1476
  */
939
1477
  private detectLanguage(filePath: string): string {
940
- const ext = extname(filePath).toLowerCase();
941
- const langMap: Record<string, string> = {
942
- '.ts': 'typescript',
943
- '.tsx': 'typescript',
944
- '.js': 'javascript',
945
- '.jsx': 'javascript',
946
- '.py': 'python',
947
- '.rs': 'rust',
948
- '.go': 'go',
949
- '.java': 'java',
950
- '.c': 'c',
951
- '.cpp': 'cpp',
952
- '.h': 'c',
953
- '.cs': 'csharp',
954
- '.rb': 'ruby',
955
- '.php': 'php',
956
- '.swift': 'swift',
957
- '.kt': 'kotlin',
958
- '.sh': 'shell',
959
- '.bash': 'shell',
960
- '.zsh': 'shell',
961
- '.lua': 'lua',
962
- '.md': 'markdown',
963
- };
964
- return langMap[ext] || 'unknown';
1478
+ return languageForExtension(filePath);
965
1479
  }
966
1480
 
967
1481
  /**