@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/dist/codeScanner.js
CHANGED
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Code Scanner - Parse and chunk source files for embedding
|
|
3
3
|
*/
|
|
4
|
-
import { readdir,
|
|
5
|
-
import {
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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`
|
|
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
|
|
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"
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
|
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`
|
|
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'
|
|
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)
|
|
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
|
|
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
|
|
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
|
-
*
|
|
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
|
|
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
|
|
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
|
|
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
|
|
578
|
-
* `Language.delete`
|
|
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
|
|
612
|
-
* on Windows, `.C`/`.H` in older C trees
|
|
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
|
|
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(
|
|
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
|
|
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)
|
|
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
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
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
|
|
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
|
-
*
|
|
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
|
|
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
|
|
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 (
|
|
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
|
|
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.
|
|
1026
|
+
return chunks.flatMap((chunk) => {
|
|
924
1027
|
const scrubbed = scrubSecrets(chunk.content);
|
|
925
|
-
if (scrubbed
|
|
926
|
-
return chunk
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
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
|
|
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 (
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 =
|
|
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(
|
|
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)
|
|
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
|
|
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"
|
|
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
|
|
1105
|
-
// exactly that to tree-sitter-typescript
|
|
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
|
|
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
|
|
1136
|
-
// chunker covers every other line by construction
|
|
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`
|
|
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
|
|
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
|
|
1254
|
-
* all scanning for that push
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
*
|
|
1631
|
-
*
|
|
1632
|
-
*
|
|
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
|
|
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
|
|
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
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
1941
|
+
file = { path: rel, content, hash };
|
|
1830
1942
|
}
|
|
1831
1943
|
catch {
|
|
1832
|
-
/* unreadable
|
|
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
|
};
|