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