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.
Files changed (55) hide show
  1. package/.ctxman/llm-profiles.json +6 -6
  2. package/README.md +16 -10
  3. package/bin/cli.js +109 -10
  4. package/bin/cm-ask.js +64 -6
  5. package/bin/cm-audit.js +76 -0
  6. package/bin/cm-count.js +45 -6
  7. package/bin/cm-diagnose.js +199 -0
  8. package/bin/cm-focus.js +322 -0
  9. package/bin/cm-map.js +71 -18
  10. package/bin/cm-pack.js +36 -5
  11. package/bin/cm-policy.js +107 -2
  12. package/bin/cm-pr.js +18 -3
  13. package/bin/cm-search.js +84 -19
  14. package/bin/cm-task.js +192 -0
  15. package/bin/cm-who.js +119 -0
  16. package/bin/cm-why.js +41 -5
  17. package/index.d.ts +940 -1
  18. package/index.js +105 -1
  19. package/lib/analyzers/token-calculator.js +76 -4
  20. package/lib/api/mcp/server.js +843 -29
  21. package/lib/cache/CacheManager.js +161 -47
  22. package/lib/core/Analyzer.js +61 -5
  23. package/lib/count/index.js +74 -14
  24. package/lib/diagnose/index.js +712 -0
  25. package/lib/focus/index.js +790 -0
  26. package/lib/map/call-graph.js +570 -0
  27. package/lib/map/graph.js +30 -5
  28. package/lib/map/index.js +12 -4
  29. package/lib/map/rank.js +45 -7
  30. package/lib/map/render.js +89 -28
  31. package/lib/pack/index.js +20 -1
  32. package/lib/pack/select.js +8 -1
  33. package/lib/policy/audit.js +3 -1
  34. package/lib/policy/index.js +5 -0
  35. package/lib/policy/workspace.js +254 -0
  36. package/lib/pr/pr-context.js +292 -31
  37. package/lib/pr/pr-renderer.js +25 -10
  38. package/lib/rag/EmbeddingProvider.js +139 -2
  39. package/lib/rag/VectorStoreFactory.js +10 -4
  40. package/lib/rag/ask.js +186 -17
  41. package/lib/rag/stores/SqliteVectorStore.js +595 -0
  42. package/lib/search/hybrid.js +324 -0
  43. package/lib/search/symbols.js +75 -11
  44. package/lib/task/index.js +630 -0
  45. package/lib/utils/file-utils.js +1 -0
  46. package/lib/utils/multi-repo-manager.js +4 -1
  47. package/lib/watch/FileWatcher.js +121 -31
  48. package/lib/watch/IncrementalAnalyzer.js +964 -16
  49. package/lib/who/index.js +893 -0
  50. package/lib/why/analyzer-bridge.js +223 -0
  51. package/lib/why/discussions.js +314 -0
  52. package/lib/why/index.js +356 -30
  53. package/lib/why/render.js +154 -9
  54. package/lib/workspace/index.js +1274 -0
  55. package/package.json +1 -1
@@ -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;