codesearch-mcp 0.2.1-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.
Files changed (62) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/README.md +15 -8
  3. package/dist/app.js +45 -7
  4. package/dist/src/commands/EvalCommand.d.ts +10 -0
  5. package/dist/src/commands/EvalCommand.js +231 -0
  6. package/dist/src/commands/IndexCommand.js +79 -53
  7. package/dist/src/db/index.d.ts +1 -1
  8. package/dist/src/db/index.js +1 -2
  9. package/dist/src/db/schema.d.ts +5 -29
  10. package/dist/src/db/schema.js +2 -8
  11. package/dist/src/dependency-traversal.d.ts +16 -0
  12. package/dist/src/dependency-traversal.js +45 -0
  13. package/dist/src/enriched-prompt.d.ts +3 -2
  14. package/dist/src/enriched-prompt.js +3 -2
  15. package/dist/src/eval-results.d.ts +12 -0
  16. package/dist/src/eval-results.js +114 -0
  17. package/dist/src/file-utils.d.ts +15 -0
  18. package/dist/src/file-utils.js +24 -1
  19. package/dist/src/help.js +4 -1
  20. package/dist/src/interfaces/IAstParser.d.ts +13 -1
  21. package/dist/src/interfaces/IDatabase.d.ts +0 -14
  22. package/dist/src/interfaces/IRetrievalEvaluator.d.ts +94 -0
  23. package/dist/src/interfaces/IRetrievalEvaluator.js +2 -0
  24. package/dist/src/interfaces/ISearcher.d.ts +11 -16
  25. package/dist/src/interfaces/index.d.ts +1 -0
  26. package/dist/src/project-scope.d.ts +19 -0
  27. package/dist/src/project-scope.js +49 -0
  28. package/dist/src/project-selector.d.ts +6 -0
  29. package/dist/src/project-selector.js +18 -0
  30. package/dist/src/retrieval-eval.d.ts +16 -0
  31. package/dist/src/retrieval-eval.js +214 -0
  32. package/dist/src/services/AstParserService.d.ts +66 -1
  33. package/dist/src/services/AstParserService.js +432 -28
  34. package/dist/src/services/DatabaseService.d.ts +0 -12
  35. package/dist/src/services/DatabaseService.js +2 -52
  36. package/dist/src/services/IndexerService.d.ts +8 -0
  37. package/dist/src/services/IndexerService.js +24 -11
  38. package/dist/src/services/McpServer.d.ts +7 -0
  39. package/dist/src/services/McpServer.js +42 -38
  40. package/dist/src/services/OpenAICompatibleEmbedder.js +2 -1
  41. package/dist/src/services/SearcherService.d.ts +189 -25
  42. package/dist/src/services/SearcherService.js +505 -203
  43. package/dist/src/symbol-rows.d.ts +14 -9
  44. package/dist/src/symbol-rows.js +17 -11
  45. package/dist/src/tools/FindSymbolTool.js +14 -5
  46. package/dist/src/tools/GetCallersTool.d.ts +3 -1
  47. package/dist/src/tools/GetCallersTool.js +16 -6
  48. package/dist/src/tools/GetDependenciesTool.d.ts +3 -1
  49. package/dist/src/tools/GetDependenciesTool.js +15 -8
  50. package/dist/src/tools/GetDependentsTool.d.ts +3 -1
  51. package/dist/src/tools/GetDependentsTool.js +31 -16
  52. package/dist/src/tools/ListFilesTool.d.ts +3 -1
  53. package/dist/src/tools/ListFilesTool.js +14 -12
  54. package/dist/src/tools/ListProjectsTool.js +5 -1
  55. package/dist/src/tools/ReadFileRangeTool.d.ts +3 -1
  56. package/dist/src/tools/ReadFileRangeTool.js +12 -11
  57. package/dist/src/tools/ReadFileTool.d.ts +3 -1
  58. package/dist/src/tools/ReadFileTool.js +12 -10
  59. package/dist/src/tools/SearchCodebaseTool.js +3 -1
  60. package/dist/src/tools/format-helpers.d.ts +16 -1
  61. package/dist/src/tools/format-helpers.js +64 -0
  62. 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
- constructor(db, embedder, discovery, config, logger = SILENT_LOGGER) {
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
- return { ...r, path: projectRoot, ...(typeof meta === 'object' ? meta : EMPTY_META) };
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 seen = new Map();
339
- for (const r of results) {
340
- const key = `${r.serviceName}::${r.filePath}`;
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, n * 100);
351
- const firstHalf = pairs.map((_, i) => `($${i * 2 + 1}::text, $${i * 2 + 2}::text)`).join(', ');
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 ${firstHalf}) AS r(fp, proj)
359
- JOIN code_dependency d ON d."fromFile" = r.fp AND d.project = r.proj
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 ${secondHalf}) AS r(fp, proj)
365
- JOIN code_dependency d ON d."toFile" = r.fp AND d.project = r.proj
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}`, params);
653
+ LIMIT ${proportionalLimit}`, files);
368
654
  const ctx = new Map();
369
- for (const { filePath } of pairs)
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: pairs.length, rows: rows.length, ms: Date.now() - t0 });
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, project, symbol = null, transitive = false) {
673
+ async getDependencies(filePath, symbol = null, transitive = false) {
388
674
  const t0 = Date.now();
389
- this.logger.debug('SearcherService: getDependencies', { filePath, project, symbol, transitive });
390
- const params = [filePath, project];
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 project = $2
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 indirect = await this.db.query(`WITH RECURSIVE deps AS (
406
- SELECT "toFile", 1 AS depth, "toFile" AS via
407
- FROM code_dependency
408
- WHERE "fromFile" = $1 AND project = $2
409
-
410
- UNION ALL
411
-
412
- SELECT d."toFile", deps.depth + 1, deps."toFile" AS via
413
- FROM code_dependency d
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, project, symbol = null, transitive = false) {
703
+ async getDependents(filePath, symbol = null, transitive = false) {
431
704
  const t0 = Date.now();
432
- this.logger.debug('SearcherService: getDependents', { filePath, project, symbol, transitive });
433
- const params = [filePath, project];
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 project = $2
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 indirect = await this.db.query(`WITH RECURSIVE rdeps AS (
449
- SELECT "fromFile", 1 AS depth, "fromFile" AS via
450
- FROM code_dependency
451
- WHERE "toFile" = $1 AND project = $2
452
-
453
- UNION ALL
454
-
455
- SELECT d."fromFile", rdeps.depth + 1, rdeps."fromFile" AS via
456
- FROM code_dependency d
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
- * One-line warning when any project in `scope` (the given `project`, or
508
- * every project with `code_chunk`/`code_symbol` rows) is outdated — shown
509
- * first in `find_symbol`/`get_callers` output. Any failure to determine the
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 outdatedNotice(project) {
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, project, excludeFile });
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
- projectDir,
780
+ this.toAbs(rootDir),
536
781
  ]);
537
- if (!result.ok || result.output === null) {
538
- return { matches: [], truncated: false };
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 rows = await this.db.query(`SELECT "filePath", name, kind, signature, line, col, container, "isStatic"
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, falling back to grep', { reason: err.message, ms: Date.now() - t0 });
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(relativePath, project, depth, pattern) {
942
+ async listFiles(relPath, depth, pattern) {
671
943
  const t0 = Date.now();
672
- this.logger.debug('SearcherService: listFiles', { relativePath, project, depth, pattern });
673
- const projectRoot = this.resolveProjectRoot(project);
674
- const resolvedTarget = path.resolve(projectRoot, relativePath);
675
- const resolvedProject = path.resolve(projectRoot);
676
- if (resolvedTarget !== resolvedProject && !resolvedTarget.startsWith(resolvedProject + path.sep)) {
677
- return { tree: '', error: 'Path escapes project root' };
678
- }
679
- if (!fs.existsSync(resolvedTarget)) {
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
- if (!fs.statSync(resolvedTarget).isDirectory()) {
683
- return { tree: '', error: `Not a directory: ${relativePath}` };
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
- this.walkDir(resolvedTarget, 0, clampedDepth - 1, pattern, lines);
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
- async readFileAbsolute(absPath, maxLines) {
695
- const t0 = Date.now();
696
- this.logger.debug('SearcherService: readFileAbsolute', { absPath, maxLines });
697
- const resolvedRoot = path.resolve(this.config.workspaceRoot);
698
- const resolved = path.resolve(absPath);
699
- if (resolved !== resolvedRoot && !resolved.startsWith(resolvedRoot + path.sep)) {
700
- return { ok: false, reason: 'invalid_path' };
701
- }
702
- if (!fs.existsSync(resolved))
703
- return { ok: false, reason: 'not_on_disk' };
704
- const lines = [];
705
- const stream = fs.createReadStream(resolved, { encoding: 'utf-8' });
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 <= maxLines) {
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
- const relPath = path.relative(resolvedRoot, resolved);
729
- this.logger.debug('SearcherService: readFileAbsolute result', { linesRead: lines.length, hasMore, ms: Date.now() - t0 });
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(filePath, startLine, endLine) {
1042
+ async readFileRange(relPath, startLine, endLine) {
733
1043
  const t0 = Date.now();
734
- this.logger.debug('SearcherService: readFileRange', { filePath, startLine, endLine });
735
- const resolvedRoot = path.resolve(this.config.workspaceRoot);
736
- const absPath = path.resolve(resolvedRoot, filePath);
737
- if (absPath !== resolvedRoot && !absPath.startsWith(resolvedRoot + path.sep)) {
738
- return { ok: false, reason: 'invalid_path' };
739
- }
740
- const [{ exists }] = await this.db.query(`SELECT EXISTS (SELECT 1 FROM code_chunk WHERE "filePath" = $1) AS exists`, [filePath]);
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
- try {
752
- for await (const line of rl) {
753
- lineNo++;
754
- if (lineNo >= startLine && lineNo <= endLine)
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
- catch (err) {
767
- rl.close();
768
- stream.destroy();
769
- throw err;
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
- walkDir(absDir, currentDepth, maxDepth, pattern, lines) {
775
- let entries;
776
- try {
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
- this.walkDir(path.join(absDir, entry.name), currentDepth + 1, maxDepth, pattern, lines);
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 (distinguishable
866
- * from a clean "no matches", which is `ok: true, output: null`).
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: chunks.length === 0 ? null : Buffer.concat(chunks).toString('utf-8') });
1194
+ resolve({ ok: true, output });
893
1195
  });
894
1196
  });
895
1197
  }