@mnemonik/shared 7.154.2 → 7.155.2

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 (52) hide show
  1. package/dist/ast/grammars.d.ts +4 -2
  2. package/dist/ast/grammars.d.ts.map +1 -1
  3. package/dist/ast/grammars.js +45 -3
  4. package/dist/ast/grammars.js.map +1 -1
  5. package/dist/codeScanner.d.ts +26 -329
  6. package/dist/codeScanner.d.ts.map +1 -1
  7. package/dist/codeScanner.js +65 -1141
  8. package/dist/codeScanner.js.map +1 -1
  9. package/dist/fileSelection.d.ts +451 -0
  10. package/dist/fileSelection.d.ts.map +1 -0
  11. package/dist/fileSelection.js +1256 -0
  12. package/dist/fileSelection.js.map +1 -0
  13. package/dist/hookTrace.d.ts +40 -0
  14. package/dist/hookTrace.d.ts.map +1 -1
  15. package/dist/hookTrace.js +126 -0
  16. package/dist/hookTrace.js.map +1 -1
  17. package/dist/index.d.ts +3 -2
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +3 -2
  20. package/dist/index.js.map +1 -1
  21. package/dist/runtimeAssets.d.ts +12 -0
  22. package/dist/runtimeAssets.d.ts.map +1 -0
  23. package/dist/runtimeAssets.js +43 -0
  24. package/dist/runtimeAssets.js.map +1 -0
  25. package/dist/runtimeReader.d.ts +15 -2
  26. package/dist/runtimeReader.d.ts.map +1 -1
  27. package/dist/runtimeReader.js +110 -21
  28. package/dist/runtimeReader.js.map +1 -1
  29. package/dist/sourceRedaction.d.ts +45 -1
  30. package/dist/sourceRedaction.d.ts.map +1 -1
  31. package/dist/sourceRedaction.js +62 -22
  32. package/dist/sourceRedaction.js.map +1 -1
  33. package/dist/sourceRedactionPool.d.ts +198 -0
  34. package/dist/sourceRedactionPool.d.ts.map +1 -0
  35. package/dist/sourceRedactionPool.js +756 -0
  36. package/dist/sourceRedactionPool.js.map +1 -0
  37. package/dist/vueSource.d.ts +0 -2
  38. package/dist/vueSource.d.ts.map +1 -1
  39. package/dist/vueSource.js +8 -19
  40. package/dist/vueSource.js.map +1 -1
  41. package/package.json +6 -2
  42. package/src/ast/grammars.ts +52 -7
  43. package/src/codeScanner.ts +121 -1321
  44. package/src/fileSelection.ts +1440 -0
  45. package/src/hookTrace.ts +143 -0
  46. package/src/index.ts +31 -2
  47. package/src/runtimeAssets.ts +49 -0
  48. package/src/runtimeReader.ts +120 -28
  49. package/src/sourceRedaction.ts +85 -20
  50. package/src/sourceRedactionPool.ts +877 -0
  51. package/src/vueSource.ts +8 -23
  52. package/tests/runtimeVerify.test.ts +166 -0
@@ -2,16 +2,12 @@
2
2
  * Code Scanner - Parse and chunk source files for embedding
3
3
  */
4
4
 
5
- import { readdir, readFile, open, lstat, realpath } from 'fs/promises';
6
- import { constants, type Stats } from 'fs';
7
- import { join, relative, extname, sep, resolve, dirname } from 'path';
5
+ import { readdir, lstat } from 'fs/promises';
6
+ import { type Stats } from 'fs';
7
+ import { join, relative, extname, sep } from 'path';
8
8
  import { createHash } from 'crypto';
9
- import ignore, { type Ignore } from 'ignore';
10
9
  import { debug as logDebug, info as logInfo, warn as logWarn } from './logger.js';
11
- import { withTimeout } from './asyncUtils.js';
12
10
  import { scrubSourceSecrets } from './sourceRedaction.js';
13
- import { isProtectedLocalPath, protectedLocalPaths } from './protectedPaths.js';
14
- import { readIdentityFile } from './projectIdentityFile.js';
15
11
  import {
16
12
  MAX_AST_PARSE_BYTES,
17
13
  MAX_SIGNATURE_CHARS,
@@ -19,23 +15,66 @@ import {
19
15
  type AstChunk,
20
16
  } from './ast/astChunker.js';
21
17
  import { astArtifactReport, resolveAstLanguage } from './ast/grammars.js';
18
+ import { softDefaultForContent } from './indexClassifier.js';
22
19
  import {
23
- classifyForIndex,
24
- gitattributesCanReinclude,
25
- parseGitattributes,
26
- softDefaultForContent,
27
- softDefaultForDirectory,
28
- type GitattributesLayer,
29
- type GitattributesRule,
30
- type IgnoreFileVerdict,
31
- type IndexClassification,
32
- type IndexExclusionReason,
33
- } from './indexClassifier.js';
20
+ FileSelector,
21
+ DEFAULT_IGNORE_PATTERNS,
22
+ fileOp,
23
+ markUnreadable,
24
+ enterDirectory,
25
+ DEFAULT_INCLUDE_EXTENSIONS,
26
+ isCursorRulePath,
27
+ languageForExtension,
28
+ AUTHORITY_FILE_MATCHERS,
29
+ type FileSelectionOptions,
30
+ type WalkState,
31
+ type IndexExcludedFile,
32
+ type IgnoreLayer,
33
+ type IgnoreStack,
34
+ } from './fileSelection.js';
35
+ // The selection moved to fileSelection.ts; its names stay exported from here.
36
+ export {
37
+ FileSelector,
38
+ DEFAULT_IGNORE_PATTERNS,
39
+ fileOp,
40
+ statTuple,
41
+ BUILT_IN_IGNORE_DIRS,
42
+ isSecretFile,
43
+ DEFAULT_INCLUDE_EXTENSIONS,
44
+ isCursorRulePath,
45
+ languageForExtension,
46
+ XCODE_AUTHORITY_PATH,
47
+ isXcodeAuthorityPath,
48
+ CMAKE_LISTS_PATH,
49
+ isCmakeListsPath,
50
+ isAuthorityOnlyPath,
51
+ AUTHORITY_FILE_MATCHERS,
52
+ FIXTURE_PATH_RE,
53
+ isFixturePath,
54
+ makeIgnoreMatcher,
55
+ isGitBoundary,
56
+ isProjectBoundary,
57
+ rootProjectId,
58
+ isLinkedWorktreeCheckout,
59
+ type FileSelectionOptions,
60
+ type FileStatTuple,
61
+ type IndexRuleFiles,
62
+ type IndexExcludedFile,
63
+ } from './fileSelection.js';
34
64
 
35
65
  /**
36
- * File operation timeout (5 seconds) to prevent hanging on slow/unresponsive filesystems
66
+ * The version of the scanner's file hash: the SHA-256 of the scrubbed text
67
+ * (`scrubSourceSecrets` over the file with NULs removed) and the content soft
68
+ * default judged on it. The daemon caches that hash per file (size, mtime,
69
+ * ctime, inode), so a restart or a release does not re-read and re-scrub every
70
+ * file. Bump this number whenever redaction or the content soft defaults change
71
+ * what they produce: every machine then re-reads and re-scrubs every file once,
72
+ * which is exactly what a redaction fix needs. Do NOT bump it for a release that
73
+ * leaves both unchanged; a needless bump costs every user a full re-read.
74
+ * tests/ScrubVersion.test.ts pins the scrubber's output on a fixture next to
75
+ * this number, so a scrubber change that forgets the bump fails there.
37
76
  */
38
- const FILE_OP_TIMEOUT_MS = 5000;
77
+ export const SCRUB_VERSION = 1;
39
78
 
40
79
  /**
41
80
  * The one file-size ceiling in Mnemonik. A file at or below this is scanned,
@@ -93,11 +132,10 @@ export interface CodeChunk {
93
132
  };
94
133
  }
95
134
 
96
- export interface ScanOptions {
135
+ /** CodeScanner's options: the file selection's (fileSelection.ts) and its chunking. */
136
+ export interface ScanOptions extends FileSelectionOptions {
97
137
  maxChunkSize?: number; // characters
98
138
  minChunkSize?: number;
99
- ignorePatterns?: string[];
100
- includeExtensions?: string[];
101
139
  /**
102
140
  * Byte ceiling above which a file is chunked heuristically instead of parsed.
103
141
  * Defaults to `MAX_AST_PARSE_BYTES`; exposed so a caller (and the routing
@@ -105,717 +143,17 @@ export interface ScanOptions {
105
143
  * mock.
106
144
  */
107
145
  maxAstParseBytes?: number;
108
- /** Additional protected paths for isolated homes/tests; defaults can only be extended. */
109
- protectedPaths?: readonly string[];
110
- }
111
-
112
- /**
113
- * Mutable per-walk state threaded through the recursive traversals.
114
- * `complete` starts true and is latched false by any caught fs error
115
- * (readdir failure, per-entry lstat/realpath/stat throw) - meaning the
116
- * result may be missing entries that still exist on disk. Callers that
117
- * derive *removals* from a walk must treat an incomplete walk as
118
- * untrustworthy; adds/changes are unaffected.
119
- */
120
- interface WalkState {
121
- complete: boolean;
122
- /**
123
- * When present, every rule file the walk reads is recorded here by
124
- * directory, so the daemon can upload the lines it actually applied.
125
- */
126
- rules?: Map<string, IndexRuleFiles>;
127
- }
128
-
129
- /** The rule-file lines one directory contributed to a walk. */
130
- export interface IndexRuleFiles {
131
- /** .gitignore + .mnemonikignore lines, unioned, in read order. */
132
- ignore: string[];
133
- /** .gitattributes lines, in read order. */
134
- attributes: string[];
135
- }
136
-
137
- /**
138
- * A file a walk declined to index because a soft default excluded it.
139
- * `stage: 'content'` means the file was read and its content decided.
140
- */
141
- export interface IndexExcludedFile {
142
- /** Root-relative POSIX path, spelled as a chunk's `filePath`. */
143
- path: string;
144
- reason: IndexExclusionReason;
145
- rule: string;
146
- stage: 'path' | 'content';
147
- }
148
-
149
- /**
150
- * Directory basenames the scanner never indexes, across every ecosystem -
151
- * generated output, package-manager/build caches, and vendored deps. SINGLE
152
- * SOURCE OF TRUTH: the scanner package's discovery + watch walks import this
153
- * (`packages/scanner/src/{discovery,watcher}.ts`) and `src/core/utils.ts`
154
- * derives from it, so every ignore surface agrees instead of drifting (the
155
- * pre-2026-07 state had four divergent copies). Add only UNAMBIGUOUS names -
156
- * a project's own `.gitignore` / `.mnemonikignore` covers the ambiguous,
157
- * project-specific cases (`vendor/`, custom dirs) hierarchically.
158
- */
159
- export const BUILT_IN_IGNORE_DIRS: readonly string[] = [
160
- // JS / web
161
- 'node_modules',
162
- '.next',
163
- '.nuxt',
164
- '.output',
165
- 'dist',
166
- 'build',
167
- 'coverage',
168
- '.turbo',
169
- '.vercel',
170
- '.svelte-kit',
171
- '.angular',
172
- '.astro',
173
- '.parcel-cache',
174
- 'bower_components',
175
- // Python
176
- 'venv',
177
- '.venv',
178
- 'env',
179
- '__pycache__',
180
- '.tox',
181
- '.mypy_cache',
182
- '.pytest_cache',
183
- '.ruff_cache',
184
- // Native mobile - iOS / Android / React Native / Flutter (2026-07-11 Pods incident)
185
- 'Pods',
186
- 'DerivedData',
187
- '.gradle',
188
- '.expo',
189
- '.dart_tool',
190
- // Infra / cloud
191
- '.terraform',
192
- 'cdk.out',
193
- '.serverless',
194
- // Build systems, caches, VCS
195
- 'target',
196
- 'bin',
197
- 'obj',
198
- '.cache',
199
- '.git',
200
- '.svn',
201
- '.hg',
202
- // The coding agent's captured shell environment, written as .sh files.
203
- // Harmless while only source extensions were collected; the moment shell
204
- // scripts became collectable these scanned as project code, which would put
205
- // an agent's own shell state into the user's searchable memory.
206
- //
207
- // Scoped to this one directory name deliberately. `.claude/` at large holds
208
- // real project content - `.claude/rules.md` is indexed on purpose
209
- // (tests/CodeScannerIgnorePatterns.test.ts), and only `.claude/worktrees/`
210
- // is suppressed, by the generic nested-git-boundary rule.
211
- 'shell-snapshots',
212
- ];
213
-
214
- /**
215
- * Non-directory built-in ignore patterns (files, globs, and the segment-
216
- * anchored fixture rule), combined with BUILT_IN_IGNORE_DIRS to form
217
- * DEFAULT_OPTIONS.ignorePatterns. See `shouldIgnore` for match semantics.
218
- *
219
- * `/tests/fixtures/*`: leading '/' = segment-anchored - matches tests/fixtures/
220
- * at the path root or immediately after a '/', so nested packages/x/tests/
221
- * fixtures/ are excluded but integration-tests/fixtures/ is NOT. Fixture
222
- * snapshot dirs were being indexed as real source and outranking the genuine
223
- * file in code_search. (Subprojects and linked worktrees are excluded
224
- * generically by the isProjectBoundary rule below, not a path convention.)
225
- */
226
- const BUILT_IN_IGNORE_FILE_PATTERNS: readonly string[] = [
227
- // NOTE: the `.env` family and every other secret-bearing FILE KIND lives in
228
- // `isSecretFile` below, not here. This glob engine cannot express negation
229
- // (`.env.example` must stay collectable) and its unanchored substring
230
- // matching would make `.env.*` also swallow `.environment.ts`.
231
- //
232
- // `/tests/fixtures/*` used to live here. It is now a soft default in
233
- // indexClassifier.ts (test fixtures, R2), which a project can re-include.
234
- '.DS_Store',
235
- '*.log',
236
- '*.lock',
237
- 'package-lock.json',
238
- 'yarn.lock',
239
- '*.min.js',
240
- '*.min.css',
241
- '*.bundle.js',
242
- '*.legacy.js',
243
- '*.map',
244
- ];
245
-
246
- /**
247
- * Files whose CONTENT is a credential rather than code. Today these are
248
- * excluded only INCIDENTALLY - `.pem`, `.key` and friends carry no
249
- * allowlisted extension, so nothing chunks them. That protection evaporates
250
- * the moment the extension allowlist widens (as it did for `.sh`/`.lua`), so
251
- * the exclusion is made explicit here and becomes a defended invariant:
252
- * unlike `ignorePatterns`, this predicate is NOT caller-overridable.
253
- *
254
- * Expressed as a predicate rather than glob strings because `shouldIgnore`'s
255
- * engine has no negation and matches globs as unanchored substrings - neither
256
- * property can state "every `.env.*` EXCEPT `.env.example`".
257
- *
258
- * Precision notes (over-exclusion is a fidelity failure in its own right):
259
- * - `.env` family: `/^\.env(\..+)?$/` matches `.env`, `.env.local`,
260
- * `.env.production` - and deliberately NOT `.envrc` or `.environment.ts`.
261
- * - `.env.example` is carved out: it is authority-collected on purpose
262
- * (AUTHORITY_FILE_MATCHERS) and holds placeholder values, not secrets.
263
- * It is the doc-truth env_vars authority; excluding it would blind that
264
- * extractor exactly as the old `process.env` over-scrub once did.
265
- * - `credentials` is matched ONLY directly inside a dot-directory
266
- * (`.aws/credentials`, `.docker/credentials`). A bare `credentials/` is a
267
- * real, common SOURCE directory name (gRPC, auth libraries) and
268
- * `shouldIgnore` cannot tell a file from a directory at its call sites -
269
- * an unqualified rule would silently delete that subtree from the index.
270
- * - `.tfvars` is excluded whole rather than per-basename: the credential file
271
- * is conventionally `terraform.tfvars` but the name is free-form, and the
272
- * variables that are NOT secret are visible in `.tf` anyway.
273
- */
274
- const SECRET_FILE_PLACEHOLDER_BASENAMES: ReadonlySet<string> = new Set(['.env.example']);
275
-
276
- const SECRET_FILE_BASENAME_PATTERNS: readonly RegExp[] = [
277
- /^\.env(\..+)?$/i, // .env, .env.local, .env.production.local
278
- /^id_(?:rsa|dsa|ecdsa|ed25519)/i, // ssh private keys (and .pub siblings)
279
- /^\.(?:npmrc|netrc|pgpass)$/i, // registry / ftp / postgres password files
280
- /^kubeconfig$/i,
281
- /\.(?:pem|key|p12|pfx|keystore|jks|kubeconfig)$/i,
282
- // Terraform variable files: `terraform.tfvars` is the conventional home for
283
- // provider credentials, and unlike the entries above nothing else was
284
- // keeping it out - it is excluded here rather than merely left off
285
- // `DEFAULT_INCLUDE_EXTENSIONS`, so a future widening of the allowlist cannot
286
- // quietly opt a project into embedding its own cloud keys.
287
- /\.tfvars(\.json)?$/i,
288
- ];
289
-
290
- /**
291
- * True when `relPath` names a file whose contents are credentials. Applied
292
- * by `shouldIgnore` (every walker: directory scan, explicit file lists, and
293
- * the authority-file walk) and by `makeIgnoreMatcher` (server-side backstop
294
- * for older or misbehaving daemons). Accepts OS-native or POSIX separators.
295
- */
296
- export function isSecretFile(relPath: string): boolean {
297
- if (!relPath) return false;
298
- const segments = relPath.split(sep).join('/').split('/');
299
- const base = segments[segments.length - 1] ?? '';
300
- if (!base || SECRET_FILE_PLACEHOLDER_BASENAMES.has(base)) return false;
301
- if (SECRET_FILE_BASENAME_PATTERNS.some((re) => re.test(base))) return true;
302
- const parent = segments[segments.length - 2];
303
- return base === 'credentials' && parent !== undefined && parent.startsWith('.');
304
- }
305
-
306
- /**
307
- * Per-directory ignore files the walkers honor, unioned. `.gitignore` is the
308
- * project's own declaration of generated/vendored paths; `.mnemonikignore`
309
- * (same gitignore syntax) is the user's Mnemonik-specific "never index this"
310
- * list (e.g. `secrets/`). Matched hierarchically via the `ignore` package.
311
- */
312
- const IGNORE_FILE_NAMES = ['.gitignore', '.mnemonikignore'] as const;
313
-
314
- /** Per-directory attributes file: linguist overrides and binary marks (indexClassifier.ts). */
315
- const ATTRIBUTES_FILE_NAME = '.gitattributes';
316
-
317
- /**
318
- * Every file type the scanner will chunk, by extension. Matched
319
- * case-INSENSITIVELY (see `isIncludedExtension`), so lowercase spellings here
320
- * also cover `.R`, `.SQL`, `.PS1` and the uppercase `.C`/`.H` of older trees.
321
- *
322
- * Exported because this list IS the product's language coverage: a project
323
- * written in something absent from it indexes zero code, and `code_search`
324
- * reports that as "no indexed matches" rather than "I do not read this
325
- * language" - invisible to the developer and to us. Coverage is a separate
326
- * dial from parse quality: a language with no structured extractor falls
327
- * through to `chunkRaw`, which is what Go, Java and C already get in
328
- * production, and crude chunks beat no chunks by an enormous margin.
329
- *
330
- * Data formats (`.json`, `.yaml`, `.toml`, `.lock`) are deliberately absent:
331
- * the ones that carry meaning are already collected verbatim by
332
- * `AUTHORITY_FILE_MATCHERS`, and blanket-indexing the extension would pull in
333
- * lockfiles and generated output. `.tfvars` is absent for a different reason -
334
- * `terraform.tfvars` is a conventional home for provider credentials and
335
- * `isSecretFile` now excludes it outright.
336
- */
337
- export const DEFAULT_INCLUDE_EXTENSIONS: readonly string[] = [
338
- '.ts',
339
- '.tsx',
340
- '.js',
341
- '.jsx',
342
- '.py',
343
- '.rs',
344
- '.go',
345
- '.java',
346
- '.c',
347
- '.cpp',
348
- '.h',
349
- '.cs',
350
- '.rb',
351
- '.php',
352
- '.swift',
353
- '.kt',
354
- // Shell and Lua are the working languages of whole real projects -
355
- // deployment tooling, container entrypoints, imapfilter/nginx/redis
356
- // configuration. Omitting them meant such a project indexed ZERO code and
357
- // code_search could not answer anything about it, while reporting that as
358
- // "no indexed matches" rather than as missing coverage.
359
- '.sh',
360
- '.bash',
361
- '.zsh',
362
- '.lua',
363
- '.md',
364
-
365
- // -- Variants of languages already on the list. `.mjs`/`.cjs`/`.mts`/`.cts`/
366
- // `.pyi` inherit their language string from a listed extension, so their
367
- // absence was oversight rather than policy. The C++ spellings are a choice,
368
- // not an inheritance: `.h` stays 'c' (C headers dominate, and reading a C++
369
- // header as C is the safer default), while the unambiguously-C++ spellings
370
- // resolve to 'cpp'.
371
- '.mjs',
372
- '.cjs',
373
- '.mts',
374
- '.cts',
375
- '.pyi',
376
- '.hpp',
377
- '.hh',
378
- '.hxx',
379
- '.cc',
380
- '.cxx',
381
- '.kts',
382
-
383
- // -- Languages the scanner could not read at all.
384
- '.dart',
385
- '.m',
386
- '.mm',
387
- '.scala',
388
- '.sc',
389
- '.ex',
390
- '.exs',
391
- '.erl',
392
- '.hrl',
393
- '.hs',
394
- '.jl',
395
- '.ml',
396
- '.mli',
397
- '.clj',
398
- '.cljs',
399
- '.cljc',
400
- '.groovy',
401
- '.gradle',
402
- '.ps1',
403
- '.psm1',
404
- '.pl',
405
- '.pm',
406
- '.r',
407
- '.sol',
408
- '.zig',
409
- '.vue',
410
- '.svelte',
411
-
412
- // -- Infrastructure and schema DSLs, where real logic lives and where "how
413
- // is this deployed?" and "what does this table hold?" go unanswered today.
414
- '.tf',
415
- '.sql',
416
- '.proto',
417
- '.graphql',
418
- '.gql',
419
- '.cmake',
420
- '.nix',
421
- '.bzl',
422
- ];
423
-
424
- /**
425
- * Cursor's rule files: `.cursorrules`, and any `.mdc` under `.cursor/rules/`.
426
- * Cursor is a launch host, so these are instruction files, the same class as
427
- * CLAUDE.md and AGENTS.md. Neither spelling can come in through
428
- * `DEFAULT_INCLUDE_EXTENSIONS`: `.cursorrules` has no extension (`extname`
429
- * answers ''), and `.mdc` is not a format this scanner reads anywhere else. So
430
- * both are admitted by path at the chunking gate (`isChunkable`) and chunked as
431
- * Markdown. They are not `.md` paths, so their whole-file content lands in
432
- * project_source_content.
433
- *
434
- * Matched at any depth, because Cursor also reads nested `.cursor/rules`
435
- * directories in a monorepo. Case-sensitive, as Cursor spells them. Accepts
436
- * OS-native or POSIX separators.
437
- */
438
- export function isCursorRulePath(relPath: string): boolean {
439
- if (!relPath) return false;
440
- const posix = relPath.split(sep).join('/');
441
- return /(^|\/)\.cursorrules$/.test(posix) || /(^|\/)\.cursor\/rules\/.+\.mdc$/.test(posix);
442
- }
443
-
444
- /**
445
- * Extension -> language string, the value carried on every chunk and on the
446
- * wire (`/scan/push` accepts any non-empty string up to 50 chars).
447
- *
448
- * A string no grammar claims is correct and expected: the AST layer resolves
449
- * such a language to `null` and the heuristic chunker takes over. Keep this in
450
- * sync with `DEFAULT_INCLUDE_EXTENSIONS` - an allowlisted extension that lands
451
- * on 'unknown' still gets chunked, but nothing downstream can reason about it.
452
- *
453
- * Two extensions are genuinely ambiguous and are resolved rather than fudged:
454
- * `.m` is Objective-C here, not MATLAB, because a repo carrying `.m` alongside
455
- * `.h`/`.mm` is overwhelmingly an Apple-platform project; `.pl` is Perl, not
456
- * Prolog, on the same frequency argument.
457
- */
458
- const EXTENSION_LANGUAGES: Readonly<Record<string, string>> = {
459
- '.ts': 'typescript',
460
- '.tsx': 'typescript',
461
- '.mts': 'typescript',
462
- '.cts': 'typescript',
463
- '.js': 'javascript',
464
- '.jsx': 'javascript',
465
- '.mjs': 'javascript',
466
- '.cjs': 'javascript',
467
- '.py': 'python',
468
- '.pyi': 'python',
469
- '.rs': 'rust',
470
- '.go': 'go',
471
- '.java': 'java',
472
- '.c': 'c',
473
- '.h': 'c',
474
- '.cpp': 'cpp',
475
- '.cc': 'cpp',
476
- '.cxx': 'cpp',
477
- '.hpp': 'cpp',
478
- '.hh': 'cpp',
479
- '.hxx': 'cpp',
480
- '.cs': 'csharp',
481
- '.rb': 'ruby',
482
- '.php': 'php',
483
- '.swift': 'swift',
484
- '.kt': 'kotlin',
485
- '.kts': 'kotlin',
486
- '.sh': 'shell',
487
- '.bash': 'shell',
488
- '.zsh': 'shell',
489
- '.lua': 'lua',
490
- '.md': 'markdown',
491
- '.dart': 'dart',
492
- '.m': 'objc',
493
- '.mm': 'objc',
494
- '.scala': 'scala',
495
- '.sc': 'scala',
496
- '.ex': 'elixir',
497
- '.exs': 'elixir',
498
- '.erl': 'erlang',
499
- '.hrl': 'erlang',
500
- '.hs': 'haskell',
501
- '.jl': 'julia',
502
- '.ml': 'ocaml',
503
- '.mli': 'ocaml',
504
- '.clj': 'clojure',
505
- '.cljs': 'clojure',
506
- '.cljc': 'clojure',
507
- '.groovy': 'groovy',
508
- '.gradle': 'groovy',
509
- '.ps1': 'powershell',
510
- '.psm1': 'powershell',
511
- '.pl': 'perl',
512
- '.pm': 'perl',
513
- '.r': 'r',
514
- '.sol': 'solidity',
515
- '.zig': 'zig',
516
- '.vue': 'vue',
517
- '.svelte': 'svelte',
518
- '.tf': 'terraform',
519
- '.sql': 'sql',
520
- '.proto': 'protobuf',
521
- '.graphql': 'graphql',
522
- '.gql': 'graphql',
523
- '.cmake': 'cmake',
524
- '.nix': 'nix',
525
- '.bzl': 'starlark',
526
- };
527
-
528
- /**
529
- * Language string for a file path or a bare extension, `'unknown'` when the
530
- * extension is unmapped. A free function rather than a method because callers
531
- * that never scan anything (AST grammar resolution, server-side symbol
532
- * preference) need the same answer without constructing a scanner.
533
- */
534
- export function languageForExtension(filePathOrExt: string): string {
535
- if (isCmakeListsPath(filePathOrExt)) return 'cmake';
536
- // `extname` FIRST. A dotfile that carries a real extension ('.eslintrc.js',
537
- // '.mocharc.cjs', '.prettierrc.ts') starts with '.' and contains no '/', so
538
- // a bare-extension-first reading swallowed the whole name and answered
539
- // 'unknown' - while the same file spelled 'src/.eslintrc.js' answered
540
- // 'javascript'. Those extensions are allowlisted, so the files are indexed
541
- // and reach the server with exactly the root-relative spelling that failed.
542
- const fromPath = EXTENSION_LANGUAGES[extname(filePathOrExt).toLowerCase()];
543
- if (fromPath !== undefined) return fromPath;
544
- // Fallback: the argument IS the extension ('.dart'), for which `extname`
545
- // returns ''. Every key contains a leading dot and no separator, so a real
546
- // path can never collide here.
547
- return EXTENSION_LANGUAGES[filePathOrExt.toLowerCase()] ?? 'unknown';
548
- }
549
-
550
- /**
551
- * THE definition of "this path is a SQL migration the schema_columns authority
552
- * collects verbatim". One predicate, referenced by both halves of the deal -
553
- * `AUTHORITY_FILE_MATCHERS` (collect it) and `isAuthorityOnlyPath` (therefore
554
- * do not chunk it) - because two hand-written regexes drifted once already and
555
- * the failure is silent in both directions.
556
- *
557
- * Root-anchored and CASE-SENSITIVE on purpose: it mirrors the server-side
558
- * extractor, which does `listFiles('migrations/')` (LIKE 'migrations/%') then
559
- * `endsWith('.sql')`, both case-sensitive. `Migrations/001.sql` (the .NET/EF
560
- * Core spelling) and `migrations/002.SQL` are NOT collected, so they must not
561
- * be suppressed from chunking either - that would index them nowhere. Same
562
- * reason a nested `packages/x/migrations/y.sql` is left alone.
563
- */
564
- const isMigrationSqlAuthorityPath = (posixRelPath: string): boolean =>
565
- /^migrations\/.*\.sql$/.test(posixRelPath);
566
-
567
- /** Content-only build authority, shared by scanner collection and server admission. */
568
- export const XCODE_AUTHORITY_PATH = /(?:^|\/)[^/]+\.xcodeproj\/project\.pbxproj$|\.xcconfig$/;
569
- export const isXcodeAuthorityPath = (path: string): boolean => XCODE_AUTHORITY_PATH.test(path);
570
- /** Exact CMake build entrypoint; ordinary .txt files remain outside collection. */
571
- export const CMAKE_LISTS_PATH = /(?:^|\/)CMakeLists\.txt$/;
572
- export const isCmakeListsPath = (path: string): boolean => CMAKE_LISTS_PATH.test(path);
573
-
574
- /**
575
- * Predicates for paths whose verbatim content is ALREADY shipped by
576
- * `collectAuthorityFiles` and that carry no additional value as embedded code
577
- * chunks. Checked at the extension gate rather than in `shouldIgnore`, because
578
- * `shouldIgnore` also guards the authority walk and must keep letting these
579
- * through.
580
- *
581
- * `migrations/**.sql` is the live case: adding `.sql` to the allowlist without
582
- * this exclusion would dual-collect every migration - once verbatim, once
583
- * chunked and embedded. On this repo alone that is 208 files of append-only
584
- * DDL (140 forward, the rest rollback/manual), embedded to answer questions
585
- * the authority path already answers exactly.
586
- *
587
- * INVARIANT: every predicate here must also appear in
588
- * `AUTHORITY_FILE_MATCHERS`, so no path can be excluded from chunking unless
589
- * the authority path definitely collects it.
590
- */
591
- const AUTHORITY_ONLY_PATH_PREDICATES: readonly ((posixRelPath: string) => boolean)[] = [
592
- isMigrationSqlAuthorityPath,
593
- isXcodeAuthorityPath,
594
- isCmakeListsPath,
595
- ];
596
-
597
- /**
598
- * True when `relPath` is collected verbatim as authority content and must not
599
- * also be chunked. Accepts OS-native or POSIX separators.
600
- */
601
- export function isAuthorityOnlyPath(relPath: string): boolean {
602
- if (!relPath) return false;
603
- const posix = relPath.split(sep).join('/');
604
- return AUTHORITY_ONLY_PATH_PREDICATES.some((matches) => matches(posix));
605
146
  }
606
147
 
607
148
  const DEFAULT_OPTIONS: Required<ScanOptions> = {
608
149
  maxChunkSize: 8000, // ~2000 tokens
609
150
  minChunkSize: 100,
610
- ignorePatterns: [...BUILT_IN_IGNORE_DIRS, ...BUILT_IN_IGNORE_FILE_PATTERNS],
151
+ ignorePatterns: [...DEFAULT_IGNORE_PATTERNS],
611
152
  includeExtensions: [...DEFAULT_INCLUDE_EXTENSIONS],
612
153
  maxAstParseBytes: MAX_AST_PARSE_BYTES,
613
154
  protectedPaths: [],
614
155
  };
615
156
 
616
- /**
617
- * Matchers for authority manifest / config / CI files whose verbatim content
618
- * is collected by `collectAuthorityFiles` and pushed to the server for
619
- * doc-truth Fingerprint parsing. Operates on relative, forward-slash paths.
620
- */
621
- export const AUTHORITY_FILE_MATCHERS: Array<(relPath: string) => boolean> = [
622
- (p) => p === 'package.json',
623
- (p) => /^(packages|apps|services|tools)\/[^/]+\/package\.json$/.test(p),
624
- (p) => p === 'tsconfig.json' || /^tsconfig\.[^/]+\.json$/.test(p),
625
- (p) => p === 'pyproject.toml',
626
- (p) => p === 'requirements.txt',
627
- (p) => p === 'setup.py',
628
- (p) => p === 'Cargo.toml',
629
- (p) => p === 'Gemfile',
630
- (p) => p === 'Makefile',
631
- (p) => p === '.env.example',
632
- (p) => /^\.github\/workflows\/[^/]+\.(yml|yaml)$/.test(p),
633
- // SQL migrations: the schema_columns authority extractor reads every .sql
634
- // under `migrations/` (listFiles('migrations/') -> LIKE 'migrations/%' then
635
- // .endsWith('.sql')). Without collecting these, that authority is empty and
636
- // every schema_table_enumeration claim falls to unverifiable. Shared with
637
- // `AUTHORITY_ONLY_PATH_PREDICATES` by reference, not by a copied regex, so
638
- // collection and the chunking exclusion cannot disagree.
639
- isMigrationSqlAuthorityPath,
640
- isXcodeAuthorityPath,
641
- isCmakeListsPath,
642
- ];
643
-
644
- /**
645
- * Segment-anchored match for `tests/fixtures/` - matches at the path root or
646
- * immediately after a `/`. Indexing no longer uses it (the fixture rule is the
647
- * soft default in indexClassifier.ts, which a project can override); it stays
648
- * for query-time hygiene over rows indexed before that rule existed
649
- * (`MemoryManager.searchCodePointers`).
650
- */
651
- export const FIXTURE_PATH_RE = /(^|\/)tests\/fixtures\//;
652
- export function isFixturePath(path: string): boolean {
653
- return FIXTURE_PATH_RE.test(path);
654
- }
655
-
656
- const BUILT_IN_IGNORE_DIR_SET = new Set<string>(BUILT_IN_IGNORE_DIRS);
657
-
658
- /**
659
- * Server-side ignore matcher for defense-in-depth path dropping on /scan/push,
660
- * /scan/reconcile, and file_push. Combines the universal BUILT_IN_IGNORE_DIRS
661
- * (segment-exact) with a project's uploaded .gitignore/.mnemonikignore patterns
662
- * (gitignore syntax via the `ignore` package). Built once per request and reused
663
- * across all pushed paths (root-relative, forward-slash). This does NOT replace
664
- * the daemon's precise hierarchical walk - it backstops it so a buggy, stale, or
665
- * malicious client cannot index a path the project ignores, making .mnemonikignore
666
- * a hard guarantee rather than best-effort.
667
- */
668
- export function makeIgnoreMatcher(
669
- extraPatterns: readonly string[] = []
670
- ): (relPath: string) => boolean {
671
- const cleaned = extraPatterns.filter((p) => typeof p === 'string' && p.trim().length > 0);
672
- const ig = cleaned.length > 0 ? ignore().add([...cleaned]) : null;
673
- return (relPath: string): boolean => {
674
- if (!relPath) return false;
675
- const norm = relPath.split(sep).join('/');
676
- if (norm.split('/').some((s) => BUILT_IN_IGNORE_DIR_SET.has(s))) return true;
677
- if (isSecretFile(norm)) return true;
678
- if (ig) {
679
- try {
680
- return ig.ignores(norm);
681
- } catch {
682
- // `ignore` throws on absolute/`..` paths (safeScanPath already rejects
683
- // those upstream); built-ins were already checked, so fail open here.
684
- return false;
685
- }
686
- }
687
- return false;
688
- };
689
- }
690
-
691
- /**
692
- * True when `dirPath` is the root of a DIFFERENT repository than the one
693
- * being scanned: it carries its own `.git` entry. A `.git` FILE marks a
694
- * linked git worktree (wherever the user keeps them) or a submodule
695
- * checkout; a `.git` DIRECTORY marks a nested clone. Files under such a
696
- * boundary belong to that repository's own project identity - scanning them
697
- * into the parent project is wrong attribution for any codebase, so the
698
- * walkers skip these subtrees. Convention-free: this is git's own marker,
699
- * not a host/tool path layout. The scan ROOT is exempt by construction
700
- * (callers only test child directories).
701
- */
702
- export async function isGitBoundary(dirPath: string): Promise<boolean> {
703
- try {
704
- await withTimeout(
705
- lstat(join(dirPath, '.git')),
706
- FILE_OP_TIMEOUT_MS,
707
- `lstat timed out: ${join(dirPath, '.git')}`
708
- );
709
- return true;
710
- } catch {
711
- return false;
712
- }
713
- }
714
-
715
- /**
716
- * How many folders deep a project's scan walks. Discovery searches a project
717
- * for subprojects to the same depth, so a subproject the scan leaves out is
718
- * always found and scanned as its own project.
719
- */
720
- export const SCANNER_MAX_DEPTH = 10;
721
-
722
- /**
723
- * True when `dirPath` starts a subtree that is not this project's to index.
724
- * A file belongs to the project of the nearest `.mnemonik.json` above it, so:
725
- * - a folder with its own identity file is a subproject, indexed as its own
726
- * project, never also into this one. A file naming the enclosing project
727
- * (`enclosingProjectId`, a copy) is the same project, and a
728
- * configuration-only file (no `projectId`) names none: neither is a
729
- * boundary. A file that cannot be read as an identity still is, and so is
730
- * a copy in a full clone (its own `.git` DIRECTORY): a second checkout of
731
- * the project, whose files would otherwise be indexed twice;
732
- * - a linked worktree (a `.git` FILE whose git directory has a `commondir`,
733
- * or is missing) is another checkout of a project, indexed from its main
734
- * checkout.
735
- * A nested clone or a submodule without its own `.mnemonik.json` belongs to
736
- * this project and is indexed with it (its `.git` directory is ignored by the
737
- * built-in rules). The scan ROOT is exempt by construction (callers only test
738
- * child directories).
739
- */
740
- export async function isProjectBoundary(
741
- dirPath: string,
742
- enclosingProjectId?: string
743
- ): Promise<boolean> {
744
- if (await lstatWithin(join(dirPath, '.mnemonik.json'))) {
745
- const identity = await withTimeout(
746
- readIdentityFile(dirPath, { configurationOnlyAbsent: true }),
747
- FILE_OP_TIMEOUT_MS,
748
- `identity read timed out: ${dirPath}`
749
- ).catch(() => undefined);
750
- if (!identity) return true;
751
- if (identity.kind === 'ok') {
752
- if (identity.identity.projectId !== enclosingProjectId) return true;
753
- if ((await lstatWithin(join(dirPath, '.git')))?.isDirectory()) return true;
754
- } else if (identity.kind !== 'absent') return true;
755
- }
756
- return isLinkedWorktreeCheckout(dirPath);
757
- }
758
-
759
- /**
760
- * The project a scan root's own identity file names, or undefined: the
761
- * enclosing project for isProjectBoundary.
762
- */
763
- export async function rootProjectId(rootPath: string): Promise<string | undefined> {
764
- const identity = await withTimeout(
765
- readIdentityFile(rootPath),
766
- FILE_OP_TIMEOUT_MS,
767
- `identity read timed out: ${rootPath}`
768
- ).catch(() => undefined);
769
- return identity?.kind === 'ok' ? identity.identity.projectId : undefined;
770
- }
771
-
772
- const lstatWithin = (path: string) =>
773
- withTimeout(lstat(path), FILE_OP_TIMEOUT_MS, `lstat timed out: ${path}`).catch(() => undefined);
774
-
775
- /**
776
- * True when `dirPath` is the top of a linked git worktree: its `.git` is a
777
- * FILE pointing at a git directory that has a `commondir`, or at one that no
778
- * longer exists (a pruned or moved worktree, or one made on another machine):
779
- * nothing shows it is this project's own. A submodule's `.git` file points at
780
- * `.git/modules/<name>`, which exists and has no `commondir`.
781
- */
782
- export async function isLinkedWorktreeCheckout(dirPath: string): Promise<boolean> {
783
- const marker = await lstatWithin(join(dirPath, '.git'));
784
- if (!marker?.isFile()) return false;
785
- try {
786
- const pointer = /^gitdir:\s*(.+?)\s*$/im.exec(
787
- await withTimeout(
788
- readFile(join(dirPath, '.git'), 'utf8'),
789
- FILE_OP_TIMEOUT_MS,
790
- `read timed out: ${join(dirPath, '.git')}`
791
- )
792
- )?.[1];
793
- if (!pointer) return false;
794
- const gitDirectory = resolve(dirPath, pointer);
795
- if (!(await lstatWithin(gitDirectory))) return true;
796
- return !!(await lstatWithin(join(gitDirectory, 'commondir')));
797
- } catch {
798
- return false;
799
- }
800
- }
801
-
802
- /**
803
- * One directory's compiled ignore-file rules, keyed by its path relative to
804
- * the scan root (`''` = root). A stack of these, root->leaf, is threaded
805
- * through the walkers; `ignoredByStack` tests deepest-first so a nested
806
- * `.gitignore` overrides a shallower one (git precedence).
807
- */
808
- interface IgnoreLayer {
809
- baseRel: string;
810
- /** Compiled .gitignore + .mnemonikignore; null when this directory has neither. */
811
- ig: Ignore | null;
812
- /** Any `!pattern` line: a path under here may be re-included from a soft default. */
813
- hasNegation: boolean;
814
- /** Parsed .gitattributes; null when this directory has none. */
815
- attributes: GitattributesRule[] | null;
816
- }
817
- type IgnoreStack = IgnoreLayer[];
818
-
819
157
  /**
820
158
  * Latch for `logAstCapabilityOnce`. A promise, not a boolean: two concurrent
821
159
  * callers must both wait on the same report rather than the second returning
@@ -875,389 +213,17 @@ export function logAstCapabilityOnce(): Promise<void> {
875
213
  return astCapabilityLog;
876
214
  }
877
215
 
878
- export class CodeScanner {
216
+ export class CodeScanner extends FileSelector {
879
217
  private options: Required<ScanOptions>;
880
- private readonly protectedPaths: readonly string[];
881
- /** Cumulative observations since this scanner started, including repeated walks. */
882
- readonly skipReasons = { symlink_skipped: 0 };
883
-
884
- private skipSymlink(stats: Stats): boolean {
885
- if (!stats.isSymbolicLink()) return false; // Includes Windows junctions.
886
- this.skipReasons.symlink_skipped++;
887
- return true;
888
- }
889
-
890
- /**
891
- * `includeExtensions` folded to lowercase for matching. The gate used to
892
- * compare `extname()` verbatim while `detectLanguage` lowercased, so a
893
- * project spelling its files the canonical way - `.R` for R, `.SQL`/`.PS1`
894
- * on Windows, `.C`/`.H` in older C trees - indexed zero of them and nothing
895
- * said why.
896
- */
897
- private readonly includeExtensionSet: ReadonlySet<string>;
898
218
 
899
219
  constructor(options: ScanOptions = {}) {
220
+ super(options);
900
221
  this.options = { ...DEFAULT_OPTIONS, ...options };
901
- this.protectedPaths = [
902
- ...new Set([...protectedLocalPaths(), ...(options.protectedPaths ?? [])]),
903
- ];
904
- this.includeExtensionSet = new Set(
905
- this.options.includeExtensions.map((ext) => ext.toLowerCase())
906
- );
907
- }
908
-
909
- private isProtected(path: string): boolean {
910
- return isProtectedLocalPath(path, this.protectedPaths);
911
- }
912
-
913
- private async isProtectedRead(path: string): Promise<boolean> {
914
- if (this.isProtected(path)) return true;
915
- return isProtectedLocalPath(await realpath(path), this.protectedPaths);
916
- }
917
-
918
- /**
919
- * The chunkable-file gate, shared by every walker and by the explicit
920
- * file-list path so all three agree on what exists. `relPath` is the path
921
- * relative to the scan root (OS-native separators accepted).
922
- */
923
- private isChunkable(absOrRelPath: string, relPath: string): boolean {
924
- if (
925
- !this.includeExtensionSet.has(extname(absOrRelPath).toLowerCase()) &&
926
- !isCursorRulePath(relPath)
927
- ) {
928
- return false;
929
- }
930
- if (isAuthorityOnlyPath(relPath)) {
931
- logDebug('Skipping chunking for authority-collected path', { relPath });
932
- return false;
933
- }
934
- return true;
935
- }
936
-
937
- /**
938
- * Read `.gitignore` + `.mnemonikignore` + `.gitattributes` in `absDir` and
939
- * compile them into one layer (the two ignore files are unioned -
940
- * `.mnemonikignore` is just an additional gitignore-syntax exclusion list).
941
- * Returns null when none of the three exists. FAIL-CLOSED: a non-ENOENT read
942
- * error (EISDIR, EACCES, timeout) latches `walk.complete = false` so callers
943
- * deriving *removals* from the walk treat it as untrustworthy rather than
944
- * silently ignoring nothing.
945
- */
946
- private async loadIgnoreLayer(
947
- absDir: string,
948
- baseRel: string,
949
- walk: WalkState,
950
- rootPath: string
951
- ): Promise<IgnoreLayer | null> {
952
- if (this.isProtected(absDir)) return null;
953
- const read = async (name: string): Promise<string[] | null> => {
954
- const filePath = join(absDir, name);
955
- try {
956
- const content = await withTimeout(
957
- this.readConfined(filePath, rootPath),
958
- FILE_OP_TIMEOUT_MS,
959
- `readFile timed out: ${filePath}`
960
- );
961
- return content.split(/\r?\n/);
962
- } catch (err) {
963
- if ((err as Error).message === 'symlink_skipped') return null;
964
- if ((err as NodeJS.ErrnoException)?.code !== 'ENOENT') {
965
- walk.complete = false;
966
- logDebug('Rule-file read failed (fail-closed)', { path: filePath, err });
967
- }
968
- return null;
969
- }
970
- };
971
- const patterns: string[] = [];
972
- for (const name of IGNORE_FILE_NAMES) {
973
- const lines = await read(name);
974
- if (lines) patterns.push(...lines);
975
- }
976
- const attributeLines = (await read(ATTRIBUTES_FILE_NAME)) ?? [];
977
- if (patterns.length > 0 || attributeLines.length > 0) {
978
- walk.rules?.set(baseRel, { ignore: patterns, attributes: attributeLines });
979
- }
980
- const attributes =
981
- attributeLines.length > 0 ? parseGitattributes(attributeLines.join('\n')) : [];
982
- // `ignore` tolerates blank/comment lines; null when nothing meaningful.
983
- if (patterns.length === 0 && attributes.length === 0) return null;
984
- return {
985
- baseRel,
986
- ig: patterns.length > 0 ? ignore().add(patterns) : null,
987
- hasNegation: patterns.some((line) => line.trimStart().startsWith('!')),
988
- attributes: attributes.length > 0 ? attributes : null,
989
- };
990
- }
991
-
992
- /**
993
- * What the .gitignore / .mnemonikignore layers in `stack` say about
994
- * `entryRel` (relative to the scan root). Tested deepest-first: the first
995
- * layer that explicitly ignores OR re-includes (via a `!negation`) wins,
996
- * matching git's rule that a deeper `.gitignore` overrides a shallower one.
997
- * Directories are tested with a trailing slash so `foo/` dir-only rules
998
- * match the directory itself. `'unignored'` means a negation matched this
999
- * path itself; a re-included parent directory leaves its files `'unmatched'`.
1000
- */
1001
- private ignoreVerdict(entryRel: string, isDir: boolean, stack: IgnoreStack): IgnoreFileVerdict {
1002
- for (let i = stack.length - 1; i >= 0; i--) {
1003
- const layer = stack[i];
1004
- if (!layer?.ig) continue;
1005
- const { baseRel, ig } = layer;
1006
- let sub: string;
1007
- if (baseRel === '') {
1008
- sub = entryRel;
1009
- } else if (entryRel === baseRel || !entryRel.startsWith(baseRel + '/')) {
1010
- continue; // this layer does not cover entryRel
1011
- } else {
1012
- sub = entryRel.slice(baseRel.length + 1);
1013
- }
1014
- if (!sub) continue;
1015
- const res = ig.test(isDir ? sub + '/' : sub);
1016
- if (res.ignored) return 'ignored';
1017
- if (res.unignored) return 'unignored';
1018
- }
1019
- return 'unmatched';
1020
- }
1021
-
1022
- private ignoredByStack(entryRel: string, isDir: boolean, stack: IgnoreStack): boolean {
1023
- return this.ignoreVerdict(entryRel, isDir, stack) === 'ignored';
1024
- }
1025
-
1026
- private static attributeLayers(stack: IgnoreStack): GitattributesLayer[] {
1027
- return stack.flatMap((layer) =>
1028
- layer.attributes ? [{ baseRel: layer.baseRel, rules: layer.attributes }] : []
1029
- );
1030
- }
1031
-
1032
- /**
1033
- * Skip this directory? User-ignored directories are skipped as before. A
1034
- * directory a soft default excludes (vendor/, generated/, ...) is pruned too -
1035
- * nothing under it can be indexed - UNLESS a rule above it could re-include
1036
- * something inside it: a `!pattern` or a `-linguist-*` attribute. Then the
1037
- * walk descends and judges each file, so the override works. Rule files
1038
- * INSIDE a pruned directory are never read: a vendored library's own
1039
- * .gitignore negations are not the project's intent.
1040
- */
1041
- private skipDirectory(relPath: string, stack: IgnoreStack): boolean {
1042
- const posixRel = relPath.split(sep).join('/');
1043
- const verdict = this.ignoreVerdict(posixRel, true, stack);
1044
- if (verdict === 'ignored') return true;
1045
- if (verdict === 'unignored') return false;
1046
- if (!softDefaultForDirectory(posixRel)) return false;
1047
- const canReinclude =
1048
- stack.some((layer) => layer.hasNegation) ||
1049
- gitattributesCanReinclude(CodeScanner.attributeLayers(stack));
1050
- return !canReinclude;
1051
- }
1052
-
1053
- /**
1054
- * The index decision for a file from its path and the rule stack: `null`
1055
- * when a user ignore rule excludes it, otherwise the classifier's answer
1056
- * (precedence in indexClassifier.ts). Content rules are the caller's next
1057
- * step for an included path without an `override`.
1058
- */
1059
- private classifyEntry(relPath: string, stack: IgnoreStack): IndexClassification | null {
1060
- const posixRel = relPath.split(sep).join('/');
1061
- const verdict = this.ignoreVerdict(posixRel, false, stack);
1062
- if (verdict === 'ignored') return null;
1063
- return classifyForIndex(posixRel, {
1064
- ignoreFileVerdict: verdict,
1065
- attributes: CodeScanner.attributeLayers(stack),
1066
- });
1067
- }
1068
-
1069
- /**
1070
- * Reconstruct the root->parent ignore-layer stack for a single file, for the
1071
- * incremental `scanFiles` path (which has a flat file list, not a recursive
1072
- * walk). Layers are memoized per directory in `cache` across a batch.
1073
- */
1074
- private async buildIgnoreStackForFile(
1075
- fileRel: string,
1076
- rootPath: string,
1077
- cache: Map<string, IgnoreLayer | null>,
1078
- walk: WalkState
1079
- ): Promise<IgnoreStack> {
1080
- const dirs = [''];
1081
- let acc = '';
1082
- for (const seg of fileRel.split('/').slice(0, -1)) {
1083
- acc = acc === '' ? seg : `${acc}/${seg}`;
1084
- dirs.push(acc);
1085
- }
1086
- const stack: IgnoreStack = [];
1087
- for (const dir of dirs) {
1088
- let layer = cache.get(dir);
1089
- if (layer === undefined) {
1090
- layer = await this.loadIgnoreLayer(
1091
- dir === '' ? rootPath : join(rootPath, dir),
1092
- dir,
1093
- walk,
1094
- rootPath
1095
- );
1096
- cache.set(dir, layer);
1097
- }
1098
- if (layer) stack.push(layer);
1099
- }
1100
- return stack;
1101
- }
1102
-
1103
- /**
1104
- * A predicate for walkers outside this class (the daemon's file watcher):
1105
- * true when the walk would descend into `absDir`, by the same rules
1106
- * `listFiles` applies - user ignore files and soft-default directories,
1107
- * with their overrides. Rule files are read once per directory for the life
1108
- * of the returned predicate; take a fresh one after a rule file changes.
1109
- * Directories outside `rootPath`, and the root itself, always pass.
1110
- */
1111
- directoryFilter(rootPath: string): (absDir: string) => Promise<boolean> {
1112
- const cache = new Map<string, IgnoreLayer | null>();
1113
- return async (absDir: string): Promise<boolean> => {
1114
- try {
1115
- const root = await this.canonicalRoot(rootPath);
1116
- const rel = relative(root, absDir).split(sep).join('/');
1117
- if (!rel || rel.startsWith('..')) return true;
1118
- const stack = await this.buildIgnoreStackForFile(rel, root, cache, { complete: true });
1119
- return !this.skipDirectory(rel, stack);
1120
- } catch {
1121
- // Fail open: watching a directory the walk would skip costs a watch
1122
- // handle; not watching one it would index loses changes.
1123
- return true;
1124
- }
1125
- };
1126
- }
1127
-
1128
- /**
1129
- * True when any directory strictly between `rootPath` and `filePath`
1130
- * starts a subtree this project does not index (see isProjectBoundary). Paths that don't
1131
- * resolve under `rootPath` are not judged here (return false) - the
1132
- * server's path validation handles escapes.
1133
- */
1134
- private async insideNestedGitBoundary(filePath: string, rootPath: string): Promise<boolean> {
1135
- const rel = relative(rootPath, filePath);
1136
- if (!rel || rel.startsWith('..')) return false;
1137
- const segments = rel.split(sep);
1138
- let current = rootPath;
1139
- // Walk intermediate directories only (exclude the file itself).
1140
- for (let i = 0; i < segments.length - 1; i++) {
1141
- const segment = segments[i];
1142
- if (segment === undefined) continue;
1143
- current = join(current, segment);
1144
- if (await this.isBoundary(current, rootPath)) return true;
1145
- }
1146
- return false;
1147
- }
1148
-
1149
- /**
1150
- * Maximum directory depth for recursive scanning
1151
- * Prevents runaway recursion on deep/symlinked structures
1152
- */
1153
- private static readonly MAX_DEPTH = SCANNER_MAX_DEPTH;
1154
-
1155
- private rootProjectIds = new Map<
1156
- string,
1157
- { at: number; projectId: Promise<string | undefined> }
1158
- >();
1159
- /**
1160
- * isProjectBoundary, with the scan root's own project as the enclosing one.
1161
- * The root's file is read at most once a minute: a walk tests many folders,
1162
- * and a project relinked since is seen on the next scan.
1163
- */
1164
- private async isBoundary(dirPath: string, rootPath: string): Promise<boolean> {
1165
- let cached = this.rootProjectIds.get(rootPath);
1166
- if (!cached || Date.now() - cached.at > 60_000) {
1167
- cached = { at: Date.now(), projectId: rootProjectId(rootPath) };
1168
- this.rootProjectIds.set(rootPath, cached);
1169
- }
1170
- return isProjectBoundary(dirPath, await cached.projectId);
1171
- }
1172
-
1173
- private canonicalRoots = new Map<string, string>();
1174
- private async canonicalRoot(root: string): Promise<string> {
1175
- let canonical = this.canonicalRoots.get(root);
1176
- if (!canonical) {
1177
- canonical = await realpath(root);
1178
- this.canonicalRoots.set(root, canonical);
1179
- }
1180
- return canonical;
1181
- }
1182
-
1183
- static containsCanonical(
1184
- candidate: string,
1185
- root: string,
1186
- paths = { sep, resolve },
1187
- caseInsensitive = process.platform === 'win32'
1188
- ): boolean {
1189
- // realpath expands filesystem aliases (including Windows 8.3 names). Never guess a short name.
1190
- candidate = paths.resolve(candidate);
1191
- root = paths.resolve(root);
1192
- if (caseInsensitive) {
1193
- candidate = candidate.toLowerCase();
1194
- root = root.toLowerCase();
1195
- }
1196
- return (
1197
- candidate === root || candidate.startsWith(root.endsWith(paths.sep) ? root : root + paths.sep)
1198
- );
1199
- }
1200
-
1201
- private async confineSymlink(fullPath: string, canonicalRoot: string): Promise<boolean> {
1202
- // Check each component: explicit-file callers must not bypass the walkers'
1203
- // no-symlink policy by supplying a path through an in-root directory link.
1204
- let path = resolve(fullPath);
1205
- if (!CodeScanner.containsCanonical(path, canonicalRoot)) return false;
1206
- for (;;) {
1207
- if (this.skipSymlink(await lstat(path))) throw new Error('symlink_skipped');
1208
- if (CodeScanner.containsCanonical(canonicalRoot, path)) break;
1209
- path = dirname(path);
1210
- }
1211
- return CodeScanner.containsCanonical(await realpath(fullPath), canonicalRoot);
1212
- }
1213
-
1214
- async readConfined(fullPath: string, canonicalRoot: string, enumerated?: Stats): Promise<string> {
1215
- const expected = enumerated ?? (await lstat(fullPath));
1216
- if (this.skipSymlink(expected)) throw new Error('symlink_skipped');
1217
- if (!expected.isFile()) throw new Error('scanner_not_regular');
1218
- if (!(await this.confineSymlink(fullPath, canonicalRoot)))
1219
- throw new Error('scanner_containment');
1220
- const canonical = await realpath(fullPath);
1221
- if (
1222
- !CodeScanner.containsCanonical(canonical, canonicalRoot) ||
1223
- (await this.isProtectedRead(canonical))
1224
- )
1225
- throw new Error('scanner_containment');
1226
- // O_NONBLOCK also prevents a substituted FIFO from hanging before fstat.
1227
- const flags =
1228
- constants.O_RDONLY |
1229
- (constants.O_NONBLOCK ?? 0) |
1230
- (process.platform === 'win32' ? 0 : (constants.O_NOFOLLOW ?? 0));
1231
- const handle = await open(fullPath, flags).catch((error: NodeJS.ErrnoException) => {
1232
- if (error.code === 'ELOOP') {
1233
- this.skipReasons.symlink_skipped++;
1234
- throw new Error('symlink_skipped');
1235
- }
1236
- throw error;
1237
- });
1238
- try {
1239
- const actual = await handle.stat();
1240
- if (!actual.isFile()) throw new Error('scanner_not_regular');
1241
- if (actual.dev !== expected.dev || actual.ino !== expected.ino)
1242
- throw new Error('scanner_identity_changed');
1243
- // Windows has no O_NOFOLLOW; lstat is Node's junction/reparse-link check.
1244
- if (process.platform === 'win32' && this.skipSymlink(await lstat(fullPath)))
1245
- throw new Error('symlink_skipped');
1246
- // Linux also proves the opened object is still at the checked path.
1247
- if (
1248
- process.platform === 'linux' &&
1249
- (await realpath(`/proc/self/fd/${handle.fd}`)) !== canonical
1250
- )
1251
- throw new Error('scanner_containment');
1252
- return await handle.readFile('utf-8');
1253
- } finally {
1254
- await handle.close();
1255
- }
1256
222
  }
1257
223
 
1258
224
  /**
1259
225
  * Scan a directory recursively and extract code chunks
1260
- * Added max depth (10) to prevent infinite recursion
226
+ * No depth limit: symlinks are skipped and WalkState.ancestors ends any cycle.
1261
227
  */
1262
228
  async scanDirectory(rootPath: string): Promise<CodeChunk[]> {
1263
229
  const { chunks } = await this.scanDirectoryWithStatus(rootPath);
@@ -1289,144 +255,6 @@ export class CodeScanner {
1289
255
  return { chunks: await this.scrubChunks(chunks), complete: walk.complete, chunkless, excluded };
1290
256
  }
1291
257
 
1292
- /**
1293
- * Enumerate scan-eligible relative file paths under `rootPath` without
1294
- * reading or chunking content. Same walk + ignorePatterns + extension
1295
- * filter as `scanDirectory`, but bounded to O(file count) directory ops
1296
- * - cheap enough to call on every periodic reconcile tick.
1297
- *
1298
- * Returns relative paths normalized against `rootPath`, matching the
1299
- * shape the server stores in `memories.metadata->>'filePath'`. Use this
1300
- * for the scanner reconciliation channel (`POST /api/v1/scan/reconcile`).
1301
- *
1302
- * Defensive: skips any path whose `relative()` result contains a `..`
1303
- * traversal segment (can happen when a symlink resolves under root but
1304
- * the readdir entry path doesn't normalize cleanly). The server's
1305
- * `safeScanPath` rejects such paths; filtering here keeps a single
1306
- * malformed entry from failing the entire reconcile push.
1307
- */
1308
- async listFiles(rootPath: string): Promise<string[]> {
1309
- const { paths } = await this.listFilesWithStatus(rootPath);
1310
- return paths;
1311
- }
1312
-
1313
- /**
1314
- * Completeness-aware variant of `listFiles`. `complete === false` means
1315
- * an fs error truncated part of the walk - the returned paths may be
1316
- * missing files that still exist on disk. The daemon's reconcile channel
1317
- * MUST NOT derive removedFiles from an incomplete inventory (a truncated
1318
- * walk otherwise reads as mass deletion and deprecates live memories).
1319
- */
1320
- async listFilesWithStatus(rootPath: string): Promise<{
1321
- paths: string[];
1322
- complete: boolean;
1323
- /**
1324
- * Listed paths a project rule re-included (a `!pattern` or a
1325
- * `-linguist-*` attribute). Content rules do not apply to them; every
1326
- * other listed path still answers to the content rules when read.
1327
- */
1328
- overridden: Set<string>;
1329
- }> {
1330
- rootPath = await this.canonicalRoot(rootPath);
1331
- const paths: string[] = [];
1332
- const overridden = new Set<string>();
1333
- const walk: WalkState = { complete: true, rules: new Map() };
1334
- await this.traversePaths(rootPath, rootPath, paths, 0, walk, [], overridden);
1335
- // Only a complete walk replaces the record: a partial one may be missing
1336
- // rule files, and uploading it would tell the server fewer rules exist.
1337
- if (walk.complete && walk.rules) this.ruleFilesByRoot.set(rootPath, walk.rules);
1338
- return {
1339
- paths: paths.filter((p) => !/(^|[/\\])\.\.([/\\]|$)/.test(p)),
1340
- complete: walk.complete,
1341
- overridden,
1342
- };
1343
- }
1344
-
1345
- /**
1346
- * The rule files the last complete `listFilesWithStatus` walk of `rootPath`
1347
- * applied, by directory ('' = root). Null before the first complete walk.
1348
- * The daemon uploads these lines so the server judges paths as it did.
1349
- */
1350
- async indexRuleFiles(rootPath: string): Promise<ReadonlyMap<string, IndexRuleFiles> | null> {
1351
- return this.ruleFilesByRoot.get(await this.canonicalRoot(rootPath)) ?? null;
1352
- }
1353
-
1354
- private readonly ruleFilesByRoot = new Map<string, Map<string, IndexRuleFiles>>();
1355
-
1356
- private async traversePaths(
1357
- currentPath: string,
1358
- rootPath: string,
1359
- out: string[],
1360
- depth: number,
1361
- walk: WalkState,
1362
- stack: IgnoreStack = [],
1363
- overridden?: Set<string>
1364
- ): Promise<void> {
1365
- if (depth >= CodeScanner.MAX_DEPTH) return;
1366
- if (this.isProtected(currentPath)) return;
1367
- let entries: string[];
1368
- try {
1369
- if (!(await this.confineSymlink(currentPath, rootPath)))
1370
- throw new Error('scanner_containment');
1371
- entries = await withTimeout(
1372
- readdir(currentPath),
1373
- FILE_OP_TIMEOUT_MS,
1374
- `readdir timed out: ${currentPath}`
1375
- );
1376
- } catch (error) {
1377
- walk.complete = false;
1378
- logDebug('Error traversing directory (listFiles)', { path: currentPath, error });
1379
- return;
1380
- }
1381
- const dirRel =
1382
- currentPath === rootPath ? '' : relative(rootPath, currentPath).split(sep).join('/');
1383
- const localLayer = await this.loadIgnoreLayer(currentPath, dirRel, walk, rootPath);
1384
- const localStack = localLayer ? [...stack, localLayer] : stack;
1385
- for (const entry of entries) {
1386
- const fullPath = join(currentPath, entry);
1387
- const relativePath = relative(rootPath, fullPath);
1388
- if (this.isProtected(fullPath)) continue;
1389
- if (this.shouldIgnore(relativePath)) continue;
1390
-
1391
- // Per-entry isolation: a single bad entry (dangling symlink, raced
1392
- // deletion, permission denial, fs timeout) must not abort the rest of
1393
- // the directory - but the inventory is no longer removal-trustworthy.
1394
- try {
1395
- const lstats = await withTimeout(
1396
- lstat(fullPath),
1397
- FILE_OP_TIMEOUT_MS,
1398
- `lstat timed out: ${fullPath}`
1399
- );
1400
- if (this.skipSymlink(lstats)) continue;
1401
- const stats = lstats;
1402
-
1403
- if (stats.isDirectory()) {
1404
- // .gitignore/.mnemonikignore, then soft-default directories
1405
- if (this.skipDirectory(relativePath, localStack)) continue;
1406
- if (await this.isBoundary(fullPath, rootPath)) continue; // a subproject or worktree
1407
- await this.traversePaths(
1408
- fullPath,
1409
- rootPath,
1410
- out,
1411
- depth + 1,
1412
- walk,
1413
- localStack,
1414
- overridden
1415
- );
1416
- } else if (stats.isFile()) {
1417
- if (!this.isChunkable(fullPath, relativePath)) continue;
1418
- const decision = this.classifyEntry(relativePath, localStack);
1419
- if (!decision?.include) continue;
1420
- out.push(relativePath);
1421
- if (decision.override) overridden?.add(relativePath);
1422
- }
1423
- } catch (error) {
1424
- walk.complete = false;
1425
- logDebug('Error traversing directory (listFiles)', { path: fullPath, error });
1426
- }
1427
- }
1428
- }
1429
-
1430
258
  /**
1431
259
  * Scan specific files and extract code chunks.
1432
260
  * Pass rootPath to compute proper relative file paths in chunk metadata.
@@ -1568,11 +396,6 @@ export class CodeScanner {
1568
396
  chunkless?: string[],
1569
397
  excluded?: IndexExcludedFile[]
1570
398
  ): Promise<void> {
1571
- // Prevent infinite recursion
1572
- if (depth >= CodeScanner.MAX_DEPTH) {
1573
- logDebug('Max directory depth reached, skipping', { path: currentPath, depth });
1574
- return;
1575
- }
1576
399
  if (this.isProtected(currentPath)) return;
1577
400
 
1578
401
  // Wrap readdir with timeout to prevent hanging
@@ -1580,13 +403,11 @@ export class CodeScanner {
1580
403
  try {
1581
404
  if (!(await this.confineSymlink(currentPath, rootPath)))
1582
405
  throw new Error('scanner_containment');
1583
- entries = await withTimeout(
1584
- readdir(currentPath),
1585
- FILE_OP_TIMEOUT_MS,
1586
- `readdir timed out: ${currentPath}`
1587
- );
406
+ // The root is the first ancestor; every other directory enters before it recurses.
407
+ if (depth === 0 && !(await enterDirectory(walk, currentPath))) return;
408
+ entries = await fileOp(() => readdir(currentPath), `readdir: ${currentPath}`);
1588
409
  } catch (error) {
1589
- walk.complete = false;
410
+ markUnreadable(walk, currentPath, error);
1590
411
  logDebug('Error traversing directory', { path: currentPath, error });
1591
412
  return;
1592
413
  }
@@ -1611,11 +432,7 @@ export class CodeScanner {
1611
432
  // deletion, permission denial, fs timeout) must not abort the rest of
1612
433
  // the directory - but the scan is no longer removal-trustworthy.
1613
434
  try {
1614
- const lstats = await withTimeout(
1615
- lstat(fullPath),
1616
- FILE_OP_TIMEOUT_MS,
1617
- `lstat timed out: ${fullPath}`
1618
- );
435
+ const lstats = await fileOp(() => lstat(fullPath), `lstat: ${fullPath}`);
1619
436
 
1620
437
  if (this.skipSymlink(lstats)) continue;
1621
438
  const stats = lstats;
@@ -1624,16 +441,22 @@ export class CodeScanner {
1624
441
  // .gitignore/.mnemonikignore, then soft-default directories
1625
442
  if (this.skipDirectory(relativePath, localStack)) continue;
1626
443
  if (await this.isBoundary(fullPath, rootPath)) continue; // a subproject or worktree
1627
- await this.traverseDirectory(
1628
- fullPath,
1629
- rootPath,
1630
- chunks,
1631
- depth + 1,
1632
- walk,
1633
- localStack,
1634
- chunkless,
1635
- excluded
1636
- );
444
+ const leave = await enterDirectory(walk, fullPath);
445
+ if (!leave) continue;
446
+ try {
447
+ await this.traverseDirectory(
448
+ fullPath,
449
+ rootPath,
450
+ chunks,
451
+ depth + 1,
452
+ walk,
453
+ localStack,
454
+ chunkless,
455
+ excluded
456
+ );
457
+ } finally {
458
+ leave();
459
+ }
1637
460
  } else if (stats.isFile()) {
1638
461
  if (!this.isChunkable(fullPath, relativePath)) continue;
1639
462
  const decision = this.classifyEntry(relativePath, localStack);
@@ -1645,49 +468,12 @@ export class CodeScanner {
1645
468
  chunks.push(...fileChunks);
1646
469
  }
1647
470
  } catch (error) {
1648
- walk.complete = false;
471
+ markUnreadable(walk, fullPath, error);
1649
472
  logDebug('Error traversing directory', { path: fullPath, error });
1650
473
  }
1651
474
  }
1652
475
  }
1653
476
 
1654
- /**
1655
- * Check if path should be ignored
1656
- * Fixed glob-to-regex conversion and substring matching.
1657
- * - Escape regex special chars before replacing * with .*
1658
- * - Replace ALL * occurrences (not just the first)
1659
- * - For non-glob patterns, match on path segments to avoid false positives
1660
- * (e.g., '.env' should not match '.environment.ts')
1661
- */
1662
- private shouldIgnore(path: string): boolean {
1663
- // Credential-bearing file kinds first, and outside the overridable
1664
- // `ignorePatterns` list - a caller-supplied pattern set must not be able
1665
- // to opt a project back into indexing its own private keys.
1666
- if (isSecretFile(path)) return true;
1667
- const segments = path.split('/');
1668
- return this.options.ignorePatterns.some((pattern) => {
1669
- if (pattern.includes('*')) {
1670
- // A leading '/' opts a glob pattern into SEGMENT-ANCHORED matching:
1671
- // the pattern body must start at the beginning of the path or right
1672
- // after a '/'. Without it, glob patterns keep their historical
1673
- // unanchored substring semantics - anchoring is opt-in per pattern so
1674
- // this change cannot alter any existing pattern's behavior.
1675
- // (Motivating bug: unanchored 'tests/fixtures/*' also matched
1676
- // 'integration-tests/fixtures/...' in every indexed project.)
1677
- const anchored = pattern.startsWith('/');
1678
- const body = anchored ? pattern.slice(1) : pattern;
1679
- // Escape regex special chars, then replace all * with .*
1680
- const escaped = body.replace(/[.+?^${}()|[\]\\]/g, '\\$&');
1681
- const source = escaped.replace(/\*/g, '.*');
1682
- const regex = new RegExp(anchored ? `(^|/)${source}` : source);
1683
- return regex.test(path);
1684
- }
1685
- // For non-glob patterns, check if any path segment matches exactly
1686
- // or if the full path ends with the pattern (for extension-like patterns)
1687
- return segments.some((segment) => segment === pattern) || path.endsWith('/' + pattern);
1688
- });
1689
- }
1690
-
1691
477
  /**
1692
478
  * Parse a file and extract code chunks.
1693
479
  * Size limit is the shared `MAX_SCANNED_FILE_BYTES` ceiling - the same
@@ -1708,9 +494,7 @@ export class CodeScanner {
1708
494
  try {
1709
495
  // Check file size before reading to avoid memory issues
1710
496
  // Wrap stat with timeout
1711
- const stats =
1712
- enumerated ??
1713
- (await withTimeout(lstat(filePath), FILE_OP_TIMEOUT_MS, `stat timed out: ${filePath}`));
497
+ const stats = enumerated ?? (await fileOp(() => lstat(filePath), `stat: ${filePath}`));
1714
498
  if (stats.size > CodeScanner.MAX_FILE_SIZE) {
1715
499
  logDebug('Skipping file exceeding size limit', {
1716
500
  filePath,
@@ -1721,10 +505,9 @@ export class CodeScanner {
1721
505
  }
1722
506
 
1723
507
  // Wrap readFile with timeout
1724
- let content = await withTimeout(
1725
- this.readConfined(filePath, rootPath, stats),
1726
- FILE_OP_TIMEOUT_MS,
1727
- `readFile timed out: ${filePath}`
508
+ let content = await fileOp(
509
+ () => this.readConfined(filePath, rootPath, stats),
510
+ `readFile: ${filePath}`
1728
511
  );
1729
512
  const relativePath = relative(rootPath, filePath);
1730
513
  // Content soft defaults (binary, generated header, minified, data dump),
@@ -2510,21 +1293,24 @@ export class CodeScanner {
2510
1293
  ): Promise<{
2511
1294
  files: Array<{ path: string; content: string; hash: string }>;
2512
1295
  complete: boolean;
1296
+ /** Entries a filesystem error or timeout left out. */
1297
+ unreadable: number;
1298
+ /** The first few unreadable entries, with the reason. */
1299
+ unreadableSamples: Array<{ path: string; reason: string }>;
2513
1300
  }> {
2514
1301
  projectRoot = await this.canonicalRoot(projectRoot);
2515
1302
  const out: Array<{ path: string; content: string; hash: string }> = [];
2516
1303
  const walkState: WalkState = { complete: true };
2517
1304
 
2518
- // M2: cap walk depth to match the scanner's MAX_DEPTH (10)
2519
1305
  const walk = async (dir: string, depth: number, stack: IgnoreStack = []): Promise<void> => {
2520
- if (depth >= CodeScanner.MAX_DEPTH) return;
2521
1306
  if (this.isProtected(dir)) return;
2522
1307
  let entries;
2523
1308
  try {
1309
+ if (depth === 0 && !(await enterDirectory(walkState, dir))) return;
2524
1310
  if (!(await this.confineSymlink(dir, projectRoot))) throw new Error('scanner_containment');
2525
- entries = await readdir(dir, { withFileTypes: true });
2526
- } catch {
2527
- walkState.complete = false;
1311
+ entries = await fileOp(() => readdir(dir, { withFileTypes: true }), `readdir: ${dir}`);
1312
+ } catch (error) {
1313
+ markUnreadable(walkState, dir, error);
2528
1314
  return;
2529
1315
  }
2530
1316
  const dirRel = dir === projectRoot ? '' : relative(projectRoot, dir).split(sep).join('/');
@@ -2538,9 +1324,9 @@ export class CodeScanner {
2538
1324
 
2539
1325
  let stats: Stats;
2540
1326
  try {
2541
- stats = await lstat(full);
2542
- } catch {
2543
- walkState.complete = false;
1327
+ stats = await fileOp(() => lstat(full), `lstat: ${full}`);
1328
+ } catch (error) {
1329
+ markUnreadable(walkState, full, error);
2544
1330
  continue;
2545
1331
  }
2546
1332
  if (this.skipSymlink(stats)) continue;
@@ -2550,12 +1336,21 @@ export class CodeScanner {
2550
1336
  if (this.ignoredByStack(rel, isDir, localStack)) continue; // .gitignore/.mnemonikignore
2551
1337
  if (isDir) {
2552
1338
  if (await this.isBoundary(full, projectRoot)) continue; // a subproject or worktree
2553
- await walk(full, depth + 1, localStack);
1339
+ const leave = await enterDirectory(walkState, full);
1340
+ if (!leave) continue;
1341
+ try {
1342
+ await walk(full, depth + 1, localStack);
1343
+ } finally {
1344
+ leave();
1345
+ }
2554
1346
  } else if (isFile && AUTHORITY_FILE_MATCHERS.some((m) => m(rel))) {
2555
1347
  let file: { path: string; content: string; hash: string } | undefined;
2556
1348
  try {
2557
1349
  if (stats.size > MAX_SCANNED_FILE_BYTES) continue;
2558
- let content = await this.readConfined(full, projectRoot, stats);
1350
+ let content = await fileOp(
1351
+ () => this.readConfined(full, projectRoot, stats),
1352
+ `readFile: ${full}`
1353
+ );
2559
1354
  // Authority files honor the same single ceiling as chunked files
2560
1355
  // (MAX_SCANNED_FILE_BYTES). Manifests are tiny; one this large is
2561
1356
  // anomalous, so say so rather than dropping it silently - a
@@ -2578,9 +1373,9 @@ export class CodeScanner {
2578
1373
  }
2579
1374
  const hash = createHash('sha256').update(content).digest('hex');
2580
1375
  file = { path: rel, content, hash };
2581
- } catch {
1376
+ } catch (error) {
2582
1377
  /* unreadable - skip */
2583
- walkState.complete = false;
1378
+ markUnreadable(walkState, full, error);
2584
1379
  }
2585
1380
  // The consumer owns backpressure; its abort must leave the walk immediately.
2586
1381
  if (file) {
@@ -2592,6 +1387,11 @@ export class CodeScanner {
2592
1387
  };
2593
1388
 
2594
1389
  await walk(projectRoot, 0);
2595
- return { files: out, complete: walkState.complete };
1390
+ return {
1391
+ files: out,
1392
+ complete: walkState.complete,
1393
+ unreadable: walkState.unreadable ?? 0,
1394
+ unreadableSamples: walkState.unreadableSamples ?? [],
1395
+ };
2596
1396
  }
2597
1397
  }