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,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,
|
|
6
|
-
*
|
|
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
|
|
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
|
|
6
|
-
* symbol references, and the function signatures of the best
|
|
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';
|