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