@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.
@@ -5,13 +5,34 @@ 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 } from './logger.js';
8
+ import { debug as logDebug, warn as logWarn } from './logger.js';
9
9
  import { withTimeout } from './asyncUtils.js';
10
10
  import { scrubSecrets } from './secretPatterns.js';
11
11
  /**
12
12
  * File operation timeout (5 seconds) to prevent hanging on slow/unresponsive filesystems
13
13
  */
14
14
  const FILE_OP_TIMEOUT_MS = 5000;
15
+ /**
16
+ * The one file-size ceiling in Mnemonik. A file at or below this is scanned,
17
+ * chunked, AND pushed verbatim; a file above it is uniformly outside the
18
+ * system — not scanned, not chunked, not citable. "Scanned ⟺ verifiable".
19
+ *
20
+ * This is deliberately a single exported constant rather than a number
21
+ * repeated per layer. Three ceilings previously existed and had drifted:
22
+ * the scan ceiling here (10 MB), a 5 MB whole-file push cap in the scanner
23
+ * daemon, and a 5 MB `content` cap in the server's scanPushSchema. Files
24
+ * between 5 and 10 MB were therefore indexed as chunks whose source content
25
+ * the server never received — chunks that could be retrieved but never
26
+ * verified against, a silent fidelity split. Every consumer now imports this:
27
+ *
28
+ * - `CodeScanner.MAX_FILE_SIZE` / `collectAuthorityFilesWithStatus` (this file)
29
+ * - `MAX_PUSH_CONTENT_BYTES` (packages/scanner/src/daemon.ts)
30
+ * - `scanFileSchema.content` (src/server/routes/schemas.ts)
31
+ *
32
+ * Raising it means widening the stored-content pipe end to end; do not raise
33
+ * one site alone.
34
+ */
35
+ export const MAX_SCANNED_FILE_BYTES = 10 * 1024 * 1024; // 10MB
15
36
  /**
16
37
  * Directory basenames the scanner never indexes, across every ecosystem —
17
38
  * generated output, package-manager/build caches, and vendored deps. SINGLE
@@ -89,7 +110,10 @@ export const BUILT_IN_IGNORE_DIRS = [
89
110
  * generically by the isGitBoundary rule below, not a path convention.)
90
111
  */
91
112
  const BUILT_IN_IGNORE_FILE_PATTERNS = [
92
- '.env',
113
+ // NOTE: the `.env` family and every other secret-bearing FILE KIND lives in
114
+ // `isSecretFile` below, not here. This glob engine cannot express negation
115
+ // (`.env.example` must stay collectable) and its unanchored substring
116
+ // matching would make `.env.*` also swallow `.environment.ts`.
93
117
  '.DS_Store',
94
118
  '*.log',
95
119
  '*.lock',
@@ -102,6 +126,66 @@ const BUILT_IN_IGNORE_FILE_PATTERNS = [
102
126
  '*.map',
103
127
  '/tests/fixtures/*',
104
128
  ];
129
+ /**
130
+ * Files whose CONTENT is a credential rather than code. Today these are
131
+ * excluded only INCIDENTALLY — `.pem`, `.key` and friends carry no
132
+ * allowlisted extension, so nothing chunks them. That protection evaporates
133
+ * the moment the extension allowlist widens (as it did for `.sh`/`.lua`), so
134
+ * the exclusion is made explicit here and becomes a defended invariant:
135
+ * unlike `ignorePatterns`, this predicate is NOT caller-overridable.
136
+ *
137
+ * Expressed as a predicate rather than glob strings because `shouldIgnore`'s
138
+ * engine has no negation and matches globs as unanchored substrings — neither
139
+ * property can state "every `.env.*` EXCEPT `.env.example`".
140
+ *
141
+ * Precision notes (over-exclusion is a fidelity failure in its own right):
142
+ * - `.env` family: `/^\.env(\..+)?$/` matches `.env`, `.env.local`,
143
+ * `.env.production` — and deliberately NOT `.envrc` or `.environment.ts`.
144
+ * - `.env.example` is carved out: it is authority-collected on purpose
145
+ * (AUTHORITY_FILE_MATCHERS) and holds placeholder values, not secrets.
146
+ * It is the doc-truth env_vars authority; excluding it would blind that
147
+ * extractor exactly as the old `process.env` over-scrub once did.
148
+ * - `credentials` is matched ONLY directly inside a dot-directory
149
+ * (`.aws/credentials`, `.docker/credentials`). A bare `credentials/` is a
150
+ * real, common SOURCE directory name (gRPC, auth libraries) and
151
+ * `shouldIgnore` cannot tell a file from a directory at its call sites —
152
+ * an unqualified rule would silently delete that subtree from the index.
153
+ * - `.tfvars` is excluded whole rather than per-basename: the credential file
154
+ * is conventionally `terraform.tfvars` but the name is free-form, and the
155
+ * variables that are NOT secret are visible in `.tf` anyway.
156
+ */
157
+ const SECRET_FILE_PLACEHOLDER_BASENAMES = new Set(['.env.example']);
158
+ const SECRET_FILE_BASENAME_PATTERNS = [
159
+ /^\.env(\..+)?$/i, // .env, .env.local, .env.production.local
160
+ /^id_(?:rsa|dsa|ecdsa|ed25519)/i, // ssh private keys (and .pub siblings)
161
+ /^\.(?:npmrc|netrc|pgpass)$/i, // registry / ftp / postgres password files
162
+ /^kubeconfig$/i,
163
+ /\.(?:pem|key|p12|pfx|keystore|jks|kubeconfig)$/i,
164
+ // Terraform variable files: `terraform.tfvars` is the conventional home for
165
+ // provider credentials, and unlike the entries above nothing else was
166
+ // keeping it out — it is excluded here rather than merely left off
167
+ // `DEFAULT_INCLUDE_EXTENSIONS`, so a future widening of the allowlist cannot
168
+ // quietly opt a project into embedding its own cloud keys.
169
+ /\.tfvars(\.json)?$/i,
170
+ ];
171
+ /**
172
+ * True when `relPath` names a file whose contents are credentials. Applied
173
+ * by `shouldIgnore` (every walker: directory scan, explicit file lists, and
174
+ * the authority-file walk) and by `makeIgnoreMatcher` (server-side backstop
175
+ * for older or misbehaving daemons). Accepts OS-native or POSIX separators.
176
+ */
177
+ export function isSecretFile(relPath) {
178
+ if (!relPath)
179
+ return false;
180
+ const segments = relPath.split(sep).join('/').split('/');
181
+ const base = segments[segments.length - 1] ?? '';
182
+ if (!base || SECRET_FILE_PLACEHOLDER_BASENAMES.has(base))
183
+ return false;
184
+ if (SECRET_FILE_BASENAME_PATTERNS.some((re) => re.test(base)))
185
+ return true;
186
+ const parent = segments[segments.length - 2];
187
+ return base === 'credentials' && parent !== undefined && parent.startsWith('.');
188
+ }
105
189
  /**
106
190
  * Per-directory ignore files the walkers honor, unioned. `.gitignore` is the
107
191
  * project's own declaration of generated/vendored paths; `.mnemonikignore`
@@ -109,38 +193,263 @@ const BUILT_IN_IGNORE_FILE_PATTERNS = [
109
193
  * list (e.g. `secrets/`). Matched hierarchically via the `ignore` package.
110
194
  */
111
195
  const IGNORE_FILE_NAMES = ['.gitignore', '.mnemonikignore'];
196
+ /**
197
+ * Every file type the scanner will chunk, by extension. Matched
198
+ * case-INSENSITIVELY (see `isIncludedExtension`), so lowercase spellings here
199
+ * also cover `.R`, `.SQL`, `.PS1` and the uppercase `.C`/`.H` of older trees.
200
+ *
201
+ * Exported because this list IS the product's language coverage: a project
202
+ * written in something absent from it indexes zero code, and `code_search`
203
+ * reports that as "no indexed matches" rather than "I do not read this
204
+ * language" — invisible to the developer and to us. Coverage is a separate
205
+ * dial from parse quality: a language with no structured extractor falls
206
+ * through to `chunkRaw`, which is what Go, Java and C already get in
207
+ * production, and crude chunks beat no chunks by an enormous margin.
208
+ *
209
+ * Data formats (`.json`, `.yaml`, `.toml`, `.lock`) are deliberately absent:
210
+ * the ones that carry meaning are already collected verbatim by
211
+ * `AUTHORITY_FILE_MATCHERS`, and blanket-indexing the extension would pull in
212
+ * lockfiles and generated output. `.tfvars` is absent for a different reason —
213
+ * `terraform.tfvars` is a conventional home for provider credentials and
214
+ * `isSecretFile` now excludes it outright.
215
+ */
216
+ export const DEFAULT_INCLUDE_EXTENSIONS = [
217
+ '.ts',
218
+ '.tsx',
219
+ '.js',
220
+ '.jsx',
221
+ '.py',
222
+ '.rs',
223
+ '.go',
224
+ '.java',
225
+ '.c',
226
+ '.cpp',
227
+ '.h',
228
+ '.cs',
229
+ '.rb',
230
+ '.php',
231
+ '.swift',
232
+ '.kt',
233
+ // Shell and Lua are the working languages of whole real projects —
234
+ // deployment tooling, container entrypoints, imapfilter/nginx/redis
235
+ // configuration. Omitting them meant such a project indexed ZERO code and
236
+ // code_search could not answer anything about it, while reporting that as
237
+ // "no indexed matches" rather than as missing coverage.
238
+ '.sh',
239
+ '.bash',
240
+ '.zsh',
241
+ '.lua',
242
+ '.md',
243
+ // ── Variants of languages already on the list. `.mjs`/`.cjs`/`.mts`/`.cts`/
244
+ // `.pyi` inherit their language string from a listed extension, so their
245
+ // absence was oversight rather than policy. The C++ spellings are a choice,
246
+ // not an inheritance: `.h` stays 'c' (C headers dominate, and reading a C++
247
+ // header as C is the safer default), while the unambiguously-C++ spellings
248
+ // resolve to 'cpp'.
249
+ '.mjs',
250
+ '.cjs',
251
+ '.mts',
252
+ '.cts',
253
+ '.pyi',
254
+ '.hpp',
255
+ '.hh',
256
+ '.hxx',
257
+ '.cc',
258
+ '.cxx',
259
+ '.kts',
260
+ // ── Languages the scanner could not read at all.
261
+ '.dart',
262
+ '.m',
263
+ '.mm',
264
+ '.scala',
265
+ '.sc',
266
+ '.ex',
267
+ '.exs',
268
+ '.erl',
269
+ '.hrl',
270
+ '.hs',
271
+ '.jl',
272
+ '.ml',
273
+ '.mli',
274
+ '.clj',
275
+ '.cljs',
276
+ '.cljc',
277
+ '.groovy',
278
+ '.gradle',
279
+ '.ps1',
280
+ '.psm1',
281
+ '.pl',
282
+ '.pm',
283
+ '.r',
284
+ '.sol',
285
+ '.zig',
286
+ '.vue',
287
+ '.svelte',
288
+ // ── Infrastructure and schema DSLs, where real logic lives and where "how
289
+ // is this deployed?" and "what does this table hold?" go unanswered today.
290
+ '.tf',
291
+ '.sql',
292
+ '.proto',
293
+ '.graphql',
294
+ '.gql',
295
+ '.cmake',
296
+ '.nix',
297
+ '.bzl',
298
+ ];
299
+ /**
300
+ * Extension → language string, the value carried on every chunk and on the
301
+ * wire (`/scan/push` accepts any non-empty string up to 50 chars).
302
+ *
303
+ * A string no grammar claims is correct and expected: the AST layer resolves
304
+ * such a language to `null` and the heuristic chunker takes over. Keep this in
305
+ * sync with `DEFAULT_INCLUDE_EXTENSIONS` — an allowlisted extension that lands
306
+ * on 'unknown' still gets chunked, but nothing downstream can reason about it.
307
+ *
308
+ * Two extensions are genuinely ambiguous and are resolved rather than fudged:
309
+ * `.m` is Objective-C here, not MATLAB, because a repo carrying `.m` alongside
310
+ * `.h`/`.mm` is overwhelmingly an Apple-platform project; `.pl` is Perl, not
311
+ * Prolog, on the same frequency argument.
312
+ */
313
+ const EXTENSION_LANGUAGES = {
314
+ '.ts': 'typescript',
315
+ '.tsx': 'typescript',
316
+ '.mts': 'typescript',
317
+ '.cts': 'typescript',
318
+ '.js': 'javascript',
319
+ '.jsx': 'javascript',
320
+ '.mjs': 'javascript',
321
+ '.cjs': 'javascript',
322
+ '.py': 'python',
323
+ '.pyi': 'python',
324
+ '.rs': 'rust',
325
+ '.go': 'go',
326
+ '.java': 'java',
327
+ '.c': 'c',
328
+ '.h': 'c',
329
+ '.cpp': 'cpp',
330
+ '.cc': 'cpp',
331
+ '.cxx': 'cpp',
332
+ '.hpp': 'cpp',
333
+ '.hh': 'cpp',
334
+ '.hxx': 'cpp',
335
+ '.cs': 'csharp',
336
+ '.rb': 'ruby',
337
+ '.php': 'php',
338
+ '.swift': 'swift',
339
+ '.kt': 'kotlin',
340
+ '.kts': 'kotlin',
341
+ '.sh': 'shell',
342
+ '.bash': 'shell',
343
+ '.zsh': 'shell',
344
+ '.lua': 'lua',
345
+ '.md': 'markdown',
346
+ '.dart': 'dart',
347
+ '.m': 'objc',
348
+ '.mm': 'objc',
349
+ '.scala': 'scala',
350
+ '.sc': 'scala',
351
+ '.ex': 'elixir',
352
+ '.exs': 'elixir',
353
+ '.erl': 'erlang',
354
+ '.hrl': 'erlang',
355
+ '.hs': 'haskell',
356
+ '.jl': 'julia',
357
+ '.ml': 'ocaml',
358
+ '.mli': 'ocaml',
359
+ '.clj': 'clojure',
360
+ '.cljs': 'clojure',
361
+ '.cljc': 'clojure',
362
+ '.groovy': 'groovy',
363
+ '.gradle': 'groovy',
364
+ '.ps1': 'powershell',
365
+ '.psm1': 'powershell',
366
+ '.pl': 'perl',
367
+ '.pm': 'perl',
368
+ '.r': 'r',
369
+ '.sol': 'solidity',
370
+ '.zig': 'zig',
371
+ '.vue': 'vue',
372
+ '.svelte': 'svelte',
373
+ '.tf': 'terraform',
374
+ '.sql': 'sql',
375
+ '.proto': 'protobuf',
376
+ '.graphql': 'graphql',
377
+ '.gql': 'graphql',
378
+ '.cmake': 'cmake',
379
+ '.nix': 'nix',
380
+ '.bzl': 'starlark',
381
+ };
382
+ /**
383
+ * Language string for a file path or a bare extension, `'unknown'` when the
384
+ * extension is unmapped. A free function rather than a method because callers
385
+ * that never scan anything (AST grammar resolution, server-side symbol
386
+ * preference) need the same answer without constructing a scanner.
387
+ */
388
+ export function languageForExtension(filePathOrExt) {
389
+ // `extname` FIRST. A dotfile that carries a real extension ('.eslintrc.js',
390
+ // '.mocharc.cjs', '.prettierrc.ts') starts with '.' and contains no '/', so
391
+ // a bare-extension-first reading swallowed the whole name and answered
392
+ // 'unknown' — while the same file spelled 'src/.eslintrc.js' answered
393
+ // 'javascript'. Those extensions are allowlisted, so the files are indexed
394
+ // and reach the server with exactly the root-relative spelling that failed.
395
+ const fromPath = EXTENSION_LANGUAGES[extname(filePathOrExt).toLowerCase()];
396
+ if (fromPath !== undefined)
397
+ return fromPath;
398
+ // Fallback: the argument IS the extension ('.dart'), for which `extname`
399
+ // returns ''. Every key contains a leading dot and no separator, so a real
400
+ // path can never collide here.
401
+ return EXTENSION_LANGUAGES[filePathOrExt.toLowerCase()] ?? 'unknown';
402
+ }
403
+ /**
404
+ * THE definition of "this path is a SQL migration the schema_columns authority
405
+ * collects verbatim". One predicate, referenced by both halves of the deal —
406
+ * `AUTHORITY_FILE_MATCHERS` (collect it) and `isAuthorityOnlyPath` (therefore
407
+ * do not chunk it) — because two hand-written regexes drifted once already and
408
+ * the failure is silent in both directions.
409
+ *
410
+ * Root-anchored and CASE-SENSITIVE on purpose: it mirrors the server-side
411
+ * extractor, which does `listFiles('migrations/')` (LIKE 'migrations/%') then
412
+ * `endsWith('.sql')`, both case-sensitive. `Migrations/001.sql` (the .NET/EF
413
+ * Core spelling) and `migrations/002.SQL` are NOT collected, so they must not
414
+ * be suppressed from chunking either — that would index them nowhere. Same
415
+ * reason a nested `packages/x/migrations/y.sql` is left alone.
416
+ */
417
+ const isMigrationSqlAuthorityPath = (posixRelPath) => /^migrations\/.*\.sql$/.test(posixRelPath);
418
+ /**
419
+ * Predicates for paths whose verbatim content is ALREADY shipped by
420
+ * `collectAuthorityFiles` and that carry no additional value as embedded code
421
+ * chunks. Checked at the extension gate rather than in `shouldIgnore`, because
422
+ * `shouldIgnore` also guards the authority walk and must keep letting these
423
+ * through.
424
+ *
425
+ * `migrations/**.sql` is the live case: adding `.sql` to the allowlist without
426
+ * this exclusion would dual-collect every migration — once verbatim, once
427
+ * chunked and embedded. On this repo alone that is 208 files of append-only
428
+ * DDL (140 forward, the rest rollback/manual), embedded to answer questions
429
+ * the authority path already answers exactly.
430
+ *
431
+ * INVARIANT: every predicate here must also appear in
432
+ * `AUTHORITY_FILE_MATCHERS`, so no path can be excluded from chunking unless
433
+ * the authority path definitely collects it.
434
+ */
435
+ const AUTHORITY_ONLY_PATH_PREDICATES = [
436
+ isMigrationSqlAuthorityPath,
437
+ ];
438
+ /**
439
+ * True when `relPath` is collected verbatim as authority content and must not
440
+ * additionally be chunked. Accepts OS-native or POSIX separators.
441
+ */
442
+ export function isAuthorityOnlyPath(relPath) {
443
+ if (!relPath)
444
+ return false;
445
+ const posix = relPath.split(sep).join('/');
446
+ return AUTHORITY_ONLY_PATH_PREDICATES.some((matches) => matches(posix));
447
+ }
112
448
  const DEFAULT_OPTIONS = {
113
449
  maxChunkSize: 8000, // ~2000 tokens
114
450
  minChunkSize: 100,
115
451
  ignorePatterns: [...BUILT_IN_IGNORE_DIRS, ...BUILT_IN_IGNORE_FILE_PATTERNS],
116
- includeExtensions: [
117
- '.ts',
118
- '.tsx',
119
- '.js',
120
- '.jsx',
121
- '.py',
122
- '.rs',
123
- '.go',
124
- '.java',
125
- '.c',
126
- '.cpp',
127
- '.h',
128
- '.cs',
129
- '.rb',
130
- '.php',
131
- '.swift',
132
- '.kt',
133
- // Shell and Lua are the working languages of whole real projects —
134
- // deployment tooling, container entrypoints, imapfilter/nginx/redis
135
- // configuration. Omitting them meant such a project indexed ZERO code and
136
- // code_search could not answer anything about it, while reporting that as
137
- // "no indexed matches" rather than as missing coverage.
138
- '.sh',
139
- '.bash',
140
- '.zsh',
141
- '.lua',
142
- '.md',
143
- ],
452
+ includeExtensions: [...DEFAULT_INCLUDE_EXTENSIONS],
144
453
  };
145
454
  /**
146
455
  * Matchers for authority manifest / config / CI files whose verbatim content
@@ -162,8 +471,10 @@ export const AUTHORITY_FILE_MATCHERS = [
162
471
  // SQL migrations: the schema_columns authority extractor reads every .sql
163
472
  // under `migrations/` (listFiles('migrations/') -> LIKE 'migrations/%' then
164
473
  // .endsWith('.sql')). Without collecting these, that authority is empty and
165
- // every schema_table_enumeration claim falls to unverifiable.
166
- (p) => /^migrations\/.*\.sql$/.test(p),
474
+ // every schema_table_enumeration claim falls to unverifiable. Shared with
475
+ // `AUTHORITY_ONLY_PATH_PREDICATES` by reference, not by a copied regex, so
476
+ // collection and the chunking exclusion cannot disagree.
477
+ isMigrationSqlAuthorityPath,
167
478
  ];
168
479
  /**
169
480
  * Segment-anchored match for the `tests/fixtures/` ignore pattern above —
@@ -197,6 +508,8 @@ export function makeIgnoreMatcher(extraPatterns = []) {
197
508
  const norm = relPath.split(sep).join('/');
198
509
  if (norm.split('/').some((s) => BUILT_IN_IGNORE_DIR_SET.has(s)))
199
510
  return true;
511
+ if (isSecretFile(norm))
512
+ return true;
200
513
  if (ig) {
201
514
  try {
202
515
  return ig.ignores(norm);
@@ -232,8 +545,31 @@ export async function isGitBoundary(dirPath) {
232
545
  }
233
546
  export class CodeScanner {
234
547
  options;
548
+ /**
549
+ * `includeExtensions` folded to lowercase for matching. The gate used to
550
+ * compare `extname()` verbatim while `detectLanguage` lowercased, so a
551
+ * project spelling its files the canonical way — `.R` for R, `.SQL`/`.PS1`
552
+ * on Windows, `.C`/`.H` in older C trees — indexed zero of them and nothing
553
+ * said why.
554
+ */
555
+ includeExtensionSet;
235
556
  constructor(options = {}) {
236
557
  this.options = { ...DEFAULT_OPTIONS, ...options };
558
+ this.includeExtensionSet = new Set(this.options.includeExtensions.map((ext) => ext.toLowerCase()));
559
+ }
560
+ /**
561
+ * The chunkable-file gate, shared by every walker and by the explicit
562
+ * file-list path so all three agree on what exists. `relPath` is the path
563
+ * relative to the scan root (OS-native separators accepted).
564
+ */
565
+ isChunkable(absOrRelPath, relPath) {
566
+ if (!this.includeExtensionSet.has(extname(absOrRelPath).toLowerCase()))
567
+ return false;
568
+ if (isAuthorityOnlyPath(relPath)) {
569
+ logDebug('Skipping chunking for authority-collected path', { relPath });
570
+ return false;
571
+ }
572
+ return true;
237
573
  }
238
574
  /**
239
575
  * Read `.gitignore` + `.mnemonikignore` in `absDir` and compile them into one
@@ -461,7 +797,7 @@ export class CodeScanner {
461
797
  else if (stats.isFile()) {
462
798
  if (this.ignoredByStack(relativePath, false, localStack))
463
799
  continue;
464
- if (this.options.includeExtensions.includes(extname(fullPath))) {
800
+ if (this.isChunkable(fullPath, relativePath)) {
465
801
  out.push(relativePath);
466
802
  }
467
803
  }
@@ -502,8 +838,7 @@ export class CodeScanner {
502
838
  if (rootPath && (await this.insideNestedGitBoundary(filePath, rootPath))) {
503
839
  continue;
504
840
  }
505
- const ext = extname(filePath);
506
- if (this.options.includeExtensions.includes(ext)) {
841
+ if (this.isChunkable(filePath, fileRel)) {
507
842
  const fileChunks = await this.parseFile(filePath, rootPath || filePath);
508
843
  chunks.push(...fileChunks);
509
844
  }
@@ -589,8 +924,7 @@ export class CodeScanner {
589
924
  else if (stats.isFile()) {
590
925
  if (this.ignoredByStack(relativePath, false, localStack))
591
926
  continue;
592
- const ext = extname(fullPath);
593
- if (this.options.includeExtensions.includes(ext)) {
927
+ if (this.isChunkable(fullPath, relativePath)) {
594
928
  const fileChunks = await this.parseFile(fullPath, rootPath);
595
929
  chunks.push(...fileChunks);
596
930
  }
@@ -611,6 +945,11 @@ export class CodeScanner {
611
945
  * (e.g., '.env' should not match '.environment.ts')
612
946
  */
613
947
  shouldIgnore(path) {
948
+ // Credential-bearing file kinds first, and outside the overridable
949
+ // `ignorePatterns` list — a caller-supplied pattern set must not be able
950
+ // to opt a project back into indexing its own private keys.
951
+ if (isSecretFile(path))
952
+ return true;
614
953
  const segments = path.split('/');
615
954
  return this.options.ignorePatterns.some((pattern) => {
616
955
  if (pattern.includes('*')) {
@@ -635,10 +974,12 @@ export class CodeScanner {
635
974
  });
636
975
  }
637
976
  /**
638
- * Parse a file and extract code chunks
639
- * Added 10MB file size limit
977
+ * Parse a file and extract code chunks.
978
+ * Size limit is the shared `MAX_SCANNED_FILE_BYTES` ceiling — the same
979
+ * number the daemon and the server's scanPushSchema enforce, so anything
980
+ * chunked here can always be shipped and stored verbatim.
640
981
  */
641
- static MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
982
+ static MAX_FILE_SIZE = MAX_SCANNED_FILE_BYTES;
642
983
  async parseFile(filePath, rootPath) {
643
984
  try {
644
985
  // Check file size before reading to avoid memory issues
@@ -726,31 +1067,7 @@ export class CodeScanner {
726
1067
  * Detect language from file extension
727
1068
  */
728
1069
  detectLanguage(filePath) {
729
- const ext = extname(filePath).toLowerCase();
730
- const langMap = {
731
- '.ts': 'typescript',
732
- '.tsx': 'typescript',
733
- '.js': 'javascript',
734
- '.jsx': 'javascript',
735
- '.py': 'python',
736
- '.rs': 'rust',
737
- '.go': 'go',
738
- '.java': 'java',
739
- '.c': 'c',
740
- '.cpp': 'cpp',
741
- '.h': 'c',
742
- '.cs': 'csharp',
743
- '.rb': 'ruby',
744
- '.php': 'php',
745
- '.swift': 'swift',
746
- '.kt': 'kotlin',
747
- '.sh': 'shell',
748
- '.bash': 'shell',
749
- '.zsh': 'shell',
750
- '.lua': 'lua',
751
- '.md': 'markdown',
752
- };
753
- return langMap[ext] || 'unknown';
1070
+ return languageForExtension(filePath);
754
1071
  }
755
1072
  /**
756
1073
  * Chunk markdown files by headers
@@ -1229,10 +1546,19 @@ export class CodeScanner {
1229
1546
  else if (isFile && AUTHORITY_FILE_MATCHERS.some((m) => m(rel))) {
1230
1547
  try {
1231
1548
  let content = await readFile(full, 'utf-8');
1232
- // C2: skip files whose content exceeds the server's 5MB cap
1233
- // manifests are tiny; an oversized one is anomalous.
1234
- if (content.length > 5_000_000)
1549
+ // Authority files honor the same single ceiling as chunked files
1550
+ // (MAX_SCANNED_FILE_BYTES). Manifests are tiny; one this large is
1551
+ // anomalous, so say so rather than dropping it silently — a
1552
+ // missing authority file otherwise looks identical to a project
1553
+ // that simply has no manifest.
1554
+ if (content.length > MAX_SCANNED_FILE_BYTES) {
1555
+ logWarn('Authority file exceeds the scan ceiling; excluded from push', {
1556
+ path: rel,
1557
+ size: content.length,
1558
+ limit: MAX_SCANNED_FILE_BYTES,
1559
+ });
1235
1560
  continue;
1561
+ }
1236
1562
  // NUL sanitation (ingestion boundary, daemon side): Postgres
1237
1563
  // `text` columns reject the literal NUL byte (U+0000). Authority
1238
1564
  // files are shipped verbatim (no chunking), so strip here before