codesearch-mcp 0.2.2-alpha → 0.2.3-alpha
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/CHANGELOG.md +34 -0
- package/README.md +15 -8
- package/dist/app.js +45 -7
- package/dist/src/commands/EvalCommand.d.ts +10 -0
- package/dist/src/commands/EvalCommand.js +231 -0
- package/dist/src/commands/IndexCommand.js +79 -53
- package/dist/src/db/index.d.ts +1 -1
- package/dist/src/db/index.js +1 -2
- package/dist/src/db/schema.d.ts +5 -29
- package/dist/src/db/schema.js +2 -8
- package/dist/src/dependency-traversal.d.ts +16 -0
- package/dist/src/dependency-traversal.js +45 -0
- package/dist/src/enriched-prompt.d.ts +3 -2
- package/dist/src/enriched-prompt.js +3 -2
- package/dist/src/eval-results.d.ts +12 -0
- package/dist/src/eval-results.js +114 -0
- package/dist/src/file-utils.d.ts +15 -0
- package/dist/src/file-utils.js +24 -1
- package/dist/src/help.js +4 -1
- package/dist/src/interfaces/IAstParser.d.ts +13 -1
- package/dist/src/interfaces/IDatabase.d.ts +0 -14
- package/dist/src/interfaces/IRetrievalEvaluator.d.ts +94 -0
- package/dist/src/interfaces/IRetrievalEvaluator.js +2 -0
- package/dist/src/interfaces/ISearcher.d.ts +11 -16
- package/dist/src/interfaces/index.d.ts +1 -0
- package/dist/src/project-scope.d.ts +19 -0
- package/dist/src/project-scope.js +49 -0
- package/dist/src/project-selector.d.ts +6 -0
- package/dist/src/project-selector.js +18 -0
- package/dist/src/retrieval-eval.d.ts +16 -0
- package/dist/src/retrieval-eval.js +214 -0
- package/dist/src/services/AstParserService.d.ts +66 -1
- package/dist/src/services/AstParserService.js +430 -25
- package/dist/src/services/DatabaseService.d.ts +0 -12
- package/dist/src/services/DatabaseService.js +2 -52
- package/dist/src/services/IndexerService.d.ts +8 -0
- package/dist/src/services/IndexerService.js +24 -11
- package/dist/src/services/McpServer.d.ts +7 -0
- package/dist/src/services/McpServer.js +42 -38
- package/dist/src/services/OpenAICompatibleEmbedder.js +2 -1
- package/dist/src/services/SearcherService.d.ts +189 -25
- package/dist/src/services/SearcherService.js +505 -203
- package/dist/src/symbol-rows.d.ts +14 -9
- package/dist/src/symbol-rows.js +17 -11
- package/dist/src/tools/FindSymbolTool.js +14 -5
- package/dist/src/tools/GetCallersTool.d.ts +3 -1
- package/dist/src/tools/GetCallersTool.js +16 -6
- package/dist/src/tools/GetDependenciesTool.d.ts +3 -1
- package/dist/src/tools/GetDependenciesTool.js +15 -8
- package/dist/src/tools/GetDependentsTool.d.ts +3 -1
- package/dist/src/tools/GetDependentsTool.js +31 -16
- package/dist/src/tools/ListFilesTool.d.ts +3 -1
- package/dist/src/tools/ListFilesTool.js +14 -12
- package/dist/src/tools/ListProjectsTool.js +5 -1
- package/dist/src/tools/ReadFileRangeTool.d.ts +3 -1
- package/dist/src/tools/ReadFileRangeTool.js +12 -11
- package/dist/src/tools/ReadFileTool.d.ts +3 -1
- package/dist/src/tools/ReadFileTool.js +12 -10
- package/dist/src/tools/SearchCodebaseTool.js +3 -1
- package/dist/src/tools/format-helpers.d.ts +16 -1
- package/dist/src/tools/format-helpers.js +64 -0
- package/package.json +9 -8
|
@@ -42,6 +42,7 @@ const file_utils_1 = require("../file-utils");
|
|
|
42
42
|
const MonorepoAwareDiscoveryService_1 = require("./MonorepoAwareDiscoveryService");
|
|
43
43
|
const legacy_ollama_index_1 = require("./legacy-ollama-index");
|
|
44
44
|
const symbol_name_1 = require("../symbol-name");
|
|
45
|
+
const dependency_traversal_1 = require("../dependency-traversal");
|
|
45
46
|
const SILENT_LOGGER = {
|
|
46
47
|
debug() { },
|
|
47
48
|
info() { },
|
|
@@ -49,25 +50,71 @@ const SILENT_LOGGER = {
|
|
|
49
50
|
error() { },
|
|
50
51
|
metric() { },
|
|
51
52
|
};
|
|
53
|
+
const NODE_FS = {
|
|
54
|
+
realpath: p => fs.promises.realpath(p),
|
|
55
|
+
stat: p => fs.promises.stat(p),
|
|
56
|
+
readdir: p => fs.promises.readdir(p, { withFileTypes: true }),
|
|
57
|
+
createReadStream: p => fs.createReadStream(p, { encoding: 'utf-8' }),
|
|
58
|
+
};
|
|
59
|
+
const GRAPHQL_KINDS = new Set([
|
|
60
|
+
'graphql_query', 'graphql_mutation', 'graphql_subscription', 'graphql_field', 'graphql_type',
|
|
61
|
+
]);
|
|
52
62
|
const EMPTY_META = { description: null, stack: null, notes: null, relations: null };
|
|
53
63
|
const GREP_MAX_RESULTS = 200;
|
|
54
64
|
const GREP_RAW_LIMIT = GREP_MAX_RESULTS * 5;
|
|
55
65
|
const GREP_EXCLUDE_DIRS = [...file_utils_1.IGNORE_DIRS].map(d => `--exclude-dir=${d}`);
|
|
56
66
|
const FIND_SYMBOL_LIMIT = 50;
|
|
57
67
|
const FIND_SYMBOL_RAW_LIMIT = FIND_SYMBOL_LIMIT * 10;
|
|
68
|
+
const PATH_CANDIDATE_LIMIT = 3;
|
|
69
|
+
/** Every path column of the index; `content` marks those whose file the indexer read. */
|
|
70
|
+
const INDEX_PATH_COLUMNS = [
|
|
71
|
+
{ table: 'code_chunk', column: '"filePath"', content: true },
|
|
72
|
+
{ table: 'code_symbol', column: '"filePath"', content: true },
|
|
73
|
+
{ table: 'code_dependency', column: '"fromFile"', content: true },
|
|
74
|
+
{ table: 'code_call', column: '"fromFile"', content: true },
|
|
75
|
+
{ table: 'code_dependency', column: '"toFile"', content: false },
|
|
76
|
+
{ table: 'code_call', column: '"calleeFile"', content: false },
|
|
77
|
+
];
|
|
78
|
+
const OUTSIDE_WORKSPACE_VALUE = (col) => `(${col} = '..' OR ${col} LIKE '../%')`;
|
|
79
|
+
function stripTrailingSlash(p) {
|
|
80
|
+
return p.length > 1 && p.endsWith('/') ? p.slice(0, -1) : p;
|
|
81
|
+
}
|
|
82
|
+
/** Segment-boundary containment: `/ws-other` is not within `/ws`; `.` contains every relative path. */
|
|
83
|
+
function isWithin(p, root) {
|
|
84
|
+
if (root === '.')
|
|
85
|
+
return true;
|
|
86
|
+
if (root === '/')
|
|
87
|
+
return p.startsWith('/');
|
|
88
|
+
return p === root || p.startsWith(root + '/');
|
|
89
|
+
}
|
|
90
|
+
function errorCode(err) {
|
|
91
|
+
return err?.code ?? 'EIO';
|
|
92
|
+
}
|
|
58
93
|
class SearcherService {
|
|
59
94
|
db;
|
|
60
95
|
embedder;
|
|
61
96
|
discovery;
|
|
62
97
|
config;
|
|
63
98
|
logger;
|
|
64
|
-
|
|
99
|
+
fs;
|
|
100
|
+
constructor(db, embedder, discovery, config, logger = SILENT_LOGGER, fsDeps = {}) {
|
|
65
101
|
this.db = db;
|
|
66
102
|
this.embedder = embedder;
|
|
67
103
|
this.discovery = discovery;
|
|
68
104
|
this.config = config;
|
|
69
105
|
this.logger = logger;
|
|
106
|
+
this.fs = { ...NODE_FS, ...fsDeps };
|
|
70
107
|
}
|
|
108
|
+
/**
|
|
109
|
+
* Whether `code_symbol.target` exists — `true` once a check finds it (never
|
|
110
|
+
* re-checked again in this process), `false` until then (re-checked on
|
|
111
|
+
* every lookup, so a `rag-index` run that adds the column takes effect
|
|
112
|
+
* without restarting `rag-mcp`). See "find_symbol works before the target
|
|
113
|
+
* column exists" (`graphql-symbols`).
|
|
114
|
+
*/
|
|
115
|
+
targetColumnPresent = false;
|
|
116
|
+
/** Real path of `workspaceRoot`; cached only once resolved, so a transient failure is retried. */
|
|
117
|
+
realRootCache;
|
|
71
118
|
/** Lazily-built, cached map of project name → real filesystem root. */
|
|
72
119
|
rootMap;
|
|
73
120
|
/** Cached, lazily-built map of leaf project name → real filesystem root (via discovery). */
|
|
@@ -95,6 +142,245 @@ class SearcherService {
|
|
|
95
142
|
}
|
|
96
143
|
return root ?? path.join(this.config.workspaceRoot, name);
|
|
97
144
|
}
|
|
145
|
+
toRel(absPath) {
|
|
146
|
+
return path.relative(this.config.workspaceRoot, absPath) || '.';
|
|
147
|
+
}
|
|
148
|
+
toAbs(relPath) {
|
|
149
|
+
return relPath === '.' ? this.workspaceRoot() : path.join(this.workspaceRoot(), relPath);
|
|
150
|
+
}
|
|
151
|
+
workspaceRoot() {
|
|
152
|
+
return stripTrailingSlash(path.posix.normalize(this.config.workspaceRoot));
|
|
153
|
+
}
|
|
154
|
+
async realWorkspaceRoot() {
|
|
155
|
+
if (this.realRootCache !== undefined)
|
|
156
|
+
return this.realRootCache;
|
|
157
|
+
try {
|
|
158
|
+
this.realRootCache = await this.fs.realpath(this.workspaceRoot());
|
|
159
|
+
return this.realRootCache;
|
|
160
|
+
}
|
|
161
|
+
catch (err) {
|
|
162
|
+
this.logger.warn('SearcherService: workspaceRoot real path unavailable', { reason: errorCode(err) });
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Lexical validation of a tool path argument: refusals for an empty value,
|
|
168
|
+
* `\`, an absolute path and a `..` escape; otherwise the canonical
|
|
169
|
+
* workspace-relative form. The only filesystem access is the cached real path
|
|
170
|
+
* of `workspaceRoot`.
|
|
171
|
+
*/
|
|
172
|
+
async resolveToolPath(input) {
|
|
173
|
+
if (input == null || input === '')
|
|
174
|
+
return { ok: false, reason: 'invalid', missing: true };
|
|
175
|
+
if (input.includes('\\'))
|
|
176
|
+
return { ok: false, reason: 'invalid', missing: false };
|
|
177
|
+
if (path.posix.isAbsolute(input)) {
|
|
178
|
+
const abs = stripTrailingSlash(path.posix.normalize(input));
|
|
179
|
+
const realRoot = await this.realWorkspaceRoot();
|
|
180
|
+
for (const root of realRoot ? [this.workspaceRoot(), realRoot] : [this.workspaceRoot()]) {
|
|
181
|
+
if (!isWithin(abs, root))
|
|
182
|
+
continue;
|
|
183
|
+
const relative = abs === root ? '.' : abs.slice(root === '/' ? 1 : root.length + 1);
|
|
184
|
+
return { ok: false, reason: 'absolute', relative };
|
|
185
|
+
}
|
|
186
|
+
return { ok: false, reason: 'outside_workspace' };
|
|
187
|
+
}
|
|
188
|
+
const relPath = stripTrailingSlash(path.posix.normalize(input));
|
|
189
|
+
if (relPath === '..' || relPath.startsWith('../'))
|
|
190
|
+
return { ok: false, reason: 'outside_workspace' };
|
|
191
|
+
return { ok: true, relPath, absPath: this.toAbs(relPath) };
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* Whether the real location of `absPath` — of the target, else of its nearest
|
|
195
|
+
* existing ancestor plus the remaining segments — lies under the real
|
|
196
|
+
* workspace root.
|
|
197
|
+
*/
|
|
198
|
+
async realLocation(absPath) {
|
|
199
|
+
const root = (await this.realWorkspaceRoot()) ?? this.workspaceRoot();
|
|
200
|
+
let existing = absPath;
|
|
201
|
+
const rest = [];
|
|
202
|
+
for (;;) {
|
|
203
|
+
try {
|
|
204
|
+
const real = path.join(await this.fs.realpath(existing), ...rest);
|
|
205
|
+
return { ok: true, inside: isWithin(real, root) };
|
|
206
|
+
}
|
|
207
|
+
catch (err) {
|
|
208
|
+
if (errorCode(err) !== 'ENOENT')
|
|
209
|
+
return { ok: false, code: errorCode(err) };
|
|
210
|
+
const parent = path.dirname(existing);
|
|
211
|
+
if (parent === existing)
|
|
212
|
+
return { ok: true, inside: false };
|
|
213
|
+
rest.unshift(path.basename(existing));
|
|
214
|
+
existing = parent;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* Workspace boundary of the disk tools. A real location outside the workspace
|
|
220
|
+
* is allowed only for content the indexer already read (a symlinked share):
|
|
221
|
+
* the file itself for `file`, any file below it for `dir`.
|
|
222
|
+
*/
|
|
223
|
+
async checkRealLocation(relPath, absPath, kind) {
|
|
224
|
+
const loc = await this.realLocation(absPath);
|
|
225
|
+
if (!loc.ok)
|
|
226
|
+
return { ok: false, reason: 'unreadable', code: loc.code };
|
|
227
|
+
if (loc.inside)
|
|
228
|
+
return { ok: true };
|
|
229
|
+
const exempt = kind === 'file' ? await this.isContentPath(relPath) : await this.hasContentUnder(relPath);
|
|
230
|
+
return exempt ? { ok: true } : { ok: false, reason: 'outside_workspace' };
|
|
231
|
+
}
|
|
232
|
+
async pathExists(columns, predicate, param) {
|
|
233
|
+
const branches = columns.map(c => `SELECT 1 FROM ${c.table} WHERE ${predicate.replace(/\$col/g, c.column)}`);
|
|
234
|
+
const [{ exists }] = await this.db.query(`SELECT EXISTS (${branches.join(' UNION ALL ')}) AS exists`, [param]);
|
|
235
|
+
return exists;
|
|
236
|
+
}
|
|
237
|
+
/** Whether any table of the index holds `relPath` (a canonical path, never `../…`). */
|
|
238
|
+
isIndexedPath(relPath) {
|
|
239
|
+
return this.pathExists(INDEX_PATH_COLUMNS, '$col = $1', relPath);
|
|
240
|
+
}
|
|
241
|
+
/** Whether the indexer read the file at `relPath`. */
|
|
242
|
+
isContentPath(relPath) {
|
|
243
|
+
return this.pathExists(INDEX_PATH_COLUMNS.filter(c => c.content), '$col = $1', relPath);
|
|
244
|
+
}
|
|
245
|
+
hasContentUnder(relPath) {
|
|
246
|
+
const prefix = relPath === '.' ? '' : (0, symbol_name_1.escapeLike)(relPath) + '/';
|
|
247
|
+
return this.pathExists(INDEX_PATH_COLUMNS.filter(c => c.content), `$col LIKE $1 ESCAPE '\\'`, prefix + '%');
|
|
248
|
+
}
|
|
249
|
+
/**
|
|
250
|
+
* Up to three index paths ending in `/<relPath>` (`file`) or directories
|
|
251
|
+
* ending in `/<relPath>` that hold one (`dir`), in byte order. For `dir`, the
|
|
252
|
+
* root of a project named `relPath` comes first.
|
|
253
|
+
*/
|
|
254
|
+
async findPathCandidates(relPath, kind) {
|
|
255
|
+
const union = INDEX_PATH_COLUMNS.map(c => `SELECT ${c.column} AS p FROM ${c.table}`).join(' UNION ALL ');
|
|
256
|
+
const notOutside = `NOT ${OUTSIDE_WORKSPACE_VALUE('p')}`;
|
|
257
|
+
if (kind === 'file') {
|
|
258
|
+
const substituted = await this.substituteProjectName(relPath);
|
|
259
|
+
const rows = await this.db.query(`SELECT p FROM (${union}) s
|
|
260
|
+
WHERE p LIKE $1 ESCAPE '\\' AND ${notOutside}
|
|
261
|
+
GROUP BY p ORDER BY p COLLATE "C" LIMIT ${PATH_CANDIDATE_LIMIT + 1}`, ['%/' + (0, symbol_name_1.escapeLike)(relPath)]);
|
|
262
|
+
const first = substituted ? [substituted] : [];
|
|
263
|
+
return [...first, ...rows.map(r => r.p).filter(p => p !== substituted)].slice(0, PATH_CANDIDATE_LIMIT);
|
|
264
|
+
}
|
|
265
|
+
const projectRoot = this.findProjectRootByName(relPath);
|
|
266
|
+
const first = projectRoot && projectRoot !== relPath ? [projectRoot] : [];
|
|
267
|
+
const rows = await this.db.query(`SELECT d AS p FROM (
|
|
268
|
+
SELECT substring(p FROM 1 FOR position($2::text IN p) + length($3::text)) AS d
|
|
269
|
+
FROM (${union}) s
|
|
270
|
+
WHERE p LIKE $1 ESCAPE '\\' AND ${notOutside}
|
|
271
|
+
) dirs
|
|
272
|
+
GROUP BY d ORDER BY d COLLATE "C" LIMIT ${PATH_CANDIDATE_LIMIT + 1}`, ['%/' + (0, symbol_name_1.escapeLike)(relPath) + '/%', '/' + relPath + '/', relPath]);
|
|
273
|
+
const rest = rows.map(r => r.p).filter(p => !first.includes(p));
|
|
274
|
+
return [...first, ...rest].slice(0, PATH_CANDIDATE_LIMIT);
|
|
275
|
+
}
|
|
276
|
+
/** `findPathCandidates`, with a failed lookup logged and answered by no candidates. */
|
|
277
|
+
async candidatesOrNone(relPath, kind) {
|
|
278
|
+
try {
|
|
279
|
+
return await this.findPathCandidates(relPath, kind);
|
|
280
|
+
}
|
|
281
|
+
catch (err) {
|
|
282
|
+
this.logger.error('SearcherService: path candidate lookup failed', { relPath, reason: err.message });
|
|
283
|
+
return [];
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
/**
|
|
287
|
+
* `relPath` with a leading project name replaced by that project's root
|
|
288
|
+
* (`repo/app/src/x.ts` → `repo/apps/app/src/x.ts`), or null when no name
|
|
289
|
+
* prefixes it, the root equals the name, or the result names nothing.
|
|
290
|
+
*/
|
|
291
|
+
async substituteProjectName(relPath) {
|
|
292
|
+
let named = [];
|
|
293
|
+
for (let attempt = 0; attempt < 2 && named.length === 0; attempt++) {
|
|
294
|
+
if (attempt === 1)
|
|
295
|
+
this.rootMap = undefined;
|
|
296
|
+
named = [...this.projectRootMap().entries()].filter(([name]) => relPath.startsWith(name + '/'));
|
|
297
|
+
}
|
|
298
|
+
const longest = named.sort((a, b) => b[0].length - a[0].length)[0];
|
|
299
|
+
if (!longest)
|
|
300
|
+
return null;
|
|
301
|
+
const [name, absRoot] = longest;
|
|
302
|
+
const root = this.toRel(absRoot);
|
|
303
|
+
if (root === name)
|
|
304
|
+
return null;
|
|
305
|
+
const candidate = (root === '.' ? '' : root + '/') + relPath.slice(name.length + 1);
|
|
306
|
+
if (await this.isIndexedPath(candidate))
|
|
307
|
+
return candidate;
|
|
308
|
+
try {
|
|
309
|
+
return (await this.fs.stat(this.toAbs(candidate))).isFile() ? candidate : null;
|
|
310
|
+
}
|
|
311
|
+
catch {
|
|
312
|
+
return null;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
findProjectRootByName(name) {
|
|
316
|
+
let root = this.projectRootMap().get(name);
|
|
317
|
+
if (root === undefined) {
|
|
318
|
+
this.rootMap = undefined;
|
|
319
|
+
root = this.projectRootMap().get(name);
|
|
320
|
+
}
|
|
321
|
+
return root === undefined ? null : this.toRel(root);
|
|
322
|
+
}
|
|
323
|
+
/** Longest workspace-relative project root accepted by `match`; discovery is re-run once before giving up. */
|
|
324
|
+
findProjectRoot(match) {
|
|
325
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
326
|
+
if (attempt === 1)
|
|
327
|
+
this.rootMap = undefined;
|
|
328
|
+
const best = [...this.projectRootMap().entries()]
|
|
329
|
+
.map(([name, abs]) => ({ name, rel: this.toRel(abs) }))
|
|
330
|
+
.filter(r => match(r.rel))
|
|
331
|
+
.sort((a, b) => b.rel.length - a.rel.length)[0];
|
|
332
|
+
if (best)
|
|
333
|
+
return best;
|
|
334
|
+
}
|
|
335
|
+
return null;
|
|
336
|
+
}
|
|
337
|
+
/** Root directory (workspace-relative) of the project the text-search fallback for `relPath` runs in. */
|
|
338
|
+
fallbackRoot(relPath) {
|
|
339
|
+
return this.findProjectRoot(r => isWithin(relPath, r))?.rel ?? null;
|
|
340
|
+
}
|
|
341
|
+
/**
|
|
342
|
+
* Why a graph tool has no data for `relPath`, or null when the path is in the
|
|
343
|
+
* index path set. Touches the disk only for an unknown path, and answers a
|
|
344
|
+
* real location outside the workspace as `not_found` without looking at it.
|
|
345
|
+
*/
|
|
346
|
+
async checkGraphPath(relPath) {
|
|
347
|
+
if (await this.isIndexedPath(relPath))
|
|
348
|
+
return null;
|
|
349
|
+
const absPath = this.toAbs(relPath);
|
|
350
|
+
const loc = await this.realLocation(absPath);
|
|
351
|
+
if (!loc.ok)
|
|
352
|
+
return { ok: false, reason: 'unreadable', code: loc.code };
|
|
353
|
+
if (!loc.inside)
|
|
354
|
+
return { ok: false, reason: 'not_found', candidates: await this.candidatesOrNone(relPath, 'file') };
|
|
355
|
+
let stat;
|
|
356
|
+
try {
|
|
357
|
+
stat = await this.fs.stat(absPath);
|
|
358
|
+
}
|
|
359
|
+
catch (err) {
|
|
360
|
+
if (errorCode(err) !== 'ENOENT')
|
|
361
|
+
return { ok: false, reason: 'unreadable', code: errorCode(err) };
|
|
362
|
+
return { ok: false, reason: 'not_found', candidates: await this.candidatesOrNone(relPath, 'file') };
|
|
363
|
+
}
|
|
364
|
+
if (!stat.isFile())
|
|
365
|
+
return { ok: false, reason: 'not_a_file' };
|
|
366
|
+
return { ok: false, reason: 'not_indexed', explanation: await this.explainNotIndexed(relPath) };
|
|
367
|
+
}
|
|
368
|
+
async explainNotIndexed(relPath) {
|
|
369
|
+
const owner = this.findProjectRoot(r => r !== relPath && isWithin(relPath, r));
|
|
370
|
+
if (!owner)
|
|
371
|
+
return { kind: 'no_project' };
|
|
372
|
+
const inProject = owner.rel === '.' ? relPath : relPath.slice(owner.rel.length + 1);
|
|
373
|
+
const exclusion = (0, file_utils_1.indexingExclusion)(inProject, (0, file_utils_1.loadIgnore)(this.toAbs(owner.rel)));
|
|
374
|
+
if (exclusion)
|
|
375
|
+
return exclusion;
|
|
376
|
+
const [{ exists }] = await this.db.query(`SELECT EXISTS (
|
|
377
|
+
SELECT 1 FROM code_chunk WHERE "serviceName" = $1
|
|
378
|
+
UNION ALL SELECT 1 FROM code_symbol WHERE project = $1
|
|
379
|
+
UNION ALL SELECT 1 FROM code_dependency WHERE project = $1
|
|
380
|
+
UNION ALL SELECT 1 FROM code_call WHERE project = $1
|
|
381
|
+
) AS exists`, [owner.name]);
|
|
382
|
+
return exists ? { kind: 'unknown' } : { kind: 'project_not_indexed', project: owner.name };
|
|
383
|
+
}
|
|
98
384
|
/**
|
|
99
385
|
* Project-level dependency edges aggregated from `code_dependency`: an edge
|
|
100
386
|
* `A → B` means some file in project A imports a file owned by project B.
|
|
@@ -182,17 +468,27 @@ class SearcherService {
|
|
|
182
468
|
FROM code_chunk
|
|
183
469
|
GROUP BY "serviceName"
|
|
184
470
|
ORDER BY "serviceName"`);
|
|
185
|
-
return rows.map(r => {
|
|
471
|
+
return Promise.all(rows.map(async (r) => {
|
|
186
472
|
const projectRoot = this.resolveProjectRoot(r.serviceName);
|
|
187
473
|
const meta = (0, file_utils_1.readRagMeta)(projectRoot);
|
|
188
|
-
|
|
189
|
-
|
|
474
|
+
let rootError = null;
|
|
475
|
+
try {
|
|
476
|
+
await this.fs.stat(projectRoot);
|
|
477
|
+
}
|
|
478
|
+
catch (err) {
|
|
479
|
+
rootError = errorCode(err);
|
|
480
|
+
}
|
|
481
|
+
return {
|
|
482
|
+
...r, path: projectRoot, relPath: this.toRel(projectRoot), rootError,
|
|
483
|
+
...(typeof meta === 'object' ? meta : EMPTY_META),
|
|
484
|
+
};
|
|
485
|
+
}));
|
|
190
486
|
}
|
|
191
|
-
async searchChunks(query, limit, project, threshold) {
|
|
487
|
+
async searchChunks(query, limit, project, threshold, rerankOverride) {
|
|
192
488
|
const t0 = Date.now();
|
|
193
489
|
await this.assertIndexMatches();
|
|
194
490
|
const effectiveThreshold = threshold ?? this.config.search.similarityThreshold;
|
|
195
|
-
const reranking = !!this.embedder.rerank;
|
|
491
|
+
const reranking = rerankOverride === false ? false : !!this.embedder.rerank;
|
|
196
492
|
// Candidate floor: decouple the pool size from `limit` so a small `limit`
|
|
197
493
|
// (e.g. the default 5) still gives the reranker a large-enough menu. Without
|
|
198
494
|
// a reranker the extra candidates are never reordered, so keep `limit`.
|
|
@@ -335,38 +631,28 @@ class SearcherService {
|
|
|
335
631
|
}
|
|
336
632
|
async fetchGraphContext(results) {
|
|
337
633
|
const t0 = Date.now();
|
|
338
|
-
const
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
if (!seen.has(key))
|
|
342
|
-
seen.set(key, { filePath: r.filePath, project: r.serviceName });
|
|
343
|
-
}
|
|
344
|
-
const pairs = [...seen.values()];
|
|
345
|
-
this.logger.debug('SearcherService: fetchGraphContext', { files: pairs.length });
|
|
346
|
-
if (pairs.length === 0)
|
|
634
|
+
const files = [...new Set(results.map(r => r.filePath))];
|
|
635
|
+
this.logger.debug('SearcherService: fetchGraphContext', { files: files.length });
|
|
636
|
+
if (files.length === 0)
|
|
347
637
|
return new Map();
|
|
348
|
-
const n = pairs.length;
|
|
349
638
|
// Use proportional limit: 50 imports + 50 usedBy per file
|
|
350
|
-
const proportionalLimit = Math.max(2000,
|
|
351
|
-
const
|
|
352
|
-
const secondHalf = pairs.map((_, i) => `($${n * 2 + i * 2 + 1}::text, $${n * 2 + i * 2 + 2}::text)`).join(', ');
|
|
353
|
-
const params = [
|
|
354
|
-
...pairs.flatMap(p => [p.filePath, p.project]),
|
|
355
|
-
...pairs.flatMap(p => [p.filePath, p.project]),
|
|
356
|
-
];
|
|
639
|
+
const proportionalLimit = Math.max(2000, files.length * 100);
|
|
640
|
+
const values = files.map((_, i) => `($${i + 1}::text)`).join(', ');
|
|
357
641
|
const rows = await this.db.query(`SELECT r.fp AS "filePath", 'import' AS direction, d."toFile" AS related
|
|
358
|
-
FROM (VALUES ${
|
|
359
|
-
JOIN code_dependency d ON d."fromFile" = r.fp
|
|
642
|
+
FROM (VALUES ${values}) AS r(fp)
|
|
643
|
+
JOIN code_dependency d ON d."fromFile" = r.fp
|
|
644
|
+
WHERE NOT ${OUTSIDE_WORKSPACE_VALUE('d."toFile"')}
|
|
360
645
|
|
|
361
646
|
UNION ALL
|
|
362
647
|
|
|
363
648
|
SELECT r.fp AS "filePath", 'usedby' AS direction, d."fromFile" AS related
|
|
364
|
-
FROM (VALUES ${
|
|
365
|
-
JOIN code_dependency d ON d."toFile" = r.fp
|
|
649
|
+
FROM (VALUES ${values}) AS r(fp)
|
|
650
|
+
JOIN code_dependency d ON d."toFile" = r.fp
|
|
651
|
+
WHERE NOT ${OUTSIDE_WORKSPACE_VALUE('d."fromFile"')}
|
|
366
652
|
|
|
367
|
-
LIMIT ${proportionalLimit}`,
|
|
653
|
+
LIMIT ${proportionalLimit}`, files);
|
|
368
654
|
const ctx = new Map();
|
|
369
|
-
for (const
|
|
655
|
+
for (const filePath of files)
|
|
370
656
|
ctx.set(filePath, { imports: new Set(), usedBy: new Set() });
|
|
371
657
|
for (const row of rows) {
|
|
372
658
|
const g = ctx.get(row.filePath);
|
|
@@ -375,7 +661,7 @@ class SearcherService {
|
|
|
375
661
|
else
|
|
376
662
|
g.usedBy.add(row.related);
|
|
377
663
|
}
|
|
378
|
-
this.logger.debug('SearcherService: fetchGraphContext result', { files:
|
|
664
|
+
this.logger.debug('SearcherService: fetchGraphContext result', { files: files.length, rows: rows.length, ms: Date.now() - t0 });
|
|
379
665
|
const result = new Map();
|
|
380
666
|
for (const [fp, g] of ctx)
|
|
381
667
|
result.set(fp, {
|
|
@@ -384,89 +670,63 @@ class SearcherService {
|
|
|
384
670
|
});
|
|
385
671
|
return result;
|
|
386
672
|
}
|
|
387
|
-
async getDependencies(filePath,
|
|
673
|
+
async getDependencies(filePath, symbol = null, transitive = false) {
|
|
388
674
|
const t0 = Date.now();
|
|
389
|
-
this.logger.debug('SearcherService: getDependencies', { filePath,
|
|
390
|
-
const params = [filePath
|
|
675
|
+
this.logger.debug('SearcherService: getDependencies', { filePath, symbol, transitive });
|
|
676
|
+
const params = [filePath];
|
|
391
677
|
let symbolFilter = '';
|
|
392
678
|
if (symbol) {
|
|
393
679
|
params.push(symbol);
|
|
394
680
|
symbolFilter = `AND "toSymbol" = $${params.length}`;
|
|
395
681
|
}
|
|
396
|
-
const direct = await this.db.query(`SELECT "fromFile", "fromSymbol", "toFile", "toSymbol", "depType"
|
|
682
|
+
const direct = await this.db.query(`SELECT DISTINCT "fromFile", "fromSymbol", "toFile", "toSymbol", "depType"
|
|
397
683
|
FROM code_dependency
|
|
398
|
-
WHERE "fromFile" = $1 AND
|
|
684
|
+
WHERE "fromFile" = $1 AND NOT ${OUTSIDE_WORKSPACE_VALUE('"toFile"')}
|
|
399
685
|
${symbolFilter}
|
|
400
686
|
ORDER BY "toFile"`, params);
|
|
401
687
|
if (!transitive) {
|
|
402
688
|
this.logger.debug('SearcherService: getDependencies result', { direct: direct.length, ms: Date.now() - t0 });
|
|
403
689
|
return direct;
|
|
404
690
|
}
|
|
405
|
-
const
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
JOIN deps ON deps."toFile" = d."fromFile"
|
|
415
|
-
WHERE d.project = $2
|
|
416
|
-
AND d."toFile" <> $1
|
|
417
|
-
AND deps.depth < 20
|
|
418
|
-
)
|
|
419
|
-
SELECT "toFile", MIN(depth) AS depth,
|
|
420
|
-
(array_agg(via ORDER BY depth))[1] AS via,
|
|
421
|
-
NULL::text AS "fromFile", NULL::text AS "fromSymbol",
|
|
422
|
-
NULL::text AS "toSymbol", NULL::text AS "depType"
|
|
423
|
-
FROM deps
|
|
424
|
-
WHERE depth > 1
|
|
425
|
-
GROUP BY "toFile"
|
|
426
|
-
ORDER BY MIN(depth), "toFile"`, [filePath, project]);
|
|
691
|
+
const neighbours = async (frontier) => {
|
|
692
|
+
const rows = await this.db.query(`SELECT DISTINCT "fromFile", "toFile" FROM code_dependency
|
|
693
|
+
WHERE "fromFile" = ANY($1::text[]) AND NOT ${OUTSIDE_WORKSPACE_VALUE('"toFile"')}`, [frontier]);
|
|
694
|
+
return rows.map(r => ({ from: r.fromFile, to: r.toFile }));
|
|
695
|
+
};
|
|
696
|
+
const indirect = (await (0, dependency_traversal_1.traverse)(filePath, neighbours)).map(r => ({
|
|
697
|
+
toFile: r.file, depth: r.depth, via: r.via,
|
|
698
|
+
fromFile: null, fromSymbol: null, toSymbol: null, depType: null,
|
|
699
|
+
}));
|
|
427
700
|
this.logger.debug('SearcherService: getDependencies result', { direct: direct.length, indirect: indirect.length, ms: Date.now() - t0 });
|
|
428
701
|
return [...direct, ...indirect];
|
|
429
702
|
}
|
|
430
|
-
async getDependents(filePath,
|
|
703
|
+
async getDependents(filePath, symbol = null, transitive = false) {
|
|
431
704
|
const t0 = Date.now();
|
|
432
|
-
this.logger.debug('SearcherService: getDependents', { filePath,
|
|
433
|
-
const params = [filePath
|
|
705
|
+
this.logger.debug('SearcherService: getDependents', { filePath, symbol, transitive });
|
|
706
|
+
const params = [filePath];
|
|
434
707
|
let symbolFilter = '';
|
|
435
708
|
if (symbol) {
|
|
436
709
|
params.push(symbol);
|
|
437
710
|
symbolFilter = `AND "toSymbol" = $${params.length}`;
|
|
438
711
|
}
|
|
439
|
-
const direct = await this.db.query(`SELECT "fromFile", "fromSymbol", "toFile", "toSymbol", "depType"
|
|
712
|
+
const direct = await this.db.query(`SELECT DISTINCT "fromFile", "fromSymbol", "toFile", "toSymbol", "depType"
|
|
440
713
|
FROM code_dependency
|
|
441
|
-
WHERE "toFile" = $1 AND
|
|
714
|
+
WHERE "toFile" = $1 AND NOT ${OUTSIDE_WORKSPACE_VALUE('"fromFile"')}
|
|
442
715
|
${symbolFilter}
|
|
443
716
|
ORDER BY "fromFile"`, params);
|
|
444
717
|
if (!transitive) {
|
|
445
718
|
this.logger.debug('SearcherService: getDependents result', { direct: direct.length, ms: Date.now() - t0 });
|
|
446
719
|
return direct;
|
|
447
720
|
}
|
|
448
|
-
const
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
JOIN rdeps ON rdeps."fromFile" = d."toFile"
|
|
458
|
-
WHERE d.project = $2
|
|
459
|
-
AND d."fromFile" <> $1
|
|
460
|
-
AND rdeps.depth < 20
|
|
461
|
-
)
|
|
462
|
-
SELECT "fromFile", MIN(depth) AS depth,
|
|
463
|
-
(array_agg(via ORDER BY depth))[1] AS via,
|
|
464
|
-
NULL::text AS "toFile", NULL::text AS "fromSymbol",
|
|
465
|
-
NULL::text AS "toSymbol", NULL::text AS "depType"
|
|
466
|
-
FROM rdeps
|
|
467
|
-
WHERE depth > 1
|
|
468
|
-
GROUP BY "fromFile"
|
|
469
|
-
ORDER BY MIN(depth), "fromFile"`, [filePath, project]);
|
|
721
|
+
const neighbours = async (frontier) => {
|
|
722
|
+
const rows = await this.db.query(`SELECT DISTINCT "fromFile", "toFile" FROM code_dependency
|
|
723
|
+
WHERE "toFile" = ANY($1::text[]) AND NOT ${OUTSIDE_WORKSPACE_VALUE('"fromFile"')}`, [frontier]);
|
|
724
|
+
return rows.map(r => ({ from: r.toFile, to: r.fromFile }));
|
|
725
|
+
};
|
|
726
|
+
const indirect = (await (0, dependency_traversal_1.traverse)(filePath, neighbours)).map(r => ({
|
|
727
|
+
fromFile: r.file, depth: r.depth, via: r.via,
|
|
728
|
+
toFile: null, fromSymbol: null, toSymbol: null, depType: null,
|
|
729
|
+
}));
|
|
470
730
|
this.logger.debug('SearcherService: getDependents result', { direct: direct.length, indirect: indirect.length, ms: Date.now() - t0 });
|
|
471
731
|
return [...direct, ...indirect];
|
|
472
732
|
}
|
|
@@ -504,41 +764,25 @@ class SearcherService {
|
|
|
504
764
|
return rows;
|
|
505
765
|
}
|
|
506
766
|
/**
|
|
507
|
-
*
|
|
508
|
-
*
|
|
509
|
-
*
|
|
510
|
-
* scope or read markers only drops the notice (warn logged); the lookup
|
|
511
|
-
* itself is unaffected.
|
|
767
|
+
* Whole-word text search for `symbol` under `rootDir` (workspace-relative).
|
|
768
|
+
* grep exiting ≥ 2 after printing matches is a partial result; without
|
|
769
|
+
* matches, or when grep cannot be spawned, the search failed.
|
|
512
770
|
*/
|
|
513
|
-
async
|
|
514
|
-
try {
|
|
515
|
-
const outdated = await this.db.listOutdatedProjects(project ? [project] : null);
|
|
516
|
-
if (outdated.length === 0)
|
|
517
|
-
return null;
|
|
518
|
-
return `⚠ Indexed before member indexing: ${[...outdated].sort().join(', ')} — run rag-index --reindex; methods and qualified names may be missing.`;
|
|
519
|
-
}
|
|
520
|
-
catch (err) {
|
|
521
|
-
this.logger.warn('SearcherService: failed to determine outdated-index scope', { reason: err.message });
|
|
522
|
-
return null;
|
|
523
|
-
}
|
|
524
|
-
}
|
|
525
|
-
async grepSymbol(symbol, project, excludeFile) {
|
|
771
|
+
async grepSymbol(symbol, rootDir, excludeFile) {
|
|
526
772
|
const t0 = Date.now();
|
|
527
|
-
this.logger.debug('SearcherService: grepSymbol', { symbol,
|
|
528
|
-
const projectDir = this.resolveProjectRoot(project);
|
|
773
|
+
this.logger.debug('SearcherService: grepSymbol', { symbol, rootDir, excludeFile });
|
|
529
774
|
const result = await this.spawnGrep([
|
|
530
775
|
'-rn', '-F', '-w',
|
|
531
776
|
'-m', String(GREP_RAW_LIMIT),
|
|
532
777
|
'--include=*.ts', '--include=*.tsx', '--include=*.js', '--include=*.jsx',
|
|
533
778
|
...GREP_EXCLUDE_DIRS,
|
|
534
779
|
symbol,
|
|
535
|
-
|
|
780
|
+
this.toAbs(rootDir),
|
|
536
781
|
]);
|
|
537
|
-
if (!result.ok
|
|
538
|
-
return {
|
|
539
|
-
}
|
|
782
|
+
if (!result.ok && result.output === null)
|
|
783
|
+
return { ok: false };
|
|
540
784
|
const excludeAbsPath = path.join(this.config.workspaceRoot, excludeFile);
|
|
541
|
-
const all = result.output.split('\n').flatMap(line => {
|
|
785
|
+
const all = (result.output ?? '').split('\n').flatMap(line => {
|
|
542
786
|
const match = line.match(/^(.+?):(\d+):/);
|
|
543
787
|
if (!match)
|
|
544
788
|
return [];
|
|
@@ -548,8 +792,8 @@ class SearcherService {
|
|
|
548
792
|
return [{ filePath: path.relative(this.config.workspaceRoot, absFile), line: parseInt(lineNum, 10) }];
|
|
549
793
|
});
|
|
550
794
|
const truncated = all.length > GREP_MAX_RESULTS;
|
|
551
|
-
this.logger.debug('SearcherService: grepSymbol result', { matches: Math.min(all.length, GREP_MAX_RESULTS), truncated, ms: Date.now() - t0 });
|
|
552
|
-
return { matches: all.slice(0, GREP_MAX_RESULTS), truncated };
|
|
795
|
+
this.logger.debug('SearcherService: grepSymbol result', { matches: Math.min(all.length, GREP_MAX_RESULTS), truncated, partial: !result.ok, ms: Date.now() - t0 });
|
|
796
|
+
return { ok: true, matches: all.slice(0, GREP_MAX_RESULTS), truncated, partial: !result.ok };
|
|
553
797
|
}
|
|
554
798
|
// Sync wrapper: validates name (throws synchronously), then returns Promise.
|
|
555
799
|
// Every kind, including 'method', is looked up in the index first.
|
|
@@ -559,11 +803,30 @@ class SearcherService {
|
|
|
559
803
|
throw new Error('Invalid symbol name');
|
|
560
804
|
return this.findSymbolFromIndex(parsed, project, kind);
|
|
561
805
|
}
|
|
806
|
+
/**
|
|
807
|
+
* Whether `code_symbol.target` exists. A present result is cached for the
|
|
808
|
+
* process; an absent one is re-checked on every call. Propagates on a
|
|
809
|
+
* failing check, same as any other failed index query.
|
|
810
|
+
*/
|
|
811
|
+
async hasTargetColumn() {
|
|
812
|
+
if (this.targetColumnPresent)
|
|
813
|
+
return true;
|
|
814
|
+
const rows = await this.db.query(`SELECT EXISTS (
|
|
815
|
+
SELECT 1 FROM information_schema.columns
|
|
816
|
+
WHERE table_schema = 'public' AND table_name = 'code_symbol' AND column_name = 'target'
|
|
817
|
+
) AS present`);
|
|
818
|
+
const present = !!rows[0]?.present;
|
|
819
|
+
if (present)
|
|
820
|
+
this.targetColumnPresent = true;
|
|
821
|
+
return present;
|
|
822
|
+
}
|
|
562
823
|
async findSymbolFromIndex(parsed, project, kind) {
|
|
563
824
|
const t0 = Date.now();
|
|
564
825
|
this.logger.debug('SearcherService: findSymbol', { parsed, project, kind, source: 'index' });
|
|
565
826
|
const container = 'container' in parsed ? parsed.container : null;
|
|
827
|
+
const isGraphqlKind = GRAPHQL_KINDS.has(kind);
|
|
566
828
|
try {
|
|
829
|
+
const hasTarget = await this.hasTargetColumn();
|
|
567
830
|
const params = [parsed.name];
|
|
568
831
|
let nameFilter = 'name = $1';
|
|
569
832
|
let orderPrefix = '';
|
|
@@ -586,10 +849,11 @@ class SearcherService {
|
|
|
586
849
|
kindFilter = `AND kind = $${params.length}`;
|
|
587
850
|
}
|
|
588
851
|
params.push(FIND_SYMBOL_LIMIT + 1);
|
|
589
|
-
const
|
|
852
|
+
const targetSelect = hasTarget ? '"target"' : 'NULL AS target';
|
|
853
|
+
const rows = await this.db.query(`SELECT "filePath", name, kind, signature, line, col, container, "isStatic", ${targetSelect}
|
|
590
854
|
FROM code_symbol
|
|
591
855
|
WHERE ${nameFilter} ${projectFilter} ${kindFilter}
|
|
592
|
-
ORDER BY ${orderPrefix}project, "filePath", line
|
|
856
|
+
ORDER BY ${orderPrefix}project, "filePath", line, col, kind COLLATE "C", container COLLATE "C" NULLS FIRST
|
|
593
857
|
LIMIT $${params.length}`, params);
|
|
594
858
|
const truncated = rows.length > FIND_SYMBOL_LIMIT;
|
|
595
859
|
const hits = rows.slice(0, FIND_SYMBOL_LIMIT).map(row => ({
|
|
@@ -601,16 +865,23 @@ class SearcherService {
|
|
|
601
865
|
qualified: row.container ? `${row.container}.${row.name}` : row.name,
|
|
602
866
|
isStatic: !!row.isStatic,
|
|
603
867
|
source: 'index',
|
|
868
|
+
target: row.target ?? null,
|
|
604
869
|
}));
|
|
605
870
|
this.logger.debug('SearcherService: findSymbol result', { source: 'index', hits: hits.length, truncated, ms: Date.now() - t0 });
|
|
606
871
|
if (hits.length > 0)
|
|
607
872
|
return { hits, truncated, grepFailed: false };
|
|
873
|
+
// GraphQL kinds never grep — an empty result just means no row yet (stale index).
|
|
874
|
+
if (isGraphqlKind)
|
|
875
|
+
return { hits: [], truncated: false, grepFailed: false };
|
|
608
876
|
if (parsed.form === 'quoted' || parsed.form === 'computed')
|
|
609
877
|
return { hits: [], truncated: false, grepFailed: false };
|
|
610
878
|
return this.grepFindSymbol(parsed, project, kind);
|
|
611
879
|
}
|
|
612
880
|
catch (err) {
|
|
613
|
-
this.logger.warn('SearcherService: symbol index unavailable
|
|
881
|
+
this.logger.warn('SearcherService: symbol index unavailable', { reason: err.message, ms: Date.now() - t0 });
|
|
882
|
+
// GraphQL kinds never grep — a failing index query propagates as an error response instead.
|
|
883
|
+
if (isGraphqlKind)
|
|
884
|
+
throw err;
|
|
614
885
|
if (parsed.form === 'quoted' || parsed.form === 'computed')
|
|
615
886
|
return { hits: [], truncated: false, grepFailed: false };
|
|
616
887
|
return this.grepFindSymbol(parsed, project, kind);
|
|
@@ -661,124 +932,146 @@ class SearcherService {
|
|
|
661
932
|
qualified: grepName,
|
|
662
933
|
isStatic: false,
|
|
663
934
|
source: 'grep',
|
|
935
|
+
target: null,
|
|
664
936
|
});
|
|
665
937
|
}
|
|
666
938
|
const truncated = parsedHits.length > FIND_SYMBOL_LIMIT;
|
|
667
939
|
this.logger.debug('SearcherService: findSymbol result', { source: 'grep', hits: Math.min(parsedHits.length, FIND_SYMBOL_LIMIT), truncated, ms: Date.now() - t0 });
|
|
668
940
|
return { hits: parsedHits.slice(0, FIND_SYMBOL_LIMIT), truncated, grepFailed: false };
|
|
669
941
|
}
|
|
670
|
-
listFiles(
|
|
942
|
+
async listFiles(relPath, depth, pattern) {
|
|
671
943
|
const t0 = Date.now();
|
|
672
|
-
this.logger.debug('SearcherService: listFiles', {
|
|
673
|
-
const
|
|
674
|
-
const
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
return { tree: '', error: `Path not found: ${relativePath}` };
|
|
944
|
+
this.logger.debug('SearcherService: listFiles', { relPath, depth, pattern });
|
|
945
|
+
const absPath = this.toAbs(relPath);
|
|
946
|
+
const loc = await this.checkRealLocation(relPath, absPath, 'dir');
|
|
947
|
+
if (!loc.ok)
|
|
948
|
+
return loc;
|
|
949
|
+
let stat;
|
|
950
|
+
try {
|
|
951
|
+
stat = await this.fs.stat(absPath);
|
|
681
952
|
}
|
|
682
|
-
|
|
683
|
-
|
|
953
|
+
catch (err) {
|
|
954
|
+
if (errorCode(err) !== 'ENOENT')
|
|
955
|
+
return { ok: false, reason: 'unreadable', code: errorCode(err) };
|
|
956
|
+
return { ok: false, reason: 'not_found', candidates: await this.candidatesOrNone(relPath, 'dir') };
|
|
684
957
|
}
|
|
958
|
+
if (!stat.isDirectory())
|
|
959
|
+
return { ok: false, reason: 'not_a_directory' };
|
|
685
960
|
const clampedDepth = Math.min(5, Math.max(1, depth));
|
|
686
961
|
const lines = [];
|
|
687
|
-
|
|
962
|
+
try {
|
|
963
|
+
await this.walkDir(absPath, 0, clampedDepth - 1, pattern, lines);
|
|
964
|
+
}
|
|
965
|
+
catch (err) {
|
|
966
|
+
return { ok: false, reason: 'unreadable', code: errorCode(err) };
|
|
967
|
+
}
|
|
688
968
|
this.logger.debug('SearcherService: listFiles result', { entries: lines.length, ms: Date.now() - t0 });
|
|
689
969
|
if (lines.length === 0) {
|
|
690
|
-
return { tree: pattern ? `No files matching "${pattern}" found.` : '(empty directory)' };
|
|
970
|
+
return { ok: true, tree: pattern ? `No files matching "${pattern}" found.` : '(empty directory)' };
|
|
691
971
|
}
|
|
692
|
-
return { tree: lines.join('\n') };
|
|
972
|
+
return { ok: true, tree: lines.join('\n') };
|
|
693
973
|
}
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
974
|
+
/**
|
|
975
|
+
* The read tools' common front: workspace boundary, then existence and type.
|
|
976
|
+
* A missing file still in the index is `deleted`; otherwise `not_found`.
|
|
977
|
+
*/
|
|
978
|
+
async openableFile(relPath, absPath) {
|
|
979
|
+
const loc = await this.checkRealLocation(relPath, absPath, 'file');
|
|
980
|
+
if (!loc.ok)
|
|
981
|
+
return loc;
|
|
982
|
+
let stat;
|
|
983
|
+
try {
|
|
984
|
+
stat = await this.fs.stat(absPath);
|
|
985
|
+
}
|
|
986
|
+
catch (err) {
|
|
987
|
+
if (errorCode(err) !== 'ENOENT')
|
|
988
|
+
return { ok: false, reason: 'unreadable', code: errorCode(err) };
|
|
989
|
+
if (await this.isIndexedPath(relPath))
|
|
990
|
+
return { ok: false, reason: 'deleted' };
|
|
991
|
+
return { ok: false, reason: 'not_found', candidates: await this.candidatesOrNone(relPath, 'file') };
|
|
992
|
+
}
|
|
993
|
+
// Checked before opening: a FIFO or device would block the reader.
|
|
994
|
+
return stat.isFile() ? null : { ok: false, reason: 'not_a_file' };
|
|
995
|
+
}
|
|
996
|
+
/**
|
|
997
|
+
* Streams the file's lines (as `readline` yields them) to `onLine` until it
|
|
998
|
+
* returns false. An open or read error discards everything read so far.
|
|
999
|
+
*/
|
|
1000
|
+
async readLines(absPath, onLine) {
|
|
1001
|
+
const stream = this.fs.createReadStream(absPath);
|
|
706
1002
|
const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
|
|
707
1003
|
let lineNo = 0;
|
|
708
|
-
let hasMore = false;
|
|
709
1004
|
try {
|
|
710
1005
|
for await (const line of rl) {
|
|
711
1006
|
lineNo++;
|
|
712
|
-
if (lineNo
|
|
713
|
-
lines.push(line);
|
|
714
|
-
}
|
|
715
|
-
else {
|
|
716
|
-
hasMore = true;
|
|
717
|
-
rl.close();
|
|
718
|
-
stream.destroy();
|
|
1007
|
+
if (!onLine(line, lineNo))
|
|
719
1008
|
break;
|
|
720
|
-
}
|
|
721
1009
|
}
|
|
1010
|
+
return { ok: true, lineCount: lineNo };
|
|
722
1011
|
}
|
|
723
1012
|
catch (err) {
|
|
1013
|
+
return { ok: false, code: errorCode(err) };
|
|
1014
|
+
}
|
|
1015
|
+
finally {
|
|
724
1016
|
rl.close();
|
|
725
|
-
stream.destroy();
|
|
726
|
-
throw err;
|
|
1017
|
+
stream.destroy?.();
|
|
727
1018
|
}
|
|
728
|
-
|
|
729
|
-
|
|
1019
|
+
}
|
|
1020
|
+
async readFile(relPath, maxLines) {
|
|
1021
|
+
const t0 = Date.now();
|
|
1022
|
+
this.logger.debug('SearcherService: readFile', { relPath, maxLines });
|
|
1023
|
+
const absPath = this.toAbs(relPath);
|
|
1024
|
+
const failure = await this.openableFile(relPath, absPath);
|
|
1025
|
+
if (failure)
|
|
1026
|
+
return failure;
|
|
1027
|
+
const lines = [];
|
|
1028
|
+
let hasMore = false;
|
|
1029
|
+
const read = await this.readLines(absPath, line => {
|
|
1030
|
+
if (lines.length < maxLines) {
|
|
1031
|
+
lines.push(line);
|
|
1032
|
+
return true;
|
|
1033
|
+
}
|
|
1034
|
+
hasMore = true;
|
|
1035
|
+
return false;
|
|
1036
|
+
});
|
|
1037
|
+
if (!read.ok)
|
|
1038
|
+
return { ok: false, reason: 'unreadable', code: read.code };
|
|
1039
|
+
this.logger.debug('SearcherService: readFile result', { linesRead: lines.length, hasMore, ms: Date.now() - t0 });
|
|
730
1040
|
return { ok: true, filePath: relPath, startLine: 1, endLine: lines.length, lines, hasMore };
|
|
731
1041
|
}
|
|
732
|
-
async readFileRange(
|
|
1042
|
+
async readFileRange(relPath, startLine, endLine) {
|
|
733
1043
|
const t0 = Date.now();
|
|
734
|
-
this.logger.debug('SearcherService: readFileRange', {
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
if (!exists)
|
|
742
|
-
return { ok: false, reason: 'not_indexed' };
|
|
743
|
-
if (!fs.existsSync(absPath))
|
|
744
|
-
return { ok: false, reason: 'not_on_disk' };
|
|
1044
|
+
this.logger.debug('SearcherService: readFileRange', { relPath, startLine, endLine });
|
|
1045
|
+
if (endLine < startLine)
|
|
1046
|
+
return { ok: false, reason: 'bad_range', startLine, endLine };
|
|
1047
|
+
const absPath = this.toAbs(relPath);
|
|
1048
|
+
const failure = await this.openableFile(relPath, absPath);
|
|
1049
|
+
if (failure)
|
|
1050
|
+
return failure;
|
|
745
1051
|
const lines = [];
|
|
746
|
-
const stream = fs.createReadStream(absPath, { encoding: 'utf-8' });
|
|
747
|
-
const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
|
|
748
|
-
let lineNo = 0;
|
|
749
1052
|
let lastRead = 0;
|
|
750
1053
|
let hasMore = false;
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
lines.push(line);
|
|
756
|
-
if (lineNo <= endLine)
|
|
757
|
-
lastRead = lineNo;
|
|
758
|
-
if (lineNo > endLine) {
|
|
759
|
-
hasMore = true;
|
|
760
|
-
rl.close();
|
|
761
|
-
stream.destroy();
|
|
762
|
-
break;
|
|
763
|
-
}
|
|
1054
|
+
const read = await this.readLines(absPath, (line, lineNo) => {
|
|
1055
|
+
if (lineNo > endLine) {
|
|
1056
|
+
hasMore = true;
|
|
1057
|
+
return false;
|
|
764
1058
|
}
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
1059
|
+
if (lineNo >= startLine)
|
|
1060
|
+
lines.push(line);
|
|
1061
|
+
lastRead = lineNo;
|
|
1062
|
+
return true;
|
|
1063
|
+
});
|
|
1064
|
+
if (!read.ok)
|
|
1065
|
+
return { ok: false, reason: 'unreadable', code: read.code };
|
|
1066
|
+
if (!hasMore && startLine > Math.max(read.lineCount, 1)) {
|
|
1067
|
+
return { ok: false, reason: 'past_end', lineCount: read.lineCount, startLine };
|
|
770
1068
|
}
|
|
771
1069
|
this.logger.debug('SearcherService: readFileRange result', { linesRead: lines.length, hasMore, ms: Date.now() - t0 });
|
|
772
|
-
return { ok: true, filePath, startLine, endLine: Math.min(endLine, lastRead), lines, hasMore };
|
|
1070
|
+
return { ok: true, filePath: relPath, startLine, endLine: Math.min(endLine, lastRead), lines, hasMore };
|
|
773
1071
|
}
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
entries = fs.readdirSync(absDir, { withFileTypes: true });
|
|
778
|
-
}
|
|
779
|
-
catch {
|
|
780
|
-
return;
|
|
781
|
-
}
|
|
1072
|
+
/** Throws when `absDir` itself cannot be read; an unreadable subdirectory is marked in place. */
|
|
1073
|
+
async walkDir(absDir, currentDepth, maxDepth, pattern, lines) {
|
|
1074
|
+
const entries = await this.fs.readdir(absDir);
|
|
782
1075
|
entries.sort((a, b) => {
|
|
783
1076
|
const ad = a.isDirectory() ? 0 : 1;
|
|
784
1077
|
const bd = b.isDirectory() ? 0 : 1;
|
|
@@ -791,9 +1084,14 @@ class SearcherService {
|
|
|
791
1084
|
continue;
|
|
792
1085
|
const indent = ' '.repeat(currentDepth);
|
|
793
1086
|
if (entry.isDirectory()) {
|
|
794
|
-
lines.push(`${indent}${entry.name}/`);
|
|
1087
|
+
const at = lines.push(`${indent}${entry.name}/`) - 1;
|
|
795
1088
|
if (currentDepth < maxDepth) {
|
|
796
|
-
|
|
1089
|
+
try {
|
|
1090
|
+
await this.walkDir(path.join(absDir, entry.name), currentDepth + 1, maxDepth, pattern, lines);
|
|
1091
|
+
}
|
|
1092
|
+
catch {
|
|
1093
|
+
lines[at] += ' (unreadable)';
|
|
1094
|
+
}
|
|
797
1095
|
}
|
|
798
1096
|
}
|
|
799
1097
|
else {
|
|
@@ -811,6 +1109,8 @@ class SearcherService {
|
|
|
811
1109
|
buildFindSymbolPattern(name, kind) {
|
|
812
1110
|
if (kind === 'function_property' || kind === 'method_signature')
|
|
813
1111
|
return null;
|
|
1112
|
+
if (GRAPHQL_KINDS.has(kind))
|
|
1113
|
+
return null;
|
|
814
1114
|
if (kind === 'constructor')
|
|
815
1115
|
return name === 'constructor' ? `(^|[[:space:]])constructor[[:space:]]*\\(` : null;
|
|
816
1116
|
const esc = name.replace(/\$/g, '\\$'); // ERE end-of-line metachar
|
|
@@ -862,8 +1162,9 @@ class SearcherService {
|
|
|
862
1162
|
}
|
|
863
1163
|
/**
|
|
864
1164
|
* Async wrapper around grep to avoid blocking the event loop. `ok: false`
|
|
865
|
-
* means grep could not be spawned or exited with status ≥ 2
|
|
866
|
-
* from a clean "no matches",
|
|
1165
|
+
* means grep could not be spawned (`output: null`) or exited with status ≥ 2,
|
|
1166
|
+
* keeping whatever it printed — distinguishable from a clean "no matches",
|
|
1167
|
+
* which is `ok: true, output: null`.
|
|
867
1168
|
*/
|
|
868
1169
|
spawnGrep(args) {
|
|
869
1170
|
return new Promise((resolve) => {
|
|
@@ -880,16 +1181,17 @@ class SearcherService {
|
|
|
880
1181
|
proc.stderr.on('data', () => { });
|
|
881
1182
|
proc.on('error', (err) => {
|
|
882
1183
|
this.logger.error('SearcherService: grep spawn failed', { reason: err.message });
|
|
883
|
-
resolve({ ok: false });
|
|
1184
|
+
resolve({ ok: false, output: null });
|
|
884
1185
|
});
|
|
885
1186
|
proc.on('close', (code) => {
|
|
1187
|
+
const output = chunks.length === 0 ? null : Buffer.concat(chunks).toString('utf-8');
|
|
886
1188
|
// grep returns 1 for "no matches" — not an error; 2+ is a real failure
|
|
887
1189
|
if (code !== null && code >= 2) {
|
|
888
1190
|
this.logger.warn('SearcherService: grep exited with code', { code });
|
|
889
|
-
resolve({ ok: false });
|
|
1191
|
+
resolve({ ok: false, output });
|
|
890
1192
|
return;
|
|
891
1193
|
}
|
|
892
|
-
resolve({ ok: true, output
|
|
1194
|
+
resolve({ ok: true, output });
|
|
893
1195
|
});
|
|
894
1196
|
});
|
|
895
1197
|
}
|