@mnemonik/shared 7.97.2 → 7.98.1

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 (130) hide show
  1. package/dist/ambientPointer.d.ts.map +1 -1
  2. package/dist/ambientPointer.js +2 -1
  3. package/dist/ambientPointer.js.map +1 -1
  4. package/dist/apiOrigin.d.ts +3 -0
  5. package/dist/apiOrigin.d.ts.map +1 -0
  6. package/dist/apiOrigin.js +5 -0
  7. package/dist/apiOrigin.js.map +1 -0
  8. package/dist/ast/astChunker.d.ts +12 -12
  9. package/dist/ast/astChunker.js +32 -32
  10. package/dist/ast/grammars.d.ts +9 -9
  11. package/dist/ast/grammars.js +15 -15
  12. package/dist/codeScanner.d.ts +56 -43
  13. package/dist/codeScanner.d.ts.map +1 -1
  14. package/dist/codeScanner.js +262 -143
  15. package/dist/codeScanner.js.map +1 -1
  16. package/dist/contextBudget.js +2 -2
  17. package/dist/contextBudget.js.map +1 -1
  18. package/dist/gitEditDetector.d.ts +6 -6
  19. package/dist/gitEditDetector.js +7 -7
  20. package/dist/hookRuntime.d.ts +35 -2
  21. package/dist/hookRuntime.d.ts.map +1 -1
  22. package/dist/hookRuntime.js +127 -33
  23. package/dist/hookRuntime.js.map +1 -1
  24. package/dist/hookTimeouts.d.ts +6 -6
  25. package/dist/hookTimeouts.js +6 -6
  26. package/dist/hostAdapter.d.ts +135 -0
  27. package/dist/hostAdapter.d.ts.map +1 -0
  28. package/dist/hostAdapter.js +323 -0
  29. package/dist/hostAdapter.js.map +1 -0
  30. package/dist/hostBinary.d.ts +29 -0
  31. package/dist/hostBinary.d.ts.map +1 -0
  32. package/dist/hostBinary.js +179 -0
  33. package/dist/hostBinary.js.map +1 -0
  34. package/dist/index.d.ts +11 -1
  35. package/dist/index.d.ts.map +1 -1
  36. package/dist/index.js +10 -1
  37. package/dist/index.js.map +1 -1
  38. package/dist/instructions.d.ts +26 -26
  39. package/dist/instructions.js +26 -26
  40. package/dist/jitDeliveryAck.d.ts.map +1 -1
  41. package/dist/jitDeliveryAck.js +2 -0
  42. package/dist/jitDeliveryAck.js.map +1 -1
  43. package/dist/projectIdentityFile.d.ts +25 -0
  44. package/dist/projectIdentityFile.d.ts.map +1 -0
  45. package/dist/projectIdentityFile.js +87 -0
  46. package/dist/projectIdentityFile.js.map +1 -0
  47. package/dist/projectSetupHandoff.d.ts +24 -0
  48. package/dist/projectSetupHandoff.d.ts.map +1 -0
  49. package/dist/projectSetupHandoff.js +249 -0
  50. package/dist/projectSetupHandoff.js.map +1 -0
  51. package/dist/protectedPaths.d.ts +9 -0
  52. package/dist/protectedPaths.d.ts.map +1 -0
  53. package/dist/protectedPaths.js +66 -0
  54. package/dist/protectedPaths.js.map +1 -0
  55. package/dist/readiness.d.ts +143 -0
  56. package/dist/readiness.d.ts.map +1 -0
  57. package/dist/readiness.js +304 -0
  58. package/dist/readiness.js.map +1 -0
  59. package/dist/repositoryFingerprint.d.ts +50 -0
  60. package/dist/repositoryFingerprint.d.ts.map +1 -0
  61. package/dist/repositoryFingerprint.js +160 -0
  62. package/dist/repositoryFingerprint.js.map +1 -0
  63. package/dist/repositoryRoot.d.ts +56 -0
  64. package/dist/repositoryRoot.d.ts.map +1 -0
  65. package/dist/repositoryRoot.js +226 -0
  66. package/dist/repositoryRoot.js.map +1 -0
  67. package/dist/runtimeReader.d.ts +102 -0
  68. package/dist/runtimeReader.d.ts.map +1 -0
  69. package/dist/runtimeReader.js +408 -0
  70. package/dist/runtimeReader.js.map +1 -0
  71. package/dist/runtimeSigners.d.ts +37 -0
  72. package/dist/runtimeSigners.d.ts.map +1 -0
  73. package/dist/runtimeSigners.js +507 -0
  74. package/dist/runtimeSigners.js.map +1 -0
  75. package/dist/scannerSupervisor.d.ts +35 -0
  76. package/dist/scannerSupervisor.d.ts.map +1 -0
  77. package/dist/scannerSupervisor.js +2 -0
  78. package/dist/scannerSupervisor.js.map +1 -0
  79. package/dist/secretPatterns.d.ts +9 -9
  80. package/dist/secretPatterns.d.ts.map +1 -1
  81. package/dist/secretPatterns.js +27 -25
  82. package/dist/secretPatterns.js.map +1 -1
  83. package/dist/testing/hostAdapterConformance.d.ts +17 -0
  84. package/dist/testing/hostAdapterConformance.d.ts.map +1 -0
  85. package/dist/testing/hostAdapterConformance.js +477 -0
  86. package/dist/testing/hostAdapterConformance.js.map +1 -0
  87. package/dist/usageGuide.d.ts +1 -1
  88. package/dist/usageGuide.d.ts.map +1 -1
  89. package/dist/usageGuide.js +4 -4
  90. package/package.json +7 -2
  91. package/queries/bash.tags.scm +1 -1
  92. package/queries/groovy.tags.scm +1 -1
  93. package/scripts/vendor-grammars.ts +13 -13
  94. package/src/ambientPointer.ts +2 -1
  95. package/src/apiOrigin.ts +4 -0
  96. package/src/ast/astChunker.ts +39 -39
  97. package/src/ast/grammars.ts +16 -16
  98. package/src/codeScanner.ts +274 -149
  99. package/src/contextBudget.ts +2 -2
  100. package/src/gitEditDetector.ts +7 -7
  101. package/src/hookRuntime.ts +162 -34
  102. package/src/hookTimeouts.ts +6 -6
  103. package/src/hostAdapter.ts +468 -0
  104. package/src/hostBinary.ts +205 -0
  105. package/src/index.ts +20 -0
  106. package/src/instructions.ts +26 -26
  107. package/src/jitDeliveryAck.ts +2 -0
  108. package/src/projectIdentityFile.ts +100 -0
  109. package/src/projectSetupHandoff.ts +275 -0
  110. package/src/protectedPaths.ts +88 -0
  111. package/src/readiness.ts +516 -0
  112. package/src/repositoryFingerprint.ts +199 -0
  113. package/src/repositoryRoot.ts +261 -0
  114. package/src/runtimeReader.ts +495 -0
  115. package/src/runtimeSigners.ts +577 -0
  116. package/src/scannerSupervisor.ts +26 -0
  117. package/src/secretPatterns.ts +27 -25
  118. package/src/testing/hostAdapterConformance.ts +538 -0
  119. package/src/usageGuide.ts +4 -4
  120. package/test-fixtures/identity/runner.d.mts +12 -0
  121. package/test-fixtures/identity/runner.mjs +282 -0
  122. package/tests/fixtures/repository-fingerprint-v1.golden.json +834 -0
  123. package/tests/projectIdentity.test.ts +56 -0
  124. package/tests/readiness.test.ts +158 -0
  125. package/tests/repositoryFingerprint.test.ts +26 -0
  126. package/tsconfig.test.json +9 -0
  127. package/wasm/bash.tags.scm +1 -1
  128. package/wasm/groovy.tags.scm +1 -1
  129. package/wasm/javascript.tags.scm +2 -2
  130. package/wasm/solidity.tags.scm +1 -1
@@ -1,13 +1,15 @@
1
1
  /**
2
2
  * Code Scanner - Parse and chunk source files for embedding
3
3
  */
4
- import { readdir, readFile, stat, lstat, realpath } from 'fs/promises';
5
- import { join, relative, extname, sep } from 'path';
4
+ import { readdir, open, lstat, realpath } from 'fs/promises';
5
+ import { constants } from 'fs';
6
+ import { join, relative, extname, sep, resolve, dirname } from 'path';
6
7
  import { createHash } from 'crypto';
7
8
  import ignore from 'ignore';
8
9
  import { debug as logDebug, info as logInfo, warn as logWarn } from './logger.js';
9
10
  import { withTimeout } from './asyncUtils.js';
10
11
  import { scrubSecrets } from './secretPatterns.js';
12
+ import { isProtectedLocalPath, protectedLocalPaths } from './protectedPaths.js';
11
13
  import { MAX_AST_PARSE_BYTES, chunkWithAst } from './ast/astChunker.js';
12
14
  import { astArtifactReport, resolveAstLanguage } from './ast/grammars.js';
13
15
  /**
@@ -17,14 +19,14 @@ const FILE_OP_TIMEOUT_MS = 5000;
17
19
  /**
18
20
  * The one file-size ceiling in Mnemonik. A file at or below this is scanned,
19
21
  * chunked, AND pushed verbatim; a file above it is uniformly outside the
20
- * system — not scanned, not chunked, not citable. "Scanned ⟺ verifiable".
22
+ * system - not scanned, not chunked, not citable. "Scanned ⟺ verifiable".
21
23
  *
22
24
  * This is deliberately a single exported constant rather than a number
23
25
  * repeated per layer. Three ceilings previously existed and had drifted:
24
26
  * the scan ceiling here (10 MB), a 5 MB whole-file push cap in the scanner
25
27
  * daemon, and a 5 MB `content` cap in the server's scanPushSchema. Files
26
28
  * between 5 and 10 MB were therefore indexed as chunks whose source content
27
- * the server never received — chunks that could be retrieved but never
29
+ * the server never received - chunks that could be retrieved but never
28
30
  * verified against, a silent fidelity split. Every consumer now imports this:
29
31
  *
30
32
  * - `CodeScanner.MAX_FILE_SIZE` / `collectAuthorityFilesWithStatus` (this file)
@@ -35,13 +37,15 @@ const FILE_OP_TIMEOUT_MS = 5000;
35
37
  * one site alone.
36
38
  */
37
39
  export const MAX_SCANNED_FILE_BYTES = 10 * 1024 * 1024; // 10MB
40
+ /** Transport ceiling; ordinary chunking may exceed maxChunkSize but never this bound. */
41
+ export const MAX_PUSH_CHUNK_CONTENT_LENGTH = 500_000;
38
42
  /**
39
- * Directory basenames the scanner never indexes, across every ecosystem —
43
+ * Directory basenames the scanner never indexes, across every ecosystem -
40
44
  * generated output, package-manager/build caches, and vendored deps. SINGLE
41
45
  * SOURCE OF TRUTH: the scanner package's discovery + watch walks import this
42
46
  * (`packages/scanner/src/{discovery,watcher}.ts`) and `src/core/utils.ts`
43
47
  * derives from it, so every ignore surface agrees instead of drifting (the
44
- * pre-2026-07 state had four divergent copies). Add only UNAMBIGUOUS names —
48
+ * pre-2026-07 state had four divergent copies). Add only UNAMBIGUOUS names -
45
49
  * a project's own `.gitignore` / `.mnemonikignore` covers the ambiguous,
46
50
  * project-specific cases (`vendor/`, custom dirs) hierarchically.
47
51
  */
@@ -70,7 +74,7 @@ export const BUILT_IN_IGNORE_DIRS = [
70
74
  '.mypy_cache',
71
75
  '.pytest_cache',
72
76
  '.ruff_cache',
73
- // Native mobile — iOS / Android / React Native / Flutter (2026-07-11 Pods incident)
77
+ // Native mobile - iOS / Android / React Native / Flutter (2026-07-11 Pods incident)
74
78
  'Pods',
75
79
  'DerivedData',
76
80
  '.gradle',
@@ -94,7 +98,7 @@ export const BUILT_IN_IGNORE_DIRS = [
94
98
  // an agent's own shell state into the user's searchable memory.
95
99
  //
96
100
  // Scoped to this one directory name deliberately. `.claude/` at large holds
97
- // real project content — `.claude/rules.md` is indexed on purpose
101
+ // real project content - `.claude/rules.md` is indexed on purpose
98
102
  // (tests/CodeScannerIgnorePatterns.test.ts), and only `.claude/worktrees/`
99
103
  // is suppressed, by the generic nested-git-boundary rule.
100
104
  'shell-snapshots',
@@ -104,7 +108,7 @@ export const BUILT_IN_IGNORE_DIRS = [
104
108
  * anchored fixture rule), combined with BUILT_IN_IGNORE_DIRS to form
105
109
  * DEFAULT_OPTIONS.ignorePatterns. See `shouldIgnore` for match semantics.
106
110
  *
107
- * `/tests/fixtures/*`: leading '/' = segment-anchored — matches tests/fixtures/
111
+ * `/tests/fixtures/*`: leading '/' = segment-anchored - matches tests/fixtures/
108
112
  * at the path root or immediately after a '/', so nested packages/x/tests/
109
113
  * fixtures/ are excluded but integration-tests/fixtures/ is NOT. Fixture
110
114
  * snapshot dirs were being indexed as real source and outranking the genuine
@@ -130,19 +134,19 @@ const BUILT_IN_IGNORE_FILE_PATTERNS = [
130
134
  ];
131
135
  /**
132
136
  * Files whose CONTENT is a credential rather than code. Today these are
133
- * excluded only INCIDENTALLY — `.pem`, `.key` and friends carry no
137
+ * excluded only INCIDENTALLY - `.pem`, `.key` and friends carry no
134
138
  * allowlisted extension, so nothing chunks them. That protection evaporates
135
139
  * the moment the extension allowlist widens (as it did for `.sh`/`.lua`), so
136
140
  * the exclusion is made explicit here and becomes a defended invariant:
137
141
  * unlike `ignorePatterns`, this predicate is NOT caller-overridable.
138
142
  *
139
143
  * Expressed as a predicate rather than glob strings because `shouldIgnore`'s
140
- * engine has no negation and matches globs as unanchored substrings — neither
144
+ * engine has no negation and matches globs as unanchored substrings - neither
141
145
  * property can state "every `.env.*` EXCEPT `.env.example`".
142
146
  *
143
147
  * Precision notes (over-exclusion is a fidelity failure in its own right):
144
148
  * - `.env` family: `/^\.env(\..+)?$/` matches `.env`, `.env.local`,
145
- * `.env.production` — and deliberately NOT `.envrc` or `.environment.ts`.
149
+ * `.env.production` - and deliberately NOT `.envrc` or `.environment.ts`.
146
150
  * - `.env.example` is carved out: it is authority-collected on purpose
147
151
  * (AUTHORITY_FILE_MATCHERS) and holds placeholder values, not secrets.
148
152
  * It is the doc-truth env_vars authority; excluding it would blind that
@@ -150,7 +154,7 @@ const BUILT_IN_IGNORE_FILE_PATTERNS = [
150
154
  * - `credentials` is matched ONLY directly inside a dot-directory
151
155
  * (`.aws/credentials`, `.docker/credentials`). A bare `credentials/` is a
152
156
  * real, common SOURCE directory name (gRPC, auth libraries) and
153
- * `shouldIgnore` cannot tell a file from a directory at its call sites —
157
+ * `shouldIgnore` cannot tell a file from a directory at its call sites -
154
158
  * an unqualified rule would silently delete that subtree from the index.
155
159
  * - `.tfvars` is excluded whole rather than per-basename: the credential file
156
160
  * is conventionally `terraform.tfvars` but the name is free-form, and the
@@ -165,7 +169,7 @@ const SECRET_FILE_BASENAME_PATTERNS = [
165
169
  /\.(?:pem|key|p12|pfx|keystore|jks|kubeconfig)$/i,
166
170
  // Terraform variable files: `terraform.tfvars` is the conventional home for
167
171
  // provider credentials, and unlike the entries above nothing else was
168
- // keeping it out — it is excluded here rather than merely left off
172
+ // keeping it out - it is excluded here rather than merely left off
169
173
  // `DEFAULT_INCLUDE_EXTENSIONS`, so a future widening of the allowlist cannot
170
174
  // quietly opt a project into embedding its own cloud keys.
171
175
  /\.tfvars(\.json)?$/i,
@@ -203,7 +207,7 @@ const IGNORE_FILE_NAMES = ['.gitignore', '.mnemonikignore'];
203
207
  * Exported because this list IS the product's language coverage: a project
204
208
  * written in something absent from it indexes zero code, and `code_search`
205
209
  * reports that as "no indexed matches" rather than "I do not read this
206
- * language" — invisible to the developer and to us. Coverage is a separate
210
+ * language" - invisible to the developer and to us. Coverage is a separate
207
211
  * dial from parse quality: a language with no structured extractor falls
208
212
  * through to `chunkRaw`, which is what Go, Java and C already get in
209
213
  * production, and crude chunks beat no chunks by an enormous margin.
@@ -211,7 +215,7 @@ const IGNORE_FILE_NAMES = ['.gitignore', '.mnemonikignore'];
211
215
  * Data formats (`.json`, `.yaml`, `.toml`, `.lock`) are deliberately absent:
212
216
  * the ones that carry meaning are already collected verbatim by
213
217
  * `AUTHORITY_FILE_MATCHERS`, and blanket-indexing the extension would pull in
214
- * lockfiles and generated output. `.tfvars` is absent for a different reason —
218
+ * lockfiles and generated output. `.tfvars` is absent for a different reason -
215
219
  * `terraform.tfvars` is a conventional home for provider credentials and
216
220
  * `isSecretFile` now excludes it outright.
217
221
  */
@@ -232,7 +236,7 @@ export const DEFAULT_INCLUDE_EXTENSIONS = [
232
236
  '.php',
233
237
  '.swift',
234
238
  '.kt',
235
- // Shell and Lua are the working languages of whole real projects —
239
+ // Shell and Lua are the working languages of whole real projects -
236
240
  // deployment tooling, container entrypoints, imapfilter/nginx/redis
237
241
  // configuration. Omitting them meant such a project indexed ZERO code and
238
242
  // code_search could not answer anything about it, while reporting that as
@@ -242,7 +246,7 @@ export const DEFAULT_INCLUDE_EXTENSIONS = [
242
246
  '.zsh',
243
247
  '.lua',
244
248
  '.md',
245
- // ── Variants of languages already on the list. `.mjs`/`.cjs`/`.mts`/`.cts`/
249
+ // -- Variants of languages already on the list. `.mjs`/`.cjs`/`.mts`/`.cts`/
246
250
  // `.pyi` inherit their language string from a listed extension, so their
247
251
  // absence was oversight rather than policy. The C++ spellings are a choice,
248
252
  // not an inheritance: `.h` stays 'c' (C headers dominate, and reading a C++
@@ -259,7 +263,7 @@ export const DEFAULT_INCLUDE_EXTENSIONS = [
259
263
  '.cc',
260
264
  '.cxx',
261
265
  '.kts',
262
- // ── Languages the scanner could not read at all.
266
+ // -- Languages the scanner could not read at all.
263
267
  '.dart',
264
268
  '.m',
265
269
  '.mm',
@@ -287,7 +291,7 @@ export const DEFAULT_INCLUDE_EXTENSIONS = [
287
291
  '.zig',
288
292
  '.vue',
289
293
  '.svelte',
290
- // ── Infrastructure and schema DSLs, where real logic lives and where "how
294
+ // -- Infrastructure and schema DSLs, where real logic lives and where "how
291
295
  // is this deployed?" and "what does this table hold?" go unanswered today.
292
296
  '.tf',
293
297
  '.sql',
@@ -299,12 +303,12 @@ export const DEFAULT_INCLUDE_EXTENSIONS = [
299
303
  '.bzl',
300
304
  ];
301
305
  /**
302
- * Extension → language string, the value carried on every chunk and on the
306
+ * Extension -> language string, the value carried on every chunk and on the
303
307
  * wire (`/scan/push` accepts any non-empty string up to 50 chars).
304
308
  *
305
309
  * A string no grammar claims is correct and expected: the AST layer resolves
306
310
  * such a language to `null` and the heuristic chunker takes over. Keep this in
307
- * sync with `DEFAULT_INCLUDE_EXTENSIONS` — an allowlisted extension that lands
311
+ * sync with `DEFAULT_INCLUDE_EXTENSIONS` - an allowlisted extension that lands
308
312
  * on 'unknown' still gets chunked, but nothing downstream can reason about it.
309
313
  *
310
314
  * Two extensions are genuinely ambiguous and are resolved rather than fudged:
@@ -391,7 +395,7 @@ export function languageForExtension(filePathOrExt) {
391
395
  // `extname` FIRST. A dotfile that carries a real extension ('.eslintrc.js',
392
396
  // '.mocharc.cjs', '.prettierrc.ts') starts with '.' and contains no '/', so
393
397
  // a bare-extension-first reading swallowed the whole name and answered
394
- // 'unknown' — while the same file spelled 'src/.eslintrc.js' answered
398
+ // 'unknown' - while the same file spelled 'src/.eslintrc.js' answered
395
399
  // 'javascript'. Those extensions are allowlisted, so the files are indexed
396
400
  // and reach the server with exactly the root-relative spelling that failed.
397
401
  const fromPath = EXTENSION_LANGUAGES[extname(filePathOrExt).toLowerCase()];
@@ -404,16 +408,16 @@ export function languageForExtension(filePathOrExt) {
404
408
  }
405
409
  /**
406
410
  * THE definition of "this path is a SQL migration the schema_columns authority
407
- * collects verbatim". One predicate, referenced by both halves of the deal —
411
+ * collects verbatim". One predicate, referenced by both halves of the deal -
408
412
  * `AUTHORITY_FILE_MATCHERS` (collect it) and `isAuthorityOnlyPath` (therefore
409
- * do not chunk it) — because two hand-written regexes drifted once already and
413
+ * do not chunk it) - because two hand-written regexes drifted once already and
410
414
  * the failure is silent in both directions.
411
415
  *
412
416
  * Root-anchored and CASE-SENSITIVE on purpose: it mirrors the server-side
413
417
  * extractor, which does `listFiles('migrations/')` (LIKE 'migrations/%') then
414
418
  * `endsWith('.sql')`, both case-sensitive. `Migrations/001.sql` (the .NET/EF
415
419
  * Core spelling) and `migrations/002.SQL` are NOT collected, so they must not
416
- * be suppressed from chunking either — that would index them nowhere. Same
420
+ * be suppressed from chunking either - that would index them nowhere. Same
417
421
  * reason a nested `packages/x/migrations/y.sql` is left alone.
418
422
  */
419
423
  const isMigrationSqlAuthorityPath = (posixRelPath) => /^migrations\/.*\.sql$/.test(posixRelPath);
@@ -425,7 +429,7 @@ const isMigrationSqlAuthorityPath = (posixRelPath) => /^migrations\/.*\.sql$/.te
425
429
  * through.
426
430
  *
427
431
  * `migrations/**.sql` is the live case: adding `.sql` to the allowlist without
428
- * this exclusion would dual-collect every migration — once verbatim, once
432
+ * this exclusion would dual-collect every migration - once verbatim, once
429
433
  * chunked and embedded. On this repo alone that is 208 files of append-only
430
434
  * DDL (140 forward, the rest rollback/manual), embedded to answer questions
431
435
  * the authority path already answers exactly.
@@ -439,7 +443,7 @@ const AUTHORITY_ONLY_PATH_PREDICATES = [
439
443
  ];
440
444
  /**
441
445
  * True when `relPath` is collected verbatim as authority content and must not
442
- * additionally be chunked. Accepts OS-native or POSIX separators.
446
+ * also be chunked. Accepts OS-native or POSIX separators.
443
447
  */
444
448
  export function isAuthorityOnlyPath(relPath) {
445
449
  if (!relPath)
@@ -453,6 +457,7 @@ const DEFAULT_OPTIONS = {
453
457
  ignorePatterns: [...BUILT_IN_IGNORE_DIRS, ...BUILT_IN_IGNORE_FILE_PATTERNS],
454
458
  includeExtensions: [...DEFAULT_INCLUDE_EXTENSIONS],
455
459
  maxAstParseBytes: MAX_AST_PARSE_BYTES,
460
+ protectedPaths: [],
456
461
  };
457
462
  /**
458
463
  * Matchers for authority manifest / config / CI files whose verbatim content
@@ -480,7 +485,7 @@ export const AUTHORITY_FILE_MATCHERS = [
480
485
  isMigrationSqlAuthorityPath,
481
486
  ];
482
487
  /**
483
- * Segment-anchored match for the `tests/fixtures/` ignore pattern above —
488
+ * Segment-anchored match for the `tests/fixtures/` ignore pattern above -
484
489
  * matches at the path root or immediately after a `/`, mirroring exactly
485
490
  * what `shouldIgnore` compiles `'/tests/fixtures/*'` to. Exported so
486
491
  * server-side consumers (e.g. the `/scan/push` route) and query-time
@@ -498,7 +503,7 @@ const BUILT_IN_IGNORE_DIR_SET = new Set(BUILT_IN_IGNORE_DIRS);
498
503
  * (segment-exact) with a project's uploaded .gitignore/.mnemonikignore patterns
499
504
  * (gitignore syntax via the `ignore` package). Built once per request and reused
500
505
  * across all pushed paths (root-relative, forward-slash). This does NOT replace
501
- * the daemon's precise hierarchical walk — it backstops it so a buggy, stale, or
506
+ * the daemon's precise hierarchical walk - it backstops it so a buggy, stale, or
502
507
  * malicious client cannot index a path the project ignores, making .mnemonikignore
503
508
  * a hard guarantee rather than best-effort.
504
509
  */
@@ -531,7 +536,7 @@ export function makeIgnoreMatcher(extraPatterns = []) {
531
536
  * being scanned: it carries its own `.git` entry. A `.git` FILE marks a
532
537
  * linked git worktree (wherever the user keeps them) or a submodule
533
538
  * checkout; a `.git` DIRECTORY marks a nested clone. Files under such a
534
- * boundary belong to that repository's own project identity — scanning them
539
+ * boundary belong to that repository's own project identity - scanning them
535
540
  * into the parent project is wrong attribution for any codebase, so the
536
541
  * walkers skip these subtrees. Convention-free: this is git's own marker,
537
542
  * not a host/tool path layout. The scan ROOT is exempt by construction
@@ -563,7 +568,7 @@ let astCapabilityLog = null;
563
568
  */
564
569
  const reportedGrammarFallbacks = new Set();
565
570
  /**
566
- * Log which grammars this install ships — ONCE per process, at daemon start,
571
+ * Log which grammars this install ships - ONCE per process, at daemon start,
567
572
  * never per file.
568
573
  *
569
574
  * A missing grammar silently degrades every file of that language to the
@@ -574,8 +579,8 @@ const reportedGrammarFallbacks = new Set();
574
579
  *
575
580
  * Deliberately a `statSync` of the artifacts (`astArtifactReport`) and not a
576
581
  * load of them (`astCapabilityReport`). Loading all 18 to print this line costs
577
- * ~690 ms and ~75 MB of RSS that is never returned — web-tree-sitter exposes no
578
- * `Language.delete` — which is a permanent tax on a daemon watching a pure
582
+ * ~690 ms and ~75 MB of RSS that is never returned - web-tree-sitter exposes no
583
+ * `Language.delete` - which is a permanent tax on a daemon watching a pure
579
584
  * TypeScript repo, paid to pre-answer a question about seventeen languages it
580
585
  * will never see. Grammars load lazily instead, on the first file of a language,
581
586
  * and the two failure modes only a load can detect (`wasm_load_failed`,
@@ -605,18 +610,38 @@ export function logAstCapabilityOnce() {
605
610
  }
606
611
  export class CodeScanner {
607
612
  options;
613
+ protectedPaths;
614
+ /** Cumulative observations since this scanner started, including repeated walks. */
615
+ skipReasons = { symlink_skipped: 0 };
616
+ skipSymlink(stats) {
617
+ if (!stats.isSymbolicLink())
618
+ return false; // Includes Windows junctions.
619
+ this.skipReasons.symlink_skipped++;
620
+ return true;
621
+ }
608
622
  /**
609
623
  * `includeExtensions` folded to lowercase for matching. The gate used to
610
624
  * compare `extname()` verbatim while `detectLanguage` lowercased, so a
611
- * project spelling its files the canonical way — `.R` for R, `.SQL`/`.PS1`
612
- * on Windows, `.C`/`.H` in older C trees — indexed zero of them and nothing
625
+ * project spelling its files the canonical way - `.R` for R, `.SQL`/`.PS1`
626
+ * on Windows, `.C`/`.H` in older C trees - indexed zero of them and nothing
613
627
  * said why.
614
628
  */
615
629
  includeExtensionSet;
616
630
  constructor(options = {}) {
617
631
  this.options = { ...DEFAULT_OPTIONS, ...options };
632
+ this.protectedPaths = [
633
+ ...new Set([...protectedLocalPaths(), ...(options.protectedPaths ?? [])]),
634
+ ];
618
635
  this.includeExtensionSet = new Set(this.options.includeExtensions.map((ext) => ext.toLowerCase()));
619
636
  }
637
+ isProtected(path) {
638
+ return isProtectedLocalPath(path, this.protectedPaths);
639
+ }
640
+ async isProtectedRead(path) {
641
+ if (this.isProtected(path))
642
+ return true;
643
+ return isProtectedLocalPath(await realpath(path), this.protectedPaths);
644
+ }
620
645
  /**
621
646
  * The chunkable-file gate, shared by every walker and by the explicit
622
647
  * file-list path so all three agree on what exists. `relPath` is the path
@@ -633,22 +658,26 @@ export class CodeScanner {
633
658
  }
634
659
  /**
635
660
  * Read `.gitignore` + `.mnemonikignore` in `absDir` and compile them into one
636
- * ignore layer (the two files are unioned — `.mnemonikignore` is just an
661
+ * ignore layer (the two files are unioned - `.mnemonikignore` is just an
637
662
  * additional gitignore-syntax exclusion list). Returns null when neither file
638
663
  * exists. FAIL-CLOSED: a non-ENOENT read error (EISDIR, EACCES, timeout)
639
664
  * latches `walk.complete = false` so callers deriving *removals* from the walk
640
665
  * treat it as untrustworthy rather than silently ignoring nothing.
641
666
  */
642
- async loadIgnoreLayer(absDir, baseRel, walk) {
667
+ async loadIgnoreLayer(absDir, baseRel, walk, rootPath) {
668
+ if (this.isProtected(absDir))
669
+ return null;
643
670
  const patterns = [];
644
671
  for (const name of IGNORE_FILE_NAMES) {
645
672
  const filePath = join(absDir, name);
646
673
  try {
647
- const content = await withTimeout(readFile(filePath, 'utf-8'), FILE_OP_TIMEOUT_MS, `readFile timed out: ${filePath}`);
674
+ const content = await withTimeout(this.readConfined(filePath, rootPath), FILE_OP_TIMEOUT_MS, `readFile timed out: ${filePath}`);
648
675
  for (const line of content.split(/\r?\n/))
649
676
  patterns.push(line);
650
677
  }
651
678
  catch (err) {
679
+ if (err.message === 'symlink_skipped')
680
+ continue;
652
681
  if (err?.code !== 'ENOENT') {
653
682
  walk.complete = false;
654
683
  logDebug('Ignore-file read failed (fail-closed)', { path: filePath, err });
@@ -695,7 +724,7 @@ export class CodeScanner {
695
724
  return false;
696
725
  }
697
726
  /**
698
- * Reconstruct the root→parent ignore-layer stack for a single file, for the
727
+ * Reconstruct the root->parent ignore-layer stack for a single file, for the
699
728
  * incremental `scanFiles` path (which has a flat file list, not a recursive
700
729
  * walk). Layers are memoized per directory in `cache` across a batch.
701
730
  */
@@ -710,7 +739,7 @@ export class CodeScanner {
710
739
  for (const dir of dirs) {
711
740
  let layer = cache.get(dir);
712
741
  if (layer === undefined) {
713
- layer = await this.loadIgnoreLayer(dir === '' ? rootPath : join(rootPath, dir), dir, walk);
742
+ layer = await this.loadIgnoreLayer(dir === '' ? rootPath : join(rootPath, dir), dir, walk, rootPath);
714
743
  cache.set(dir, layer);
715
744
  }
716
745
  if (layer)
@@ -721,7 +750,7 @@ export class CodeScanner {
721
750
  /**
722
751
  * True when any directory strictly between `rootPath` and `filePath`
723
752
  * carries its own `.git` entry (see isGitBoundary). Paths that don't
724
- * resolve under `rootPath` are not judged here (return false) — the
753
+ * resolve under `rootPath` are not judged here (return false) - the
725
754
  * server's path validation handles escapes.
726
755
  */
727
756
  async insideNestedGitBoundary(filePath, rootPath) {
@@ -746,17 +775,81 @@ export class CodeScanner {
746
775
  * Prevents runaway recursion on deep/symlinked structures
747
776
  */
748
777
  static MAX_DEPTH = 10;
749
- /**
750
- * Symlink confinement shared by every walker (traverseDirectory,
751
- * traversePaths, collectAuthorityFiles): true when the symlink at
752
- * `fullPath` resolves inside `rootPath` and may be followed, false when
753
- * it escapes the project root. A dangling link (realpath ENOENT) throws
754
- * to the caller's per-entry handler.
755
- */
756
- async confineSymlink(fullPath, rootPath) {
757
- const resolved = await realpath(fullPath);
758
- const resolvedRoot = await realpath(rootPath);
759
- return resolved.startsWith(resolvedRoot + '/') || resolved === resolvedRoot;
778
+ canonicalRoots = new Map();
779
+ async canonicalRoot(root) {
780
+ let canonical = this.canonicalRoots.get(root);
781
+ if (!canonical) {
782
+ canonical = await realpath(root);
783
+ this.canonicalRoots.set(root, canonical);
784
+ }
785
+ return canonical;
786
+ }
787
+ static containsCanonical(candidate, root, paths = { sep, resolve }, caseInsensitive = process.platform === 'win32') {
788
+ // realpath expands filesystem aliases (including Windows 8.3 names). Never guess a short name.
789
+ candidate = paths.resolve(candidate);
790
+ root = paths.resolve(root);
791
+ if (caseInsensitive) {
792
+ candidate = candidate.toLowerCase();
793
+ root = root.toLowerCase();
794
+ }
795
+ return (candidate === root || candidate.startsWith(root.endsWith(paths.sep) ? root : root + paths.sep));
796
+ }
797
+ async confineSymlink(fullPath, canonicalRoot) {
798
+ // Check each component: explicit-file callers must not bypass the walkers'
799
+ // no-symlink policy by supplying a path through an in-root directory link.
800
+ let path = resolve(fullPath);
801
+ if (!CodeScanner.containsCanonical(path, canonicalRoot))
802
+ return false;
803
+ for (;;) {
804
+ if (this.skipSymlink(await lstat(path)))
805
+ throw new Error('symlink_skipped');
806
+ if (CodeScanner.containsCanonical(canonicalRoot, path))
807
+ break;
808
+ path = dirname(path);
809
+ }
810
+ return CodeScanner.containsCanonical(await realpath(fullPath), canonicalRoot);
811
+ }
812
+ async readConfined(fullPath, canonicalRoot, enumerated) {
813
+ const expected = enumerated ?? (await lstat(fullPath));
814
+ if (this.skipSymlink(expected))
815
+ throw new Error('symlink_skipped');
816
+ if (!expected.isFile())
817
+ throw new Error('scanner_not_regular');
818
+ if (!(await this.confineSymlink(fullPath, canonicalRoot)))
819
+ throw new Error('scanner_containment');
820
+ const canonical = await realpath(fullPath);
821
+ if (!CodeScanner.containsCanonical(canonical, canonicalRoot) ||
822
+ (await this.isProtectedRead(canonical)))
823
+ throw new Error('scanner_containment');
824
+ // O_NONBLOCK also prevents a substituted FIFO from hanging before fstat.
825
+ const flags = constants.O_RDONLY |
826
+ (constants.O_NONBLOCK ?? 0) |
827
+ (process.platform === 'win32' ? 0 : (constants.O_NOFOLLOW ?? 0));
828
+ const handle = await open(fullPath, flags).catch((error) => {
829
+ if (error.code === 'ELOOP') {
830
+ this.skipReasons.symlink_skipped++;
831
+ throw new Error('symlink_skipped');
832
+ }
833
+ throw error;
834
+ });
835
+ try {
836
+ const actual = await handle.stat();
837
+ if (!actual.isFile())
838
+ throw new Error('scanner_not_regular');
839
+ if (actual.dev !== expected.dev || actual.ino !== expected.ino)
840
+ throw new Error('scanner_identity_changed');
841
+ // Windows has no O_NOFOLLOW; lstat is Node's junction/reparse-link check.
842
+ if (process.platform === 'win32' && this.skipSymlink(await lstat(fullPath)))
843
+ throw new Error('symlink_skipped');
844
+ // Linux also proves the opened object is still at the checked path.
845
+ if (process.platform === 'linux' &&
846
+ (await realpath(`/proc/self/fd/${handle.fd}`)) !== canonical)
847
+ throw new Error('scanner_containment');
848
+ return await handle.readFile('utf-8');
849
+ }
850
+ finally {
851
+ await handle.close();
852
+ }
760
853
  }
761
854
  /**
762
855
  * Scan a directory recursively and extract code chunks
@@ -769,12 +862,13 @@ export class CodeScanner {
769
862
  /**
770
863
  * Completeness-aware variant of `scanDirectory`. `complete === false`
771
864
  * means at least one fs error was swallowed during the walk, so the
772
- * chunk set may be missing files that still exist on disk — callers
865
+ * chunk set may be missing files that still exist on disk - callers
773
866
  * deriving removals from the result must not trust it as an inventory.
774
867
  * Chunks are secret-scrubbed before returning (same daemon-side
775
868
  * redaction guarantee as `scanFiles`).
776
869
  */
777
870
  async scanDirectoryWithStatus(rootPath) {
871
+ rootPath = await this.canonicalRoot(rootPath);
778
872
  const chunks = [];
779
873
  const walk = { complete: true };
780
874
  await this.traverseDirectory(rootPath, rootPath, chunks, 0, walk);
@@ -784,7 +878,7 @@ export class CodeScanner {
784
878
  * Enumerate scan-eligible relative file paths under `rootPath` without
785
879
  * reading or chunking content. Same walk + ignorePatterns + extension
786
880
  * filter as `scanDirectory`, but bounded to O(file count) directory ops
787
- * — cheap enough to call on every periodic reconcile tick.
881
+ * - cheap enough to call on every periodic reconcile tick.
788
882
  *
789
883
  * Returns relative paths normalized against `rootPath`, matching the
790
884
  * shape the server stores in `memories.metadata->>'filePath'`. Use this
@@ -802,12 +896,13 @@ export class CodeScanner {
802
896
  }
803
897
  /**
804
898
  * Completeness-aware variant of `listFiles`. `complete === false` means
805
- * an fs error truncated part of the walk — the returned paths may be
899
+ * an fs error truncated part of the walk - the returned paths may be
806
900
  * missing files that still exist on disk. The daemon's reconcile channel
807
901
  * MUST NOT derive removedFiles from an incomplete inventory (a truncated
808
902
  * walk otherwise reads as mass deletion and deprecates live memories).
809
903
  */
810
904
  async listFilesWithStatus(rootPath) {
905
+ rootPath = await this.canonicalRoot(rootPath);
811
906
  const paths = [];
812
907
  const walk = { complete: true };
813
908
  await this.traversePaths(rootPath, rootPath, paths, 0, walk);
@@ -819,8 +914,12 @@ export class CodeScanner {
819
914
  async traversePaths(currentPath, rootPath, out, depth, walk, stack = []) {
820
915
  if (depth >= CodeScanner.MAX_DEPTH)
821
916
  return;
917
+ if (this.isProtected(currentPath))
918
+ return;
822
919
  let entries;
823
920
  try {
921
+ if (!(await this.confineSymlink(currentPath, rootPath)))
922
+ throw new Error('scanner_containment');
824
923
  entries = await withTimeout(readdir(currentPath), FILE_OP_TIMEOUT_MS, `readdir timed out: ${currentPath}`);
825
924
  }
826
925
  catch (error) {
@@ -829,24 +928,23 @@ export class CodeScanner {
829
928
  return;
830
929
  }
831
930
  const dirRel = currentPath === rootPath ? '' : relative(rootPath, currentPath).split(sep).join('/');
832
- const localLayer = await this.loadIgnoreLayer(currentPath, dirRel, walk);
931
+ const localLayer = await this.loadIgnoreLayer(currentPath, dirRel, walk, rootPath);
833
932
  const localStack = localLayer ? [...stack, localLayer] : stack;
834
933
  for (const entry of entries) {
835
934
  const fullPath = join(currentPath, entry);
836
935
  const relativePath = relative(rootPath, fullPath);
936
+ if (this.isProtected(fullPath))
937
+ continue;
837
938
  if (this.shouldIgnore(relativePath))
838
939
  continue;
839
940
  // Per-entry isolation: a single bad entry (dangling symlink, raced
840
941
  // deletion, permission denial, fs timeout) must not abort the rest of
841
- // the directory — but the inventory is no longer removal-trustworthy.
942
+ // the directory - but the inventory is no longer removal-trustworthy.
842
943
  try {
843
944
  const lstats = await withTimeout(lstat(fullPath), FILE_OP_TIMEOUT_MS, `lstat timed out: ${fullPath}`);
844
- if (lstats.isSymbolicLink() && !(await this.confineSymlink(fullPath, rootPath))) {
945
+ if (this.skipSymlink(lstats))
845
946
  continue;
846
- }
847
- const stats = lstats.isSymbolicLink()
848
- ? await withTimeout(stat(fullPath), FILE_OP_TIMEOUT_MS, `stat timed out: ${fullPath}`)
849
- : lstats;
947
+ const stats = lstats;
850
948
  if (stats.isDirectory()) {
851
949
  if (this.ignoredByStack(relativePath, true, localStack))
852
950
  continue; // .gitignore/.mnemonikignore
@@ -873,11 +971,16 @@ export class CodeScanner {
873
971
  * Pass rootPath to compute proper relative file paths in chunk metadata.
874
972
  */
875
973
  async scanFiles(filePaths, rootPath) {
974
+ rootPath = await this.canonicalRoot(rootPath);
876
975
  const chunks = [];
877
976
  const ignoreCache = new Map();
878
977
  const walk = { complete: true };
879
978
  for (const filePath of filePaths) {
880
979
  try {
980
+ if (!(await this.confineSymlink(filePath, rootPath)))
981
+ continue;
982
+ if (this.isProtected(filePath) || (rootPath && this.isProtected(rootPath)))
983
+ continue;
881
984
  const fileRel = rootPath ? relative(rootPath, filePath).split(sep).join('/') : filePath;
882
985
  if (this.shouldIgnore(fileRel)) {
883
986
  continue;
@@ -892,7 +995,7 @@ export class CodeScanner {
892
995
  }
893
996
  }
894
997
  // Explicit file lists arrive from watcher events, which can race a
895
- // worktree/nested-clone appearing — apply the same nested-git-boundary
998
+ // worktree/nested-clone appearing - apply the same nested-git-boundary
896
999
  // rule the walks use (an ancestor between root and the file carrying
897
1000
  // its own .git means the file belongs to another repository).
898
1001
  if (rootPath && (await this.insideNestedGitBoundary(filePath, rootPath))) {
@@ -920,15 +1023,27 @@ export class CodeScanner {
920
1023
  * honor the same redaction guarantee.
921
1024
  */
922
1025
  scrubChunks(chunks) {
923
- return chunks.map((chunk) => {
1026
+ return chunks.flatMap((chunk) => {
924
1027
  const scrubbed = scrubSecrets(chunk.content);
925
- if (scrubbed === chunk.content)
926
- return chunk;
927
- return {
928
- ...chunk,
929
- content: scrubbed,
930
- contentHash: this.hash(scrubbed),
931
- };
1028
+ if (scrubbed.length <= MAX_PUSH_CHUNK_CONTENT_LENGTH) {
1029
+ return scrubbed === chunk.content
1030
+ ? [chunk]
1031
+ : [{ ...chunk, content: scrubbed, contentHash: this.hash(scrubbed) }];
1032
+ }
1033
+ const pieces = [];
1034
+ let startLine = chunk.startLine;
1035
+ for (let offset = 0; offset < scrubbed.length;) {
1036
+ let end = Math.min(offset + MAX_PUSH_CHUNK_CONTENT_LENGTH, scrubbed.length);
1037
+ // Keep a UTF-16 surrogate pair together at a transport boundary.
1038
+ if (end < scrubbed.length && /[\uD800-\uDBFF]/.test(scrubbed.charAt(end - 1)))
1039
+ end--;
1040
+ const content = scrubbed.slice(offset, end);
1041
+ const endLine = startLine + (content.match(/\n/g)?.length ?? 0);
1042
+ pieces.push({ ...chunk, content, startLine, endLine, contentHash: this.hash(content) });
1043
+ startLine = endLine;
1044
+ offset = end;
1045
+ }
1046
+ return pieces;
932
1047
  });
933
1048
  }
934
1049
  /**
@@ -941,9 +1056,13 @@ export class CodeScanner {
941
1056
  logDebug('Max directory depth reached, skipping', { path: currentPath, depth });
942
1057
  return;
943
1058
  }
1059
+ if (this.isProtected(currentPath))
1060
+ return;
944
1061
  // Wrap readdir with timeout to prevent hanging
945
1062
  let entries;
946
1063
  try {
1064
+ if (!(await this.confineSymlink(currentPath, rootPath)))
1065
+ throw new Error('scanner_containment');
947
1066
  entries = await withTimeout(readdir(currentPath), FILE_OP_TIMEOUT_MS, `readdir timed out: ${currentPath}`);
948
1067
  }
949
1068
  catch (error) {
@@ -953,27 +1072,25 @@ export class CodeScanner {
953
1072
  }
954
1073
  // Layer this directory's .gitignore/.mnemonikignore onto the inherited stack.
955
1074
  const dirRel = currentPath === rootPath ? '' : relative(rootPath, currentPath).split(sep).join('/');
956
- const localLayer = await this.loadIgnoreLayer(currentPath, dirRel, walk);
1075
+ const localLayer = await this.loadIgnoreLayer(currentPath, dirRel, walk, rootPath);
957
1076
  const localStack = localLayer ? [...stack, localLayer] : stack;
958
1077
  for (const entry of entries) {
959
1078
  const fullPath = join(currentPath, entry);
960
1079
  const relativePath = relative(rootPath, fullPath);
1080
+ if (this.isProtected(fullPath))
1081
+ continue;
961
1082
  // Check ignore patterns
962
1083
  if (this.shouldIgnore(relativePath)) {
963
1084
  continue;
964
1085
  }
965
1086
  // Per-entry isolation: a single bad entry (dangling symlink, raced
966
1087
  // deletion, permission denial, fs timeout) must not abort the rest of
967
- // the directory — but the scan is no longer removal-trustworthy.
1088
+ // the directory - but the scan is no longer removal-trustworthy.
968
1089
  try {
969
1090
  const lstats = await withTimeout(lstat(fullPath), FILE_OP_TIMEOUT_MS, `lstat timed out: ${fullPath}`);
970
- if (lstats.isSymbolicLink() && !(await this.confineSymlink(fullPath, rootPath))) {
971
- logDebug('Skipping symlink escaping project root', { fullPath, rootPath });
1091
+ if (this.skipSymlink(lstats))
972
1092
  continue;
973
- }
974
- const stats = lstats.isSymbolicLink()
975
- ? await withTimeout(stat(fullPath), FILE_OP_TIMEOUT_MS, `stat timed out: ${fullPath}`)
976
- : lstats;
1093
+ const stats = lstats;
977
1094
  if (stats.isDirectory()) {
978
1095
  if (this.ignoredByStack(relativePath, true, localStack))
979
1096
  continue; // .gitignore/.mnemonikignore
@@ -985,7 +1102,7 @@ export class CodeScanner {
985
1102
  if (this.ignoredByStack(relativePath, false, localStack))
986
1103
  continue;
987
1104
  if (this.isChunkable(fullPath, relativePath)) {
988
- const fileChunks = await this.parseFile(fullPath, rootPath);
1105
+ const fileChunks = await this.parseFile(fullPath, rootPath, stats);
989
1106
  chunks.push(...fileChunks);
990
1107
  }
991
1108
  }
@@ -1006,7 +1123,7 @@ export class CodeScanner {
1006
1123
  */
1007
1124
  shouldIgnore(path) {
1008
1125
  // Credential-bearing file kinds first, and outside the overridable
1009
- // `ignorePatterns` list — a caller-supplied pattern set must not be able
1126
+ // `ignorePatterns` list - a caller-supplied pattern set must not be able
1010
1127
  // to opt a project back into indexing its own private keys.
1011
1128
  if (isSecretFile(path))
1012
1129
  return true;
@@ -1016,10 +1133,10 @@ export class CodeScanner {
1016
1133
  // A leading '/' opts a glob pattern into SEGMENT-ANCHORED matching:
1017
1134
  // the pattern body must start at the beginning of the path or right
1018
1135
  // after a '/'. Without it, glob patterns keep their historical
1019
- // unanchored substring semantics — anchoring is opt-in per pattern so
1136
+ // unanchored substring semantics - anchoring is opt-in per pattern so
1020
1137
  // this change cannot alter any existing pattern's behavior.
1021
1138
  // (Motivating bug: unanchored 'tests/fixtures/*' also matched
1022
- // 'integration-tests/fixtures/…' in every indexed project.)
1139
+ // 'integration-tests/fixtures/...' in every indexed project.)
1023
1140
  const anchored = pattern.startsWith('/');
1024
1141
  const body = anchored ? pattern.slice(1) : pattern;
1025
1142
  // Escape regex special chars, then replace all * with .*
@@ -1035,16 +1152,17 @@ export class CodeScanner {
1035
1152
  }
1036
1153
  /**
1037
1154
  * Parse a file and extract code chunks.
1038
- * Size limit is the shared `MAX_SCANNED_FILE_BYTES` ceiling — the same
1155
+ * Size limit is the shared `MAX_SCANNED_FILE_BYTES` ceiling - the same
1039
1156
  * number the daemon and the server's scanPushSchema enforce, so anything
1040
1157
  * chunked here can always be shipped and stored verbatim.
1041
1158
  */
1042
1159
  static MAX_FILE_SIZE = MAX_SCANNED_FILE_BYTES;
1043
- async parseFile(filePath, rootPath) {
1160
+ async parseFile(filePath, rootPath, enumerated) {
1044
1161
  try {
1045
1162
  // Check file size before reading to avoid memory issues
1046
1163
  // Wrap stat with timeout
1047
- const stats = await withTimeout(stat(filePath), FILE_OP_TIMEOUT_MS, `stat timed out: ${filePath}`);
1164
+ const stats = enumerated ??
1165
+ (await withTimeout(lstat(filePath), FILE_OP_TIMEOUT_MS, `stat timed out: ${filePath}`));
1048
1166
  if (stats.size > CodeScanner.MAX_FILE_SIZE) {
1049
1167
  logDebug('Skipping file exceeding size limit', {
1050
1168
  filePath,
@@ -1054,12 +1172,12 @@ export class CodeScanner {
1054
1172
  return [];
1055
1173
  }
1056
1174
  // Wrap readFile with timeout
1057
- let content = await withTimeout(readFile(filePath, 'utf-8'), FILE_OP_TIMEOUT_MS, `readFile timed out: ${filePath}`);
1175
+ let content = await withTimeout(this.readConfined(filePath, rootPath, stats), FILE_OP_TIMEOUT_MS, `readFile timed out: ${filePath}`);
1058
1176
  // NUL sanitation (ingestion boundary, daemon side): Postgres `text`
1059
- // columns reject the literal NUL byte (U+0000) — a single one aborts
1177
+ // columns reject the literal NUL byte (U+0000) - a single one aborts
1060
1178
  // the whole INSERT/UPDATE. Some source files legitimately contain
1061
1179
  // them. Strip before any chunking so every chunk's content, its
1062
- // contentHash, and its embedding all agree — mirrors the server-side
1180
+ // contentHash, and its embedding all agree - mirrors the server-side
1063
1181
  // strip in ProjectManager (kept as defense in depth for content that
1064
1182
  // reaches the server via an older scanner) but doing it here means
1065
1183
  // the daemon never even transmits the NUL bytes.
@@ -1078,7 +1196,7 @@ export class CodeScanner {
1078
1196
  size: stats.size,
1079
1197
  };
1080
1198
  // AST first for the languages with a vendored grammar. `null` means "no
1081
- // grammar for this" — the honest majority of the allowlist — and the
1199
+ // grammar for this" - the honest majority of the allowlist - and the
1082
1200
  // heuristic path below takes over with no log, because that is not a
1083
1201
  // degradation.
1084
1202
  const astLanguage = resolveAstLanguage(fileMetadata.extension, language);
@@ -1101,8 +1219,8 @@ export class CodeScanner {
1101
1219
  }
1102
1220
  // `errorNodes` is handed back precisely so this degradation is not
1103
1221
  // silent, and discarding it made it silent. One ERROR node can swallow
1104
- // the rest of a file — `typeof import(...)` in a type argument does
1105
- // exactly that to tree-sitter-typescript — after which no definition is
1222
+ // the rest of a file - `typeof import(...)` in a type argument does
1223
+ // exactly that to tree-sitter-typescript - after which no definition is
1106
1224
  // captured and the file arrives as one unnamed whole-file chunk. Naming
1107
1225
  // the file, the error count and how many definitions survived is what
1108
1226
  // makes that visible in a scan log.
@@ -1110,7 +1228,7 @@ export class CodeScanner {
1110
1228
  // Deliberately NOT a fallback to the heuristic path: measured on the
1111
1229
  // real construct (tests/AgentInjectionDeliveryWiring.test.ts), the
1112
1230
  // heuristic path names nothing either (its name pattern needs a
1113
- // leading `function`/`class`/`const`) AND covers less — it emitted 3
1231
+ // leading `function`/`class`/`const`) AND covers less - it emitted 3
1114
1232
  // chunks starting at line 18 and dropped lines 1-17, because the
1115
1233
  // uncovered-region supplement only fires above 50 lines. Trading
1116
1234
  // total coverage for no gain is worse than one honest blob, so the
@@ -1132,8 +1250,8 @@ export class CodeScanner {
1132
1250
  // file cannot be bounded on line boundaries at all.
1133
1251
  const bounded = this.boundAstChunks(ast.chunks);
1134
1252
  if (bounded) {
1135
- // Zero chunks here means the file has no non-whitespace line — the AST
1136
- // chunker covers every other line by construction — so returning
1253
+ // Zero chunks here means the file has no non-whitespace line - the AST
1254
+ // chunker covers every other line by construction - so returning
1137
1255
  // nothing is the correct answer, not a lost file.
1138
1256
  return bounded.map(({ symbolName, symbolKind, signature, symbolContainer, ...chunk }) => ({
1139
1257
  ...chunk,
@@ -1149,7 +1267,7 @@ export class CodeScanner {
1149
1267
  },
1150
1268
  }));
1151
1269
  }
1152
- // One line longer than `maxChunkSize` — a generated `*_pb2.py` carries
1270
+ // One line longer than `maxChunkSize` - a generated `*_pb2.py` carries
1153
1271
  // the whole serialized descriptor on one. Splitting it would have to
1154
1272
  // cut mid-line, and two chunks over one line range collide on the
1155
1273
  // `filePath:startLine-endLine` staleness key, so the whole file goes to
@@ -1168,7 +1286,7 @@ export class CodeScanner {
1168
1286
  else if (ast.unsupported === 'grammar_unavailable') {
1169
1287
  // Process-global, not per file: `loadGrammar` caches its failures, so
1170
1288
  // every file of this language degrades for the same reason. Warned per
1171
- // file, this printed 1,037 identical lines in one scan of this repo —
1289
+ // file, this printed 1,037 identical lines in one scan of this repo -
1172
1290
  // the log flood `logAstCapabilityOnce` exists to avoid. Once per
1173
1291
  // (reason, grammar); the file-specific reasons below stay per file.
1174
1292
  const key = `${ast.unsupported}:${astLanguage}`;
@@ -1186,7 +1304,7 @@ export class CodeScanner {
1186
1304
  else {
1187
1305
  // Not a silent no-op: name the file AND the reason, then degrade to the
1188
1306
  // heuristic path. A degraded scan indistinguishable from a healthy one
1189
- // is the defect class this routing exists to avoid — and degrading to
1307
+ // is the defect class this routing exists to avoid - and degrading to
1190
1308
  // zero chunks would delete the file from the index instead.
1191
1309
  logWarn('AST chunking unavailable; falling back to heuristic chunking', {
1192
1310
  filePath: relativePath,
@@ -1250,8 +1368,8 @@ export class CodeScanner {
1250
1368
  * chunk that came from a definition is exactly as big as the definition, and a
1251
1369
  * span that is one enormous line is left whole on purpose. Both used to reach
1252
1370
  * the wire verbatim, where `scanChunkSchema` caps content at 500,000 chars and
1253
- * rejects the WHOLE push if any chunk is over — so one generated file stopped
1254
- * all scanning for that push — and where anything past 8191 tokens is only
1371
+ * rejects the WHOLE push if any chunk is over - so one generated file stopped
1372
+ * all scanning for that push - and where anything past 8191 tokens is only
1255
1373
  * partially embedded.
1256
1374
  *
1257
1375
  * The invariants, all of which the split preserves:
@@ -1259,8 +1377,8 @@ export class CodeScanner {
1259
1377
  * joined by '\n', never a join of disjoint regions and never an elision;
1260
1378
  * - total coverage is unchanged: the pieces tile the original range, in order,
1261
1379
  * with no gap and no overlap;
1262
- * - the definition's identity rides on the piece that carries its signature —
1263
- * the first one — and the continuations are plain `raw` spans, because a
1380
+ * - the definition's identity rides on the piece that carries its signature -
1381
+ * the first one - and the continuations are plain `raw` spans, because a
1264
1382
  * continuation is not the definition and must not claim its name.
1265
1383
  *
1266
1384
  * `null` (the caller degrades the whole file to the heuristic chunker) is
@@ -1460,7 +1578,7 @@ export class CodeScanner {
1460
1578
  i++; // skip closing backtick
1461
1579
  continue;
1462
1580
  }
1463
- // Regex literal — heuristic: / after operator chars or keywords that precede expressions
1581
+ // Regex literal - heuristic: / after operator chars or keywords that precede expressions
1464
1582
  if (c === '/' && i > 0) {
1465
1583
  // Look back for operator context (skip whitespace)
1466
1584
  let j = i - 1;
@@ -1497,7 +1615,7 @@ export class CodeScanner {
1497
1615
  i++; // skip escaped char
1498
1616
  }
1499
1617
  else if (content.charAt(i) === '[') {
1500
- // character class — skip to ]
1618
+ // character class - skip to ]
1501
1619
  i++;
1502
1620
  while (i < len && content.charAt(i) !== ']') {
1503
1621
  if (content.charAt(i) === '\\')
@@ -1599,7 +1717,7 @@ export class CodeScanner {
1599
1717
  regex: '(?:export\\s+)?const\\s+\\w+\\s*=\\s*(?:async\\s+)?\\([^)]*\\)\\s*=>\\s*\\{',
1600
1718
  type: 'function',
1601
1719
  },
1602
- // Class methods — matches indented methods with optional modifiers.
1720
+ // Class methods - matches indented methods with optional modifiers.
1603
1721
  // Excludes control flow keywords (if, for, while, switch, catch, return).
1604
1722
  {
1605
1723
  regex: '^\\s+(?:(?:private|protected|public|static|abstract|override|readonly|async|get|set)\\s+)*(?!if|for|while|switch|catch|return|throw|new|import|export)\\w+\\s*(?:<[^>]*>)?\\s*\\([^)]*\\)[^{]*\\{',
@@ -1627,13 +1745,9 @@ export class CodeScanner {
1627
1745
  /**
1628
1746
  * Raw chunking with overlap, bounded by character count (not line count).
1629
1747
  *
1630
- * The previous implementation took `floor(maxChunkSize / 80)` lines per
1631
- * chunk on the assumption of ~80 chars/line. Long-line files (minified
1632
- * JS, JSON blobs, generated code) produced chunks many times larger than
1633
- * `maxChunkSize`, which then exceeded OpenAI's 8191-token embedding
1634
- * limit and surfaced as 400s on /scan/push (Sentry MNEMONIK-58).
1635
- *
1636
- * Now: walk lines and accumulate character length; emit when the next
1748
+ * Line-count estimates do not bound long-line files such as minified JS,
1749
+ * JSON blobs and generated code. Walk lines and accumulate character
1750
+ * length; emit when the next
1637
1751
  * line would push the running total past `maxChunkSize`. Single lines
1638
1752
  * longer than `maxChunkSize` are force-split into char-based segments.
1639
1753
  * 10% overlap is carried by character count from the tail of the
@@ -1736,7 +1850,7 @@ export class CodeScanner {
1736
1850
  /**
1737
1851
  * Walk the project for authority manifest/config/CI files and return their
1738
1852
  * verbatim content. Reuses ignorePatterns; matches AUTHORITY_FILE_MATCHERS
1739
- * (not includeExtensions). Content-only — no chunking, no embeddings.
1853
+ * (not includeExtensions). Content-only - no chunking, no embeddings.
1740
1854
  */
1741
1855
  async collectAuthorityFiles(projectRoot) {
1742
1856
  const { files } = await this.collectAuthorityFilesWithStatus(projectRoot);
@@ -1745,19 +1859,24 @@ export class CodeScanner {
1745
1859
  /**
1746
1860
  * Completeness-aware variant of `collectAuthorityFiles`. `complete ===
1747
1861
  * false` means an fs error hid part of the walk, so authority paths may
1748
- * be missing — the daemon folds this into the same removal gate as
1862
+ * be missing - the daemon folds this into the same removal gate as
1749
1863
  * `listFilesWithStatus` (a partial authority walk would likewise drop
1750
1864
  * inventory entries and read as deletion).
1751
1865
  */
1752
- async collectAuthorityFilesWithStatus(projectRoot) {
1866
+ async collectAuthorityFilesWithStatus(projectRoot, onFile) {
1867
+ projectRoot = await this.canonicalRoot(projectRoot);
1753
1868
  const out = [];
1754
1869
  const walkState = { complete: true };
1755
1870
  // M2: cap walk depth to match the scanner's MAX_DEPTH (10)
1756
1871
  const walk = async (dir, depth, stack = []) => {
1757
1872
  if (depth >= CodeScanner.MAX_DEPTH)
1758
1873
  return;
1874
+ if (this.isProtected(dir))
1875
+ return;
1759
1876
  let entries;
1760
1877
  try {
1878
+ if (!(await this.confineSymlink(dir, projectRoot)))
1879
+ throw new Error('scanner_containment');
1761
1880
  entries = await readdir(dir, { withFileTypes: true });
1762
1881
  }
1763
1882
  catch {
@@ -1765,36 +1884,26 @@ export class CodeScanner {
1765
1884
  return;
1766
1885
  }
1767
1886
  const dirRel = dir === projectRoot ? '' : relative(projectRoot, dir).split(sep).join('/');
1768
- const localLayer = await this.loadIgnoreLayer(dir, dirRel, walkState);
1887
+ const localLayer = await this.loadIgnoreLayer(dir, dirRel, walkState, projectRoot);
1769
1888
  const localStack = localLayer ? [...stack, localLayer] : stack;
1770
1889
  for (const ent of entries) {
1771
1890
  const full = join(dir, ent.name);
1772
1891
  const rel = relative(projectRoot, full).split(sep).join('/');
1892
+ if (this.isProtected(full))
1893
+ continue;
1773
1894
  if (this.shouldIgnore(rel))
1774
1895
  continue;
1775
- // Resolve the entry kind through symlinks, confined to the project
1776
- // root: a symlink named like an authority file (`package.json`,
1777
- // `.env.example`, ...) pointing outside the root would otherwise be
1778
- // read verbatim and exfiltrated to the server. Same predicate as
1779
- // traverseDirectory/traversePaths (confineSymlink). Confined
1780
- // symlinked directories are traversed (Dirent.isDirectory() is
1781
- // false for them, so they were previously silently skipped).
1782
- let isDir = ent.isDirectory();
1783
- let isFile = ent.isFile();
1784
- if (ent.isSymbolicLink()) {
1785
- try {
1786
- if (!(await this.confineSymlink(full, projectRoot)))
1787
- continue;
1788
- const stats = await stat(full);
1789
- isDir = stats.isDirectory();
1790
- isFile = stats.isFile();
1791
- }
1792
- catch {
1793
- /* dangling/unreadable link — skip just this entry */
1794
- walkState.complete = false;
1795
- continue;
1796
- }
1896
+ let stats;
1897
+ try {
1898
+ stats = await lstat(full);
1797
1899
  }
1900
+ catch {
1901
+ walkState.complete = false;
1902
+ continue;
1903
+ }
1904
+ if (this.skipSymlink(stats))
1905
+ continue;
1906
+ const isDir = stats.isDirectory(), isFile = stats.isFile();
1798
1907
  if (this.ignoredByStack(rel, isDir, localStack))
1799
1908
  continue; // .gitignore/.mnemonikignore
1800
1909
  if (isDir) {
@@ -1803,11 +1912,14 @@ export class CodeScanner {
1803
1912
  await walk(full, depth + 1, localStack);
1804
1913
  }
1805
1914
  else if (isFile && AUTHORITY_FILE_MATCHERS.some((m) => m(rel))) {
1915
+ let file;
1806
1916
  try {
1807
- let content = await readFile(full, 'utf-8');
1917
+ if (stats.size > MAX_SCANNED_FILE_BYTES)
1918
+ continue;
1919
+ let content = await this.readConfined(full, projectRoot, stats);
1808
1920
  // Authority files honor the same single ceiling as chunked files
1809
1921
  // (MAX_SCANNED_FILE_BYTES). Manifests are tiny; one this large is
1810
- // anomalous, so say so rather than dropping it silently — a
1922
+ // anomalous, so say so rather than dropping it silently - a
1811
1923
  // missing authority file otherwise looks identical to a project
1812
1924
  // that simply has no manifest.
1813
1925
  if (content.length > MAX_SCANNED_FILE_BYTES) {
@@ -1826,12 +1938,19 @@ export class CodeScanner {
1826
1938
  content = content.replace(/\0/g, '');
1827
1939
  }
1828
1940
  const hash = createHash('sha256').update(content).digest('hex');
1829
- out.push({ path: rel, content, hash });
1941
+ file = { path: rel, content, hash };
1830
1942
  }
1831
1943
  catch {
1832
- /* unreadable — skip */
1944
+ /* unreadable - skip */
1833
1945
  walkState.complete = false;
1834
1946
  }
1947
+ // The consumer owns backpressure; its abort must leave the walk immediately.
1948
+ if (file) {
1949
+ if (onFile)
1950
+ await onFile(file);
1951
+ else
1952
+ out.push(file);
1953
+ }
1835
1954
  }
1836
1955
  }
1837
1956
  };