ctxman 3.1.0 → 3.2.7
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 +17 -10
- package/bin/cli.js +107 -8
- package/bin/cm-ask.js +74 -70
- package/bin/cm-audit.js +80 -7
- 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/examples/github-actions/ctxman-check.yml +1 -1
- package/index.d.ts +931 -1
- package/index.js +105 -1
- package/lib/analyzers/performance-dashboard.js +7 -3
- package/lib/analyzers/token-calculator.js +76 -4
- package/lib/api/mcp/server.js +713 -14
- package/lib/cache/CacheManager.js +161 -47
- package/lib/core/Analyzer.js +61 -5
- 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/parsers/gitignore-parser.js +4 -1
- package/lib/policy/audit.js +22 -2
- package/lib/policy/index.js +6 -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 +291 -0
- package/lib/rag/sources/FileDataSource.js +6 -3
- 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/logger.js +5 -2
- 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/wizards/config-generator.js +20 -18
- package/lib/wizards/project-detector.js +2 -1
- package/lib/workspace/index.js +1274 -0
- package/package.json +2 -2
|
@@ -0,0 +1,790 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Focus Context (`ctxman focus <file>[:symbol] --depth N`)
|
|
3
|
+
*
|
|
4
|
+
* Packs a target file (or `file:symbol`) together with its neighborhood in the repository
|
|
5
|
+
* import graph and cross-file AST symbol call graph:
|
|
6
|
+
* - outgoing imports & symbol calls (`target -> dep -> transitive dep`) up to `depth` hops
|
|
7
|
+
* - incoming importers & symbol callers (`caller -> transitive caller -> target`) up to `depth` hops
|
|
8
|
+
*
|
|
9
|
+
* Supports both single-repository mode and multi-repository workspace mode (`options.repos` / `--repo <dir>`),
|
|
10
|
+
* traversing cross-repository import and symbol call edges under `<repoAlias>/<path>` namespaces.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import fs from 'fs';
|
|
14
|
+
import path from 'path';
|
|
15
|
+
import { Scanner } from '../core/Scanner.js';
|
|
16
|
+
import { buildRepoGraph, rankFiles, splitTargetSymbol, normalizeSymbolName } from '../map/index.js';
|
|
17
|
+
import { packContext, PackError, TOOL_DEFAULT_BUDGET } from '../pack/index.js';
|
|
18
|
+
import { enforceExport } from '../policy/enforce.js';
|
|
19
|
+
import {
|
|
20
|
+
resolveWorkspaceRepos,
|
|
21
|
+
enforceWorkspacePolicies,
|
|
22
|
+
buildWorkspaceGraph,
|
|
23
|
+
packWorkspace,
|
|
24
|
+
} from '../workspace/index.js';
|
|
25
|
+
|
|
26
|
+
export const DEFAULT_FOCUS_DEPTH = 1;
|
|
27
|
+
export const DIRECTIONS = ['both', 'imports', 'importers', 'calls', 'callers'];
|
|
28
|
+
|
|
29
|
+
export class FocusError extends PackError {
|
|
30
|
+
constructor(code, message) {
|
|
31
|
+
super(code, message);
|
|
32
|
+
this.name = 'FocusError';
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const toPosix = (relativePath) => relativePath.split(path.sep).join('/');
|
|
37
|
+
|
|
38
|
+
function relativeInside(root, target) {
|
|
39
|
+
const relative = path.relative(root, target);
|
|
40
|
+
if (relative === '' || path.isAbsolute(relative)) return null;
|
|
41
|
+
if (relative === '..' || relative.startsWith(`..${path.sep}`)) return null;
|
|
42
|
+
return relative;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const TEST_PATH_RE =
|
|
46
|
+
/(?:^|\/)(?:test|tests|__tests__|spec|specs)(?:\/|$)|\.(?:test|spec)\.[a-z0-9]+$/i;
|
|
47
|
+
|
|
48
|
+
function isTestPath(posixPath) {
|
|
49
|
+
return TEST_PATH_RE.test(posixPath);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Breadth-first traversal along a directed adjacency list (`Map<string, Set<string>>`).
|
|
54
|
+
* @param {string} start - Starting node path
|
|
55
|
+
* @param {Map<string, Set<string>>} adjacency
|
|
56
|
+
* @param {number} maxDepth
|
|
57
|
+
* @returns {Map<string, number>} Neighbor path -> shortest hop distance (1..maxDepth)
|
|
58
|
+
*/
|
|
59
|
+
function bfsDirected(start, adjacency, maxDepth) {
|
|
60
|
+
const distances = new Map();
|
|
61
|
+
if (maxDepth <= 0) return distances;
|
|
62
|
+
|
|
63
|
+
const queue = [[start, 0]];
|
|
64
|
+
const visited = new Set([start]);
|
|
65
|
+
|
|
66
|
+
while (queue.length > 0) {
|
|
67
|
+
const [current, dist] = queue.shift();
|
|
68
|
+
if (dist >= maxDepth) continue;
|
|
69
|
+
const nextNodes = adjacency.get(current);
|
|
70
|
+
if (!nextNodes) continue;
|
|
71
|
+
for (const next of nextNodes) {
|
|
72
|
+
if (visited.has(next)) continue;
|
|
73
|
+
visited.add(next);
|
|
74
|
+
distances.set(next, dist + 1);
|
|
75
|
+
queue.push([next, dist + 1]);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return distances;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Compute intra-file method call closure so symbol-level focus follows helper functions
|
|
84
|
+
* within the same file before/after crossing file boundaries.
|
|
85
|
+
*
|
|
86
|
+
* @param {import('../map/graph.js').MapNode | undefined} node
|
|
87
|
+
* @param {string} symbol
|
|
88
|
+
* @param {'outgoing'|'incoming'} dir
|
|
89
|
+
* @returns {Set<string>} Set of method names in `node` reachable from/to `symbol`
|
|
90
|
+
*/
|
|
91
|
+
function intraFileSymbolClosure(node, symbol, dir) {
|
|
92
|
+
const reached = new Set([symbol, normalizeSymbolName(symbol)].filter(Boolean));
|
|
93
|
+
if (!node || !Array.isArray(node.methods) || node.methods.length === 0) return reached;
|
|
94
|
+
|
|
95
|
+
const methods = node.methods;
|
|
96
|
+
let changed = true;
|
|
97
|
+
while (changed) {
|
|
98
|
+
changed = false;
|
|
99
|
+
for (const method of methods) {
|
|
100
|
+
const mName = method.name;
|
|
101
|
+
const mNorm = normalizeSymbolName(mName);
|
|
102
|
+
const calls = method.calls || [];
|
|
103
|
+
if (dir === 'outgoing') {
|
|
104
|
+
if (reached.has(mName) || reached.has(mNorm)) {
|
|
105
|
+
for (const callee of calls) {
|
|
106
|
+
if (
|
|
107
|
+
methods.some(
|
|
108
|
+
(other) => other.name === callee || normalizeSymbolName(other.name) === callee
|
|
109
|
+
) &&
|
|
110
|
+
!reached.has(callee)
|
|
111
|
+
) {
|
|
112
|
+
reached.add(callee);
|
|
113
|
+
changed = true;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
} else {
|
|
118
|
+
// incoming: if `method` calls any symbol in `reached`, then `method` is an intra-file caller
|
|
119
|
+
if (!reached.has(mName) && calls.some((c) => reached.has(c))) {
|
|
120
|
+
reached.add(mName);
|
|
121
|
+
if (mNorm) reached.add(mNorm);
|
|
122
|
+
changed = true;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return reached;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Symbol-level directional BFS across `graph.callEdges`.
|
|
132
|
+
*
|
|
133
|
+
* @param {import('../map/graph.js').RepoGraph} graph
|
|
134
|
+
* @param {string} startFile
|
|
135
|
+
* @param {string} startSymbol
|
|
136
|
+
* @param {'outgoing'|'incoming'} dir
|
|
137
|
+
* @param {number} maxDepth
|
|
138
|
+
* @returns {{ distances: Map<string, number>, traversedEdges: Array<object> }}
|
|
139
|
+
*/
|
|
140
|
+
function bfsSymbolCallGraph(graph, startFile, startSymbol, dir, maxDepth) {
|
|
141
|
+
const distances = new Map();
|
|
142
|
+
const traversedEdges = [];
|
|
143
|
+
if (maxDepth <= 0) return { distances, traversedEdges };
|
|
144
|
+
|
|
145
|
+
const nodeMap = new Map(graph.nodes.map((n) => [n.path, n]));
|
|
146
|
+
const callEdges = graph.callEdges || [];
|
|
147
|
+
const queue = [{ file: startFile, symbol: startSymbol, dist: 0 }];
|
|
148
|
+
const visitedStates = new Set([`${startFile}:${startSymbol}`]);
|
|
149
|
+
|
|
150
|
+
while (queue.length > 0) {
|
|
151
|
+
const { file, symbol, dist } = queue.shift();
|
|
152
|
+
if (dist >= maxDepth) continue;
|
|
153
|
+
|
|
154
|
+
const activeSymbols = intraFileSymbolClosure(nodeMap.get(file), symbol, dir);
|
|
155
|
+
|
|
156
|
+
for (const edge of callEdges) {
|
|
157
|
+
if (dir === 'outgoing') {
|
|
158
|
+
if (
|
|
159
|
+
edge.from === file &&
|
|
160
|
+
(activeSymbols.has(edge.caller) || activeSymbols.has(normalizeSymbolName(edge.caller)))
|
|
161
|
+
) {
|
|
162
|
+
traversedEdges.push(edge);
|
|
163
|
+
const nextDistance = dist + 1;
|
|
164
|
+
if (edge.to !== startFile) {
|
|
165
|
+
const prev = distances.get(edge.to);
|
|
166
|
+
if (prev === undefined || nextDistance < prev) {
|
|
167
|
+
distances.set(edge.to, nextDistance);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
const stateKey = `${edge.to}:${edge.callee}`;
|
|
171
|
+
if (!visitedStates.has(stateKey)) {
|
|
172
|
+
visitedStates.add(stateKey);
|
|
173
|
+
queue.push({ file: edge.to, symbol: edge.callee, dist: nextDistance });
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
} else {
|
|
177
|
+
if (
|
|
178
|
+
edge.to === file &&
|
|
179
|
+
(activeSymbols.has(edge.callee) || activeSymbols.has(normalizeSymbolName(edge.callee)))
|
|
180
|
+
) {
|
|
181
|
+
traversedEdges.push(edge);
|
|
182
|
+
const nextDistance = dist + 1;
|
|
183
|
+
if (edge.from !== startFile) {
|
|
184
|
+
const prev = distances.get(edge.from);
|
|
185
|
+
if (prev === undefined || nextDistance < prev) {
|
|
186
|
+
distances.set(edge.from, nextDistance);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
const stateKey = `${edge.from}:${edge.caller}`;
|
|
190
|
+
if (!visitedStates.has(stateKey)) {
|
|
191
|
+
visitedStates.add(stateKey);
|
|
192
|
+
queue.push({ file: edge.from, symbol: edge.caller, dist: nextDistance });
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return { distances, traversedEdges };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function resolveDirection(options) {
|
|
203
|
+
if (options.direction !== undefined) {
|
|
204
|
+
if (!DIRECTIONS.includes(options.direction)) {
|
|
205
|
+
throw new FocusError(
|
|
206
|
+
'INVALID_OPTION',
|
|
207
|
+
`Unknown direction "${options.direction}" (use ${DIRECTIONS.join(', ')})`
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
return options.direction;
|
|
211
|
+
}
|
|
212
|
+
if (options.callsOnly) return 'calls';
|
|
213
|
+
if (options.callersOnly) return 'callers';
|
|
214
|
+
const wantImports = options.imports !== false;
|
|
215
|
+
const wantImporters = options.importers !== false;
|
|
216
|
+
if (wantImports && wantImporters) return 'both';
|
|
217
|
+
if (wantImports) return 'imports';
|
|
218
|
+
if (wantImporters) return 'importers';
|
|
219
|
+
throw new FocusError(
|
|
220
|
+
'INVALID_OPTION',
|
|
221
|
+
'At least one of imports or importers must be enabled (use depth: 0 for target only)'
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Resolve and validate target file (and optional `:symbol`) inside project root
|
|
227
|
+
*/
|
|
228
|
+
function resolveTargetFile(root, target, cwd, explicitSymbol) {
|
|
229
|
+
const rootPath = path.resolve(root);
|
|
230
|
+
if (!fs.statSync(rootPath, { throwIfNoEntry: false })?.isDirectory()) {
|
|
231
|
+
throw new FocusError('ROOT_NOT_FOUND', `Not a directory: ${root}`);
|
|
232
|
+
}
|
|
233
|
+
if (typeof target !== 'string' || target.trim() === '') {
|
|
234
|
+
throw new FocusError('INVALID_OPTION', 'Target file is required');
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const realRoot = fs.realpathSync(rootPath);
|
|
238
|
+
const baseDir = cwd ? path.resolve(cwd) : rootPath;
|
|
239
|
+
|
|
240
|
+
let rawTarget = target;
|
|
241
|
+
let parsedSymbol = explicitSymbol ? String(explicitSymbol).trim() : null;
|
|
242
|
+
|
|
243
|
+
const literalCandidate = path.resolve(baseDir, rawTarget);
|
|
244
|
+
if (!fs.existsSync(literalCandidate)) {
|
|
245
|
+
const split = splitTargetSymbol(rawTarget);
|
|
246
|
+
if (split.symbol) {
|
|
247
|
+
rawTarget = split.pathPart;
|
|
248
|
+
if (!parsedSymbol) parsedSymbol = split.symbol;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const candidate = path.resolve(baseDir, rawTarget);
|
|
253
|
+
|
|
254
|
+
let relative = relativeInside(rootPath, candidate);
|
|
255
|
+
if (relative === null && realRoot !== rootPath) {
|
|
256
|
+
relative = relativeInside(realRoot, candidate);
|
|
257
|
+
}
|
|
258
|
+
if (relative === null) {
|
|
259
|
+
throw new FocusError('PATH_OUTSIDE_ROOT', `Target file is outside the project root: ${target}`);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const absolutePath = path.join(rootPath, relative);
|
|
263
|
+
const posixPath = toPosix(relative);
|
|
264
|
+
|
|
265
|
+
const scanner = new Scanner(rootPath);
|
|
266
|
+
const policy = scanner.getPolicy();
|
|
267
|
+
|
|
268
|
+
// Check policy denial before touching or revealing file existence details
|
|
269
|
+
if (policy?.active && (policy.isAuditLog(relative) || scanner.isDenied(relative))) {
|
|
270
|
+
enforceExport(policy, {
|
|
271
|
+
files: [posixPath],
|
|
272
|
+
tokens: 0,
|
|
273
|
+
redact: true,
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
let stat;
|
|
278
|
+
try {
|
|
279
|
+
stat = fs.statSync(absolutePath, { throwIfNoEntry: false });
|
|
280
|
+
if (stat) {
|
|
281
|
+
const realTarget = fs.realpathSync(absolutePath);
|
|
282
|
+
if (relativeInside(realRoot, realTarget) === null) {
|
|
283
|
+
throw new FocusError(
|
|
284
|
+
'PATH_OUTSIDE_ROOT',
|
|
285
|
+
`Target file resolves outside the project root: ${target}`
|
|
286
|
+
);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
} catch (error) {
|
|
290
|
+
if (error instanceof FocusError) throw error;
|
|
291
|
+
stat = null;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
if (!stat || !stat.isFile()) {
|
|
295
|
+
throw new FocusError('FILE_NOT_FOUND', `Target file not found: ${posixPath}`);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
return {
|
|
299
|
+
rootPath,
|
|
300
|
+
targetRel: posixPath,
|
|
301
|
+
targetOSRel: relative,
|
|
302
|
+
symbol: parsedSymbol || null,
|
|
303
|
+
scanner,
|
|
304
|
+
policy,
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Resolve and validate target file (and optional `:symbol`) across multiple workspace repositories.
|
|
310
|
+
* Supports `<alias>/<path>[:symbol]`, absolute paths inside any workspace repo, or relative paths.
|
|
311
|
+
*/
|
|
312
|
+
function resolveWorkspaceTargetFile(resolvedRepos, primaryRoot, target, cwd, explicitSymbol) {
|
|
313
|
+
if (typeof target !== 'string' || target.trim() === '') {
|
|
314
|
+
throw new FocusError('INVALID_OPTION', 'Target file is required');
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const baseDir = cwd ? path.resolve(cwd) : resolvedRepos[0].root;
|
|
318
|
+
let rawTarget = target.trim();
|
|
319
|
+
let parsedSymbol = explicitSymbol ? String(explicitSymbol).trim() : null;
|
|
320
|
+
|
|
321
|
+
const existsLiterally = (candidateStr) => {
|
|
322
|
+
if (fs.existsSync(path.resolve(baseDir, candidateStr))) return true;
|
|
323
|
+
const posix = toPosix(candidateStr).replace(/^\/+/, '');
|
|
324
|
+
for (const repo of resolvedRepos) {
|
|
325
|
+
if (posix.startsWith(`${repo.alias}/`)) {
|
|
326
|
+
if (fs.existsSync(path.join(repo.root, posix.slice(repo.alias.length + 1)))) return true;
|
|
327
|
+
}
|
|
328
|
+
if (fs.existsSync(path.resolve(repo.root, candidateStr))) return true;
|
|
329
|
+
}
|
|
330
|
+
return false;
|
|
331
|
+
};
|
|
332
|
+
|
|
333
|
+
if (!existsLiterally(rawTarget)) {
|
|
334
|
+
const split = splitTargetSymbol(rawTarget);
|
|
335
|
+
if (split.symbol) {
|
|
336
|
+
rawTarget = split.pathPart;
|
|
337
|
+
if (!parsedSymbol) parsedSymbol = split.symbol;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
const findContainingRepo = (absCandidate) => {
|
|
342
|
+
let best = null;
|
|
343
|
+
for (const repo of resolvedRepos) {
|
|
344
|
+
let rel = relativeInside(repo.root, absCandidate);
|
|
345
|
+
if (rel === null && repo.realRoot !== repo.root) {
|
|
346
|
+
rel = relativeInside(repo.realRoot, absCandidate);
|
|
347
|
+
}
|
|
348
|
+
if (rel !== null) {
|
|
349
|
+
if (!best || repo.root.length > best.repo.root.length) {
|
|
350
|
+
best = { repo, relative: rel };
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
return best;
|
|
355
|
+
};
|
|
356
|
+
|
|
357
|
+
let matchedRepo = null;
|
|
358
|
+
let relative = null;
|
|
359
|
+
|
|
360
|
+
const cleanPosix = toPosix(rawTarget).replace(/^\.\//, '');
|
|
361
|
+
|
|
362
|
+
// 1. Check `<alias>/<path>` prefix
|
|
363
|
+
for (const repo of resolvedRepos) {
|
|
364
|
+
if (cleanPosix.startsWith(`${repo.alias}/`)) {
|
|
365
|
+
const sub = cleanPosix.slice(repo.alias.length + 1);
|
|
366
|
+
const absCandidate = path.resolve(repo.root, sub);
|
|
367
|
+
let rel = relativeInside(repo.root, absCandidate);
|
|
368
|
+
if (rel === null && repo.realRoot !== repo.root) {
|
|
369
|
+
rel = relativeInside(repo.realRoot, absCandidate);
|
|
370
|
+
}
|
|
371
|
+
if (rel === null) {
|
|
372
|
+
throw new FocusError(
|
|
373
|
+
'PATH_OUTSIDE_ROOT',
|
|
374
|
+
`Target file is outside repository ${repo.alias}: ${target}`
|
|
375
|
+
);
|
|
376
|
+
}
|
|
377
|
+
matchedRepo = repo;
|
|
378
|
+
relative = rel;
|
|
379
|
+
break;
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// 2. Check absolute path or `..` traversal relative to baseDir
|
|
384
|
+
if (!matchedRepo && (path.isAbsolute(rawTarget) || cleanPosix.startsWith('..'))) {
|
|
385
|
+
const absCandidate = path.resolve(baseDir, rawTarget);
|
|
386
|
+
const found = findContainingRepo(absCandidate);
|
|
387
|
+
if (!found) {
|
|
388
|
+
throw new FocusError(
|
|
389
|
+
'PATH_OUTSIDE_ROOT',
|
|
390
|
+
`Target file is outside the workspace roots: ${target}`
|
|
391
|
+
);
|
|
392
|
+
}
|
|
393
|
+
matchedRepo = found.repo;
|
|
394
|
+
relative = found.relative;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// 3. Check relative path across workspace repos (existing or policy-denied file first)
|
|
398
|
+
if (!matchedRepo) {
|
|
399
|
+
for (const repo of resolvedRepos) {
|
|
400
|
+
const absCandidate = path.resolve(repo.root, rawTarget);
|
|
401
|
+
const rel = relativeInside(repo.root, absCandidate);
|
|
402
|
+
if (rel === null) continue;
|
|
403
|
+
const scanner = new Scanner(repo.root);
|
|
404
|
+
const policy = scanner.getPolicy();
|
|
405
|
+
const isDenied = policy?.active && (policy.isAuditLog(rel) || scanner.isDenied(rel));
|
|
406
|
+
if (isDenied || fs.existsSync(path.join(repo.root, rel))) {
|
|
407
|
+
matchedRepo = repo;
|
|
408
|
+
relative = rel;
|
|
409
|
+
break;
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// 4. Fallback to primary repository so standard missing/outside checks apply
|
|
415
|
+
if (!matchedRepo) {
|
|
416
|
+
const primary = resolvedRepos[0];
|
|
417
|
+
const absCandidate = path.resolve(primary.root, rawTarget);
|
|
418
|
+
const rel = relativeInside(primary.root, absCandidate);
|
|
419
|
+
if (rel === null) {
|
|
420
|
+
throw new FocusError(
|
|
421
|
+
'PATH_OUTSIDE_ROOT',
|
|
422
|
+
`Target file is outside the workspace roots: ${target}`
|
|
423
|
+
);
|
|
424
|
+
}
|
|
425
|
+
matchedRepo = primary;
|
|
426
|
+
relative = rel;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
const absolutePath = path.join(matchedRepo.root, relative);
|
|
430
|
+
const posixRel = toPosix(relative);
|
|
431
|
+
const workspacePath = `${matchedRepo.alias}/${posixRel}`;
|
|
432
|
+
|
|
433
|
+
const scanner = new Scanner(matchedRepo.root);
|
|
434
|
+
const policy = scanner.getPolicy();
|
|
435
|
+
|
|
436
|
+
if (policy?.active && (policy.isAuditLog(relative) || scanner.isDenied(relative))) {
|
|
437
|
+
enforceExport(policy, {
|
|
438
|
+
files: [posixRel],
|
|
439
|
+
tokens: 0,
|
|
440
|
+
redact: true,
|
|
441
|
+
});
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
let stat;
|
|
445
|
+
try {
|
|
446
|
+
stat = fs.statSync(absolutePath, { throwIfNoEntry: false });
|
|
447
|
+
if (stat) {
|
|
448
|
+
const realTarget = fs.realpathSync(absolutePath);
|
|
449
|
+
if (relativeInside(matchedRepo.realRoot, realTarget) === null) {
|
|
450
|
+
throw new FocusError(
|
|
451
|
+
'PATH_OUTSIDE_ROOT',
|
|
452
|
+
`Target file resolves outside repository ${matchedRepo.alias}: ${target}`
|
|
453
|
+
);
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
} catch (error) {
|
|
457
|
+
if (error instanceof FocusError) throw error;
|
|
458
|
+
stat = null;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
if (!stat || !stat.isFile()) {
|
|
462
|
+
throw new FocusError('FILE_NOT_FOUND', `Target file not found: ${workspacePath}`);
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
return {
|
|
466
|
+
targetRepo: matchedRepo,
|
|
467
|
+
targetRel: workspacePath,
|
|
468
|
+
repoRelPath: posixRel,
|
|
469
|
+
symbol: parsedSymbol || null,
|
|
470
|
+
};
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/**
|
|
474
|
+
* Pack a target file (or `file:symbol`) and its import/importer and cross-file symbol call
|
|
475
|
+
* neighborhood into a single LLM-ready document.
|
|
476
|
+
*
|
|
477
|
+
* @param {string} root - Project root directory
|
|
478
|
+
* @param {string} target - Project-relative (or `<alias>/<path>` in multi-repo mode) target file or `file:symbol`
|
|
479
|
+
* @param {object} [options]
|
|
480
|
+
* @param {Array<string|{path: string, alias?: string}>} [options.repos] - Additional repository roots for multi-repo workspace focus
|
|
481
|
+
* @param {string} [options.symbol] - Optional function/method symbol in `target` to focus call graph on
|
|
482
|
+
* @param {number} [options.depth=1] - Graph traversal hops (0 = target file only)
|
|
483
|
+
* @param {'both'|'imports'|'importers'|'calls'|'callers'} [options.direction='both']
|
|
484
|
+
* @param {boolean} [options.imports=true] - Include outgoing imports
|
|
485
|
+
* @param {boolean} [options.importers=true] - Include incoming importers
|
|
486
|
+
* @param {boolean} [options.callGraph=true] - Include cross-file AST symbol call graph edges
|
|
487
|
+
* @param {boolean} [options.includeTests=true] - Include test files in neighborhood
|
|
488
|
+
* @param {number} [options.history=0] - Git commits mined for tie-breaking PageRank
|
|
489
|
+
* @param {string} [options.cwd] - Base directory for resolving relative `target`
|
|
490
|
+
* @param {'xml'|'markdown'|'plain'|'json'} [options.style='xml']
|
|
491
|
+
* @param {number} [options.budget] - Token limit; target first, then closest neighbors
|
|
492
|
+
* @param {boolean} [options.compress=false] - Elide function bodies
|
|
493
|
+
* @param {boolean} [options.lineNumbers=false] - Prefix lines with original line numbers
|
|
494
|
+
* @param {boolean} [options.tree=true] - Include directory tree at top
|
|
495
|
+
* @param {string} [options.header] - Header text at top
|
|
496
|
+
* @param {string} [options.instructions] - Instructions appended at end
|
|
497
|
+
* @param {boolean} [options.redact=true] - Redact secrets
|
|
498
|
+
* @param {string} [options.targetModel] - Tokenizer / context-fit model id
|
|
499
|
+
* @param {string[]} [options.excludeFiles] - Absolute file paths to omit (e.g. output file)
|
|
500
|
+
* @returns {Promise<object>} PackResult augmented with `focus` metadata
|
|
501
|
+
*/
|
|
502
|
+
export async function focusContext(root, target, options = {}) {
|
|
503
|
+
const depth = options.depth ?? DEFAULT_FOCUS_DEPTH;
|
|
504
|
+
if (!Number.isInteger(depth) || depth < 0) {
|
|
505
|
+
throw new FocusError('INVALID_OPTION', `depth must be a non-negative integer, got ${depth}`);
|
|
506
|
+
}
|
|
507
|
+
const direction = depth === 0 ? 'both' : resolveDirection(options);
|
|
508
|
+
const includeTests = options.includeTests !== false;
|
|
509
|
+
const useCallGraph = options.callGraph !== false;
|
|
510
|
+
|
|
511
|
+
const extraRepos = Array.isArray(options.repos) ? options.repos.filter(Boolean) : [];
|
|
512
|
+
const isMultiRepo = extraRepos.length > 0;
|
|
513
|
+
|
|
514
|
+
let resolvedRepos = null;
|
|
515
|
+
let rootPath = null;
|
|
516
|
+
let targetRel = null;
|
|
517
|
+
let symbol = null;
|
|
518
|
+
let policy = null;
|
|
519
|
+
let graph = null;
|
|
520
|
+
|
|
521
|
+
if (isMultiRepo) {
|
|
522
|
+
resolvedRepos = resolveWorkspaceRepos([root, ...extraRepos]);
|
|
523
|
+
enforceWorkspacePolicies(resolvedRepos, {
|
|
524
|
+
filesByRepo: new Map(),
|
|
525
|
+
tokens: 0,
|
|
526
|
+
redact: options.redact !== false,
|
|
527
|
+
targetModel: options.targetModel ?? null,
|
|
528
|
+
});
|
|
529
|
+
const resolvedTarget = resolveWorkspaceTargetFile(
|
|
530
|
+
resolvedRepos,
|
|
531
|
+
root,
|
|
532
|
+
target,
|
|
533
|
+
options.cwd,
|
|
534
|
+
options.symbol
|
|
535
|
+
);
|
|
536
|
+
targetRel = resolvedTarget.targetRel;
|
|
537
|
+
symbol = resolvedTarget.symbol;
|
|
538
|
+
graph = buildWorkspaceGraph(resolvedRepos, {
|
|
539
|
+
history: options.history ?? 0,
|
|
540
|
+
redact: options.redact !== false,
|
|
541
|
+
});
|
|
542
|
+
} else {
|
|
543
|
+
const resolvedTarget = resolveTargetFile(root, target, options.cwd, options.symbol);
|
|
544
|
+
rootPath = resolvedTarget.rootPath;
|
|
545
|
+
targetRel = resolvedTarget.targetRel;
|
|
546
|
+
symbol = resolvedTarget.symbol;
|
|
547
|
+
policy = resolvedTarget.policy;
|
|
548
|
+
graph = buildRepoGraph(rootPath, {
|
|
549
|
+
history: options.history ?? 0,
|
|
550
|
+
redact: options.redact !== false,
|
|
551
|
+
});
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
const nodeMap = new Map(graph.nodes.map((node) => [node.path, node]));
|
|
555
|
+
const inGraph = nodeMap.has(targetRel);
|
|
556
|
+
|
|
557
|
+
if (symbol) {
|
|
558
|
+
const targetNode = nodeMap.get(targetRel);
|
|
559
|
+
const hasSymbol = targetNode?.methods?.some(
|
|
560
|
+
(m) => m.name === symbol || normalizeSymbolName(m.name) === symbol
|
|
561
|
+
);
|
|
562
|
+
if (!hasSymbol) {
|
|
563
|
+
throw new FocusError('SYMBOL_NOT_FOUND', `Symbol "${symbol}" not found in ${targetRel}`);
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
const outgoing = new Map();
|
|
568
|
+
const incoming = new Map();
|
|
569
|
+
for (const edge of graph.edges) {
|
|
570
|
+
if (!outgoing.has(edge.from)) outgoing.set(edge.from, new Set());
|
|
571
|
+
outgoing.get(edge.from).add(edge.to);
|
|
572
|
+
if (!incoming.has(edge.to)) incoming.set(edge.to, new Set());
|
|
573
|
+
incoming.get(edge.to).add(edge.from);
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
const callOutgoing = new Map();
|
|
577
|
+
const callIncoming = new Map();
|
|
578
|
+
for (const edge of graph.callEdges || []) {
|
|
579
|
+
if (!callOutgoing.has(edge.from)) callOutgoing.set(edge.from, new Set());
|
|
580
|
+
callOutgoing.get(edge.from).add(edge.to);
|
|
581
|
+
if (!callIncoming.has(edge.to)) callIncoming.set(edge.to, new Set());
|
|
582
|
+
callIncoming.get(edge.to).add(edge.from);
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
let importOutDistances = new Map();
|
|
586
|
+
let importInDistances = new Map();
|
|
587
|
+
let callOutDistances = new Map();
|
|
588
|
+
let callInDistances = new Map();
|
|
589
|
+
let symbolTraversedEdges = null;
|
|
590
|
+
|
|
591
|
+
if (symbol) {
|
|
592
|
+
// Symbol-level focus traverses the cross-file AST symbol call graph from `targetRel:symbol`
|
|
593
|
+
const wantOut = direction === 'both' || direction === 'imports' || direction === 'calls';
|
|
594
|
+
const wantIn = direction === 'both' || direction === 'importers' || direction === 'callers';
|
|
595
|
+
const outRes = wantOut
|
|
596
|
+
? bfsSymbolCallGraph(graph, targetRel, symbol, 'outgoing', depth)
|
|
597
|
+
: { distances: new Map(), traversedEdges: [] };
|
|
598
|
+
const inRes = wantIn
|
|
599
|
+
? bfsSymbolCallGraph(graph, targetRel, symbol, 'incoming', depth)
|
|
600
|
+
: { distances: new Map(), traversedEdges: [] };
|
|
601
|
+
callOutDistances = outRes.distances;
|
|
602
|
+
callInDistances = inRes.distances;
|
|
603
|
+
symbolTraversedEdges = [...outRes.traversedEdges, ...inRes.traversedEdges];
|
|
604
|
+
} else if (direction === 'calls') {
|
|
605
|
+
callOutDistances = bfsDirected(targetRel, callOutgoing, depth);
|
|
606
|
+
} else if (direction === 'callers') {
|
|
607
|
+
callInDistances = bfsDirected(targetRel, callIncoming, depth);
|
|
608
|
+
} else {
|
|
609
|
+
if (direction === 'both' || direction === 'imports') {
|
|
610
|
+
importOutDistances = bfsDirected(targetRel, outgoing, depth);
|
|
611
|
+
if (useCallGraph) {
|
|
612
|
+
callOutDistances = bfsDirected(targetRel, callOutgoing, depth);
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
if (direction === 'both' || direction === 'importers') {
|
|
616
|
+
importInDistances = bfsDirected(targetRel, incoming, depth);
|
|
617
|
+
if (useCallGraph) {
|
|
618
|
+
callInDistances = bfsDirected(targetRel, callIncoming, depth);
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
const rankGraph = isMultiRepo ? { ...graph, root: resolvedRepos[0].root } : graph;
|
|
624
|
+
const ranked = rankFiles(rankGraph, {
|
|
625
|
+
focus: [targetRel],
|
|
626
|
+
callGraph: useCallGraph || Boolean(symbol),
|
|
627
|
+
});
|
|
628
|
+
const scoreMap = new Map(ranked.map((row) => [row.path, row.score]));
|
|
629
|
+
|
|
630
|
+
const allNeighborPaths = new Set([
|
|
631
|
+
...importOutDistances.keys(),
|
|
632
|
+
...importInDistances.keys(),
|
|
633
|
+
...callOutDistances.keys(),
|
|
634
|
+
...callInDistances.keys(),
|
|
635
|
+
]);
|
|
636
|
+
|
|
637
|
+
const minDefined = (a, b) => (a !== undefined && b !== undefined ? Math.min(a, b) : (a ?? b));
|
|
638
|
+
|
|
639
|
+
const neighbors = [];
|
|
640
|
+
for (const neighborPath of allNeighborPaths) {
|
|
641
|
+
if (neighborPath === targetRel) continue;
|
|
642
|
+
if (!includeTests && isTestPath(neighborPath)) continue;
|
|
643
|
+
|
|
644
|
+
const dImpOut = importOutDistances.get(neighborPath);
|
|
645
|
+
const dImpIn = importInDistances.get(neighborPath);
|
|
646
|
+
const dCallOut = callOutDistances.get(neighborPath);
|
|
647
|
+
const dCallIn = callInDistances.get(neighborPath);
|
|
648
|
+
|
|
649
|
+
const dOut = minDefined(dImpOut, dCallOut);
|
|
650
|
+
const dIn = minDefined(dImpIn, dCallIn);
|
|
651
|
+
const distance = minDefined(dOut, dIn);
|
|
652
|
+
|
|
653
|
+
let relation;
|
|
654
|
+
if (
|
|
655
|
+
symbol ||
|
|
656
|
+
direction === 'calls' ||
|
|
657
|
+
direction === 'callers' ||
|
|
658
|
+
(dImpOut === undefined && dImpIn === undefined)
|
|
659
|
+
) {
|
|
660
|
+
relation =
|
|
661
|
+
dOut !== undefined && dIn !== undefined ? 'both' : dOut !== undefined ? 'call' : 'caller';
|
|
662
|
+
} else {
|
|
663
|
+
relation =
|
|
664
|
+
dImpOut !== undefined && dImpIn !== undefined
|
|
665
|
+
? 'both'
|
|
666
|
+
: dImpOut !== undefined
|
|
667
|
+
? 'import'
|
|
668
|
+
: 'importer';
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
const neighborNode = nodeMap.get(neighborPath);
|
|
672
|
+
neighbors.push({
|
|
673
|
+
path: neighborPath,
|
|
674
|
+
...(isMultiRepo && neighborNode?.repo ? { repo: neighborNode.repo } : {}),
|
|
675
|
+
distance,
|
|
676
|
+
relation,
|
|
677
|
+
score: Math.round((scoreMap.get(neighborPath) ?? 0) * 10000) / 10000,
|
|
678
|
+
});
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
// Order neighbors: closest hop first, highest personalized PageRank second, lexical path third
|
|
682
|
+
neighbors.sort(
|
|
683
|
+
(a, b) =>
|
|
684
|
+
a.distance - b.distance ||
|
|
685
|
+
b.score - a.score ||
|
|
686
|
+
(a.path < b.path ? -1 : a.path > b.path ? 1 : 0)
|
|
687
|
+
);
|
|
688
|
+
|
|
689
|
+
const orderedFiles = [targetRel, ...neighbors.map((n) => n.path)];
|
|
690
|
+
|
|
691
|
+
let packResult;
|
|
692
|
+
if (isMultiRepo) {
|
|
693
|
+
packResult = await packWorkspace(resolvedRepos, {
|
|
694
|
+
style: options.style,
|
|
695
|
+
include: options.include,
|
|
696
|
+
exclude: options.exclude,
|
|
697
|
+
files: orderedFiles,
|
|
698
|
+
rankOrder: orderedFiles,
|
|
699
|
+
excludeFiles: options.excludeFiles,
|
|
700
|
+
maxFileSize: options.maxFileSize,
|
|
701
|
+
tree: options.tree,
|
|
702
|
+
header: options.header,
|
|
703
|
+
instructions: options.instructions,
|
|
704
|
+
lineNumbers: options.lineNumbers,
|
|
705
|
+
compress: options.compress,
|
|
706
|
+
redact: options.redact,
|
|
707
|
+
budget: options.budget,
|
|
708
|
+
split: options.split,
|
|
709
|
+
targetModel: options.targetModel,
|
|
710
|
+
});
|
|
711
|
+
} else {
|
|
712
|
+
packResult = await packContext(rootPath, {
|
|
713
|
+
style: options.style,
|
|
714
|
+
include: options.include,
|
|
715
|
+
exclude: options.exclude,
|
|
716
|
+
files: orderedFiles,
|
|
717
|
+
rankOrder: orderedFiles,
|
|
718
|
+
cwd: rootPath,
|
|
719
|
+
excludeFiles: options.excludeFiles,
|
|
720
|
+
maxFileSize: options.maxFileSize,
|
|
721
|
+
tree: options.tree,
|
|
722
|
+
header: options.header,
|
|
723
|
+
instructions: options.instructions,
|
|
724
|
+
lineNumbers: options.lineNumbers,
|
|
725
|
+
compress: options.compress,
|
|
726
|
+
redact: options.redact,
|
|
727
|
+
budget: options.budget,
|
|
728
|
+
split: options.split,
|
|
729
|
+
targetModel: options.targetModel,
|
|
730
|
+
});
|
|
731
|
+
|
|
732
|
+
if (policy?.active) {
|
|
733
|
+
enforceExport(policy, {
|
|
734
|
+
files: packResult.files,
|
|
735
|
+
tokens: packResult.totalTokens,
|
|
736
|
+
redact: options.redact !== false,
|
|
737
|
+
targetModel: options.targetModel ?? null,
|
|
738
|
+
});
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
const packedFileSet = new Set(packResult.files.map((f) => f.path));
|
|
743
|
+
const candidateCallEdges = symbolTraversedEdges ?? (graph.callEdges || []);
|
|
744
|
+
const relevantCallEdges = [];
|
|
745
|
+
const seenCallEdgeKeys = new Set();
|
|
746
|
+
for (const edge of candidateCallEdges) {
|
|
747
|
+
if (!packedFileSet.has(edge.from) || !packedFileSet.has(edge.to)) continue;
|
|
748
|
+
const key = `${edge.from}:${edge.caller}:${edge.callerLine}->${edge.to}:${edge.callee}:${edge.calleeLine}`;
|
|
749
|
+
if (seenCallEdgeKeys.has(key)) continue;
|
|
750
|
+
seenCallEdgeKeys.add(key);
|
|
751
|
+
relevantCallEdges.push(edge);
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
return {
|
|
755
|
+
...packResult,
|
|
756
|
+
focus: {
|
|
757
|
+
target: targetRel,
|
|
758
|
+
symbol: symbol || null,
|
|
759
|
+
depth,
|
|
760
|
+
direction,
|
|
761
|
+
inGraph,
|
|
762
|
+
...(isMultiRepo ? { repos: packResult.repos } : {}),
|
|
763
|
+
neighbors,
|
|
764
|
+
callEdges: relevantCallEdges,
|
|
765
|
+
},
|
|
766
|
+
};
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
/**
|
|
770
|
+
* One-line summary of a focusContext() result
|
|
771
|
+
* @param {object} result
|
|
772
|
+
* @returns {string}
|
|
773
|
+
*/
|
|
774
|
+
export function describeFocus(result) {
|
|
775
|
+
const { focus } = result;
|
|
776
|
+
const packedNeighbors = Math.max(0, result.files.length - 1);
|
|
777
|
+
const targetLabel = focus.symbol ? `${focus.target}:${focus.symbol}` : focus.target;
|
|
778
|
+
const parts = [
|
|
779
|
+
`target ${targetLabel} + ${packedNeighbors}/${focus.neighbors.length} neighbor(s) (depth ${focus.depth}, ${focus.direction})`,
|
|
780
|
+
`${result.totalTokens} tokens (${result.style})`,
|
|
781
|
+
];
|
|
782
|
+
if (result.omitted.length) parts.push(`${result.omitted.length} file(s) left out by the budget`);
|
|
783
|
+
if (result.compression)
|
|
784
|
+
parts.push(`${result.compression.savedTokens} tokens saved by compression`);
|
|
785
|
+
if (result.redactions.total) parts.push(`${result.redactions.total} secret(s) redacted`);
|
|
786
|
+
return `Focused ${parts.join('; ')}.`;
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
export { TOOL_DEFAULT_BUDGET };
|
|
790
|
+
export default focusContext;
|