@mnemonik/shared 7.154.3 → 7.156.0

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