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