ctxman 3.1.1 → 3.2.8
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/.ctxman/llm-profiles.json +6 -6
- package/README.md +16 -10
- package/bin/cli.js +109 -10
- package/bin/cm-ask.js +64 -6
- package/bin/cm-audit.js +76 -0
- package/bin/cm-count.js +45 -6
- package/bin/cm-diagnose.js +199 -0
- package/bin/cm-focus.js +322 -0
- package/bin/cm-map.js +71 -18
- package/bin/cm-pack.js +36 -5
- package/bin/cm-policy.js +107 -2
- package/bin/cm-pr.js +18 -3
- package/bin/cm-search.js +84 -19
- package/bin/cm-task.js +192 -0
- package/bin/cm-who.js +119 -0
- package/bin/cm-why.js +41 -5
- package/index.d.ts +940 -1
- package/index.js +105 -1
- package/lib/analyzers/token-calculator.js +76 -4
- package/lib/api/mcp/server.js +843 -29
- package/lib/cache/CacheManager.js +161 -47
- package/lib/core/Analyzer.js +61 -5
- package/lib/count/index.js +74 -14
- package/lib/diagnose/index.js +712 -0
- package/lib/focus/index.js +790 -0
- package/lib/map/call-graph.js +570 -0
- package/lib/map/graph.js +30 -5
- package/lib/map/index.js +12 -4
- package/lib/map/rank.js +45 -7
- package/lib/map/render.js +89 -28
- package/lib/pack/index.js +20 -1
- package/lib/pack/select.js +8 -1
- package/lib/policy/audit.js +3 -1
- package/lib/policy/index.js +5 -0
- package/lib/policy/workspace.js +254 -0
- package/lib/pr/pr-context.js +292 -31
- package/lib/pr/pr-renderer.js +25 -10
- package/lib/rag/EmbeddingProvider.js +139 -2
- package/lib/rag/VectorStoreFactory.js +10 -4
- package/lib/rag/ask.js +186 -17
- package/lib/rag/stores/SqliteVectorStore.js +595 -0
- package/lib/search/hybrid.js +324 -0
- package/lib/search/symbols.js +75 -11
- package/lib/task/index.js +630 -0
- package/lib/utils/file-utils.js +1 -0
- package/lib/utils/multi-repo-manager.js +4 -1
- package/lib/watch/FileWatcher.js +121 -31
- package/lib/watch/IncrementalAnalyzer.js +964 -16
- package/lib/who/index.js +893 -0
- package/lib/why/analyzer-bridge.js +223 -0
- package/lib/why/discussions.js +314 -0
- package/lib/why/index.js +356 -30
- package/lib/why/render.js +154 -9
- package/lib/workspace/index.js +1274 -0
- package/package.json +1 -1
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hybrid Code Search: Reciprocal Rank Fusion (RRF) over BM25 lexical chunks and
|
|
3
|
+
* local vector embeddings (`askCodebase`).
|
|
4
|
+
*
|
|
5
|
+
* Combines exact identifier/keyword recall (`BM25Index` over methods and 40-line windows)
|
|
6
|
+
* with conceptual semantic similarity (`askCodebase` file embeddings):
|
|
7
|
+
*
|
|
8
|
+
* score_RRF(chunk) = w_bm25 / (k + rank_bm25) + w_semantic / (k + rank_semantic)
|
|
9
|
+
*
|
|
10
|
+
* Graceful degradation:
|
|
11
|
+
* - `mode: 'bm25'` -> pure lexical BM25 (instant, zero external packages)
|
|
12
|
+
* - `mode: 'hybrid'` -> RRF fusion of BM25 + semantic; if optional RAG dependencies
|
|
13
|
+
* (`@xenova/transformers` / `@lancedb/lancedb`) are unavailable,
|
|
14
|
+
* falls back to BM25 with `semanticAvailable: false` and a warning
|
|
15
|
+
* - `mode: 'semantic'` -> pure vector search projected onto code chunks (falls back to BM25
|
|
16
|
+
* only when `fallbackToBm25: true`, otherwise throws)
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import path from 'path';
|
|
20
|
+
import {
|
|
21
|
+
getProjectIndex,
|
|
22
|
+
sliceLines,
|
|
23
|
+
fitToTokenBudget,
|
|
24
|
+
DEFAULT_LIMIT,
|
|
25
|
+
DEFAULT_MAX_TOKENS,
|
|
26
|
+
WINDOW_LINES,
|
|
27
|
+
} from './bm25.js';
|
|
28
|
+
import { askCodebase } from '../rag/ask.js';
|
|
29
|
+
import { PolicyConfigError } from '../policy/index.js';
|
|
30
|
+
import TokenUtils from '../utils/token-utils.js';
|
|
31
|
+
import { redactSecrets } from '../utils/secret-redactor.js';
|
|
32
|
+
|
|
33
|
+
export const SEARCH_MODES = ['bm25', 'semantic', 'hybrid'];
|
|
34
|
+
export const DEFAULT_RRF_K = 60;
|
|
35
|
+
|
|
36
|
+
const toPosix = (p) => String(p).split(path.sep).join('/');
|
|
37
|
+
|
|
38
|
+
function chunkKey(posixFile, startLine, endLine) {
|
|
39
|
+
return `${posixFile}:${startLine}-${endLine}`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Pick the best representative chunk from a project file entry when a semantic match had
|
|
44
|
+
* zero lexical overlap in BM25: prefers the first named function/method chunk, else window 0.
|
|
45
|
+
*/
|
|
46
|
+
function representativeChunk(entry, fallbackText) {
|
|
47
|
+
if (entry && Array.isArray(entry.chunks) && entry.chunks.length > 0) {
|
|
48
|
+
const named = entry.chunks.find((chunk) => Boolean(chunk.name));
|
|
49
|
+
return named ?? entry.chunks[0];
|
|
50
|
+
}
|
|
51
|
+
const lineCount = Math.max(1, String(fallbackText || '').split('\n').length);
|
|
52
|
+
return {
|
|
53
|
+
name: null,
|
|
54
|
+
kind: 'window',
|
|
55
|
+
startLine: 1,
|
|
56
|
+
endLine: Math.min(WINDOW_LINES, lineCount),
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Run BM25, semantic, or Reciprocal Rank Fusion (RRF) hybrid search over a project.
|
|
62
|
+
*
|
|
63
|
+
* @param {string} root - Project root directory
|
|
64
|
+
* @param {string} query - Natural-language or identifier query
|
|
65
|
+
* @param {object} [options]
|
|
66
|
+
* @param {'hybrid'|'bm25'|'semantic'} [options.mode='hybrid']
|
|
67
|
+
* @param {number} [options.limit=10] - Maximum chunks returned
|
|
68
|
+
* @param {number} [options.maxTokens=4000] - Token budget across returned chunk texts
|
|
69
|
+
* @param {boolean} [options.redact=true] - Redact secrets in returned code
|
|
70
|
+
* @param {number} [options.windowLines=40] - Fallback window size for code outside methods
|
|
71
|
+
* @param {number} [options.rrfK=60] - RRF smoothing constant k
|
|
72
|
+
* @param {number} [options.bm25Weight=1] - Weight applied to BM25 RRF component
|
|
73
|
+
* @param {number} [options.semanticWeight=1] - Weight applied to semantic RRF component
|
|
74
|
+
* @param {boolean} [options.fallbackToBm25=false] - Fall back to BM25 even in `'semantic'` mode
|
|
75
|
+
* @param {(event: 'indexing'|'indexed') => void} [options.onProgress]
|
|
76
|
+
* @param {(storePath: string) => object} [options.createStore] - Injectable vector store factory
|
|
77
|
+
* @returns {Promise<{
|
|
78
|
+
* query: string,
|
|
79
|
+
* mode: 'bm25'|'semantic'|'hybrid',
|
|
80
|
+
* effectiveMode: 'bm25'|'semantic'|'hybrid',
|
|
81
|
+
* semanticAvailable: boolean,
|
|
82
|
+
* indexed: boolean,
|
|
83
|
+
* root: string,
|
|
84
|
+
* files: number,
|
|
85
|
+
* chunks: number,
|
|
86
|
+
* tokens: number,
|
|
87
|
+
* warnings: string[],
|
|
88
|
+
* results: Array<{
|
|
89
|
+
* file: string,
|
|
90
|
+
* name: string|null,
|
|
91
|
+
* kind: string,
|
|
92
|
+
* parent?: string,
|
|
93
|
+
* startLine: number,
|
|
94
|
+
* endLine: number,
|
|
95
|
+
* score: number,
|
|
96
|
+
* bm25Rank: number|null,
|
|
97
|
+
* semanticRank: number|null,
|
|
98
|
+
* bm25Score: number|null,
|
|
99
|
+
* semanticScore: number|null,
|
|
100
|
+
* tokens: number,
|
|
101
|
+
* text: string|null,
|
|
102
|
+
* truncated?: boolean
|
|
103
|
+
* }>
|
|
104
|
+
* }>}
|
|
105
|
+
*/
|
|
106
|
+
export async function hybridSearch(root, query, options = {}) {
|
|
107
|
+
if (typeof query !== 'string' || !query.trim()) {
|
|
108
|
+
throw new Error('The search query is empty');
|
|
109
|
+
}
|
|
110
|
+
const mode = options.mode ?? 'hybrid';
|
|
111
|
+
if (!SEARCH_MODES.includes(mode)) {
|
|
112
|
+
throw new Error(`Invalid search mode "${mode}" (expected one of: ${SEARCH_MODES.join(', ')})`);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const limit =
|
|
116
|
+
Number.isInteger(options.limit) && options.limit > 0 ? options.limit : DEFAULT_LIMIT;
|
|
117
|
+
const maxTokens =
|
|
118
|
+
Number.isInteger(options.maxTokens) && options.maxTokens > 0
|
|
119
|
+
? options.maxTokens
|
|
120
|
+
: DEFAULT_MAX_TOKENS;
|
|
121
|
+
const redact = options.redact !== false;
|
|
122
|
+
const windowLines = options.windowLines ?? WINDOW_LINES;
|
|
123
|
+
const rrfK = Number.isFinite(options.rrfK) && options.rrfK > 0 ? options.rrfK : DEFAULT_RRF_K;
|
|
124
|
+
const bm25Weight =
|
|
125
|
+
Number.isFinite(options.bm25Weight) && options.bm25Weight >= 0 ? options.bm25Weight : 1;
|
|
126
|
+
const semanticWeight =
|
|
127
|
+
Number.isFinite(options.semanticWeight) && options.semanticWeight >= 0
|
|
128
|
+
? options.semanticWeight
|
|
129
|
+
: 1;
|
|
130
|
+
|
|
131
|
+
const project = getProjectIndex(root, { windowLines });
|
|
132
|
+
const filesByPosix = new Map();
|
|
133
|
+
for (const entry of project.files.values()) {
|
|
134
|
+
filesByPosix.set(toPosix(entry.relativePath), entry);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const candidatePoolSize = Math.max(limit * 3, 20);
|
|
138
|
+
const warnings = [];
|
|
139
|
+
|
|
140
|
+
// 1. Collect BM25 candidates when mode includes lexical ranking (or as fallback pool)
|
|
141
|
+
const bm25Hits =
|
|
142
|
+
mode === 'bm25' || mode === 'hybrid'
|
|
143
|
+
? project.index.search(query, { limit: candidatePoolSize })
|
|
144
|
+
: [];
|
|
145
|
+
|
|
146
|
+
// 2. Collect semantic candidates when mode is 'hybrid' or 'semantic'
|
|
147
|
+
let semanticResult = null;
|
|
148
|
+
let semanticAvailable = false;
|
|
149
|
+
if (mode === 'hybrid' || mode === 'semantic') {
|
|
150
|
+
try {
|
|
151
|
+
semanticResult = await askCodebase(project.root, query, {
|
|
152
|
+
limit: candidatePoolSize,
|
|
153
|
+
redact,
|
|
154
|
+
onProgress: options.onProgress,
|
|
155
|
+
createStore: options.createStore,
|
|
156
|
+
});
|
|
157
|
+
semanticAvailable = true;
|
|
158
|
+
} catch (error) {
|
|
159
|
+
if (error instanceof PolicyConfigError) throw error;
|
|
160
|
+
if (mode === 'semantic' && !options.fallbackToBm25) {
|
|
161
|
+
throw error;
|
|
162
|
+
}
|
|
163
|
+
warnings.push(
|
|
164
|
+
`Semantic search unavailable (${error.message}); fell back to BM25 lexical ranking.`
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const effectiveMode = mode === 'bm25' ? 'bm25' : semanticAvailable ? mode : 'bm25';
|
|
170
|
+
|
|
171
|
+
// If semantic failed and we fell back from 'semantic' -> 'bm25', compute BM25 hits now
|
|
172
|
+
const activeBm25Hits =
|
|
173
|
+
effectiveMode === 'bm25' && bm25Hits.length === 0
|
|
174
|
+
? project.index.search(query, { limit: candidatePoolSize })
|
|
175
|
+
: bm25Hits;
|
|
176
|
+
|
|
177
|
+
// 3. Fuse candidates in a unified map keyed by `posixFile:startLine-endLine`
|
|
178
|
+
const candidates = new Map();
|
|
179
|
+
const bestBm25KeyPerFile = new Map();
|
|
180
|
+
|
|
181
|
+
activeBm25Hits.forEach(({ doc: { entry, chunk }, score }, idx) => {
|
|
182
|
+
const posixFile = toPosix(entry.relativePath);
|
|
183
|
+
const key = chunkKey(posixFile, chunk.startLine, chunk.endLine);
|
|
184
|
+
const rank = idx + 1;
|
|
185
|
+
if (!bestBm25KeyPerFile.has(posixFile)) {
|
|
186
|
+
bestBm25KeyPerFile.set(posixFile, key);
|
|
187
|
+
}
|
|
188
|
+
candidates.set(key, {
|
|
189
|
+
file: entry.relativePath,
|
|
190
|
+
posixFile,
|
|
191
|
+
entry,
|
|
192
|
+
chunk,
|
|
193
|
+
fallbackText: null,
|
|
194
|
+
bm25Rank: rank,
|
|
195
|
+
bm25Score: score,
|
|
196
|
+
semanticRank: null,
|
|
197
|
+
semanticScore: null,
|
|
198
|
+
});
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
if (semanticAvailable && semanticResult) {
|
|
202
|
+
semanticResult.results.forEach((semHit, idx) => {
|
|
203
|
+
const posixFile = toPosix(semHit.file);
|
|
204
|
+
const rank = idx + 1;
|
|
205
|
+
const existingKey = bestBm25KeyPerFile.get(posixFile);
|
|
206
|
+
|
|
207
|
+
if (existingKey && candidates.has(existingKey)) {
|
|
208
|
+
const item = candidates.get(existingKey);
|
|
209
|
+
if (item.semanticRank === null) {
|
|
210
|
+
item.semanticRank = rank;
|
|
211
|
+
item.semanticScore = semHit.score;
|
|
212
|
+
}
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const entry = filesByPosix.get(posixFile) ?? null;
|
|
217
|
+
const chunk = representativeChunk(entry, semHit.text);
|
|
218
|
+
const key = chunkKey(posixFile, chunk.startLine, chunk.endLine);
|
|
219
|
+
if (candidates.has(key)) {
|
|
220
|
+
const item = candidates.get(key);
|
|
221
|
+
if (item.semanticRank === null) {
|
|
222
|
+
item.semanticRank = rank;
|
|
223
|
+
item.semanticScore = semHit.score;
|
|
224
|
+
}
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
candidates.set(key, {
|
|
229
|
+
file: entry ? entry.relativePath : semHit.file,
|
|
230
|
+
posixFile,
|
|
231
|
+
entry,
|
|
232
|
+
chunk,
|
|
233
|
+
fallbackText: semHit.text,
|
|
234
|
+
bm25Rank: null,
|
|
235
|
+
bm25Score: null,
|
|
236
|
+
semanticRank: rank,
|
|
237
|
+
semanticScore: semHit.score,
|
|
238
|
+
});
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// 4. Score and sort candidates
|
|
243
|
+
const scored = [];
|
|
244
|
+
for (const item of candidates.values()) {
|
|
245
|
+
let finalScore;
|
|
246
|
+
if (effectiveMode === 'bm25') {
|
|
247
|
+
finalScore = item.bm25Score ?? 0;
|
|
248
|
+
} else if (effectiveMode === 'semantic') {
|
|
249
|
+
finalScore =
|
|
250
|
+
item.semanticScore !== null && item.semanticScore !== undefined
|
|
251
|
+
? Number(item.semanticScore)
|
|
252
|
+
: 1 / (rrfK + (item.semanticRank ?? candidatePoolSize));
|
|
253
|
+
} else {
|
|
254
|
+
const bm25Rrf = item.bm25Rank !== null ? bm25Weight / (rrfK + item.bm25Rank) : 0;
|
|
255
|
+
const semRrf = item.semanticRank !== null ? semanticWeight / (rrfK + item.semanticRank) : 0;
|
|
256
|
+
finalScore = bm25Rrf + semRrf;
|
|
257
|
+
}
|
|
258
|
+
scored.push({ ...item, finalScore });
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
scored.sort((a, b) => {
|
|
262
|
+
if (b.finalScore !== a.finalScore) return b.finalScore - a.finalScore;
|
|
263
|
+
// Tie-break: matched in both signals first, then better BM25 rank, then lexical path
|
|
264
|
+
const aBoth = a.bm25Rank !== null && a.semanticRank !== null ? 1 : 0;
|
|
265
|
+
const bBoth = b.bm25Rank !== null && b.semanticRank !== null ? 1 : 0;
|
|
266
|
+
if (bBoth !== aBoth) return bBoth - aBoth;
|
|
267
|
+
if ((a.bm25Rank ?? Infinity) !== (b.bm25Rank ?? Infinity)) {
|
|
268
|
+
return (a.bm25Rank ?? Infinity) - (b.bm25Rank ?? Infinity);
|
|
269
|
+
}
|
|
270
|
+
return a.posixFile < b.posixFile ? -1 : a.posixFile > b.posixFile ? 1 : 0;
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
// 5. Materialize source snippets within maxTokens budget
|
|
274
|
+
let used = 0;
|
|
275
|
+
const results = scored.slice(0, limit).map((item) => {
|
|
276
|
+
const filePathForTokens = item.entry ? item.entry.path : path.join(project.root, item.file);
|
|
277
|
+
const raw = item.entry
|
|
278
|
+
? sliceLines(item.entry.content, item.chunk.startLine, item.chunk.endLine)
|
|
279
|
+
: sliceLines(item.fallbackText ?? '', item.chunk.startLine, item.chunk.endLine);
|
|
280
|
+
let text = redact ? redactSecrets(raw).text : raw;
|
|
281
|
+
let tokens = TokenUtils.calculate(text, filePathForTokens);
|
|
282
|
+
|
|
283
|
+
const out = {
|
|
284
|
+
file: item.file,
|
|
285
|
+
name: item.chunk.name,
|
|
286
|
+
kind: item.chunk.kind,
|
|
287
|
+
...(item.chunk.parent ? { parent: item.chunk.parent } : {}),
|
|
288
|
+
startLine: item.chunk.startLine,
|
|
289
|
+
endLine: item.chunk.endLine,
|
|
290
|
+
score: Math.round(item.finalScore * 10000) / 10000,
|
|
291
|
+
bm25Rank: item.bm25Rank,
|
|
292
|
+
semanticRank: item.semanticRank,
|
|
293
|
+
bm25Score: item.bm25Score !== null ? Math.round(item.bm25Score * 10000) / 10000 : null,
|
|
294
|
+
semanticScore:
|
|
295
|
+
item.semanticScore !== null && item.semanticScore !== undefined
|
|
296
|
+
? Math.round(Number(item.semanticScore) * 10000) / 10000
|
|
297
|
+
: null,
|
|
298
|
+
};
|
|
299
|
+
|
|
300
|
+
if (used + tokens > maxTokens) {
|
|
301
|
+
text = fitToTokenBudget(text, maxTokens - used, filePathForTokens);
|
|
302
|
+
tokens = text ? TokenUtils.calculate(text, filePathForTokens) : 0;
|
|
303
|
+
out.truncated = true;
|
|
304
|
+
}
|
|
305
|
+
used += tokens;
|
|
306
|
+
return { ...out, tokens, text: text || null };
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
return {
|
|
310
|
+
query,
|
|
311
|
+
mode,
|
|
312
|
+
effectiveMode,
|
|
313
|
+
semanticAvailable,
|
|
314
|
+
indexed: Boolean(semanticResult?.indexed),
|
|
315
|
+
root: project.root,
|
|
316
|
+
files: project.files.size,
|
|
317
|
+
chunks: project.index.size,
|
|
318
|
+
tokens: used,
|
|
319
|
+
warnings,
|
|
320
|
+
results,
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
export default hybridSearch;
|
package/lib/search/symbols.js
CHANGED
|
@@ -6,14 +6,31 @@
|
|
|
6
6
|
* lookup to methods of that class.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
+
import fs from 'fs';
|
|
9
10
|
import path from 'path';
|
|
10
11
|
import TokenUtils from '../utils/token-utils.js';
|
|
11
12
|
import { redactSecrets } from '../utils/secret-redactor.js';
|
|
12
13
|
import { compareStrings, fitToTokenBudget, getProjectIndex, sliceLines } from './bm25.js';
|
|
14
|
+
import { resolveWorkspaceRepos, enforceWorkspacePolicies } from '../workspace/index.js';
|
|
13
15
|
|
|
14
16
|
export const DEFAULT_SYMBOL_LIMIT = 10;
|
|
15
17
|
export const DEFAULT_SYMBOL_MAX_TOKENS = 20000;
|
|
16
18
|
const MAX_SUGGESTIONS = 10;
|
|
19
|
+
const toPosix = (p) => String(p).split(path.sep).join('/');
|
|
20
|
+
|
|
21
|
+
function buildWorkspaceRepoInputs(primaryRoot, extraRepos) {
|
|
22
|
+
const realPrimary = fs.existsSync(primaryRoot)
|
|
23
|
+
? fs.realpathSync(primaryRoot)
|
|
24
|
+
: path.resolve(primaryRoot);
|
|
25
|
+
const isContainerOfExtra = extraRepos.some((item) => {
|
|
26
|
+
const raw = typeof item === 'string' ? item : item?.path;
|
|
27
|
+
if (typeof raw !== 'string' || !raw.trim()) return false;
|
|
28
|
+
const resolved = path.resolve(raw);
|
|
29
|
+
const real = fs.existsSync(resolved) ? fs.realpathSync(resolved) : resolved;
|
|
30
|
+
return real !== realPrimary && real.startsWith(realPrimary + path.sep);
|
|
31
|
+
});
|
|
32
|
+
return isContainerOfExtra ? [...extraRepos] : [primaryRoot, ...extraRepos];
|
|
33
|
+
}
|
|
17
34
|
|
|
18
35
|
/**
|
|
19
36
|
* Name without an accessor prefix: `get value` -> `value`
|
|
@@ -81,6 +98,7 @@ function suggest(candidates, name) {
|
|
|
81
98
|
name: unit.name,
|
|
82
99
|
kind: unit.kind,
|
|
83
100
|
...(unit.parent ? { parent: unit.parent } : {}),
|
|
101
|
+
...(entry.repo ? { repo: entry.repo } : {}),
|
|
84
102
|
file: entry.relativePath,
|
|
85
103
|
startLine: unit.startLine,
|
|
86
104
|
distance,
|
|
@@ -123,35 +141,77 @@ export function lookupSymbols(candidates, query) {
|
|
|
123
141
|
}
|
|
124
142
|
|
|
125
143
|
/**
|
|
126
|
-
* Source of the methods, functions or classes with a given name in a project.
|
|
144
|
+
* Source of the methods, functions or classes with a given name in a project (or multi-repo workspace).
|
|
127
145
|
*
|
|
128
146
|
* @param {string} root - Project directory (files found with the Scanner, so the ignore
|
|
129
147
|
* rules apply)
|
|
130
148
|
* @param {string} query - Symbol name, or `Class.method`
|
|
131
149
|
* @param {Object} [options]
|
|
132
|
-
* @param {string} [options.file] - Only this project-relative file
|
|
150
|
+
* @param {string} [options.file] - Only this project-relative (or `<alias>/<path>`) file
|
|
133
151
|
* @param {number} [options.limit=10] - Maximum number of symbols returned with source
|
|
134
152
|
* @param {number} [options.maxTokens=20000] - Token budget for the sources; sources past it
|
|
135
153
|
* are cut (`truncated: true`)
|
|
136
154
|
* @param {boolean} [options.redact=true] - Redact secrets in the sources
|
|
137
|
-
* @
|
|
138
|
-
*
|
|
155
|
+
* @param {Array<string|{path: string, alias?: string}>} [options.repos] - Additional repository roots for multi-repo symbol lookup
|
|
156
|
+
* @returns {{query: string, match: string, total: number, repos?: Array<{alias: string, root: string}>,
|
|
157
|
+
* symbols: Array, suggestions?: Array}}
|
|
139
158
|
* @throws {Error} When the query is empty or `file` is not a project file
|
|
140
159
|
*/
|
|
141
160
|
export function getSymbol(
|
|
142
161
|
root,
|
|
143
162
|
query,
|
|
144
|
-
{
|
|
163
|
+
{
|
|
164
|
+
file,
|
|
165
|
+
limit = DEFAULT_SYMBOL_LIMIT,
|
|
166
|
+
maxTokens = DEFAULT_SYMBOL_MAX_TOKENS,
|
|
167
|
+
redact = true,
|
|
168
|
+
repos,
|
|
169
|
+
} = {}
|
|
145
170
|
) {
|
|
146
171
|
const wanted = typeof query === 'string' ? query.trim() : '';
|
|
147
172
|
if (!wanted) throw new Error('The symbol name is empty');
|
|
148
173
|
|
|
149
|
-
const
|
|
150
|
-
let entries
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
174
|
+
const isWorkspace = Array.isArray(repos) && repos.length > 0;
|
|
175
|
+
let entries;
|
|
176
|
+
let resolvedRepos = null;
|
|
177
|
+
|
|
178
|
+
if (isWorkspace) {
|
|
179
|
+
resolvedRepos = resolveWorkspaceRepos(buildWorkspaceRepoInputs(root, repos));
|
|
180
|
+
enforceWorkspacePolicies(resolvedRepos, {
|
|
181
|
+
filesByRepo: new Map(),
|
|
182
|
+
tokens: 0,
|
|
183
|
+
redact,
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
entries = [];
|
|
187
|
+
for (const repo of resolvedRepos) {
|
|
188
|
+
const project = getProjectIndex(repo.root);
|
|
189
|
+
for (const entry of project.files.values()) {
|
|
190
|
+
const rawRel = toPosix(entry.relativePath);
|
|
191
|
+
entries.push({
|
|
192
|
+
...entry,
|
|
193
|
+
repo: repo.alias,
|
|
194
|
+
relativePath: `${repo.alias}/${rawRel}`,
|
|
195
|
+
rawRelativePath: rawRel,
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (file) {
|
|
201
|
+
const target = toPosix(path.normalize(file)).replace(/^\/+/, '');
|
|
202
|
+
entries = entries.filter(
|
|
203
|
+
(entry) => entry.relativePath === target || entry.rawRelativePath === target
|
|
204
|
+
);
|
|
205
|
+
if (entries.length === 0) throw new Error(`File not found in project: ${file}`);
|
|
206
|
+
}
|
|
207
|
+
} else {
|
|
208
|
+
const project = getProjectIndex(root);
|
|
209
|
+
entries = [...project.files.values()];
|
|
210
|
+
if (file) {
|
|
211
|
+
const target = path.normalize(file);
|
|
212
|
+
entries = entries.filter((entry) => path.normalize(entry.relativePath) === target);
|
|
213
|
+
if (entries.length === 0) throw new Error(`File not found in project: ${file}`);
|
|
214
|
+
}
|
|
155
215
|
}
|
|
156
216
|
|
|
157
217
|
const candidates = entries.flatMap((entry) => entry.units.map((unit) => ({ entry, unit })));
|
|
@@ -166,6 +226,7 @@ export function getSymbol(
|
|
|
166
226
|
name: unit.name,
|
|
167
227
|
kind: unit.kind,
|
|
168
228
|
...(unit.parent ? { parent: unit.parent } : {}),
|
|
229
|
+
...(entry.repo ? { repo: entry.repo } : {}),
|
|
169
230
|
file: entry.relativePath,
|
|
170
231
|
startLine: unit.startLine,
|
|
171
232
|
endLine: unit.endLine,
|
|
@@ -183,6 +244,9 @@ export function getSymbol(
|
|
|
183
244
|
query: wanted,
|
|
184
245
|
match,
|
|
185
246
|
total: found.length,
|
|
247
|
+
...(resolvedRepos
|
|
248
|
+
? { repos: resolvedRepos.map(({ alias, root: r }) => ({ alias, root: r })) }
|
|
249
|
+
: {}),
|
|
186
250
|
symbols,
|
|
187
251
|
...(match === 'none' ? { suggestions } : {}),
|
|
188
252
|
};
|