@mnemonik/shared 7.97.2 → 7.98.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.
- package/dist/ambientPointer.d.ts.map +1 -1
- package/dist/ambientPointer.js +2 -1
- package/dist/ambientPointer.js.map +1 -1
- package/dist/apiOrigin.d.ts +3 -0
- package/dist/apiOrigin.d.ts.map +1 -0
- package/dist/apiOrigin.js +5 -0
- package/dist/apiOrigin.js.map +1 -0
- package/dist/ast/astChunker.d.ts +12 -12
- package/dist/ast/astChunker.js +32 -32
- package/dist/ast/grammars.d.ts +9 -9
- package/dist/ast/grammars.js +15 -15
- package/dist/codeScanner.d.ts +56 -43
- package/dist/codeScanner.d.ts.map +1 -1
- package/dist/codeScanner.js +262 -143
- package/dist/codeScanner.js.map +1 -1
- package/dist/contextBudget.js +2 -2
- package/dist/contextBudget.js.map +1 -1
- package/dist/gitEditDetector.d.ts +6 -6
- package/dist/gitEditDetector.js +7 -7
- package/dist/hookRuntime.d.ts +35 -2
- package/dist/hookRuntime.d.ts.map +1 -1
- package/dist/hookRuntime.js +127 -33
- package/dist/hookRuntime.js.map +1 -1
- package/dist/hookTimeouts.d.ts +6 -6
- package/dist/hookTimeouts.js +6 -6
- package/dist/hostAdapter.d.ts +135 -0
- package/dist/hostAdapter.d.ts.map +1 -0
- package/dist/hostAdapter.js +323 -0
- package/dist/hostAdapter.js.map +1 -0
- package/dist/hostBinary.d.ts +29 -0
- package/dist/hostBinary.d.ts.map +1 -0
- package/dist/hostBinary.js +179 -0
- package/dist/hostBinary.js.map +1 -0
- package/dist/index.d.ts +11 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +10 -1
- package/dist/index.js.map +1 -1
- package/dist/instructions.d.ts +26 -26
- package/dist/instructions.js +26 -26
- package/dist/jitDeliveryAck.d.ts.map +1 -1
- package/dist/jitDeliveryAck.js +2 -0
- package/dist/jitDeliveryAck.js.map +1 -1
- package/dist/projectIdentityFile.d.ts +25 -0
- package/dist/projectIdentityFile.d.ts.map +1 -0
- package/dist/projectIdentityFile.js +87 -0
- package/dist/projectIdentityFile.js.map +1 -0
- package/dist/projectSetupHandoff.d.ts +24 -0
- package/dist/projectSetupHandoff.d.ts.map +1 -0
- package/dist/projectSetupHandoff.js +249 -0
- package/dist/projectSetupHandoff.js.map +1 -0
- package/dist/protectedPaths.d.ts +9 -0
- package/dist/protectedPaths.d.ts.map +1 -0
- package/dist/protectedPaths.js +66 -0
- package/dist/protectedPaths.js.map +1 -0
- package/dist/readiness.d.ts +143 -0
- package/dist/readiness.d.ts.map +1 -0
- package/dist/readiness.js +304 -0
- package/dist/readiness.js.map +1 -0
- package/dist/repositoryFingerprint.d.ts +50 -0
- package/dist/repositoryFingerprint.d.ts.map +1 -0
- package/dist/repositoryFingerprint.js +160 -0
- package/dist/repositoryFingerprint.js.map +1 -0
- package/dist/repositoryRoot.d.ts +56 -0
- package/dist/repositoryRoot.d.ts.map +1 -0
- package/dist/repositoryRoot.js +226 -0
- package/dist/repositoryRoot.js.map +1 -0
- package/dist/runtimeReader.d.ts +102 -0
- package/dist/runtimeReader.d.ts.map +1 -0
- package/dist/runtimeReader.js +408 -0
- package/dist/runtimeReader.js.map +1 -0
- package/dist/runtimeSigners.d.ts +37 -0
- package/dist/runtimeSigners.d.ts.map +1 -0
- package/dist/runtimeSigners.js +507 -0
- package/dist/runtimeSigners.js.map +1 -0
- package/dist/scannerSupervisor.d.ts +35 -0
- package/dist/scannerSupervisor.d.ts.map +1 -0
- package/dist/scannerSupervisor.js +2 -0
- package/dist/scannerSupervisor.js.map +1 -0
- package/dist/secretPatterns.d.ts +9 -9
- package/dist/secretPatterns.d.ts.map +1 -1
- package/dist/secretPatterns.js +27 -25
- package/dist/secretPatterns.js.map +1 -1
- package/dist/testing/hostAdapterConformance.d.ts +17 -0
- package/dist/testing/hostAdapterConformance.d.ts.map +1 -0
- package/dist/testing/hostAdapterConformance.js +477 -0
- package/dist/testing/hostAdapterConformance.js.map +1 -0
- package/dist/usageGuide.d.ts +1 -1
- package/dist/usageGuide.d.ts.map +1 -1
- package/dist/usageGuide.js +4 -4
- package/package.json +7 -2
- package/queries/bash.tags.scm +1 -1
- package/queries/groovy.tags.scm +1 -1
- package/scripts/vendor-grammars.ts +13 -13
- package/src/ambientPointer.ts +2 -1
- package/src/apiOrigin.ts +4 -0
- package/src/ast/astChunker.ts +39 -39
- package/src/ast/grammars.ts +16 -16
- package/src/codeScanner.ts +274 -149
- package/src/contextBudget.ts +2 -2
- package/src/gitEditDetector.ts +7 -7
- package/src/hookRuntime.ts +162 -34
- package/src/hookTimeouts.ts +6 -6
- package/src/hostAdapter.ts +468 -0
- package/src/hostBinary.ts +205 -0
- package/src/index.ts +20 -0
- package/src/instructions.ts +26 -26
- package/src/jitDeliveryAck.ts +2 -0
- package/src/projectIdentityFile.ts +100 -0
- package/src/projectSetupHandoff.ts +275 -0
- package/src/protectedPaths.ts +88 -0
- package/src/readiness.ts +516 -0
- package/src/repositoryFingerprint.ts +199 -0
- package/src/repositoryRoot.ts +261 -0
- package/src/runtimeReader.ts +495 -0
- package/src/runtimeSigners.ts +577 -0
- package/src/scannerSupervisor.ts +26 -0
- package/src/secretPatterns.ts +27 -25
- package/src/testing/hostAdapterConformance.ts +538 -0
- package/src/usageGuide.ts +4 -4
- package/test-fixtures/identity/runner.d.mts +12 -0
- package/test-fixtures/identity/runner.mjs +282 -0
- package/tests/fixtures/repository-fingerprint-v1.golden.json +834 -0
- package/tests/projectIdentity.test.ts +56 -0
- package/tests/readiness.test.ts +158 -0
- package/tests/repositoryFingerprint.test.ts +26 -0
- package/tsconfig.test.json +9 -0
- package/wasm/bash.tags.scm +1 -1
- package/wasm/groovy.tags.scm +1 -1
- package/wasm/javascript.tags.scm +2 -2
- package/wasm/solidity.tags.scm +1 -1
package/src/codeScanner.ts
CHANGED
|
@@ -2,13 +2,15 @@
|
|
|
2
2
|
* Code Scanner - Parse and chunk source files for embedding
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
import { readdir,
|
|
6
|
-
import {
|
|
5
|
+
import { readdir, open, lstat, realpath } from 'fs/promises';
|
|
6
|
+
import { constants, type Stats } from 'fs';
|
|
7
|
+
import { join, relative, extname, sep, resolve, dirname } from 'path';
|
|
7
8
|
import { createHash } from 'crypto';
|
|
8
9
|
import ignore, { type Ignore } from 'ignore';
|
|
9
10
|
import { debug as logDebug, info as logInfo, warn as logWarn } from './logger.js';
|
|
10
11
|
import { withTimeout } from './asyncUtils.js';
|
|
11
12
|
import { scrubSecrets } from './secretPatterns.js';
|
|
13
|
+
import { isProtectedLocalPath, protectedLocalPaths } from './protectedPaths.js';
|
|
12
14
|
import { MAX_AST_PARSE_BYTES, chunkWithAst, type AstChunk } from './ast/astChunker.js';
|
|
13
15
|
import { astArtifactReport, resolveAstLanguage } from './ast/grammars.js';
|
|
14
16
|
|
|
@@ -20,14 +22,14 @@ const FILE_OP_TIMEOUT_MS = 5000;
|
|
|
20
22
|
/**
|
|
21
23
|
* The one file-size ceiling in Mnemonik. A file at or below this is scanned,
|
|
22
24
|
* chunked, AND pushed verbatim; a file above it is uniformly outside the
|
|
23
|
-
* system
|
|
25
|
+
* system - not scanned, not chunked, not citable. "Scanned ⟺ verifiable".
|
|
24
26
|
*
|
|
25
27
|
* This is deliberately a single exported constant rather than a number
|
|
26
28
|
* repeated per layer. Three ceilings previously existed and had drifted:
|
|
27
29
|
* the scan ceiling here (10 MB), a 5 MB whole-file push cap in the scanner
|
|
28
30
|
* daemon, and a 5 MB `content` cap in the server's scanPushSchema. Files
|
|
29
31
|
* between 5 and 10 MB were therefore indexed as chunks whose source content
|
|
30
|
-
* the server never received
|
|
32
|
+
* the server never received - chunks that could be retrieved but never
|
|
31
33
|
* verified against, a silent fidelity split. Every consumer now imports this:
|
|
32
34
|
*
|
|
33
35
|
* - `CodeScanner.MAX_FILE_SIZE` / `collectAuthorityFilesWithStatus` (this file)
|
|
@@ -39,6 +41,9 @@ const FILE_OP_TIMEOUT_MS = 5000;
|
|
|
39
41
|
*/
|
|
40
42
|
export const MAX_SCANNED_FILE_BYTES = 10 * 1024 * 1024; // 10MB
|
|
41
43
|
|
|
44
|
+
/** Transport ceiling; ordinary chunking may exceed maxChunkSize but never this bound. */
|
|
45
|
+
export const MAX_PUSH_CHUNK_CONTENT_LENGTH = 500_000;
|
|
46
|
+
|
|
42
47
|
export interface CodeChunk {
|
|
43
48
|
content: string;
|
|
44
49
|
filePath: string;
|
|
@@ -75,12 +80,14 @@ export interface ScanOptions {
|
|
|
75
80
|
* mock.
|
|
76
81
|
*/
|
|
77
82
|
maxAstParseBytes?: number;
|
|
83
|
+
/** Additional protected paths for isolated homes/tests; defaults can only be extended. */
|
|
84
|
+
protectedPaths?: readonly string[];
|
|
78
85
|
}
|
|
79
86
|
|
|
80
87
|
/**
|
|
81
88
|
* Mutable per-walk state threaded through the recursive traversals.
|
|
82
89
|
* `complete` starts true and is latched false by any caught fs error
|
|
83
|
-
* (readdir failure, per-entry lstat/realpath/stat throw)
|
|
90
|
+
* (readdir failure, per-entry lstat/realpath/stat throw) - meaning the
|
|
84
91
|
* result may be missing entries that still exist on disk. Callers that
|
|
85
92
|
* derive *removals* from a walk must treat an incomplete walk as
|
|
86
93
|
* untrustworthy; adds/changes are unaffected.
|
|
@@ -90,12 +97,12 @@ interface WalkState {
|
|
|
90
97
|
}
|
|
91
98
|
|
|
92
99
|
/**
|
|
93
|
-
* Directory basenames the scanner never indexes, across every ecosystem
|
|
100
|
+
* Directory basenames the scanner never indexes, across every ecosystem -
|
|
94
101
|
* generated output, package-manager/build caches, and vendored deps. SINGLE
|
|
95
102
|
* SOURCE OF TRUTH: the scanner package's discovery + watch walks import this
|
|
96
103
|
* (`packages/scanner/src/{discovery,watcher}.ts`) and `src/core/utils.ts`
|
|
97
104
|
* derives from it, so every ignore surface agrees instead of drifting (the
|
|
98
|
-
* pre-2026-07 state had four divergent copies). Add only UNAMBIGUOUS names
|
|
105
|
+
* pre-2026-07 state had four divergent copies). Add only UNAMBIGUOUS names -
|
|
99
106
|
* a project's own `.gitignore` / `.mnemonikignore` covers the ambiguous,
|
|
100
107
|
* project-specific cases (`vendor/`, custom dirs) hierarchically.
|
|
101
108
|
*/
|
|
@@ -124,7 +131,7 @@ export const BUILT_IN_IGNORE_DIRS: readonly string[] = [
|
|
|
124
131
|
'.mypy_cache',
|
|
125
132
|
'.pytest_cache',
|
|
126
133
|
'.ruff_cache',
|
|
127
|
-
// Native mobile
|
|
134
|
+
// Native mobile - iOS / Android / React Native / Flutter (2026-07-11 Pods incident)
|
|
128
135
|
'Pods',
|
|
129
136
|
'DerivedData',
|
|
130
137
|
'.gradle',
|
|
@@ -148,7 +155,7 @@ export const BUILT_IN_IGNORE_DIRS: readonly string[] = [
|
|
|
148
155
|
// an agent's own shell state into the user's searchable memory.
|
|
149
156
|
//
|
|
150
157
|
// Scoped to this one directory name deliberately. `.claude/` at large holds
|
|
151
|
-
// real project content
|
|
158
|
+
// real project content - `.claude/rules.md` is indexed on purpose
|
|
152
159
|
// (tests/CodeScannerIgnorePatterns.test.ts), and only `.claude/worktrees/`
|
|
153
160
|
// is suppressed, by the generic nested-git-boundary rule.
|
|
154
161
|
'shell-snapshots',
|
|
@@ -159,7 +166,7 @@ export const BUILT_IN_IGNORE_DIRS: readonly string[] = [
|
|
|
159
166
|
* anchored fixture rule), combined with BUILT_IN_IGNORE_DIRS to form
|
|
160
167
|
* DEFAULT_OPTIONS.ignorePatterns. See `shouldIgnore` for match semantics.
|
|
161
168
|
*
|
|
162
|
-
* `/tests/fixtures/*`: leading '/' = segment-anchored
|
|
169
|
+
* `/tests/fixtures/*`: leading '/' = segment-anchored - matches tests/fixtures/
|
|
163
170
|
* at the path root or immediately after a '/', so nested packages/x/tests/
|
|
164
171
|
* fixtures/ are excluded but integration-tests/fixtures/ is NOT. Fixture
|
|
165
172
|
* snapshot dirs were being indexed as real source and outranking the genuine
|
|
@@ -186,19 +193,19 @@ const BUILT_IN_IGNORE_FILE_PATTERNS: readonly string[] = [
|
|
|
186
193
|
|
|
187
194
|
/**
|
|
188
195
|
* Files whose CONTENT is a credential rather than code. Today these are
|
|
189
|
-
* excluded only INCIDENTALLY
|
|
196
|
+
* excluded only INCIDENTALLY - `.pem`, `.key` and friends carry no
|
|
190
197
|
* allowlisted extension, so nothing chunks them. That protection evaporates
|
|
191
198
|
* the moment the extension allowlist widens (as it did for `.sh`/`.lua`), so
|
|
192
199
|
* the exclusion is made explicit here and becomes a defended invariant:
|
|
193
200
|
* unlike `ignorePatterns`, this predicate is NOT caller-overridable.
|
|
194
201
|
*
|
|
195
202
|
* Expressed as a predicate rather than glob strings because `shouldIgnore`'s
|
|
196
|
-
* engine has no negation and matches globs as unanchored substrings
|
|
203
|
+
* engine has no negation and matches globs as unanchored substrings - neither
|
|
197
204
|
* property can state "every `.env.*` EXCEPT `.env.example`".
|
|
198
205
|
*
|
|
199
206
|
* Precision notes (over-exclusion is a fidelity failure in its own right):
|
|
200
207
|
* - `.env` family: `/^\.env(\..+)?$/` matches `.env`, `.env.local`,
|
|
201
|
-
* `.env.production`
|
|
208
|
+
* `.env.production` - and deliberately NOT `.envrc` or `.environment.ts`.
|
|
202
209
|
* - `.env.example` is carved out: it is authority-collected on purpose
|
|
203
210
|
* (AUTHORITY_FILE_MATCHERS) and holds placeholder values, not secrets.
|
|
204
211
|
* It is the doc-truth env_vars authority; excluding it would blind that
|
|
@@ -206,7 +213,7 @@ const BUILT_IN_IGNORE_FILE_PATTERNS: readonly string[] = [
|
|
|
206
213
|
* - `credentials` is matched ONLY directly inside a dot-directory
|
|
207
214
|
* (`.aws/credentials`, `.docker/credentials`). A bare `credentials/` is a
|
|
208
215
|
* real, common SOURCE directory name (gRPC, auth libraries) and
|
|
209
|
-
* `shouldIgnore` cannot tell a file from a directory at its call sites
|
|
216
|
+
* `shouldIgnore` cannot tell a file from a directory at its call sites -
|
|
210
217
|
* an unqualified rule would silently delete that subtree from the index.
|
|
211
218
|
* - `.tfvars` is excluded whole rather than per-basename: the credential file
|
|
212
219
|
* is conventionally `terraform.tfvars` but the name is free-form, and the
|
|
@@ -222,7 +229,7 @@ const SECRET_FILE_BASENAME_PATTERNS: readonly RegExp[] = [
|
|
|
222
229
|
/\.(?:pem|key|p12|pfx|keystore|jks|kubeconfig)$/i,
|
|
223
230
|
// Terraform variable files: `terraform.tfvars` is the conventional home for
|
|
224
231
|
// provider credentials, and unlike the entries above nothing else was
|
|
225
|
-
// keeping it out
|
|
232
|
+
// keeping it out - it is excluded here rather than merely left off
|
|
226
233
|
// `DEFAULT_INCLUDE_EXTENSIONS`, so a future widening of the allowlist cannot
|
|
227
234
|
// quietly opt a project into embedding its own cloud keys.
|
|
228
235
|
/\.tfvars(\.json)?$/i,
|
|
@@ -260,7 +267,7 @@ const IGNORE_FILE_NAMES = ['.gitignore', '.mnemonikignore'] as const;
|
|
|
260
267
|
* Exported because this list IS the product's language coverage: a project
|
|
261
268
|
* written in something absent from it indexes zero code, and `code_search`
|
|
262
269
|
* reports that as "no indexed matches" rather than "I do not read this
|
|
263
|
-
* language"
|
|
270
|
+
* language" - invisible to the developer and to us. Coverage is a separate
|
|
264
271
|
* dial from parse quality: a language with no structured extractor falls
|
|
265
272
|
* through to `chunkRaw`, which is what Go, Java and C already get in
|
|
266
273
|
* production, and crude chunks beat no chunks by an enormous margin.
|
|
@@ -268,7 +275,7 @@ const IGNORE_FILE_NAMES = ['.gitignore', '.mnemonikignore'] as const;
|
|
|
268
275
|
* Data formats (`.json`, `.yaml`, `.toml`, `.lock`) are deliberately absent:
|
|
269
276
|
* the ones that carry meaning are already collected verbatim by
|
|
270
277
|
* `AUTHORITY_FILE_MATCHERS`, and blanket-indexing the extension would pull in
|
|
271
|
-
* lockfiles and generated output. `.tfvars` is absent for a different reason
|
|
278
|
+
* lockfiles and generated output. `.tfvars` is absent for a different reason -
|
|
272
279
|
* `terraform.tfvars` is a conventional home for provider credentials and
|
|
273
280
|
* `isSecretFile` now excludes it outright.
|
|
274
281
|
*/
|
|
@@ -289,7 +296,7 @@ export const DEFAULT_INCLUDE_EXTENSIONS: readonly string[] = [
|
|
|
289
296
|
'.php',
|
|
290
297
|
'.swift',
|
|
291
298
|
'.kt',
|
|
292
|
-
// Shell and Lua are the working languages of whole real projects
|
|
299
|
+
// Shell and Lua are the working languages of whole real projects -
|
|
293
300
|
// deployment tooling, container entrypoints, imapfilter/nginx/redis
|
|
294
301
|
// configuration. Omitting them meant such a project indexed ZERO code and
|
|
295
302
|
// code_search could not answer anything about it, while reporting that as
|
|
@@ -300,7 +307,7 @@ export const DEFAULT_INCLUDE_EXTENSIONS: readonly string[] = [
|
|
|
300
307
|
'.lua',
|
|
301
308
|
'.md',
|
|
302
309
|
|
|
303
|
-
//
|
|
310
|
+
// -- Variants of languages already on the list. `.mjs`/`.cjs`/`.mts`/`.cts`/
|
|
304
311
|
// `.pyi` inherit their language string from a listed extension, so their
|
|
305
312
|
// absence was oversight rather than policy. The C++ spellings are a choice,
|
|
306
313
|
// not an inheritance: `.h` stays 'c' (C headers dominate, and reading a C++
|
|
@@ -318,7 +325,7 @@ export const DEFAULT_INCLUDE_EXTENSIONS: readonly string[] = [
|
|
|
318
325
|
'.cxx',
|
|
319
326
|
'.kts',
|
|
320
327
|
|
|
321
|
-
//
|
|
328
|
+
// -- Languages the scanner could not read at all.
|
|
322
329
|
'.dart',
|
|
323
330
|
'.m',
|
|
324
331
|
'.mm',
|
|
@@ -347,7 +354,7 @@ export const DEFAULT_INCLUDE_EXTENSIONS: readonly string[] = [
|
|
|
347
354
|
'.vue',
|
|
348
355
|
'.svelte',
|
|
349
356
|
|
|
350
|
-
//
|
|
357
|
+
// -- Infrastructure and schema DSLs, where real logic lives and where "how
|
|
351
358
|
// is this deployed?" and "what does this table hold?" go unanswered today.
|
|
352
359
|
'.tf',
|
|
353
360
|
'.sql',
|
|
@@ -360,12 +367,12 @@ export const DEFAULT_INCLUDE_EXTENSIONS: readonly string[] = [
|
|
|
360
367
|
];
|
|
361
368
|
|
|
362
369
|
/**
|
|
363
|
-
* Extension
|
|
370
|
+
* Extension -> language string, the value carried on every chunk and on the
|
|
364
371
|
* wire (`/scan/push` accepts any non-empty string up to 50 chars).
|
|
365
372
|
*
|
|
366
373
|
* A string no grammar claims is correct and expected: the AST layer resolves
|
|
367
374
|
* such a language to `null` and the heuristic chunker takes over. Keep this in
|
|
368
|
-
* sync with `DEFAULT_INCLUDE_EXTENSIONS`
|
|
375
|
+
* sync with `DEFAULT_INCLUDE_EXTENSIONS` - an allowlisted extension that lands
|
|
369
376
|
* on 'unknown' still gets chunked, but nothing downstream can reason about it.
|
|
370
377
|
*
|
|
371
378
|
* Two extensions are genuinely ambiguous and are resolved rather than fudged:
|
|
@@ -453,7 +460,7 @@ export function languageForExtension(filePathOrExt: string): string {
|
|
|
453
460
|
// `extname` FIRST. A dotfile that carries a real extension ('.eslintrc.js',
|
|
454
461
|
// '.mocharc.cjs', '.prettierrc.ts') starts with '.' and contains no '/', so
|
|
455
462
|
// a bare-extension-first reading swallowed the whole name and answered
|
|
456
|
-
// 'unknown'
|
|
463
|
+
// 'unknown' - while the same file spelled 'src/.eslintrc.js' answered
|
|
457
464
|
// 'javascript'. Those extensions are allowlisted, so the files are indexed
|
|
458
465
|
// and reach the server with exactly the root-relative spelling that failed.
|
|
459
466
|
const fromPath = EXTENSION_LANGUAGES[extname(filePathOrExt).toLowerCase()];
|
|
@@ -466,16 +473,16 @@ export function languageForExtension(filePathOrExt: string): string {
|
|
|
466
473
|
|
|
467
474
|
/**
|
|
468
475
|
* THE definition of "this path is a SQL migration the schema_columns authority
|
|
469
|
-
* collects verbatim". One predicate, referenced by both halves of the deal
|
|
476
|
+
* collects verbatim". One predicate, referenced by both halves of the deal -
|
|
470
477
|
* `AUTHORITY_FILE_MATCHERS` (collect it) and `isAuthorityOnlyPath` (therefore
|
|
471
|
-
* do not chunk it)
|
|
478
|
+
* do not chunk it) - because two hand-written regexes drifted once already and
|
|
472
479
|
* the failure is silent in both directions.
|
|
473
480
|
*
|
|
474
481
|
* Root-anchored and CASE-SENSITIVE on purpose: it mirrors the server-side
|
|
475
482
|
* extractor, which does `listFiles('migrations/')` (LIKE 'migrations/%') then
|
|
476
483
|
* `endsWith('.sql')`, both case-sensitive. `Migrations/001.sql` (the .NET/EF
|
|
477
484
|
* Core spelling) and `migrations/002.SQL` are NOT collected, so they must not
|
|
478
|
-
* be suppressed from chunking either
|
|
485
|
+
* be suppressed from chunking either - that would index them nowhere. Same
|
|
479
486
|
* reason a nested `packages/x/migrations/y.sql` is left alone.
|
|
480
487
|
*/
|
|
481
488
|
const isMigrationSqlAuthorityPath = (posixRelPath: string): boolean =>
|
|
@@ -489,7 +496,7 @@ const isMigrationSqlAuthorityPath = (posixRelPath: string): boolean =>
|
|
|
489
496
|
* through.
|
|
490
497
|
*
|
|
491
498
|
* `migrations/**.sql` is the live case: adding `.sql` to the allowlist without
|
|
492
|
-
* this exclusion would dual-collect every migration
|
|
499
|
+
* this exclusion would dual-collect every migration - once verbatim, once
|
|
493
500
|
* chunked and embedded. On this repo alone that is 208 files of append-only
|
|
494
501
|
* DDL (140 forward, the rest rollback/manual), embedded to answer questions
|
|
495
502
|
* the authority path already answers exactly.
|
|
@@ -504,7 +511,7 @@ const AUTHORITY_ONLY_PATH_PREDICATES: readonly ((posixRelPath: string) => boolea
|
|
|
504
511
|
|
|
505
512
|
/**
|
|
506
513
|
* True when `relPath` is collected verbatim as authority content and must not
|
|
507
|
-
*
|
|
514
|
+
* also be chunked. Accepts OS-native or POSIX separators.
|
|
508
515
|
*/
|
|
509
516
|
export function isAuthorityOnlyPath(relPath: string): boolean {
|
|
510
517
|
if (!relPath) return false;
|
|
@@ -518,6 +525,7 @@ const DEFAULT_OPTIONS: Required<ScanOptions> = {
|
|
|
518
525
|
ignorePatterns: [...BUILT_IN_IGNORE_DIRS, ...BUILT_IN_IGNORE_FILE_PATTERNS],
|
|
519
526
|
includeExtensions: [...DEFAULT_INCLUDE_EXTENSIONS],
|
|
520
527
|
maxAstParseBytes: MAX_AST_PARSE_BYTES,
|
|
528
|
+
protectedPaths: [],
|
|
521
529
|
};
|
|
522
530
|
|
|
523
531
|
/**
|
|
@@ -547,7 +555,7 @@ export const AUTHORITY_FILE_MATCHERS: Array<(relPath: string) => boolean> = [
|
|
|
547
555
|
];
|
|
548
556
|
|
|
549
557
|
/**
|
|
550
|
-
* Segment-anchored match for the `tests/fixtures/` ignore pattern above
|
|
558
|
+
* Segment-anchored match for the `tests/fixtures/` ignore pattern above -
|
|
551
559
|
* matches at the path root or immediately after a `/`, mirroring exactly
|
|
552
560
|
* what `shouldIgnore` compiles `'/tests/fixtures/*'` to. Exported so
|
|
553
561
|
* server-side consumers (e.g. the `/scan/push` route) and query-time
|
|
@@ -567,7 +575,7 @@ const BUILT_IN_IGNORE_DIR_SET = new Set<string>(BUILT_IN_IGNORE_DIRS);
|
|
|
567
575
|
* (segment-exact) with a project's uploaded .gitignore/.mnemonikignore patterns
|
|
568
576
|
* (gitignore syntax via the `ignore` package). Built once per request and reused
|
|
569
577
|
* across all pushed paths (root-relative, forward-slash). This does NOT replace
|
|
570
|
-
* the daemon's precise hierarchical walk
|
|
578
|
+
* the daemon's precise hierarchical walk - it backstops it so a buggy, stale, or
|
|
571
579
|
* malicious client cannot index a path the project ignores, making .mnemonikignore
|
|
572
580
|
* a hard guarantee rather than best-effort.
|
|
573
581
|
*/
|
|
@@ -599,7 +607,7 @@ export function makeIgnoreMatcher(
|
|
|
599
607
|
* being scanned: it carries its own `.git` entry. A `.git` FILE marks a
|
|
600
608
|
* linked git worktree (wherever the user keeps them) or a submodule
|
|
601
609
|
* checkout; a `.git` DIRECTORY marks a nested clone. Files under such a
|
|
602
|
-
* boundary belong to that repository's own project identity
|
|
610
|
+
* boundary belong to that repository's own project identity - scanning them
|
|
603
611
|
* into the parent project is wrong attribution for any codebase, so the
|
|
604
612
|
* walkers skip these subtrees. Convention-free: this is git's own marker,
|
|
605
613
|
* not a host/tool path layout. The scan ROOT is exempt by construction
|
|
@@ -620,7 +628,7 @@ export async function isGitBoundary(dirPath: string): Promise<boolean> {
|
|
|
620
628
|
|
|
621
629
|
/**
|
|
622
630
|
* One directory's compiled ignore-file rules, keyed by its path relative to
|
|
623
|
-
* the scan root (`''` = root). A stack of these, root
|
|
631
|
+
* the scan root (`''` = root). A stack of these, root->leaf, is threaded
|
|
624
632
|
* through the walkers; `ignoredByStack` tests deepest-first so a nested
|
|
625
633
|
* `.gitignore` overrides a shallower one (git precedence).
|
|
626
634
|
*/
|
|
@@ -649,7 +657,7 @@ let astCapabilityLog: Promise<void> | null = null;
|
|
|
649
657
|
const reportedGrammarFallbacks = new Set<string>();
|
|
650
658
|
|
|
651
659
|
/**
|
|
652
|
-
* Log which grammars this install ships
|
|
660
|
+
* Log which grammars this install ships - ONCE per process, at daemon start,
|
|
653
661
|
* never per file.
|
|
654
662
|
*
|
|
655
663
|
* A missing grammar silently degrades every file of that language to the
|
|
@@ -660,8 +668,8 @@ const reportedGrammarFallbacks = new Set<string>();
|
|
|
660
668
|
*
|
|
661
669
|
* Deliberately a `statSync` of the artifacts (`astArtifactReport`) and not a
|
|
662
670
|
* load of them (`astCapabilityReport`). Loading all 18 to print this line costs
|
|
663
|
-
* ~690 ms and ~75 MB of RSS that is never returned
|
|
664
|
-
* `Language.delete`
|
|
671
|
+
* ~690 ms and ~75 MB of RSS that is never returned - web-tree-sitter exposes no
|
|
672
|
+
* `Language.delete` - which is a permanent tax on a daemon watching a pure
|
|
665
673
|
* TypeScript repo, paid to pre-answer a question about seventeen languages it
|
|
666
674
|
* will never see. Grammars load lazily instead, on the first file of a language,
|
|
667
675
|
* and the two failure modes only a load can detect (`wasm_load_failed`,
|
|
@@ -691,23 +699,44 @@ export function logAstCapabilityOnce(): Promise<void> {
|
|
|
691
699
|
|
|
692
700
|
export class CodeScanner {
|
|
693
701
|
private options: Required<ScanOptions>;
|
|
702
|
+
private readonly protectedPaths: readonly string[];
|
|
703
|
+
/** Cumulative observations since this scanner started, including repeated walks. */
|
|
704
|
+
readonly skipReasons = { symlink_skipped: 0 };
|
|
705
|
+
|
|
706
|
+
private skipSymlink(stats: Stats): boolean {
|
|
707
|
+
if (!stats.isSymbolicLink()) return false; // Includes Windows junctions.
|
|
708
|
+
this.skipReasons.symlink_skipped++;
|
|
709
|
+
return true;
|
|
710
|
+
}
|
|
694
711
|
|
|
695
712
|
/**
|
|
696
713
|
* `includeExtensions` folded to lowercase for matching. The gate used to
|
|
697
714
|
* compare `extname()` verbatim while `detectLanguage` lowercased, so a
|
|
698
|
-
* project spelling its files the canonical way
|
|
699
|
-
* on Windows, `.C`/`.H` in older C trees
|
|
715
|
+
* project spelling its files the canonical way - `.R` for R, `.SQL`/`.PS1`
|
|
716
|
+
* on Windows, `.C`/`.H` in older C trees - indexed zero of them and nothing
|
|
700
717
|
* said why.
|
|
701
718
|
*/
|
|
702
719
|
private readonly includeExtensionSet: ReadonlySet<string>;
|
|
703
720
|
|
|
704
721
|
constructor(options: ScanOptions = {}) {
|
|
705
722
|
this.options = { ...DEFAULT_OPTIONS, ...options };
|
|
723
|
+
this.protectedPaths = [
|
|
724
|
+
...new Set([...protectedLocalPaths(), ...(options.protectedPaths ?? [])]),
|
|
725
|
+
];
|
|
706
726
|
this.includeExtensionSet = new Set(
|
|
707
727
|
this.options.includeExtensions.map((ext) => ext.toLowerCase())
|
|
708
728
|
);
|
|
709
729
|
}
|
|
710
730
|
|
|
731
|
+
private isProtected(path: string): boolean {
|
|
732
|
+
return isProtectedLocalPath(path, this.protectedPaths);
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
private async isProtectedRead(path: string): Promise<boolean> {
|
|
736
|
+
if (this.isProtected(path)) return true;
|
|
737
|
+
return isProtectedLocalPath(await realpath(path), this.protectedPaths);
|
|
738
|
+
}
|
|
739
|
+
|
|
711
740
|
/**
|
|
712
741
|
* The chunkable-file gate, shared by every walker and by the explicit
|
|
713
742
|
* file-list path so all three agree on what exists. `relPath` is the path
|
|
@@ -724,7 +753,7 @@ export class CodeScanner {
|
|
|
724
753
|
|
|
725
754
|
/**
|
|
726
755
|
* Read `.gitignore` + `.mnemonikignore` in `absDir` and compile them into one
|
|
727
|
-
* ignore layer (the two files are unioned
|
|
756
|
+
* ignore layer (the two files are unioned - `.mnemonikignore` is just an
|
|
728
757
|
* additional gitignore-syntax exclusion list). Returns null when neither file
|
|
729
758
|
* exists. FAIL-CLOSED: a non-ENOENT read error (EISDIR, EACCES, timeout)
|
|
730
759
|
* latches `walk.complete = false` so callers deriving *removals* from the walk
|
|
@@ -733,19 +762,22 @@ export class CodeScanner {
|
|
|
733
762
|
private async loadIgnoreLayer(
|
|
734
763
|
absDir: string,
|
|
735
764
|
baseRel: string,
|
|
736
|
-
walk: WalkState
|
|
765
|
+
walk: WalkState,
|
|
766
|
+
rootPath: string
|
|
737
767
|
): Promise<IgnoreLayer | null> {
|
|
768
|
+
if (this.isProtected(absDir)) return null;
|
|
738
769
|
const patterns: string[] = [];
|
|
739
770
|
for (const name of IGNORE_FILE_NAMES) {
|
|
740
771
|
const filePath = join(absDir, name);
|
|
741
772
|
try {
|
|
742
773
|
const content = await withTimeout(
|
|
743
|
-
|
|
774
|
+
this.readConfined(filePath, rootPath),
|
|
744
775
|
FILE_OP_TIMEOUT_MS,
|
|
745
776
|
`readFile timed out: ${filePath}`
|
|
746
777
|
);
|
|
747
778
|
for (const line of content.split(/\r?\n/)) patterns.push(line);
|
|
748
779
|
} catch (err) {
|
|
780
|
+
if ((err as Error).message === 'symlink_skipped') continue;
|
|
749
781
|
if ((err as NodeJS.ErrnoException)?.code !== 'ENOENT') {
|
|
750
782
|
walk.complete = false;
|
|
751
783
|
logDebug('Ignore-file read failed (fail-closed)', { path: filePath, err });
|
|
@@ -787,7 +819,7 @@ export class CodeScanner {
|
|
|
787
819
|
}
|
|
788
820
|
|
|
789
821
|
/**
|
|
790
|
-
* Reconstruct the root
|
|
822
|
+
* Reconstruct the root->parent ignore-layer stack for a single file, for the
|
|
791
823
|
* incremental `scanFiles` path (which has a flat file list, not a recursive
|
|
792
824
|
* walk). Layers are memoized per directory in `cache` across a batch.
|
|
793
825
|
*/
|
|
@@ -807,7 +839,12 @@ export class CodeScanner {
|
|
|
807
839
|
for (const dir of dirs) {
|
|
808
840
|
let layer = cache.get(dir);
|
|
809
841
|
if (layer === undefined) {
|
|
810
|
-
layer = await this.loadIgnoreLayer(
|
|
842
|
+
layer = await this.loadIgnoreLayer(
|
|
843
|
+
dir === '' ? rootPath : join(rootPath, dir),
|
|
844
|
+
dir,
|
|
845
|
+
walk,
|
|
846
|
+
rootPath
|
|
847
|
+
);
|
|
811
848
|
cache.set(dir, layer);
|
|
812
849
|
}
|
|
813
850
|
if (layer) stack.push(layer);
|
|
@@ -818,7 +855,7 @@ export class CodeScanner {
|
|
|
818
855
|
/**
|
|
819
856
|
* True when any directory strictly between `rootPath` and `filePath`
|
|
820
857
|
* carries its own `.git` entry (see isGitBoundary). Paths that don't
|
|
821
|
-
* resolve under `rootPath` are not judged here (return false)
|
|
858
|
+
* resolve under `rootPath` are not judged here (return false) - the
|
|
822
859
|
* server's path validation handles escapes.
|
|
823
860
|
*/
|
|
824
861
|
private async insideNestedGitBoundary(filePath: string, rootPath: string): Promise<boolean> {
|
|
@@ -842,17 +879,89 @@ export class CodeScanner {
|
|
|
842
879
|
*/
|
|
843
880
|
private static readonly MAX_DEPTH = 10;
|
|
844
881
|
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
882
|
+
private canonicalRoots = new Map<string, string>();
|
|
883
|
+
private async canonicalRoot(root: string): Promise<string> {
|
|
884
|
+
let canonical = this.canonicalRoots.get(root);
|
|
885
|
+
if (!canonical) {
|
|
886
|
+
canonical = await realpath(root);
|
|
887
|
+
this.canonicalRoots.set(root, canonical);
|
|
888
|
+
}
|
|
889
|
+
return canonical;
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
static containsCanonical(
|
|
893
|
+
candidate: string,
|
|
894
|
+
root: string,
|
|
895
|
+
paths = { sep, resolve },
|
|
896
|
+
caseInsensitive = process.platform === 'win32'
|
|
897
|
+
): boolean {
|
|
898
|
+
// realpath expands filesystem aliases (including Windows 8.3 names). Never guess a short name.
|
|
899
|
+
candidate = paths.resolve(candidate);
|
|
900
|
+
root = paths.resolve(root);
|
|
901
|
+
if (caseInsensitive) {
|
|
902
|
+
candidate = candidate.toLowerCase();
|
|
903
|
+
root = root.toLowerCase();
|
|
904
|
+
}
|
|
905
|
+
return (
|
|
906
|
+
candidate === root || candidate.startsWith(root.endsWith(paths.sep) ? root : root + paths.sep)
|
|
907
|
+
);
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
private async confineSymlink(fullPath: string, canonicalRoot: string): Promise<boolean> {
|
|
911
|
+
// Check each component: explicit-file callers must not bypass the walkers'
|
|
912
|
+
// no-symlink policy by supplying a path through an in-root directory link.
|
|
913
|
+
let path = resolve(fullPath);
|
|
914
|
+
if (!CodeScanner.containsCanonical(path, canonicalRoot)) return false;
|
|
915
|
+
for (;;) {
|
|
916
|
+
if (this.skipSymlink(await lstat(path))) throw new Error('symlink_skipped');
|
|
917
|
+
if (CodeScanner.containsCanonical(canonicalRoot, path)) break;
|
|
918
|
+
path = dirname(path);
|
|
919
|
+
}
|
|
920
|
+
return CodeScanner.containsCanonical(await realpath(fullPath), canonicalRoot);
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
async readConfined(fullPath: string, canonicalRoot: string, enumerated?: Stats): Promise<string> {
|
|
924
|
+
const expected = enumerated ?? (await lstat(fullPath));
|
|
925
|
+
if (this.skipSymlink(expected)) throw new Error('symlink_skipped');
|
|
926
|
+
if (!expected.isFile()) throw new Error('scanner_not_regular');
|
|
927
|
+
if (!(await this.confineSymlink(fullPath, canonicalRoot)))
|
|
928
|
+
throw new Error('scanner_containment');
|
|
929
|
+
const canonical = await realpath(fullPath);
|
|
930
|
+
if (
|
|
931
|
+
!CodeScanner.containsCanonical(canonical, canonicalRoot) ||
|
|
932
|
+
(await this.isProtectedRead(canonical))
|
|
933
|
+
)
|
|
934
|
+
throw new Error('scanner_containment');
|
|
935
|
+
// O_NONBLOCK also prevents a substituted FIFO from hanging before fstat.
|
|
936
|
+
const flags =
|
|
937
|
+
constants.O_RDONLY |
|
|
938
|
+
(constants.O_NONBLOCK ?? 0) |
|
|
939
|
+
(process.platform === 'win32' ? 0 : (constants.O_NOFOLLOW ?? 0));
|
|
940
|
+
const handle = await open(fullPath, flags).catch((error: NodeJS.ErrnoException) => {
|
|
941
|
+
if (error.code === 'ELOOP') {
|
|
942
|
+
this.skipReasons.symlink_skipped++;
|
|
943
|
+
throw new Error('symlink_skipped');
|
|
944
|
+
}
|
|
945
|
+
throw error;
|
|
946
|
+
});
|
|
947
|
+
try {
|
|
948
|
+
const actual = await handle.stat();
|
|
949
|
+
if (!actual.isFile()) throw new Error('scanner_not_regular');
|
|
950
|
+
if (actual.dev !== expected.dev || actual.ino !== expected.ino)
|
|
951
|
+
throw new Error('scanner_identity_changed');
|
|
952
|
+
// Windows has no O_NOFOLLOW; lstat is Node's junction/reparse-link check.
|
|
953
|
+
if (process.platform === 'win32' && this.skipSymlink(await lstat(fullPath)))
|
|
954
|
+
throw new Error('symlink_skipped');
|
|
955
|
+
// Linux also proves the opened object is still at the checked path.
|
|
956
|
+
if (
|
|
957
|
+
process.platform === 'linux' &&
|
|
958
|
+
(await realpath(`/proc/self/fd/${handle.fd}`)) !== canonical
|
|
959
|
+
)
|
|
960
|
+
throw new Error('scanner_containment');
|
|
961
|
+
return await handle.readFile('utf-8');
|
|
962
|
+
} finally {
|
|
963
|
+
await handle.close();
|
|
964
|
+
}
|
|
856
965
|
}
|
|
857
966
|
|
|
858
967
|
/**
|
|
@@ -867,7 +976,7 @@ export class CodeScanner {
|
|
|
867
976
|
/**
|
|
868
977
|
* Completeness-aware variant of `scanDirectory`. `complete === false`
|
|
869
978
|
* means at least one fs error was swallowed during the walk, so the
|
|
870
|
-
* chunk set may be missing files that still exist on disk
|
|
979
|
+
* chunk set may be missing files that still exist on disk - callers
|
|
871
980
|
* deriving removals from the result must not trust it as an inventory.
|
|
872
981
|
* Chunks are secret-scrubbed before returning (same daemon-side
|
|
873
982
|
* redaction guarantee as `scanFiles`).
|
|
@@ -875,6 +984,7 @@ export class CodeScanner {
|
|
|
875
984
|
async scanDirectoryWithStatus(
|
|
876
985
|
rootPath: string
|
|
877
986
|
): Promise<{ chunks: CodeChunk[]; complete: boolean }> {
|
|
987
|
+
rootPath = await this.canonicalRoot(rootPath);
|
|
878
988
|
const chunks: CodeChunk[] = [];
|
|
879
989
|
const walk: WalkState = { complete: true };
|
|
880
990
|
await this.traverseDirectory(rootPath, rootPath, chunks, 0, walk);
|
|
@@ -885,7 +995,7 @@ export class CodeScanner {
|
|
|
885
995
|
* Enumerate scan-eligible relative file paths under `rootPath` without
|
|
886
996
|
* reading or chunking content. Same walk + ignorePatterns + extension
|
|
887
997
|
* filter as `scanDirectory`, but bounded to O(file count) directory ops
|
|
888
|
-
*
|
|
998
|
+
* - cheap enough to call on every periodic reconcile tick.
|
|
889
999
|
*
|
|
890
1000
|
* Returns relative paths normalized against `rootPath`, matching the
|
|
891
1001
|
* shape the server stores in `memories.metadata->>'filePath'`. Use this
|
|
@@ -904,12 +1014,13 @@ export class CodeScanner {
|
|
|
904
1014
|
|
|
905
1015
|
/**
|
|
906
1016
|
* Completeness-aware variant of `listFiles`. `complete === false` means
|
|
907
|
-
* an fs error truncated part of the walk
|
|
1017
|
+
* an fs error truncated part of the walk - the returned paths may be
|
|
908
1018
|
* missing files that still exist on disk. The daemon's reconcile channel
|
|
909
1019
|
* MUST NOT derive removedFiles from an incomplete inventory (a truncated
|
|
910
1020
|
* walk otherwise reads as mass deletion and deprecates live memories).
|
|
911
1021
|
*/
|
|
912
1022
|
async listFilesWithStatus(rootPath: string): Promise<{ paths: string[]; complete: boolean }> {
|
|
1023
|
+
rootPath = await this.canonicalRoot(rootPath);
|
|
913
1024
|
const paths: string[] = [];
|
|
914
1025
|
const walk: WalkState = { complete: true };
|
|
915
1026
|
await this.traversePaths(rootPath, rootPath, paths, 0, walk);
|
|
@@ -928,8 +1039,11 @@ export class CodeScanner {
|
|
|
928
1039
|
stack: IgnoreStack = []
|
|
929
1040
|
): Promise<void> {
|
|
930
1041
|
if (depth >= CodeScanner.MAX_DEPTH) return;
|
|
1042
|
+
if (this.isProtected(currentPath)) return;
|
|
931
1043
|
let entries: string[];
|
|
932
1044
|
try {
|
|
1045
|
+
if (!(await this.confineSymlink(currentPath, rootPath)))
|
|
1046
|
+
throw new Error('scanner_containment');
|
|
933
1047
|
entries = await withTimeout(
|
|
934
1048
|
readdir(currentPath),
|
|
935
1049
|
FILE_OP_TIMEOUT_MS,
|
|
@@ -942,28 +1056,25 @@ export class CodeScanner {
|
|
|
942
1056
|
}
|
|
943
1057
|
const dirRel =
|
|
944
1058
|
currentPath === rootPath ? '' : relative(rootPath, currentPath).split(sep).join('/');
|
|
945
|
-
const localLayer = await this.loadIgnoreLayer(currentPath, dirRel, walk);
|
|
1059
|
+
const localLayer = await this.loadIgnoreLayer(currentPath, dirRel, walk, rootPath);
|
|
946
1060
|
const localStack = localLayer ? [...stack, localLayer] : stack;
|
|
947
1061
|
for (const entry of entries) {
|
|
948
1062
|
const fullPath = join(currentPath, entry);
|
|
949
1063
|
const relativePath = relative(rootPath, fullPath);
|
|
1064
|
+
if (this.isProtected(fullPath)) continue;
|
|
950
1065
|
if (this.shouldIgnore(relativePath)) continue;
|
|
951
1066
|
|
|
952
1067
|
// Per-entry isolation: a single bad entry (dangling symlink, raced
|
|
953
1068
|
// deletion, permission denial, fs timeout) must not abort the rest of
|
|
954
|
-
// the directory
|
|
1069
|
+
// the directory - but the inventory is no longer removal-trustworthy.
|
|
955
1070
|
try {
|
|
956
1071
|
const lstats = await withTimeout(
|
|
957
1072
|
lstat(fullPath),
|
|
958
1073
|
FILE_OP_TIMEOUT_MS,
|
|
959
1074
|
`lstat timed out: ${fullPath}`
|
|
960
1075
|
);
|
|
961
|
-
if (
|
|
962
|
-
|
|
963
|
-
}
|
|
964
|
-
const stats = lstats.isSymbolicLink()
|
|
965
|
-
? await withTimeout(stat(fullPath), FILE_OP_TIMEOUT_MS, `stat timed out: ${fullPath}`)
|
|
966
|
-
: lstats;
|
|
1076
|
+
if (this.skipSymlink(lstats)) continue;
|
|
1077
|
+
const stats = lstats;
|
|
967
1078
|
|
|
968
1079
|
if (stats.isDirectory()) {
|
|
969
1080
|
if (this.ignoredByStack(relativePath, true, localStack)) continue; // .gitignore/.mnemonikignore
|
|
@@ -987,12 +1098,15 @@ export class CodeScanner {
|
|
|
987
1098
|
* Pass rootPath to compute proper relative file paths in chunk metadata.
|
|
988
1099
|
*/
|
|
989
1100
|
async scanFiles(filePaths: string[], rootPath: string): Promise<CodeChunk[]> {
|
|
1101
|
+
rootPath = await this.canonicalRoot(rootPath);
|
|
990
1102
|
const chunks: CodeChunk[] = [];
|
|
991
1103
|
const ignoreCache = new Map<string, IgnoreLayer | null>();
|
|
992
1104
|
const walk: WalkState = { complete: true };
|
|
993
1105
|
|
|
994
1106
|
for (const filePath of filePaths) {
|
|
995
1107
|
try {
|
|
1108
|
+
if (!(await this.confineSymlink(filePath, rootPath))) continue;
|
|
1109
|
+
if (this.isProtected(filePath) || (rootPath && this.isProtected(rootPath))) continue;
|
|
996
1110
|
const fileRel = rootPath ? relative(rootPath, filePath).split(sep).join('/') : filePath;
|
|
997
1111
|
if (this.shouldIgnore(fileRel)) {
|
|
998
1112
|
continue;
|
|
@@ -1007,7 +1121,7 @@ export class CodeScanner {
|
|
|
1007
1121
|
}
|
|
1008
1122
|
}
|
|
1009
1123
|
// Explicit file lists arrive from watcher events, which can race a
|
|
1010
|
-
// worktree/nested-clone appearing
|
|
1124
|
+
// worktree/nested-clone appearing - apply the same nested-git-boundary
|
|
1011
1125
|
// rule the walks use (an ancestor between root and the file carrying
|
|
1012
1126
|
// its own .git means the file belongs to another repository).
|
|
1013
1127
|
if (rootPath && (await this.insideNestedGitBoundary(filePath, rootPath))) {
|
|
@@ -1037,14 +1151,26 @@ export class CodeScanner {
|
|
|
1037
1151
|
* honor the same redaction guarantee.
|
|
1038
1152
|
*/
|
|
1039
1153
|
private scrubChunks(chunks: CodeChunk[]): CodeChunk[] {
|
|
1040
|
-
return chunks.
|
|
1154
|
+
return chunks.flatMap((chunk) => {
|
|
1041
1155
|
const scrubbed = scrubSecrets(chunk.content);
|
|
1042
|
-
if (scrubbed
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1156
|
+
if (scrubbed.length <= MAX_PUSH_CHUNK_CONTENT_LENGTH) {
|
|
1157
|
+
return scrubbed === chunk.content
|
|
1158
|
+
? [chunk]
|
|
1159
|
+
: [{ ...chunk, content: scrubbed, contentHash: this.hash(scrubbed) }];
|
|
1160
|
+
}
|
|
1161
|
+
const pieces: CodeChunk[] = [];
|
|
1162
|
+
let startLine = chunk.startLine;
|
|
1163
|
+
for (let offset = 0; offset < scrubbed.length;) {
|
|
1164
|
+
let end = Math.min(offset + MAX_PUSH_CHUNK_CONTENT_LENGTH, scrubbed.length);
|
|
1165
|
+
// Keep a UTF-16 surrogate pair together at a transport boundary.
|
|
1166
|
+
if (end < scrubbed.length && /[\uD800-\uDBFF]/.test(scrubbed.charAt(end - 1))) end--;
|
|
1167
|
+
const content = scrubbed.slice(offset, end);
|
|
1168
|
+
const endLine = startLine + (content.match(/\n/g)?.length ?? 0);
|
|
1169
|
+
pieces.push({ ...chunk, content, startLine, endLine, contentHash: this.hash(content) });
|
|
1170
|
+
startLine = endLine;
|
|
1171
|
+
offset = end;
|
|
1172
|
+
}
|
|
1173
|
+
return pieces;
|
|
1048
1174
|
});
|
|
1049
1175
|
}
|
|
1050
1176
|
|
|
@@ -1065,10 +1191,13 @@ export class CodeScanner {
|
|
|
1065
1191
|
logDebug('Max directory depth reached, skipping', { path: currentPath, depth });
|
|
1066
1192
|
return;
|
|
1067
1193
|
}
|
|
1194
|
+
if (this.isProtected(currentPath)) return;
|
|
1068
1195
|
|
|
1069
1196
|
// Wrap readdir with timeout to prevent hanging
|
|
1070
1197
|
let entries: string[];
|
|
1071
1198
|
try {
|
|
1199
|
+
if (!(await this.confineSymlink(currentPath, rootPath)))
|
|
1200
|
+
throw new Error('scanner_containment');
|
|
1072
1201
|
entries = await withTimeout(
|
|
1073
1202
|
readdir(currentPath),
|
|
1074
1203
|
FILE_OP_TIMEOUT_MS,
|
|
@@ -1083,12 +1212,13 @@ export class CodeScanner {
|
|
|
1083
1212
|
// Layer this directory's .gitignore/.mnemonikignore onto the inherited stack.
|
|
1084
1213
|
const dirRel =
|
|
1085
1214
|
currentPath === rootPath ? '' : relative(rootPath, currentPath).split(sep).join('/');
|
|
1086
|
-
const localLayer = await this.loadIgnoreLayer(currentPath, dirRel, walk);
|
|
1215
|
+
const localLayer = await this.loadIgnoreLayer(currentPath, dirRel, walk, rootPath);
|
|
1087
1216
|
const localStack = localLayer ? [...stack, localLayer] : stack;
|
|
1088
1217
|
|
|
1089
1218
|
for (const entry of entries) {
|
|
1090
1219
|
const fullPath = join(currentPath, entry);
|
|
1091
1220
|
const relativePath = relative(rootPath, fullPath);
|
|
1221
|
+
if (this.isProtected(fullPath)) continue;
|
|
1092
1222
|
|
|
1093
1223
|
// Check ignore patterns
|
|
1094
1224
|
if (this.shouldIgnore(relativePath)) {
|
|
@@ -1097,7 +1227,7 @@ export class CodeScanner {
|
|
|
1097
1227
|
|
|
1098
1228
|
// Per-entry isolation: a single bad entry (dangling symlink, raced
|
|
1099
1229
|
// deletion, permission denial, fs timeout) must not abort the rest of
|
|
1100
|
-
// the directory
|
|
1230
|
+
// the directory - but the scan is no longer removal-trustworthy.
|
|
1101
1231
|
try {
|
|
1102
1232
|
const lstats = await withTimeout(
|
|
1103
1233
|
lstat(fullPath),
|
|
@@ -1105,14 +1235,8 @@ export class CodeScanner {
|
|
|
1105
1235
|
`lstat timed out: ${fullPath}`
|
|
1106
1236
|
);
|
|
1107
1237
|
|
|
1108
|
-
if (
|
|
1109
|
-
|
|
1110
|
-
continue;
|
|
1111
|
-
}
|
|
1112
|
-
|
|
1113
|
-
const stats = lstats.isSymbolicLink()
|
|
1114
|
-
? await withTimeout(stat(fullPath), FILE_OP_TIMEOUT_MS, `stat timed out: ${fullPath}`)
|
|
1115
|
-
: lstats;
|
|
1238
|
+
if (this.skipSymlink(lstats)) continue;
|
|
1239
|
+
const stats = lstats;
|
|
1116
1240
|
|
|
1117
1241
|
if (stats.isDirectory()) {
|
|
1118
1242
|
if (this.ignoredByStack(relativePath, true, localStack)) continue; // .gitignore/.mnemonikignore
|
|
@@ -1121,7 +1245,7 @@ export class CodeScanner {
|
|
|
1121
1245
|
} else if (stats.isFile()) {
|
|
1122
1246
|
if (this.ignoredByStack(relativePath, false, localStack)) continue;
|
|
1123
1247
|
if (this.isChunkable(fullPath, relativePath)) {
|
|
1124
|
-
const fileChunks = await this.parseFile(fullPath, rootPath);
|
|
1248
|
+
const fileChunks = await this.parseFile(fullPath, rootPath, stats);
|
|
1125
1249
|
chunks.push(...fileChunks);
|
|
1126
1250
|
}
|
|
1127
1251
|
}
|
|
@@ -1142,7 +1266,7 @@ export class CodeScanner {
|
|
|
1142
1266
|
*/
|
|
1143
1267
|
private shouldIgnore(path: string): boolean {
|
|
1144
1268
|
// Credential-bearing file kinds first, and outside the overridable
|
|
1145
|
-
// `ignorePatterns` list
|
|
1269
|
+
// `ignorePatterns` list - a caller-supplied pattern set must not be able
|
|
1146
1270
|
// to opt a project back into indexing its own private keys.
|
|
1147
1271
|
if (isSecretFile(path)) return true;
|
|
1148
1272
|
const segments = path.split('/');
|
|
@@ -1151,10 +1275,10 @@ export class CodeScanner {
|
|
|
1151
1275
|
// A leading '/' opts a glob pattern into SEGMENT-ANCHORED matching:
|
|
1152
1276
|
// the pattern body must start at the beginning of the path or right
|
|
1153
1277
|
// after a '/'. Without it, glob patterns keep their historical
|
|
1154
|
-
// unanchored substring semantics
|
|
1278
|
+
// unanchored substring semantics - anchoring is opt-in per pattern so
|
|
1155
1279
|
// this change cannot alter any existing pattern's behavior.
|
|
1156
1280
|
// (Motivating bug: unanchored 'tests/fixtures/*' also matched
|
|
1157
|
-
// 'integration-tests/fixtures
|
|
1281
|
+
// 'integration-tests/fixtures/...' in every indexed project.)
|
|
1158
1282
|
const anchored = pattern.startsWith('/');
|
|
1159
1283
|
const body = anchored ? pattern.slice(1) : pattern;
|
|
1160
1284
|
// Escape regex special chars, then replace all * with .*
|
|
@@ -1171,21 +1295,23 @@ export class CodeScanner {
|
|
|
1171
1295
|
|
|
1172
1296
|
/**
|
|
1173
1297
|
* Parse a file and extract code chunks.
|
|
1174
|
-
* Size limit is the shared `MAX_SCANNED_FILE_BYTES` ceiling
|
|
1298
|
+
* Size limit is the shared `MAX_SCANNED_FILE_BYTES` ceiling - the same
|
|
1175
1299
|
* number the daemon and the server's scanPushSchema enforce, so anything
|
|
1176
1300
|
* chunked here can always be shipped and stored verbatim.
|
|
1177
1301
|
*/
|
|
1178
1302
|
private static readonly MAX_FILE_SIZE = MAX_SCANNED_FILE_BYTES;
|
|
1179
1303
|
|
|
1180
|
-
private async parseFile(
|
|
1304
|
+
private async parseFile(
|
|
1305
|
+
filePath: string,
|
|
1306
|
+
rootPath: string,
|
|
1307
|
+
enumerated?: Stats
|
|
1308
|
+
): Promise<CodeChunk[]> {
|
|
1181
1309
|
try {
|
|
1182
1310
|
// Check file size before reading to avoid memory issues
|
|
1183
1311
|
// Wrap stat with timeout
|
|
1184
|
-
const stats =
|
|
1185
|
-
|
|
1186
|
-
FILE_OP_TIMEOUT_MS,
|
|
1187
|
-
`stat timed out: ${filePath}`
|
|
1188
|
-
);
|
|
1312
|
+
const stats =
|
|
1313
|
+
enumerated ??
|
|
1314
|
+
(await withTimeout(lstat(filePath), FILE_OP_TIMEOUT_MS, `stat timed out: ${filePath}`));
|
|
1189
1315
|
if (stats.size > CodeScanner.MAX_FILE_SIZE) {
|
|
1190
1316
|
logDebug('Skipping file exceeding size limit', {
|
|
1191
1317
|
filePath,
|
|
@@ -1197,15 +1323,15 @@ export class CodeScanner {
|
|
|
1197
1323
|
|
|
1198
1324
|
// Wrap readFile with timeout
|
|
1199
1325
|
let content = await withTimeout(
|
|
1200
|
-
|
|
1326
|
+
this.readConfined(filePath, rootPath, stats),
|
|
1201
1327
|
FILE_OP_TIMEOUT_MS,
|
|
1202
1328
|
`readFile timed out: ${filePath}`
|
|
1203
1329
|
);
|
|
1204
1330
|
// NUL sanitation (ingestion boundary, daemon side): Postgres `text`
|
|
1205
|
-
// columns reject the literal NUL byte (U+0000)
|
|
1331
|
+
// columns reject the literal NUL byte (U+0000) - a single one aborts
|
|
1206
1332
|
// the whole INSERT/UPDATE. Some source files legitimately contain
|
|
1207
1333
|
// them. Strip before any chunking so every chunk's content, its
|
|
1208
|
-
// contentHash, and its embedding all agree
|
|
1334
|
+
// contentHash, and its embedding all agree - mirrors the server-side
|
|
1209
1335
|
// strip in ProjectManager (kept as defense in depth for content that
|
|
1210
1336
|
// reaches the server via an older scanner) but doing it here means
|
|
1211
1337
|
// the daemon never even transmits the NUL bytes.
|
|
@@ -1227,7 +1353,7 @@ export class CodeScanner {
|
|
|
1227
1353
|
};
|
|
1228
1354
|
|
|
1229
1355
|
// AST first for the languages with a vendored grammar. `null` means "no
|
|
1230
|
-
// grammar for this"
|
|
1356
|
+
// grammar for this" - the honest majority of the allowlist - and the
|
|
1231
1357
|
// heuristic path below takes over with no log, because that is not a
|
|
1232
1358
|
// degradation.
|
|
1233
1359
|
const astLanguage = resolveAstLanguage(fileMetadata.extension, language);
|
|
@@ -1250,8 +1376,8 @@ export class CodeScanner {
|
|
|
1250
1376
|
}
|
|
1251
1377
|
// `errorNodes` is handed back precisely so this degradation is not
|
|
1252
1378
|
// silent, and discarding it made it silent. One ERROR node can swallow
|
|
1253
|
-
// the rest of a file
|
|
1254
|
-
// exactly that to tree-sitter-typescript
|
|
1379
|
+
// the rest of a file - `typeof import(...)` in a type argument does
|
|
1380
|
+
// exactly that to tree-sitter-typescript - after which no definition is
|
|
1255
1381
|
// captured and the file arrives as one unnamed whole-file chunk. Naming
|
|
1256
1382
|
// the file, the error count and how many definitions survived is what
|
|
1257
1383
|
// makes that visible in a scan log.
|
|
@@ -1259,7 +1385,7 @@ export class CodeScanner {
|
|
|
1259
1385
|
// Deliberately NOT a fallback to the heuristic path: measured on the
|
|
1260
1386
|
// real construct (tests/AgentInjectionDeliveryWiring.test.ts), the
|
|
1261
1387
|
// heuristic path names nothing either (its name pattern needs a
|
|
1262
|
-
// leading `function`/`class`/`const`) AND covers less
|
|
1388
|
+
// leading `function`/`class`/`const`) AND covers less - it emitted 3
|
|
1263
1389
|
// chunks starting at line 18 and dropped lines 1-17, because the
|
|
1264
1390
|
// uncovered-region supplement only fires above 50 lines. Trading
|
|
1265
1391
|
// total coverage for no gain is worse than one honest blob, so the
|
|
@@ -1281,8 +1407,8 @@ export class CodeScanner {
|
|
|
1281
1407
|
// file cannot be bounded on line boundaries at all.
|
|
1282
1408
|
const bounded = this.boundAstChunks(ast.chunks);
|
|
1283
1409
|
if (bounded) {
|
|
1284
|
-
// Zero chunks here means the file has no non-whitespace line
|
|
1285
|
-
// chunker covers every other line by construction
|
|
1410
|
+
// Zero chunks here means the file has no non-whitespace line - the AST
|
|
1411
|
+
// chunker covers every other line by construction - so returning
|
|
1286
1412
|
// nothing is the correct answer, not a lost file.
|
|
1287
1413
|
return bounded.map(
|
|
1288
1414
|
({ symbolName, symbolKind, signature, symbolContainer, ...chunk }) => ({
|
|
@@ -1300,7 +1426,7 @@ export class CodeScanner {
|
|
|
1300
1426
|
})
|
|
1301
1427
|
);
|
|
1302
1428
|
}
|
|
1303
|
-
// One line longer than `maxChunkSize`
|
|
1429
|
+
// One line longer than `maxChunkSize` - a generated `*_pb2.py` carries
|
|
1304
1430
|
// the whole serialized descriptor on one. Splitting it would have to
|
|
1305
1431
|
// cut mid-line, and two chunks over one line range collide on the
|
|
1306
1432
|
// `filePath:startLine-endLine` staleness key, so the whole file goes to
|
|
@@ -1318,7 +1444,7 @@ export class CodeScanner {
|
|
|
1318
1444
|
} else if (ast.unsupported === 'grammar_unavailable') {
|
|
1319
1445
|
// Process-global, not per file: `loadGrammar` caches its failures, so
|
|
1320
1446
|
// every file of this language degrades for the same reason. Warned per
|
|
1321
|
-
// file, this printed 1,037 identical lines in one scan of this repo
|
|
1447
|
+
// file, this printed 1,037 identical lines in one scan of this repo -
|
|
1322
1448
|
// the log flood `logAstCapabilityOnce` exists to avoid. Once per
|
|
1323
1449
|
// (reason, grammar); the file-specific reasons below stay per file.
|
|
1324
1450
|
const key = `${ast.unsupported}:${astLanguage}`;
|
|
@@ -1335,7 +1461,7 @@ export class CodeScanner {
|
|
|
1335
1461
|
} else {
|
|
1336
1462
|
// Not a silent no-op: name the file AND the reason, then degrade to the
|
|
1337
1463
|
// heuristic path. A degraded scan indistinguishable from a healthy one
|
|
1338
|
-
// is the defect class this routing exists to avoid
|
|
1464
|
+
// is the defect class this routing exists to avoid - and degrading to
|
|
1339
1465
|
// zero chunks would delete the file from the index instead.
|
|
1340
1466
|
logWarn('AST chunking unavailable; falling back to heuristic chunking', {
|
|
1341
1467
|
filePath: relativePath,
|
|
@@ -1405,8 +1531,8 @@ export class CodeScanner {
|
|
|
1405
1531
|
* chunk that came from a definition is exactly as big as the definition, and a
|
|
1406
1532
|
* span that is one enormous line is left whole on purpose. Both used to reach
|
|
1407
1533
|
* the wire verbatim, where `scanChunkSchema` caps content at 500,000 chars and
|
|
1408
|
-
* rejects the WHOLE push if any chunk is over
|
|
1409
|
-
* all scanning for that push
|
|
1534
|
+
* rejects the WHOLE push if any chunk is over - so one generated file stopped
|
|
1535
|
+
* all scanning for that push - and where anything past 8191 tokens is only
|
|
1410
1536
|
* partially embedded.
|
|
1411
1537
|
*
|
|
1412
1538
|
* The invariants, all of which the split preserves:
|
|
@@ -1414,8 +1540,8 @@ export class CodeScanner {
|
|
|
1414
1540
|
* joined by '\n', never a join of disjoint regions and never an elision;
|
|
1415
1541
|
* - total coverage is unchanged: the pieces tile the original range, in order,
|
|
1416
1542
|
* with no gap and no overlap;
|
|
1417
|
-
* - the definition's identity rides on the piece that carries its signature
|
|
1418
|
-
* the first one
|
|
1543
|
+
* - the definition's identity rides on the piece that carries its signature -
|
|
1544
|
+
* the first one - and the continuations are plain `raw` spans, because a
|
|
1419
1545
|
* continuation is not the definition and must not claim its name.
|
|
1420
1546
|
*
|
|
1421
1547
|
* `null` (the caller degrades the whole file to the heuristic chunker) is
|
|
@@ -1624,7 +1750,7 @@ export class CodeScanner {
|
|
|
1624
1750
|
continue;
|
|
1625
1751
|
}
|
|
1626
1752
|
|
|
1627
|
-
// Regex literal
|
|
1753
|
+
// Regex literal - heuristic: / after operator chars or keywords that precede expressions
|
|
1628
1754
|
if (c === '/' && i > 0) {
|
|
1629
1755
|
// Look back for operator context (skip whitespace)
|
|
1630
1756
|
let j = i - 1;
|
|
@@ -1658,7 +1784,7 @@ export class CodeScanner {
|
|
|
1658
1784
|
if (content.charAt(i) === '\\') {
|
|
1659
1785
|
i++; // skip escaped char
|
|
1660
1786
|
} else if (content.charAt(i) === '[') {
|
|
1661
|
-
// character class
|
|
1787
|
+
// character class - skip to ]
|
|
1662
1788
|
i++;
|
|
1663
1789
|
while (i < len && content.charAt(i) !== ']') {
|
|
1664
1790
|
if (content.charAt(i) === '\\') i++;
|
|
@@ -1780,7 +1906,7 @@ export class CodeScanner {
|
|
|
1780
1906
|
regex: '(?:export\\s+)?const\\s+\\w+\\s*=\\s*(?:async\\s+)?\\([^)]*\\)\\s*=>\\s*\\{',
|
|
1781
1907
|
type: 'function',
|
|
1782
1908
|
},
|
|
1783
|
-
// Class methods
|
|
1909
|
+
// Class methods - matches indented methods with optional modifiers.
|
|
1784
1910
|
// Excludes control flow keywords (if, for, while, switch, catch, return).
|
|
1785
1911
|
{
|
|
1786
1912
|
regex:
|
|
@@ -1813,13 +1939,9 @@ export class CodeScanner {
|
|
|
1813
1939
|
/**
|
|
1814
1940
|
* Raw chunking with overlap, bounded by character count (not line count).
|
|
1815
1941
|
*
|
|
1816
|
-
*
|
|
1817
|
-
*
|
|
1818
|
-
*
|
|
1819
|
-
* `maxChunkSize`, which then exceeded OpenAI's 8191-token embedding
|
|
1820
|
-
* limit and surfaced as 400s on /scan/push (Sentry MNEMONIK-58).
|
|
1821
|
-
*
|
|
1822
|
-
* Now: walk lines and accumulate character length; emit when the next
|
|
1942
|
+
* Line-count estimates do not bound long-line files such as minified JS,
|
|
1943
|
+
* JSON blobs and generated code. Walk lines and accumulate character
|
|
1944
|
+
* length; emit when the next
|
|
1823
1945
|
* line would push the running total past `maxChunkSize`. Single lines
|
|
1824
1946
|
* longer than `maxChunkSize` are force-split into char-based segments.
|
|
1825
1947
|
* 10% overlap is carried by character count from the tail of the
|
|
@@ -1929,7 +2051,7 @@ export class CodeScanner {
|
|
|
1929
2051
|
/**
|
|
1930
2052
|
* Walk the project for authority manifest/config/CI files and return their
|
|
1931
2053
|
* verbatim content. Reuses ignorePatterns; matches AUTHORITY_FILE_MATCHERS
|
|
1932
|
-
* (not includeExtensions). Content-only
|
|
2054
|
+
* (not includeExtensions). Content-only - no chunking, no embeddings.
|
|
1933
2055
|
*/
|
|
1934
2056
|
async collectAuthorityFiles(
|
|
1935
2057
|
projectRoot: string
|
|
@@ -1941,67 +2063,65 @@ export class CodeScanner {
|
|
|
1941
2063
|
/**
|
|
1942
2064
|
* Completeness-aware variant of `collectAuthorityFiles`. `complete ===
|
|
1943
2065
|
* false` means an fs error hid part of the walk, so authority paths may
|
|
1944
|
-
* be missing
|
|
2066
|
+
* be missing - the daemon folds this into the same removal gate as
|
|
1945
2067
|
* `listFilesWithStatus` (a partial authority walk would likewise drop
|
|
1946
2068
|
* inventory entries and read as deletion).
|
|
1947
2069
|
*/
|
|
1948
|
-
async collectAuthorityFilesWithStatus(
|
|
2070
|
+
async collectAuthorityFilesWithStatus(
|
|
2071
|
+
projectRoot: string,
|
|
2072
|
+
onFile?: (file: { path: string; content: string; hash: string }) => Promise<void>
|
|
2073
|
+
): Promise<{
|
|
1949
2074
|
files: Array<{ path: string; content: string; hash: string }>;
|
|
1950
2075
|
complete: boolean;
|
|
1951
2076
|
}> {
|
|
2077
|
+
projectRoot = await this.canonicalRoot(projectRoot);
|
|
1952
2078
|
const out: Array<{ path: string; content: string; hash: string }> = [];
|
|
1953
2079
|
const walkState: WalkState = { complete: true };
|
|
1954
2080
|
|
|
1955
2081
|
// M2: cap walk depth to match the scanner's MAX_DEPTH (10)
|
|
1956
2082
|
const walk = async (dir: string, depth: number, stack: IgnoreStack = []): Promise<void> => {
|
|
1957
2083
|
if (depth >= CodeScanner.MAX_DEPTH) return;
|
|
2084
|
+
if (this.isProtected(dir)) return;
|
|
1958
2085
|
let entries;
|
|
1959
2086
|
try {
|
|
2087
|
+
if (!(await this.confineSymlink(dir, projectRoot))) throw new Error('scanner_containment');
|
|
1960
2088
|
entries = await readdir(dir, { withFileTypes: true });
|
|
1961
2089
|
} catch {
|
|
1962
2090
|
walkState.complete = false;
|
|
1963
2091
|
return;
|
|
1964
2092
|
}
|
|
1965
2093
|
const dirRel = dir === projectRoot ? '' : relative(projectRoot, dir).split(sep).join('/');
|
|
1966
|
-
const localLayer = await this.loadIgnoreLayer(dir, dirRel, walkState);
|
|
2094
|
+
const localLayer = await this.loadIgnoreLayer(dir, dirRel, walkState, projectRoot);
|
|
1967
2095
|
const localStack = localLayer ? [...stack, localLayer] : stack;
|
|
1968
2096
|
for (const ent of entries) {
|
|
1969
2097
|
const full = join(dir, ent.name);
|
|
1970
2098
|
const rel = relative(projectRoot, full).split(sep).join('/');
|
|
2099
|
+
if (this.isProtected(full)) continue;
|
|
1971
2100
|
if (this.shouldIgnore(rel)) continue;
|
|
1972
2101
|
|
|
1973
|
-
|
|
1974
|
-
|
|
1975
|
-
|
|
1976
|
-
|
|
1977
|
-
|
|
1978
|
-
|
|
1979
|
-
// false for them, so they were previously silently skipped).
|
|
1980
|
-
let isDir = ent.isDirectory();
|
|
1981
|
-
let isFile = ent.isFile();
|
|
1982
|
-
if (ent.isSymbolicLink()) {
|
|
1983
|
-
try {
|
|
1984
|
-
if (!(await this.confineSymlink(full, projectRoot))) continue;
|
|
1985
|
-
const stats = await stat(full);
|
|
1986
|
-
isDir = stats.isDirectory();
|
|
1987
|
-
isFile = stats.isFile();
|
|
1988
|
-
} catch {
|
|
1989
|
-
/* dangling/unreadable link — skip just this entry */
|
|
1990
|
-
walkState.complete = false;
|
|
1991
|
-
continue;
|
|
1992
|
-
}
|
|
2102
|
+
let stats: Stats;
|
|
2103
|
+
try {
|
|
2104
|
+
stats = await lstat(full);
|
|
2105
|
+
} catch {
|
|
2106
|
+
walkState.complete = false;
|
|
2107
|
+
continue;
|
|
1993
2108
|
}
|
|
2109
|
+
if (this.skipSymlink(stats)) continue;
|
|
2110
|
+
const isDir = stats.isDirectory(),
|
|
2111
|
+
isFile = stats.isFile();
|
|
1994
2112
|
|
|
1995
2113
|
if (this.ignoredByStack(rel, isDir, localStack)) continue; // .gitignore/.mnemonikignore
|
|
1996
2114
|
if (isDir) {
|
|
1997
2115
|
if (await isGitBoundary(full)) continue; // another repo's subtree
|
|
1998
2116
|
await walk(full, depth + 1, localStack);
|
|
1999
2117
|
} else if (isFile && AUTHORITY_FILE_MATCHERS.some((m) => m(rel))) {
|
|
2118
|
+
let file: { path: string; content: string; hash: string } | undefined;
|
|
2000
2119
|
try {
|
|
2001
|
-
|
|
2120
|
+
if (stats.size > MAX_SCANNED_FILE_BYTES) continue;
|
|
2121
|
+
let content = await this.readConfined(full, projectRoot, stats);
|
|
2002
2122
|
// Authority files honor the same single ceiling as chunked files
|
|
2003
2123
|
// (MAX_SCANNED_FILE_BYTES). Manifests are tiny; one this large is
|
|
2004
|
-
// anomalous, so say so rather than dropping it silently
|
|
2124
|
+
// anomalous, so say so rather than dropping it silently - a
|
|
2005
2125
|
// missing authority file otherwise looks identical to a project
|
|
2006
2126
|
// that simply has no manifest.
|
|
2007
2127
|
if (content.length > MAX_SCANNED_FILE_BYTES) {
|
|
@@ -2020,11 +2140,16 @@ export class CodeScanner {
|
|
|
2020
2140
|
content = content.replace(/\0/g, '');
|
|
2021
2141
|
}
|
|
2022
2142
|
const hash = createHash('sha256').update(content).digest('hex');
|
|
2023
|
-
|
|
2143
|
+
file = { path: rel, content, hash };
|
|
2024
2144
|
} catch {
|
|
2025
|
-
/* unreadable
|
|
2145
|
+
/* unreadable - skip */
|
|
2026
2146
|
walkState.complete = false;
|
|
2027
2147
|
}
|
|
2148
|
+
// The consumer owns backpressure; its abort must leave the walk immediately.
|
|
2149
|
+
if (file) {
|
|
2150
|
+
if (onFile) await onFile(file);
|
|
2151
|
+
else out.push(file);
|
|
2152
|
+
}
|
|
2028
2153
|
}
|
|
2029
2154
|
}
|
|
2030
2155
|
};
|