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,570 @@
1
+ import path from 'path';
2
+
3
+ /**
4
+ * Language keywords and control-flow tokens that can precede `(` without being
5
+ * function or method call expressions.
6
+ */
7
+ const CALL_STOPWORDS = new Set([
8
+ // JS / TS / C-family control & operators
9
+ 'if',
10
+ 'else',
11
+ 'for',
12
+ 'foreach',
13
+ 'while',
14
+ 'do',
15
+ 'switch',
16
+ 'case',
17
+ 'default',
18
+ 'try',
19
+ 'catch',
20
+ 'finally',
21
+ 'throw',
22
+ 'return',
23
+ 'break',
24
+ 'continue',
25
+ 'new',
26
+ 'delete',
27
+ 'typeof',
28
+ 'instanceof',
29
+ 'void',
30
+ 'in',
31
+ 'of',
32
+ 'await',
33
+ 'async',
34
+ 'yield',
35
+ 'function',
36
+ 'class',
37
+ 'extends',
38
+ 'super',
39
+ 'this',
40
+ 'self',
41
+ 'import',
42
+ 'export',
43
+ 'from',
44
+ 'require',
45
+ 'define',
46
+ // Python / Ruby / Rust / Go / Kotlin / Swift / Java / C#
47
+ 'def',
48
+ 'fn',
49
+ 'func',
50
+ 'fun',
51
+ 'pub',
52
+ 'mod',
53
+ 'use',
54
+ 'impl',
55
+ 'where',
56
+ 'match',
57
+ 'when',
58
+ 'guard',
59
+ 'defer',
60
+ 'go',
61
+ 'select',
62
+ 'chan',
63
+ 'map',
64
+ 'struct',
65
+ 'interface',
66
+ 'enum',
67
+ 'trait',
68
+ 'type',
69
+ 'var',
70
+ 'let',
71
+ 'const',
72
+ 'val',
73
+ 'mut',
74
+ 'ref',
75
+ 'move',
76
+ 'unsafe',
77
+ 'extern',
78
+ 'crate',
79
+ 'synchronized',
80
+ 'lock',
81
+ 'using',
82
+ 'fixed',
83
+ 'checked',
84
+ 'unchecked',
85
+ 'sizeof',
86
+ 'nameof',
87
+ 'typeof',
88
+ 'alignof',
89
+ 'decltype',
90
+ 'static_assert',
91
+ 'elif',
92
+ 'except',
93
+ 'with',
94
+ 'as',
95
+ 'pass',
96
+ 'raise',
97
+ 'del',
98
+ 'lambda',
99
+ 'unless',
100
+ 'until',
101
+ 'rescue',
102
+ 'ensure',
103
+ 'defined',
104
+ // Common built-in constructors / globals that should not match unrelated repo methods
105
+ 'Array',
106
+ 'Object',
107
+ 'String',
108
+ 'Number',
109
+ 'Boolean',
110
+ 'Symbol',
111
+ 'BigInt',
112
+ 'Promise',
113
+ 'Map',
114
+ 'Set',
115
+ 'WeakMap',
116
+ 'WeakSet',
117
+ 'Date',
118
+ 'RegExp',
119
+ 'Error',
120
+ 'TypeError',
121
+ 'RangeError',
122
+ 'SyntaxError',
123
+ 'URL',
124
+ 'Buffer',
125
+ 'Math',
126
+ 'JSON',
127
+ 'console',
128
+ 'parseInt',
129
+ 'parseFloat',
130
+ 'isNaN',
131
+ 'isFinite',
132
+ 'setTimeout',
133
+ 'clearTimeout',
134
+ 'setInterval',
135
+ 'clearInterval',
136
+ ]);
137
+
138
+ /**
139
+ * Maximum number of repository-wide files defining a symbol before an un-imported
140
+ * call is considered too ambiguous to link globally.
141
+ */
142
+ const MAX_GLOBAL_DEFINERS = 5;
143
+
144
+ /**
145
+ * Strip comments and string literal contents while preserving line breaks and
146
+ * template literal `${...}` expressions so call sites inside `${foo()}` are kept
147
+ * and string/comment text does not produce false call matches.
148
+ *
149
+ * @param {string} source
150
+ * @param {string} [ext]
151
+ * @returns {string}
152
+ */
153
+ export function stripCommentsAndStrings(source, ext = '') {
154
+ const hashComment =
155
+ ext === '.py' || ext === '.rb' || ext === '.php' || ext === '.sh' || ext === '.pl';
156
+ let out = '';
157
+ let i = 0;
158
+ const len = source.length;
159
+ let state = 'code'; // 'code' | 'line_comment' | 'block_comment' | 'single' | 'double' | 'template'
160
+ const templateBraceDepth = [];
161
+
162
+ while (i < len) {
163
+ const ch = source[i];
164
+ const next = source[i + 1];
165
+
166
+ if (state === 'code') {
167
+ if (ch === '/' && next === '/') {
168
+ state = 'line_comment';
169
+ out += ' ';
170
+ i += 2;
171
+ continue;
172
+ }
173
+ if (ch === '/' && next === '*') {
174
+ state = 'block_comment';
175
+ out += ' ';
176
+ i += 2;
177
+ continue;
178
+ }
179
+ if (hashComment && ch === '#') {
180
+ state = 'line_comment';
181
+ out += ' ';
182
+ i += 1;
183
+ continue;
184
+ }
185
+ if (ch === "'") {
186
+ state = 'single';
187
+ out += ' ';
188
+ i += 1;
189
+ continue;
190
+ }
191
+ if (ch === '"') {
192
+ state = 'double';
193
+ out += ' ';
194
+ i += 1;
195
+ continue;
196
+ }
197
+ if (ch === '`') {
198
+ state = 'template';
199
+ out += ' ';
200
+ i += 1;
201
+ continue;
202
+ }
203
+ if (ch === '{' && templateBraceDepth.length > 0) {
204
+ templateBraceDepth[templateBraceDepth.length - 1] += 1;
205
+ out += ch;
206
+ i += 1;
207
+ continue;
208
+ }
209
+ if (ch === '}' && templateBraceDepth.length > 0) {
210
+ templateBraceDepth[templateBraceDepth.length - 1] -= 1;
211
+ if (templateBraceDepth[templateBraceDepth.length - 1] === 0) {
212
+ templateBraceDepth.pop();
213
+ state = 'template';
214
+ out += ' ';
215
+ i += 1;
216
+ continue;
217
+ }
218
+ }
219
+ out += ch;
220
+ i += 1;
221
+ continue;
222
+ }
223
+
224
+ if (state === 'line_comment') {
225
+ if (ch === '\n') {
226
+ state = 'code';
227
+ out += '\n';
228
+ } else {
229
+ out += ' ';
230
+ }
231
+ i += 1;
232
+ continue;
233
+ }
234
+
235
+ if (state === 'block_comment') {
236
+ if (ch === '*' && next === '/') {
237
+ state = 'code';
238
+ out += ' ';
239
+ i += 2;
240
+ continue;
241
+ }
242
+ out += ch === '\n' ? '\n' : ' ';
243
+ i += 1;
244
+ continue;
245
+ }
246
+
247
+ if (state === 'single') {
248
+ if (ch === '\\') {
249
+ out += next === '\n' ? ' \n' : ' ';
250
+ i += 2;
251
+ continue;
252
+ }
253
+ if (ch === "'" || ch === '\n') {
254
+ state = 'code';
255
+ out += ch === '\n' ? '\n' : ' ';
256
+ i += 1;
257
+ continue;
258
+ }
259
+ out += ' ';
260
+ i += 1;
261
+ continue;
262
+ }
263
+
264
+ if (state === 'double') {
265
+ if (ch === '\\') {
266
+ out += next === '\n' ? ' \n' : ' ';
267
+ i += 2;
268
+ continue;
269
+ }
270
+ if (ch === '"' || ch === '\n') {
271
+ state = 'code';
272
+ out += ch === '\n' ? '\n' : ' ';
273
+ i += 1;
274
+ continue;
275
+ }
276
+ out += ' ';
277
+ i += 1;
278
+ continue;
279
+ }
280
+
281
+ if (state === 'template') {
282
+ if (ch === '\\') {
283
+ out += next === '\n' ? ' \n' : ' ';
284
+ i += 2;
285
+ continue;
286
+ }
287
+ if (ch === '$' && next === '{') {
288
+ templateBraceDepth.push(1);
289
+ state = 'code';
290
+ out += ' ';
291
+ i += 2;
292
+ continue;
293
+ }
294
+ if (ch === '`') {
295
+ state = 'code';
296
+ out += ' ';
297
+ i += 1;
298
+ continue;
299
+ }
300
+ out += ch === '\n' ? '\n' : ' ';
301
+ i += 1;
302
+ }
303
+ }
304
+
305
+ return out;
306
+ }
307
+
308
+ /**
309
+ * Normalize an extracted method name (e.g. `get user` -> `user`).
310
+ * @param {string} name
311
+ * @returns {string}
312
+ */
313
+ export function normalizeSymbolName(name) {
314
+ if (!name) return '';
315
+ return String(name)
316
+ .replace(/^(?:get|set)\s+/, '')
317
+ .trim();
318
+ }
319
+
320
+ /**
321
+ * Mask the method's own declaration header at the start of its source so that
322
+ * `function foo(a = bar())` does not count `foo(` as a call, while keeping default
323
+ * parameter call expressions like `bar()`.
324
+ *
325
+ * @param {string} strippedSource
326
+ * @param {string} declarationName
327
+ * @returns {string}
328
+ */
329
+ function maskDeclarationHeader(strippedSource, declarationName) {
330
+ if (!declarationName) return strippedSource;
331
+ const escaped = declarationName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
332
+ const declRe = new RegExp(`(?<![\\w$])${escaped}(\\s*(?:<[^>;{}()]*>\\s*)?\\()`);
333
+ return strippedSource.replace(
334
+ declRe,
335
+ (_match, suffix) => ' '.repeat(declarationName.length) + suffix
336
+ );
337
+ }
338
+
339
+ /**
340
+ * Extract unique called symbol names from a code snippet (method body or module top-level).
341
+ *
342
+ * @param {string} source
343
+ * @param {string} [declarationName]
344
+ * @param {string} [ext]
345
+ * @returns {string[]}
346
+ */
347
+ export function extractCallsFromSource(source, declarationName = '', ext = '') {
348
+ const stripped = stripCommentsAndStrings(source, ext);
349
+ const body = maskDeclarationHeader(stripped, normalizeSymbolName(declarationName));
350
+ const calls = new Set();
351
+
352
+ // Match function or method calls: `foo(`, `obj.foo(`, `pkg::foo(`, `foo<T>(`
353
+ // Skip declarations preceded by `function `, `def `, `fn `, `func `, `fun `
354
+ const callRe =
355
+ /(?<!\b(?:function|def|fn|func|fun)\s+)(?<![\w$])([A-Za-z_$][\w$]*)\s*(?:<[^>;{}()=\n]*>\s*)?\(/g;
356
+
357
+ let match;
358
+ while ((match = callRe.exec(body)) !== null) {
359
+ const callee = match[1];
360
+ if (!callee || CALL_STOPWORDS.has(callee)) continue;
361
+ calls.add(callee);
362
+ }
363
+
364
+ return [...calls].sort();
365
+ }
366
+
367
+ /**
368
+ * Extract method-level and top-level module call sites for a single file.
369
+ *
370
+ * @param {string} content - Full file source
371
+ * @param {Array<{name: string, line: number, signature?: string}>} methods - Extracted methods
372
+ * @param {string} filePath - Relative file path
373
+ * @param {import('../analyzers/method-analyzer.js').default} analyzer
374
+ * @returns {{
375
+ * methodCalls: Map<string, string[]>,
376
+ * callers: Array<{ name: string, line: number, calls: string[] }>
377
+ * }}
378
+ */
379
+ export function extractFileCallSites(content, methods, filePath, analyzer) {
380
+ const ext = path.extname(filePath).toLowerCase();
381
+ const sources = analyzer.extractMethodSources(content, methods, filePath);
382
+ const lines = content.split('\n');
383
+ const coveredLines = new Set();
384
+ const methodCalls = new Map();
385
+ const callers = [];
386
+
387
+ for (let i = 0; i < methods.length; i++) {
388
+ const method = methods[i];
389
+ const source = sources[i] || '';
390
+ const lineCount = source ? source.split('\n').length : 1;
391
+ for (let l = method.line; l < method.line + lineCount; l++) {
392
+ coveredLines.add(l);
393
+ }
394
+
395
+ const calls = extractCallsFromSource(source, method.name, ext);
396
+ methodCalls.set(`${method.name}:${method.line}`, calls);
397
+ if (calls.length > 0) {
398
+ callers.push({
399
+ name: method.name,
400
+ line: method.line,
401
+ calls,
402
+ });
403
+ }
404
+ }
405
+
406
+ // Also inspect top-level lines outside any method body so module-entry scripts
407
+ // (e.g. `cli.js` calling `handle()`) produce cross-file call edges.
408
+ const topLevelLines = lines.map((line, idx) => (coveredLines.has(idx + 1) ? '' : line));
409
+ const topLevelSource = topLevelLines.join('\n');
410
+ const topCalls = extractCallsFromSource(topLevelSource, '', ext);
411
+ if (topCalls.length > 0) {
412
+ const strippedTop = stripCommentsAndStrings(topLevelSource, ext);
413
+ const strippedLines = strippedTop.split('\n');
414
+ for (const callee of topCalls) {
415
+ const escaped = callee.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
416
+ const lineRe = new RegExp(`(?<![\\w$])${escaped}\\s*(?:<[^>;{}()=\\n]*>\\s*)?\\(`);
417
+ let firstLine = 1;
418
+ for (let idx = 0; idx < strippedLines.length; idx++) {
419
+ if (lineRe.test(strippedLines[idx])) {
420
+ firstLine = idx + 1;
421
+ break;
422
+ }
423
+ }
424
+ callers.push({
425
+ name: '<module>',
426
+ line: firstLine,
427
+ calls: [callee],
428
+ });
429
+ }
430
+ }
431
+
432
+ return { methodCalls, callers };
433
+ }
434
+
435
+ /**
436
+ * Build cross-file AST symbol call edges from per-file call sites, method tables,
437
+ * and resolved module import edges.
438
+ *
439
+ * Resolution order for a call to `callee` in file `from`:
440
+ * 1. Direct import targets (`from -> to`) defining `callee`.
441
+ * 2. Two-hop barrel/re-export import targets (`from -> index -> to`) defining `callee`.
442
+ * 3. Repository-wide symbol table fallback when `callee` is defined in <= MAX_GLOBAL_DEFINERS
443
+ * other files (enabling dynamic dispatch, same-package calls, or un-imported helpers).
444
+ *
445
+ * @param {Array<{ path: string, methods: Array<{ name: string, line: number, signature?: string, calls?: string[] }> }>} nodes
446
+ * @param {Array<{ from: string, to: string, weight: number }>} importEdges
447
+ * @param {Map<string, Array<{ name: string, line: number, calls: string[] }>>} callersByFile
448
+ * @returns {Array<{ from: string, caller: string, callerLine: number, to: string, callee: string, calleeLine: number, weight: number }>}
449
+ */
450
+ export function buildCallGraph(nodes, importEdges, callersByFile) {
451
+ // 1. Index all method definitions by symbol name
452
+ const symbolDefs = new Map();
453
+ const addDef = (symbol, def) => {
454
+ if (!symbol) return;
455
+ let list = symbolDefs.get(symbol);
456
+ if (!list) {
457
+ list = [];
458
+ symbolDefs.set(symbol, list);
459
+ }
460
+ if (!list.some((d) => d.path === def.path && d.line === def.line && d.name === def.name)) {
461
+ list.push(def);
462
+ }
463
+ };
464
+
465
+ for (const node of nodes) {
466
+ for (const method of node.methods || []) {
467
+ const def = { path: node.path, name: method.name, line: method.line };
468
+ addDef(method.name, def);
469
+ const normalized = normalizeSymbolName(method.name);
470
+ if (normalized && normalized !== method.name) {
471
+ addDef(normalized, def);
472
+ }
473
+ }
474
+ }
475
+
476
+ // 2. Build 1-hop and 2-hop (barrel re-export) import reachability maps
477
+ const directImports = new Map();
478
+ for (const edge of importEdges || []) {
479
+ if (edge.from === edge.to) continue;
480
+ let targets = directImports.get(edge.from);
481
+ if (!targets) {
482
+ targets = new Map();
483
+ directImports.set(edge.from, targets);
484
+ }
485
+ targets.set(edge.to, (targets.get(edge.to) || 0) + (edge.weight || 1));
486
+ }
487
+
488
+ const barrelImports = new Map();
489
+ for (const [from, firstHop] of directImports) {
490
+ const secondHop = new Map();
491
+ for (const [mid, w1] of firstHop) {
492
+ const nextTargets = directImports.get(mid);
493
+ if (!nextTargets) continue;
494
+ for (const [to, w2] of nextTargets) {
495
+ if (to === from || firstHop.has(to)) continue;
496
+ secondHop.set(to, Math.max(secondHop.get(to) || 0, Math.min(w1, w2)));
497
+ }
498
+ }
499
+ if (secondHop.size > 0) {
500
+ barrelImports.set(from, secondHop);
501
+ }
502
+ }
503
+
504
+ // 3. Resolve cross-file call edges
505
+ const edgeMap = new Map();
506
+
507
+ for (const node of nodes) {
508
+ const from = node.path;
509
+ const callers = callersByFile.get(from) || [];
510
+ const direct = directImports.get(from);
511
+ const barrel = barrelImports.get(from);
512
+
513
+ for (const caller of callers) {
514
+ for (const calledName of caller.calls || []) {
515
+ const defs = symbolDefs.get(calledName);
516
+ if (!defs || defs.length === 0) continue;
517
+
518
+ const externalDefs = defs.filter((d) => d.path !== from);
519
+ if (externalDefs.length === 0) continue;
520
+
521
+ let matched = [];
522
+ if (direct && direct.size > 0) {
523
+ matched = externalDefs.filter((d) => direct.has(d.path));
524
+ }
525
+ if (matched.length === 0 && barrel && barrel.size > 0) {
526
+ matched = externalDefs.filter((d) => barrel.has(d.path));
527
+ }
528
+ if (matched.length === 0 && externalDefs.length <= MAX_GLOBAL_DEFINERS) {
529
+ // If the file already defines this symbol locally and did not import another file
530
+ // defining it, prefer the local definition rather than linking globally.
531
+ const definedLocally = defs.some((d) => d.path === from);
532
+ if (!definedLocally) {
533
+ matched = externalDefs;
534
+ }
535
+ }
536
+
537
+ if (matched.length === 0) continue;
538
+
539
+ const weight = 1 / matched.length;
540
+ for (const target of matched) {
541
+ const key = `${from}\0${caller.name}\0${caller.line}\0${target.path}\0${target.name}\0${target.line}`;
542
+ const existing = edgeMap.get(key);
543
+ if (existing) {
544
+ existing.weight += weight;
545
+ } else {
546
+ edgeMap.set(key, {
547
+ from,
548
+ caller: caller.name,
549
+ callerLine: caller.line,
550
+ to: target.path,
551
+ callee: target.name,
552
+ calleeLine: target.line,
553
+ weight,
554
+ });
555
+ }
556
+ }
557
+ }
558
+ }
559
+ }
560
+
561
+ return [...edgeMap.values()].sort(
562
+ (a, b) =>
563
+ a.from.localeCompare(b.from) ||
564
+ a.caller.localeCompare(b.caller) ||
565
+ a.to.localeCompare(b.to) ||
566
+ a.callee.localeCompare(b.callee) ||
567
+ a.callerLine - b.callerLine ||
568
+ a.calleeLine - b.calleeLine
569
+ );
570
+ }
package/lib/map/graph.js CHANGED
@@ -2,9 +2,9 @@
2
2
  * Repository graph for `ctxman map`
3
3
  *
4
4
  * One node per source file the scanner returns (so .gitignore, .contextignore and
5
- * .contextinclude apply), one edge per resolved import, plus what ranking needs: the
6
- * functions of each file, how often other files mention them, and how often git
7
- * changed each file recently.
5
+ * .contextinclude apply), one edge per resolved import, cross-file AST symbol call
6
+ * edges (`callEdges`), plus what ranking needs: the functions of each file, how often
7
+ * other files mention them, and how often git changed each file recently.
8
8
  */
9
9
 
10
10
  import fs from 'fs';
@@ -15,6 +15,7 @@ import MethodAnalyzer from '../analyzers/method-analyzer.js';
15
15
  import { redactSecrets } from '../utils/secret-redactor.js';
16
16
  import { languageOf, extractImports, extractDeclarations } from './imports.js';
17
17
  import { ImportResolver } from './resolver.js';
18
+ import { extractFileCallSites, buildCallGraph } from './call-graph.js';
18
19
 
19
20
  /** Commits of git history mined by default */
20
21
  export const DEFAULT_HISTORY = 300;
@@ -31,6 +32,18 @@ const IDENTIFIER = /[A-Za-z_$][\w$]*/g;
31
32
  * @property {string} name - Function or method name
32
33
  * @property {number} line - 1-based line of the declaration
33
34
  * @property {string} signature - Declaration text on one line (secrets redacted)
35
+ * @property {string[]} [calls] - Symbol names called within this method's body
36
+ */
37
+
38
+ /**
39
+ * @typedef {Object} MapCallEdge
40
+ * @property {string} from - Caller file path (project-relative)
41
+ * @property {string} caller - Caller function/method name (or `<module>` for top-level calls)
42
+ * @property {number} callerLine - 1-based line of the caller
43
+ * @property {string} to - Callee file path (project-relative)
44
+ * @property {string} callee - Callee function/method name
45
+ * @property {number} calleeLine - 1-based line of the callee declaration
46
+ * @property {number} weight - Edge confidence weight in (0, 1]
34
47
  */
35
48
 
36
49
  /**
@@ -48,13 +61,14 @@ const IDENTIFIER = /[A-Za-z_$][\w$]*/g;
48
61
  * @property {string} root - Absolute project root
49
62
  * @property {MapNode[]} nodes - Sorted by path
50
63
  * @property {Array<{from: string, to: string, weight: number}>} edges - Importer → imported
64
+ * @property {MapCallEdge[]} callEdges - Cross-file function call edges (caller → callee)
51
65
  * @property {{available: boolean, requested: number, commits: number,
52
66
  * counts: Object<string, number>}} history - Commits touching each file
53
67
  * @property {Object} stats
54
68
  */
55
69
 
56
70
  /**
57
- * Scan a project and build its file dependency graph
71
+ * Scan a project and build its file dependency and symbol call graph
58
72
  * @param {string} root - Project directory
59
73
  * @param {Object} [options]
60
74
  * @param {number} [options.history=300] - Commits to mine for change frequency (0 disables)
@@ -86,6 +100,7 @@ export function buildRepoGraph(root, options = {}) {
86
100
  const skipped = [];
87
101
  const nodes = [];
88
102
  const identifiers = [];
103
+ const callersByFile = new Map();
89
104
  const candidates = scanned
90
105
  .map((file) => ({ file, relative: file.relativePath.split(path.sep).join('/') }))
91
106
  .filter(({ relative }) => languageOf(relative))
@@ -110,11 +125,18 @@ export function buildRepoGraph(root, options = {}) {
110
125
  }
111
126
 
112
127
  const language = languageOf(relative);
128
+ const methods = extractMethods(analyzer, content, lines, file.path, redact ? redactions : null);
129
+ const { methodCalls, callers } = extractFileCallSites(content, methods, file.path, analyzer);
130
+ for (const method of methods) {
131
+ method.calls = methodCalls.get(`${method.name}:${method.line}`) || [];
132
+ }
133
+ callersByFile.set(relative, callers);
134
+
113
135
  nodes.push({
114
136
  path: relative,
115
137
  language,
116
138
  bytes: file.size,
117
- methods: extractMethods(analyzer, content, lines, file.path, redact ? redactions : null),
139
+ methods,
118
140
  symbolRefs: 0,
119
141
  unresolvedImports: 0,
120
142
  imports: extractImports(language, content),
@@ -138,6 +160,7 @@ export function buildRepoGraph(root, options = {}) {
138
160
  delete node.declarations;
139
161
  }
140
162
  countSymbolReferences(nodes, identifiers);
163
+ const callEdges = buildCallGraph(nodes, edges, callersByFile);
141
164
  timings.resolve = performance.now() - started;
142
165
 
143
166
  started = performance.now();
@@ -152,11 +175,13 @@ export function buildRepoGraph(root, options = {}) {
152
175
  root: absoluteRoot,
153
176
  nodes,
154
177
  edges,
178
+ callEdges,
155
179
  history: mined,
156
180
  stats: {
157
181
  scannedFiles: scanned.length,
158
182
  files: nodes.length,
159
183
  edges: edges.length,
184
+ callEdges: callEdges.length,
160
185
  unresolvedImports: nodes.reduce((sum, node) => sum + node.unresolvedImports, 0),
161
186
  skipped,
162
187
  redactions,
package/lib/map/index.js CHANGED
@@ -2,11 +2,11 @@
2
2
  * Repository map (`ctxman map`)
3
3
  *
4
4
  * A ranked overview of a codebase for an LLM, after Aider's repo map: a file dependency
5
- * graph built from imports, PageRank over it blended with git change frequency and
6
- * symbol references, and the function signatures of the best files trimmed to a token
7
- * budget.
5
+ * graph built from imports and cross-file AST symbol call edges, PageRank over it blended
6
+ * with git change frequency and symbol references, and the function signatures of the best
7
+ * files trimmed to a token budget.
8
8
  *
9
- * buildRepoGraph(root, opts) scan + parse + resolve imports (+ git history)
9
+ * buildRepoGraph(root, opts) scan + parse + resolve imports & call graph (+ git history)
10
10
  * rankFiles(graph, opts) scores, best first
11
11
  * renderRepoMap(root, opts) all of the above, rendered within the budget
12
12
  */
@@ -16,6 +16,7 @@ export {
16
16
  rankFiles,
17
17
  pageRank,
18
18
  resolveFocus,
19
+ splitTargetSymbol,
19
20
  toProjectPath,
20
21
  DEFAULT_WEIGHTS,
21
22
  DEFAULT_DAMPING,
@@ -31,3 +32,10 @@ export {
31
32
  } from './render.js';
32
33
  export { extractImports, languageOf, LANGUAGE_BY_EXTENSION } from './imports.js';
33
34
  export { ImportResolver } from './resolver.js';
35
+ export {
36
+ stripCommentsAndStrings,
37
+ normalizeSymbolName,
38
+ extractCallsFromSource,
39
+ extractFileCallSites,
40
+ buildCallGraph,
41
+ } from './call-graph.js';