@mnemonik/shared 6.47.0 → 6.50.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.
@@ -6,7 +6,7 @@ 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 } from './logger.js';
9
+ import { debug as logDebug, warn as logWarn } from './logger.js';
10
10
  import { withTimeout } from './asyncUtils.js';
11
11
  import { scrubSecrets } from './secretPatterns.js';
12
12
 
@@ -15,6 +15,28 @@ import { scrubSecrets } from './secretPatterns.js';
15
15
  */
16
16
  const FILE_OP_TIMEOUT_MS = 5000;
17
17
 
18
+ /**
19
+ * The one file-size ceiling in Mnemonik. A file at or below this is scanned,
20
+ * chunked, AND pushed verbatim; a file above it is uniformly outside the
21
+ * system — not scanned, not chunked, not citable. "Scanned ⟺ verifiable".
22
+ *
23
+ * This is deliberately a single exported constant rather than a number
24
+ * repeated per layer. Three ceilings previously existed and had drifted:
25
+ * the scan ceiling here (10 MB), a 5 MB whole-file push cap in the scanner
26
+ * daemon, and a 5 MB `content` cap in the server's scanPushSchema. Files
27
+ * between 5 and 10 MB were therefore indexed as chunks whose source content
28
+ * the server never received — chunks that could be retrieved but never
29
+ * verified against, a silent fidelity split. Every consumer now imports this:
30
+ *
31
+ * - `CodeScanner.MAX_FILE_SIZE` / `collectAuthorityFilesWithStatus` (this file)
32
+ * - `MAX_PUSH_CONTENT_BYTES` (packages/scanner/src/daemon.ts)
33
+ * - `scanFileSchema.content` (src/server/routes/schemas.ts)
34
+ *
35
+ * Raising it means widening the stored-content pipe end to end; do not raise
36
+ * one site alone.
37
+ */
38
+ export const MAX_SCANNED_FILE_BYTES = 10 * 1024 * 1024; // 10MB
39
+
18
40
  export interface CodeChunk {
19
41
  content: string;
20
42
  filePath: string;
@@ -129,7 +151,10 @@ export const BUILT_IN_IGNORE_DIRS: readonly string[] = [
129
151
  * generically by the isGitBoundary rule below, not a path convention.)
130
152
  */
131
153
  const BUILT_IN_IGNORE_FILE_PATTERNS: readonly string[] = [
132
- '.env',
154
+ // NOTE: the `.env` family and every other secret-bearing FILE KIND lives in
155
+ // `isSecretFile` below, not here. This glob engine cannot express negation
156
+ // (`.env.example` must stay collectable) and its unanchored substring
157
+ // matching would make `.env.*` also swallow `.environment.ts`.
133
158
  '.DS_Store',
134
159
  '*.log',
135
160
  '*.lock',
@@ -143,6 +168,66 @@ const BUILT_IN_IGNORE_FILE_PATTERNS: readonly string[] = [
143
168
  '/tests/fixtures/*',
144
169
  ];
145
170
 
171
+ /**
172
+ * Files whose CONTENT is a credential rather than code. Today these are
173
+ * excluded only INCIDENTALLY — `.pem`, `.key` and friends carry no
174
+ * allowlisted extension, so nothing chunks them. That protection evaporates
175
+ * the moment the extension allowlist widens (as it did for `.sh`/`.lua`), so
176
+ * the exclusion is made explicit here and becomes a defended invariant:
177
+ * unlike `ignorePatterns`, this predicate is NOT caller-overridable.
178
+ *
179
+ * Expressed as a predicate rather than glob strings because `shouldIgnore`'s
180
+ * engine has no negation and matches globs as unanchored substrings — neither
181
+ * property can state "every `.env.*` EXCEPT `.env.example`".
182
+ *
183
+ * Precision notes (over-exclusion is a fidelity failure in its own right):
184
+ * - `.env` family: `/^\.env(\..+)?$/` matches `.env`, `.env.local`,
185
+ * `.env.production` — and deliberately NOT `.envrc` or `.environment.ts`.
186
+ * - `.env.example` is carved out: it is authority-collected on purpose
187
+ * (AUTHORITY_FILE_MATCHERS) and holds placeholder values, not secrets.
188
+ * It is the doc-truth env_vars authority; excluding it would blind that
189
+ * extractor exactly as the old `process.env` over-scrub once did.
190
+ * - `credentials` is matched ONLY directly inside a dot-directory
191
+ * (`.aws/credentials`, `.docker/credentials`). A bare `credentials/` is a
192
+ * real, common SOURCE directory name (gRPC, auth libraries) and
193
+ * `shouldIgnore` cannot tell a file from a directory at its call sites —
194
+ * an unqualified rule would silently delete that subtree from the index.
195
+ * - `.tfvars` is excluded whole rather than per-basename: the credential file
196
+ * is conventionally `terraform.tfvars` but the name is free-form, and the
197
+ * variables that are NOT secret are visible in `.tf` anyway.
198
+ */
199
+ const SECRET_FILE_PLACEHOLDER_BASENAMES: ReadonlySet<string> = new Set(['.env.example']);
200
+
201
+ const SECRET_FILE_BASENAME_PATTERNS: readonly RegExp[] = [
202
+ /^\.env(\..+)?$/i, // .env, .env.local, .env.production.local
203
+ /^id_(?:rsa|dsa|ecdsa|ed25519)/i, // ssh private keys (and .pub siblings)
204
+ /^\.(?:npmrc|netrc|pgpass)$/i, // registry / ftp / postgres password files
205
+ /^kubeconfig$/i,
206
+ /\.(?:pem|key|p12|pfx|keystore|jks|kubeconfig)$/i,
207
+ // Terraform variable files: `terraform.tfvars` is the conventional home for
208
+ // provider credentials, and unlike the entries above nothing else was
209
+ // keeping it out — it is excluded here rather than merely left off
210
+ // `DEFAULT_INCLUDE_EXTENSIONS`, so a future widening of the allowlist cannot
211
+ // quietly opt a project into embedding its own cloud keys.
212
+ /\.tfvars(\.json)?$/i,
213
+ ];
214
+
215
+ /**
216
+ * True when `relPath` names a file whose contents are credentials. Applied
217
+ * by `shouldIgnore` (every walker: directory scan, explicit file lists, and
218
+ * the authority-file walk) and by `makeIgnoreMatcher` (server-side backstop
219
+ * for older or misbehaving daemons). Accepts OS-native or POSIX separators.
220
+ */
221
+ export function isSecretFile(relPath: string): boolean {
222
+ if (!relPath) return false;
223
+ const segments = relPath.split(sep).join('/').split('/');
224
+ const base = segments[segments.length - 1] ?? '';
225
+ if (!base || SECRET_FILE_PLACEHOLDER_BASENAMES.has(base)) return false;
226
+ if (SECRET_FILE_BASENAME_PATTERNS.some((re) => re.test(base))) return true;
227
+ const parent = segments[segments.length - 2];
228
+ return base === 'credentials' && parent !== undefined && parent.startsWith('.');
229
+ }
230
+
146
231
  /**
147
232
  * Per-directory ignore files the walkers honor, unioned. `.gitignore` is the
148
233
  * project's own declaration of generated/vendored paths; `.mnemonikignore`
@@ -151,38 +236,271 @@ const BUILT_IN_IGNORE_FILE_PATTERNS: readonly string[] = [
151
236
  */
152
237
  const IGNORE_FILE_NAMES = ['.gitignore', '.mnemonikignore'] as const;
153
238
 
239
+ /**
240
+ * Every file type the scanner will chunk, by extension. Matched
241
+ * case-INSENSITIVELY (see `isIncludedExtension`), so lowercase spellings here
242
+ * also cover `.R`, `.SQL`, `.PS1` and the uppercase `.C`/`.H` of older trees.
243
+ *
244
+ * Exported because this list IS the product's language coverage: a project
245
+ * written in something absent from it indexes zero code, and `code_search`
246
+ * reports that as "no indexed matches" rather than "I do not read this
247
+ * language" — invisible to the developer and to us. Coverage is a separate
248
+ * dial from parse quality: a language with no structured extractor falls
249
+ * through to `chunkRaw`, which is what Go, Java and C already get in
250
+ * production, and crude chunks beat no chunks by an enormous margin.
251
+ *
252
+ * Data formats (`.json`, `.yaml`, `.toml`, `.lock`) are deliberately absent:
253
+ * the ones that carry meaning are already collected verbatim by
254
+ * `AUTHORITY_FILE_MATCHERS`, and blanket-indexing the extension would pull in
255
+ * lockfiles and generated output. `.tfvars` is absent for a different reason —
256
+ * `terraform.tfvars` is a conventional home for provider credentials and
257
+ * `isSecretFile` now excludes it outright.
258
+ */
259
+ export const DEFAULT_INCLUDE_EXTENSIONS: readonly string[] = [
260
+ '.ts',
261
+ '.tsx',
262
+ '.js',
263
+ '.jsx',
264
+ '.py',
265
+ '.rs',
266
+ '.go',
267
+ '.java',
268
+ '.c',
269
+ '.cpp',
270
+ '.h',
271
+ '.cs',
272
+ '.rb',
273
+ '.php',
274
+ '.swift',
275
+ '.kt',
276
+ // Shell and Lua are the working languages of whole real projects —
277
+ // deployment tooling, container entrypoints, imapfilter/nginx/redis
278
+ // configuration. Omitting them meant such a project indexed ZERO code and
279
+ // code_search could not answer anything about it, while reporting that as
280
+ // "no indexed matches" rather than as missing coverage.
281
+ '.sh',
282
+ '.bash',
283
+ '.zsh',
284
+ '.lua',
285
+ '.md',
286
+
287
+ // ── Variants of languages already on the list. `.mjs`/`.cjs`/`.mts`/`.cts`/
288
+ // `.pyi` inherit their language string from a listed extension, so their
289
+ // absence was oversight rather than policy. The C++ spellings are a choice,
290
+ // not an inheritance: `.h` stays 'c' (C headers dominate, and reading a C++
291
+ // header as C is the safer default), while the unambiguously-C++ spellings
292
+ // resolve to 'cpp'.
293
+ '.mjs',
294
+ '.cjs',
295
+ '.mts',
296
+ '.cts',
297
+ '.pyi',
298
+ '.hpp',
299
+ '.hh',
300
+ '.hxx',
301
+ '.cc',
302
+ '.cxx',
303
+ '.kts',
304
+
305
+ // ── Languages the scanner could not read at all.
306
+ '.dart',
307
+ '.m',
308
+ '.mm',
309
+ '.scala',
310
+ '.sc',
311
+ '.ex',
312
+ '.exs',
313
+ '.erl',
314
+ '.hrl',
315
+ '.hs',
316
+ '.jl',
317
+ '.ml',
318
+ '.mli',
319
+ '.clj',
320
+ '.cljs',
321
+ '.cljc',
322
+ '.groovy',
323
+ '.gradle',
324
+ '.ps1',
325
+ '.psm1',
326
+ '.pl',
327
+ '.pm',
328
+ '.r',
329
+ '.sol',
330
+ '.zig',
331
+ '.vue',
332
+ '.svelte',
333
+
334
+ // ── Infrastructure and schema DSLs, where real logic lives and where "how
335
+ // is this deployed?" and "what does this table hold?" go unanswered today.
336
+ '.tf',
337
+ '.sql',
338
+ '.proto',
339
+ '.graphql',
340
+ '.gql',
341
+ '.cmake',
342
+ '.nix',
343
+ '.bzl',
344
+ ];
345
+
346
+ /**
347
+ * Extension → language string, the value carried on every chunk and on the
348
+ * wire (`/scan/push` accepts any non-empty string up to 50 chars).
349
+ *
350
+ * A string no grammar claims is correct and expected: the AST layer resolves
351
+ * such a language to `null` and the heuristic chunker takes over. Keep this in
352
+ * sync with `DEFAULT_INCLUDE_EXTENSIONS` — an allowlisted extension that lands
353
+ * on 'unknown' still gets chunked, but nothing downstream can reason about it.
354
+ *
355
+ * Two extensions are genuinely ambiguous and are resolved rather than fudged:
356
+ * `.m` is Objective-C here, not MATLAB, because a repo carrying `.m` alongside
357
+ * `.h`/`.mm` is overwhelmingly an Apple-platform project; `.pl` is Perl, not
358
+ * Prolog, on the same frequency argument.
359
+ */
360
+ const EXTENSION_LANGUAGES: Readonly<Record<string, string>> = {
361
+ '.ts': 'typescript',
362
+ '.tsx': 'typescript',
363
+ '.mts': 'typescript',
364
+ '.cts': 'typescript',
365
+ '.js': 'javascript',
366
+ '.jsx': 'javascript',
367
+ '.mjs': 'javascript',
368
+ '.cjs': 'javascript',
369
+ '.py': 'python',
370
+ '.pyi': 'python',
371
+ '.rs': 'rust',
372
+ '.go': 'go',
373
+ '.java': 'java',
374
+ '.c': 'c',
375
+ '.h': 'c',
376
+ '.cpp': 'cpp',
377
+ '.cc': 'cpp',
378
+ '.cxx': 'cpp',
379
+ '.hpp': 'cpp',
380
+ '.hh': 'cpp',
381
+ '.hxx': 'cpp',
382
+ '.cs': 'csharp',
383
+ '.rb': 'ruby',
384
+ '.php': 'php',
385
+ '.swift': 'swift',
386
+ '.kt': 'kotlin',
387
+ '.kts': 'kotlin',
388
+ '.sh': 'shell',
389
+ '.bash': 'shell',
390
+ '.zsh': 'shell',
391
+ '.lua': 'lua',
392
+ '.md': 'markdown',
393
+ '.dart': 'dart',
394
+ '.m': 'objc',
395
+ '.mm': 'objc',
396
+ '.scala': 'scala',
397
+ '.sc': 'scala',
398
+ '.ex': 'elixir',
399
+ '.exs': 'elixir',
400
+ '.erl': 'erlang',
401
+ '.hrl': 'erlang',
402
+ '.hs': 'haskell',
403
+ '.jl': 'julia',
404
+ '.ml': 'ocaml',
405
+ '.mli': 'ocaml',
406
+ '.clj': 'clojure',
407
+ '.cljs': 'clojure',
408
+ '.cljc': 'clojure',
409
+ '.groovy': 'groovy',
410
+ '.gradle': 'groovy',
411
+ '.ps1': 'powershell',
412
+ '.psm1': 'powershell',
413
+ '.pl': 'perl',
414
+ '.pm': 'perl',
415
+ '.r': 'r',
416
+ '.sol': 'solidity',
417
+ '.zig': 'zig',
418
+ '.vue': 'vue',
419
+ '.svelte': 'svelte',
420
+ '.tf': 'terraform',
421
+ '.sql': 'sql',
422
+ '.proto': 'protobuf',
423
+ '.graphql': 'graphql',
424
+ '.gql': 'graphql',
425
+ '.cmake': 'cmake',
426
+ '.nix': 'nix',
427
+ '.bzl': 'starlark',
428
+ };
429
+
430
+ /**
431
+ * Language string for a file path or a bare extension, `'unknown'` when the
432
+ * extension is unmapped. A free function rather than a method because callers
433
+ * that never scan anything (AST grammar resolution, server-side symbol
434
+ * preference) need the same answer without constructing a scanner.
435
+ */
436
+ export function languageForExtension(filePathOrExt: string): string {
437
+ // `extname` FIRST. A dotfile that carries a real extension ('.eslintrc.js',
438
+ // '.mocharc.cjs', '.prettierrc.ts') starts with '.' and contains no '/', so
439
+ // a bare-extension-first reading swallowed the whole name and answered
440
+ // 'unknown' — while the same file spelled 'src/.eslintrc.js' answered
441
+ // 'javascript'. Those extensions are allowlisted, so the files are indexed
442
+ // and reach the server with exactly the root-relative spelling that failed.
443
+ const fromPath = EXTENSION_LANGUAGES[extname(filePathOrExt).toLowerCase()];
444
+ if (fromPath !== undefined) return fromPath;
445
+ // Fallback: the argument IS the extension ('.dart'), for which `extname`
446
+ // returns ''. Every key contains a leading dot and no separator, so a real
447
+ // path can never collide here.
448
+ return EXTENSION_LANGUAGES[filePathOrExt.toLowerCase()] ?? 'unknown';
449
+ }
450
+
451
+ /**
452
+ * THE definition of "this path is a SQL migration the schema_columns authority
453
+ * collects verbatim". One predicate, referenced by both halves of the deal —
454
+ * `AUTHORITY_FILE_MATCHERS` (collect it) and `isAuthorityOnlyPath` (therefore
455
+ * do not chunk it) — because two hand-written regexes drifted once already and
456
+ * the failure is silent in both directions.
457
+ *
458
+ * Root-anchored and CASE-SENSITIVE on purpose: it mirrors the server-side
459
+ * extractor, which does `listFiles('migrations/')` (LIKE 'migrations/%') then
460
+ * `endsWith('.sql')`, both case-sensitive. `Migrations/001.sql` (the .NET/EF
461
+ * Core spelling) and `migrations/002.SQL` are NOT collected, so they must not
462
+ * be suppressed from chunking either — that would index them nowhere. Same
463
+ * reason a nested `packages/x/migrations/y.sql` is left alone.
464
+ */
465
+ const isMigrationSqlAuthorityPath = (posixRelPath: string): boolean =>
466
+ /^migrations\/.*\.sql$/.test(posixRelPath);
467
+
468
+ /**
469
+ * Predicates for paths whose verbatim content is ALREADY shipped by
470
+ * `collectAuthorityFiles` and that carry no additional value as embedded code
471
+ * chunks. Checked at the extension gate rather than in `shouldIgnore`, because
472
+ * `shouldIgnore` also guards the authority walk and must keep letting these
473
+ * through.
474
+ *
475
+ * `migrations/**.sql` is the live case: adding `.sql` to the allowlist without
476
+ * this exclusion would dual-collect every migration — once verbatim, once
477
+ * chunked and embedded. On this repo alone that is 208 files of append-only
478
+ * DDL (140 forward, the rest rollback/manual), embedded to answer questions
479
+ * the authority path already answers exactly.
480
+ *
481
+ * INVARIANT: every predicate here must also appear in
482
+ * `AUTHORITY_FILE_MATCHERS`, so no path can be excluded from chunking unless
483
+ * the authority path definitely collects it.
484
+ */
485
+ const AUTHORITY_ONLY_PATH_PREDICATES: readonly ((posixRelPath: string) => boolean)[] = [
486
+ isMigrationSqlAuthorityPath,
487
+ ];
488
+
489
+ /**
490
+ * True when `relPath` is collected verbatim as authority content and must not
491
+ * additionally be chunked. Accepts OS-native or POSIX separators.
492
+ */
493
+ export function isAuthorityOnlyPath(relPath: string): boolean {
494
+ if (!relPath) return false;
495
+ const posix = relPath.split(sep).join('/');
496
+ return AUTHORITY_ONLY_PATH_PREDICATES.some((matches) => matches(posix));
497
+ }
498
+
154
499
  const DEFAULT_OPTIONS: Required<ScanOptions> = {
155
500
  maxChunkSize: 8000, // ~2000 tokens
156
501
  minChunkSize: 100,
157
502
  ignorePatterns: [...BUILT_IN_IGNORE_DIRS, ...BUILT_IN_IGNORE_FILE_PATTERNS],
158
- includeExtensions: [
159
- '.ts',
160
- '.tsx',
161
- '.js',
162
- '.jsx',
163
- '.py',
164
- '.rs',
165
- '.go',
166
- '.java',
167
- '.c',
168
- '.cpp',
169
- '.h',
170
- '.cs',
171
- '.rb',
172
- '.php',
173
- '.swift',
174
- '.kt',
175
- // Shell and Lua are the working languages of whole real projects —
176
- // deployment tooling, container entrypoints, imapfilter/nginx/redis
177
- // configuration. Omitting them meant such a project indexed ZERO code and
178
- // code_search could not answer anything about it, while reporting that as
179
- // "no indexed matches" rather than as missing coverage.
180
- '.sh',
181
- '.bash',
182
- '.zsh',
183
- '.lua',
184
- '.md',
185
- ],
503
+ includeExtensions: [...DEFAULT_INCLUDE_EXTENSIONS],
186
504
  };
187
505
 
188
506
  /**
@@ -205,8 +523,10 @@ export const AUTHORITY_FILE_MATCHERS: Array<(relPath: string) => boolean> = [
205
523
  // SQL migrations: the schema_columns authority extractor reads every .sql
206
524
  // under `migrations/` (listFiles('migrations/') -> LIKE 'migrations/%' then
207
525
  // .endsWith('.sql')). Without collecting these, that authority is empty and
208
- // every schema_table_enumeration claim falls to unverifiable.
209
- (p) => /^migrations\/.*\.sql$/.test(p),
526
+ // every schema_table_enumeration claim falls to unverifiable. Shared with
527
+ // `AUTHORITY_ONLY_PATH_PREDICATES` by reference, not by a copied regex, so
528
+ // collection and the chunking exclusion cannot disagree.
529
+ isMigrationSqlAuthorityPath,
210
530
  ];
211
531
 
212
532
  /**
@@ -243,6 +563,7 @@ export function makeIgnoreMatcher(
243
563
  if (!relPath) return false;
244
564
  const norm = relPath.split(sep).join('/');
245
565
  if (norm.split('/').some((s) => BUILT_IN_IGNORE_DIR_SET.has(s))) return true;
566
+ if (isSecretFile(norm)) return true;
246
567
  if (ig) {
247
568
  try {
248
569
  return ig.ignores(norm);
@@ -295,8 +616,34 @@ type IgnoreStack = IgnoreLayer[];
295
616
  export class CodeScanner {
296
617
  private options: Required<ScanOptions>;
297
618
 
619
+ /**
620
+ * `includeExtensions` folded to lowercase for matching. The gate used to
621
+ * compare `extname()` verbatim while `detectLanguage` lowercased, so a
622
+ * project spelling its files the canonical way — `.R` for R, `.SQL`/`.PS1`
623
+ * on Windows, `.C`/`.H` in older C trees — indexed zero of them and nothing
624
+ * said why.
625
+ */
626
+ private readonly includeExtensionSet: ReadonlySet<string>;
627
+
298
628
  constructor(options: ScanOptions = {}) {
299
629
  this.options = { ...DEFAULT_OPTIONS, ...options };
630
+ this.includeExtensionSet = new Set(
631
+ this.options.includeExtensions.map((ext) => ext.toLowerCase())
632
+ );
633
+ }
634
+
635
+ /**
636
+ * The chunkable-file gate, shared by every walker and by the explicit
637
+ * file-list path so all three agree on what exists. `relPath` is the path
638
+ * relative to the scan root (OS-native separators accepted).
639
+ */
640
+ private isChunkable(absOrRelPath: string, relPath: string): boolean {
641
+ if (!this.includeExtensionSet.has(extname(absOrRelPath).toLowerCase())) return false;
642
+ if (isAuthorityOnlyPath(relPath)) {
643
+ logDebug('Skipping chunking for authority-collected path', { relPath });
644
+ return false;
645
+ }
646
+ return true;
300
647
  }
301
648
 
302
649
  /**
@@ -548,7 +895,7 @@ export class CodeScanner {
548
895
  await this.traversePaths(fullPath, rootPath, out, depth + 1, walk, localStack);
549
896
  } else if (stats.isFile()) {
550
897
  if (this.ignoredByStack(relativePath, false, localStack)) continue;
551
- if (this.options.includeExtensions.includes(extname(fullPath))) {
898
+ if (this.isChunkable(fullPath, relativePath)) {
552
899
  out.push(relativePath);
553
900
  }
554
901
  }
@@ -591,8 +938,7 @@ export class CodeScanner {
591
938
  continue;
592
939
  }
593
940
 
594
- const ext = extname(filePath);
595
- if (this.options.includeExtensions.includes(ext)) {
941
+ if (this.isChunkable(filePath, fileRel)) {
596
942
  const fileChunks = await this.parseFile(filePath, rootPath || filePath);
597
943
  chunks.push(...fileChunks);
598
944
  }
@@ -698,8 +1044,7 @@ export class CodeScanner {
698
1044
  await this.traverseDirectory(fullPath, rootPath, chunks, depth + 1, walk, localStack);
699
1045
  } else if (stats.isFile()) {
700
1046
  if (this.ignoredByStack(relativePath, false, localStack)) continue;
701
- const ext = extname(fullPath);
702
- if (this.options.includeExtensions.includes(ext)) {
1047
+ if (this.isChunkable(fullPath, relativePath)) {
703
1048
  const fileChunks = await this.parseFile(fullPath, rootPath);
704
1049
  chunks.push(...fileChunks);
705
1050
  }
@@ -720,6 +1065,10 @@ export class CodeScanner {
720
1065
  * (e.g., '.env' should not match '.environment.ts')
721
1066
  */
722
1067
  private shouldIgnore(path: string): boolean {
1068
+ // Credential-bearing file kinds first, and outside the overridable
1069
+ // `ignorePatterns` list — a caller-supplied pattern set must not be able
1070
+ // to opt a project back into indexing its own private keys.
1071
+ if (isSecretFile(path)) return true;
723
1072
  const segments = path.split('/');
724
1073
  return this.options.ignorePatterns.some((pattern) => {
725
1074
  if (pattern.includes('*')) {
@@ -745,10 +1094,12 @@ export class CodeScanner {
745
1094
  }
746
1095
 
747
1096
  /**
748
- * Parse a file and extract code chunks
749
- * Added 10MB file size limit
1097
+ * Parse a file and extract code chunks.
1098
+ * Size limit is the shared `MAX_SCANNED_FILE_BYTES` ceiling — the same
1099
+ * number the daemon and the server's scanPushSchema enforce, so anything
1100
+ * chunked here can always be shipped and stored verbatim.
750
1101
  */
751
- private static readonly MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
1102
+ private static readonly MAX_FILE_SIZE = MAX_SCANNED_FILE_BYTES;
752
1103
 
753
1104
  private async parseFile(filePath: string, rootPath: string): Promise<CodeChunk[]> {
754
1105
  try {
@@ -854,31 +1205,7 @@ export class CodeScanner {
854
1205
  * Detect language from file extension
855
1206
  */
856
1207
  private detectLanguage(filePath: string): string {
857
- const ext = extname(filePath).toLowerCase();
858
- const langMap: Record<string, string> = {
859
- '.ts': 'typescript',
860
- '.tsx': 'typescript',
861
- '.js': 'javascript',
862
- '.jsx': 'javascript',
863
- '.py': 'python',
864
- '.rs': 'rust',
865
- '.go': 'go',
866
- '.java': 'java',
867
- '.c': 'c',
868
- '.cpp': 'cpp',
869
- '.h': 'c',
870
- '.cs': 'csharp',
871
- '.rb': 'ruby',
872
- '.php': 'php',
873
- '.swift': 'swift',
874
- '.kt': 'kotlin',
875
- '.sh': 'shell',
876
- '.bash': 'shell',
877
- '.zsh': 'shell',
878
- '.lua': 'lua',
879
- '.md': 'markdown',
880
- };
881
- return langMap[ext] || 'unknown';
1208
+ return languageForExtension(filePath);
882
1209
  }
883
1210
 
884
1211
  /**
@@ -1396,9 +1723,19 @@ export class CodeScanner {
1396
1723
  } else if (isFile && AUTHORITY_FILE_MATCHERS.some((m) => m(rel))) {
1397
1724
  try {
1398
1725
  let content = await readFile(full, 'utf-8');
1399
- // C2: skip files whose content exceeds the server's 5MB cap
1400
- // manifests are tiny; an oversized one is anomalous.
1401
- if (content.length > 5_000_000) continue;
1726
+ // Authority files honor the same single ceiling as chunked files
1727
+ // (MAX_SCANNED_FILE_BYTES). Manifests are tiny; one this large is
1728
+ // anomalous, so say so rather than dropping it silently — a
1729
+ // missing authority file otherwise looks identical to a project
1730
+ // that simply has no manifest.
1731
+ if (content.length > MAX_SCANNED_FILE_BYTES) {
1732
+ logWarn('Authority file exceeds the scan ceiling; excluded from push', {
1733
+ path: rel,
1734
+ size: content.length,
1735
+ limit: MAX_SCANNED_FILE_BYTES,
1736
+ });
1737
+ continue;
1738
+ }
1402
1739
  // NUL sanitation (ingestion boundary, daemon side): Postgres
1403
1740
  // `text` columns reject the literal NUL byte (U+0000). Authority
1404
1741
  // files are shipped verbatim (no chunking), so strip here before
package/src/index.ts CHANGED
@@ -9,16 +9,26 @@ export { MCP_INSTRUCTIONS, MCP_INSTRUCTIONS_RAW, getMcpInstructions } from './in
9
9
  export { USAGE_GUIDE } from './usageGuide.js';
10
10
  export {
11
11
  CodeScanner,
12
+ MAX_SCANNED_FILE_BYTES,
12
13
  AUTHORITY_FILE_MATCHERS,
13
14
  BUILT_IN_IGNORE_DIRS,
15
+ DEFAULT_INCLUDE_EXTENSIONS,
16
+ languageForExtension,
14
17
  makeIgnoreMatcher,
15
18
  isGitBoundary,
16
19
  FIXTURE_PATH_RE,
17
20
  isFixturePath,
21
+ isSecretFile,
22
+ isAuthorityOnlyPath,
18
23
  type CodeChunk,
19
24
  type ScanOptions,
20
25
  } from './codeScanner.js';
21
- export { SECRET_PATTERNS, SECRET_REDACTION_PLACEHOLDER, scrubSecrets } from './secretPatterns.js';
26
+ export {
27
+ SECRET_PATTERNS,
28
+ SECRET_REDACTION_PLACEHOLDER,
29
+ scrubSecrets,
30
+ redactHighEntropyTokens,
31
+ } from './secretPatterns.js';
22
32
  export {
23
33
  FETCH_TIMEOUT_MS,
24
34
  TELEMETRY_TIMEOUT_MS,