ucn 3.8.13 → 3.8.15
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/.claude/skills/ucn/SKILL.md +3 -1
- package/.github/workflows/ci.yml +13 -1
- package/README.md +1 -0
- package/cli/index.js +165 -246
- package/core/analysis.js +1400 -0
- package/core/build-worker.js +194 -0
- package/core/cache.js +105 -7
- package/core/callers.js +194 -64
- package/core/deadcode.js +22 -66
- package/core/discovery.js +9 -54
- package/core/execute.js +139 -54
- package/core/graph.js +615 -0
- package/core/imports.js +50 -16
- package/core/output/analysis-ext.js +271 -0
- package/core/output/analysis.js +491 -0
- package/core/output/extraction.js +188 -0
- package/core/output/find.js +355 -0
- package/core/output/graph.js +399 -0
- package/core/output/refactoring.js +293 -0
- package/core/output/reporting.js +331 -0
- package/core/output/search.js +307 -0
- package/core/output/shared.js +271 -0
- package/core/output/tracing.js +416 -0
- package/core/output.js +15 -3293
- package/core/parallel-build.js +165 -0
- package/core/project.js +299 -3633
- package/core/registry.js +59 -0
- package/core/reporting.js +258 -0
- package/core/search.js +890 -0
- package/core/stacktrace.js +1 -1
- package/core/tracing.js +631 -0
- package/core/verify.js +10 -13
- package/eslint.config.js +43 -0
- package/jsconfig.json +10 -0
- package/languages/go.js +21 -2
- package/languages/html.js +8 -0
- package/languages/index.js +102 -40
- package/languages/java.js +13 -0
- package/languages/javascript.js +17 -1
- package/languages/python.js +14 -0
- package/languages/rust.js +13 -0
- package/languages/utils.js +1 -1
- package/mcp/server.js +45 -28
- package/package.json +8 -3
package/core/analysis.js
ADDED
|
@@ -0,0 +1,1400 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* core/analysis.js — Analysis commands (context, smart, related, impact, about, diffImpact, detectCompleteness)
|
|
3
|
+
*
|
|
4
|
+
* Extracted from project.js. All functions take an `index` (ProjectIndex)
|
|
5
|
+
* as the first argument instead of using `this`.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
'use strict';
|
|
9
|
+
|
|
10
|
+
const fs = require('fs');
|
|
11
|
+
const path = require('path');
|
|
12
|
+
const { execFileSync } = require('child_process');
|
|
13
|
+
const { parse } = require('./parser');
|
|
14
|
+
const { detectLanguage, langTraits } = require('../languages');
|
|
15
|
+
const { NON_CALLABLE_TYPES, addTestExclusions } = require('./shared');
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Context: quick caller/callee view for a symbol.
|
|
19
|
+
*
|
|
20
|
+
* @param {object} index - ProjectIndex instance
|
|
21
|
+
* @param {string} name - Symbol name
|
|
22
|
+
* @param {object} options - { file, className, includeMethods, includeUncertain, exclude, minConfidence }
|
|
23
|
+
* @returns {object|null}
|
|
24
|
+
*/
|
|
25
|
+
function context(index, name, options = {}) {
|
|
26
|
+
index._beginOp();
|
|
27
|
+
try {
|
|
28
|
+
const resolved = index.resolveSymbol(name, { file: options.file, className: options.className });
|
|
29
|
+
let { def, warnings } = resolved;
|
|
30
|
+
if (!def) {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Special handling for class/struct/interface types
|
|
35
|
+
if (['class', 'struct', 'interface', 'type'].includes(def.type)) {
|
|
36
|
+
const methods = index.findMethodsForType(name);
|
|
37
|
+
|
|
38
|
+
let typeCallers = index.findCallers(name, { includeMethods: options.includeMethods, includeUncertain: options.includeUncertain });
|
|
39
|
+
// Apply exclude filter
|
|
40
|
+
if (options.exclude && options.exclude.length > 0) {
|
|
41
|
+
typeCallers = typeCallers.filter(c => index.matchesFilters(c.relativePath, { exclude: options.exclude }));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const result = {
|
|
45
|
+
type: def.type,
|
|
46
|
+
name: name,
|
|
47
|
+
file: def.relativePath,
|
|
48
|
+
startLine: def.startLine,
|
|
49
|
+
endLine: def.endLine,
|
|
50
|
+
methods: methods.map(m => ({
|
|
51
|
+
name: m.name,
|
|
52
|
+
file: m.relativePath,
|
|
53
|
+
line: m.startLine,
|
|
54
|
+
params: m.params,
|
|
55
|
+
returnType: m.returnType,
|
|
56
|
+
receiver: m.receiver
|
|
57
|
+
})),
|
|
58
|
+
// Also include places where the type is used in function parameters/returns
|
|
59
|
+
callers: typeCallers
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
if (warnings.length > 0) {
|
|
63
|
+
result.warnings = warnings;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return result;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const stats = { uncertain: 0 };
|
|
70
|
+
let callers = index.findCallers(name, { includeMethods: options.includeMethods, includeUncertain: options.includeUncertain, stats, targetDefinitions: [def] });
|
|
71
|
+
let callees = index.findCallees(def, { includeMethods: options.includeMethods, includeUncertain: options.includeUncertain, stats });
|
|
72
|
+
|
|
73
|
+
// Apply exclude filter
|
|
74
|
+
if (options.exclude && options.exclude.length > 0) {
|
|
75
|
+
callers = callers.filter(c => index.matchesFilters(c.relativePath, { exclude: options.exclude }));
|
|
76
|
+
callees = callees.filter(c => index.matchesFilters(c.relativePath, { exclude: options.exclude }));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Apply confidence filtering
|
|
80
|
+
let confidenceFiltered = 0;
|
|
81
|
+
if (options.minConfidence > 0) {
|
|
82
|
+
const { filterByConfidence } = require('./confidence');
|
|
83
|
+
const callerResult = filterByConfidence(callers, options.minConfidence);
|
|
84
|
+
const calleeResult = filterByConfidence(callees, options.minConfidence);
|
|
85
|
+
callers = callerResult.kept;
|
|
86
|
+
callees = calleeResult.kept;
|
|
87
|
+
confidenceFiltered = callerResult.filtered + calleeResult.filtered;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const filesInScope = new Set([def.file]);
|
|
91
|
+
callers.forEach(c => filesInScope.add(c.file));
|
|
92
|
+
callees.forEach(c => filesInScope.add(c.file));
|
|
93
|
+
let dynamicImports = 0;
|
|
94
|
+
for (const f of filesInScope) {
|
|
95
|
+
const fe = index.files.get(f);
|
|
96
|
+
if (fe?.dynamicImports) dynamicImports += fe.dynamicImports;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const result = {
|
|
100
|
+
function: name,
|
|
101
|
+
file: def.relativePath,
|
|
102
|
+
startLine: def.startLine,
|
|
103
|
+
endLine: def.endLine,
|
|
104
|
+
params: def.params,
|
|
105
|
+
returnType: def.returnType,
|
|
106
|
+
callers,
|
|
107
|
+
callees,
|
|
108
|
+
meta: {
|
|
109
|
+
complete: stats.uncertain === 0 && dynamicImports === 0 && confidenceFiltered === 0,
|
|
110
|
+
skipped: 0,
|
|
111
|
+
dynamicImports,
|
|
112
|
+
uncertain: stats.uncertain,
|
|
113
|
+
confidenceFiltered,
|
|
114
|
+
includeMethods: !!options.includeMethods,
|
|
115
|
+
projectLanguage: index._getPredominantLanguage(),
|
|
116
|
+
// Structural facts for reliability hints
|
|
117
|
+
...(def.isMethod && { isMethod: true }),
|
|
118
|
+
...(def.className && { className: def.className }),
|
|
119
|
+
...(def.receiver && { receiver: def.receiver })
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
if (warnings.length > 0) {
|
|
124
|
+
result.warnings = warnings;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return result;
|
|
128
|
+
} finally { index._endOp(); }
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Smart extraction: function + dependencies inline.
|
|
133
|
+
*
|
|
134
|
+
* @param {object} index - ProjectIndex instance
|
|
135
|
+
* @param {string} name - Symbol name
|
|
136
|
+
* @param {object} options - { file, className, includeMethods, includeUncertain, withTypes }
|
|
137
|
+
* @returns {object|null}
|
|
138
|
+
*/
|
|
139
|
+
function smart(index, name, options = {}) {
|
|
140
|
+
index._beginOp();
|
|
141
|
+
try {
|
|
142
|
+
const { def } = index.resolveSymbol(name, { file: options.file, className: options.className });
|
|
143
|
+
if (!def) {
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
const code = index.extractCode(def);
|
|
147
|
+
const stats = { uncertain: 0 };
|
|
148
|
+
const callees = index.findCallees(def, { includeMethods: options.includeMethods, includeUncertain: options.includeUncertain, stats });
|
|
149
|
+
|
|
150
|
+
const filesInScope = new Set([def.file]);
|
|
151
|
+
callees.forEach(c => filesInScope.add(c.file));
|
|
152
|
+
let dynamicImports = 0;
|
|
153
|
+
for (const f of filesInScope) {
|
|
154
|
+
const fe = index.files.get(f);
|
|
155
|
+
if (fe?.dynamicImports) dynamicImports += fe.dynamicImports;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Extract code for each dependency, excluding the exact same function
|
|
159
|
+
// (but keeping same-name overloads, e.g. Java toJson(Object) vs toJson(Object, Class))
|
|
160
|
+
const defBindingId = def.bindingId;
|
|
161
|
+
const dependencies = callees
|
|
162
|
+
.filter(callee => callee.bindingId !== defBindingId)
|
|
163
|
+
.map(callee => ({
|
|
164
|
+
...callee,
|
|
165
|
+
code: index.extractCode(callee)
|
|
166
|
+
}));
|
|
167
|
+
|
|
168
|
+
// Find type definitions if requested
|
|
169
|
+
const types = [];
|
|
170
|
+
if (options.withTypes) {
|
|
171
|
+
// Look for type annotations in params/return type
|
|
172
|
+
const typeNames = index.extractTypeNames(def);
|
|
173
|
+
for (const typeName of typeNames) {
|
|
174
|
+
const typeSymbols = index.symbols.get(typeName);
|
|
175
|
+
if (typeSymbols) {
|
|
176
|
+
for (const sym of typeSymbols) {
|
|
177
|
+
if (['type', 'interface', 'class', 'struct'].includes(sym.type)) {
|
|
178
|
+
types.push({
|
|
179
|
+
...sym,
|
|
180
|
+
code: index.extractCode(sym)
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
return {
|
|
189
|
+
target: {
|
|
190
|
+
...def,
|
|
191
|
+
code
|
|
192
|
+
},
|
|
193
|
+
dependencies,
|
|
194
|
+
types,
|
|
195
|
+
meta: {
|
|
196
|
+
complete: stats.uncertain === 0 && dynamicImports === 0,
|
|
197
|
+
skipped: 0,
|
|
198
|
+
dynamicImports,
|
|
199
|
+
uncertain: stats.uncertain,
|
|
200
|
+
projectLanguage: index._getPredominantLanguage()
|
|
201
|
+
}
|
|
202
|
+
};
|
|
203
|
+
} finally { index._endOp(); }
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Detect completeness signal metadata for the project.
|
|
208
|
+
*
|
|
209
|
+
* @param {object} index - ProjectIndex instance
|
|
210
|
+
* @returns {object} { complete, warnings, projectLanguage }
|
|
211
|
+
*/
|
|
212
|
+
function detectCompleteness(index) {
|
|
213
|
+
// Return cached result if available
|
|
214
|
+
if (index._completenessCache) {
|
|
215
|
+
return index._completenessCache;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const warnings = [];
|
|
219
|
+
let dynamicImports = 0;
|
|
220
|
+
let evalUsage = 0;
|
|
221
|
+
let reflectionUsage = 0;
|
|
222
|
+
|
|
223
|
+
for (const [filePath, fileEntry] of index.files) {
|
|
224
|
+
// Skip node_modules - we don't care about their patterns
|
|
225
|
+
if (filePath.includes('node_modules')) continue;
|
|
226
|
+
|
|
227
|
+
try {
|
|
228
|
+
const content = index._readFile(filePath);
|
|
229
|
+
|
|
230
|
+
if (langTraits(fileEntry.language)?.hasDynamicImports) {
|
|
231
|
+
// Dynamic imports: import(), require(variable), __import__
|
|
232
|
+
dynamicImports += (content.match(/import\s*\([^'"]/g) || []).length;
|
|
233
|
+
dynamicImports += (content.match(/require\s*\([^'"]/g) || []).length;
|
|
234
|
+
dynamicImports += (content.match(/__import__\s*\(/g) || []).length;
|
|
235
|
+
|
|
236
|
+
// eval, Function constructor
|
|
237
|
+
evalUsage += (content.match(/(^|[^a-zA-Z_])eval\s*\(/gm) || []).length;
|
|
238
|
+
evalUsage += (content.match(/new\s+Function\s*\(/g) || []).length;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Reflection: getattr, hasattr, Reflect
|
|
242
|
+
reflectionUsage += (content.match(/\bgetattr\s*\(/g) || []).length;
|
|
243
|
+
reflectionUsage += (content.match(/\bhasattr\s*\(/g) || []).length;
|
|
244
|
+
reflectionUsage += (content.match(/\bReflect\./g) || []).length;
|
|
245
|
+
} catch (e) {
|
|
246
|
+
// Skip unreadable files
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
if (dynamicImports > 0) {
|
|
251
|
+
warnings.push({
|
|
252
|
+
type: 'dynamic_imports',
|
|
253
|
+
count: dynamicImports,
|
|
254
|
+
message: `${dynamicImports} dynamic import(s) detected - some dependencies may be missed`
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
if (evalUsage > 0) {
|
|
259
|
+
warnings.push({
|
|
260
|
+
type: 'eval',
|
|
261
|
+
count: evalUsage,
|
|
262
|
+
message: `${evalUsage} eval/exec usage(s) detected - dynamically generated code not analyzed`
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
if (reflectionUsage > 0) {
|
|
267
|
+
warnings.push({
|
|
268
|
+
type: 'reflection',
|
|
269
|
+
count: reflectionUsage,
|
|
270
|
+
message: `${reflectionUsage} reflection usage(s) detected - dynamic attribute access not tracked`
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
index._completenessCache = {
|
|
275
|
+
complete: warnings.length === 0,
|
|
276
|
+
warnings,
|
|
277
|
+
projectLanguage: index._getPredominantLanguage()
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
return index._completenessCache;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Find related functions — same file, similar names, shared dependencies.
|
|
285
|
+
*
|
|
286
|
+
* @param {object} index - ProjectIndex instance
|
|
287
|
+
* @param {string} name - Function name
|
|
288
|
+
* @param {object} options - { file, className, top, all }
|
|
289
|
+
* @returns {object|null}
|
|
290
|
+
*/
|
|
291
|
+
function related(index, name, options = {}) {
|
|
292
|
+
index._beginOp();
|
|
293
|
+
try {
|
|
294
|
+
const { def } = index.resolveSymbol(name, { file: options.file, className: options.className });
|
|
295
|
+
if (!def) {
|
|
296
|
+
return null;
|
|
297
|
+
}
|
|
298
|
+
const related = {
|
|
299
|
+
target: {
|
|
300
|
+
name: def.name,
|
|
301
|
+
file: def.relativePath,
|
|
302
|
+
line: def.startLine,
|
|
303
|
+
type: def.type
|
|
304
|
+
},
|
|
305
|
+
sameFile: [],
|
|
306
|
+
similarNames: [],
|
|
307
|
+
sharedCallers: [],
|
|
308
|
+
sharedCallees: []
|
|
309
|
+
};
|
|
310
|
+
|
|
311
|
+
// 1. Same file functions (sorted by proximity to target)
|
|
312
|
+
const fileEntry = index.files.get(def.file);
|
|
313
|
+
if (fileEntry) {
|
|
314
|
+
for (const sym of fileEntry.symbols) {
|
|
315
|
+
if (sym.name !== name && !NON_CALLABLE_TYPES.has(sym.type)) {
|
|
316
|
+
related.sameFile.push({
|
|
317
|
+
name: sym.name,
|
|
318
|
+
line: sym.startLine,
|
|
319
|
+
params: sym.params
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
// Sort by distance from target function (nearest first)
|
|
324
|
+
related.sameFile.sort((a, b) =>
|
|
325
|
+
Math.abs(a.line - def.startLine) - Math.abs(b.line - def.startLine)
|
|
326
|
+
);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// 2. Similar names (shared prefix/suffix, camelCase similarity)
|
|
330
|
+
const nameParts = name.replace(/([a-z])([A-Z])/g, '$1_$2').toLowerCase().split('_');
|
|
331
|
+
for (const [symName, symbols] of index.symbols) {
|
|
332
|
+
if (symName === name) continue;
|
|
333
|
+
const symParts = symName.replace(/([a-z])([A-Z])/g, '$1_$2').toLowerCase().split('_');
|
|
334
|
+
|
|
335
|
+
// Check for shared parts (require >=50% of the longer name to match)
|
|
336
|
+
const sharedParts = nameParts.filter(p => symParts.includes(p) && p.length > 3);
|
|
337
|
+
const maxParts = Math.max(nameParts.length, symParts.length);
|
|
338
|
+
if (sharedParts.length > 0 && sharedParts.length / maxParts >= 0.5) {
|
|
339
|
+
const sym = symbols[0];
|
|
340
|
+
related.similarNames.push({
|
|
341
|
+
name: symName,
|
|
342
|
+
file: sym.relativePath,
|
|
343
|
+
line: sym.startLine,
|
|
344
|
+
sharedParts,
|
|
345
|
+
type: sym.type
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
// Sort by number of shared parts
|
|
350
|
+
related.similarNames.sort((a, b) => b.sharedParts.length - a.sharedParts.length);
|
|
351
|
+
const similarLimit = options.top || (options.all ? Infinity : 10);
|
|
352
|
+
related.similarNamesTotal = related.similarNames.length;
|
|
353
|
+
if (related.similarNames.length > similarLimit) related.similarNames = related.similarNames.slice(0, similarLimit);
|
|
354
|
+
|
|
355
|
+
// 3. Shared callers - functions called by the same callers
|
|
356
|
+
const myCallers = new Set(index.findCallers(name).map(c => c.callerName).filter(Boolean));
|
|
357
|
+
if (myCallers.size > 0) {
|
|
358
|
+
const callerCounts = new Map();
|
|
359
|
+
for (const callerName of myCallers) {
|
|
360
|
+
const callerDef = index.symbols.get(callerName)?.[0];
|
|
361
|
+
if (callerDef) {
|
|
362
|
+
const callees = index.findCallees(callerDef);
|
|
363
|
+
for (const callee of callees) {
|
|
364
|
+
if (callee.name !== name) {
|
|
365
|
+
callerCounts.set(callee.name, (callerCounts.get(callee.name) || 0) + 1);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
// Sort by shared caller count
|
|
371
|
+
const maxShared = options.top || (options.all ? Infinity : 5);
|
|
372
|
+
const allSorted = Array.from(callerCounts.entries())
|
|
373
|
+
.sort((a, b) => b[1] - a[1]);
|
|
374
|
+
related.sharedCallersTotal = allSorted.length;
|
|
375
|
+
const sorted = allSorted.slice(0, maxShared);
|
|
376
|
+
for (const [symName, count] of sorted) {
|
|
377
|
+
const sym = index.symbols.get(symName)?.[0];
|
|
378
|
+
if (sym) {
|
|
379
|
+
related.sharedCallers.push({
|
|
380
|
+
name: symName,
|
|
381
|
+
file: sym.relativePath,
|
|
382
|
+
line: sym.startLine,
|
|
383
|
+
sharedCallerCount: count
|
|
384
|
+
});
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// 4. Shared callees - functions that call the same things
|
|
390
|
+
// Optimized: instead of computing callees for every symbol (O(N*M)),
|
|
391
|
+
// find who else calls each of our callees (O(K) where K = our callee count)
|
|
392
|
+
if (def.type === 'function' || def.params !== undefined) {
|
|
393
|
+
const myCallees = index.findCallees(def);
|
|
394
|
+
const myCalleeNames = new Set(myCallees.map(c => c.name));
|
|
395
|
+
if (myCalleeNames.size > 0) {
|
|
396
|
+
const calleeCounts = new Map();
|
|
397
|
+
for (const calleeName of myCalleeNames) {
|
|
398
|
+
// Find other functions that also call this callee
|
|
399
|
+
const callers = index.findCallers(calleeName);
|
|
400
|
+
for (const caller of callers) {
|
|
401
|
+
if (caller.callerName && caller.callerName !== name) {
|
|
402
|
+
calleeCounts.set(caller.callerName, (calleeCounts.get(caller.callerName) || 0) + 1);
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
// Sort by shared callee count
|
|
407
|
+
const allSorted = Array.from(calleeCounts.entries())
|
|
408
|
+
.sort((a, b) => b[1] - a[1]);
|
|
409
|
+
related.sharedCalleesTotal = allSorted.length;
|
|
410
|
+
const sorted = allSorted.slice(0, options.top || (options.all ? Infinity : 5));
|
|
411
|
+
for (const [symName, count] of sorted) {
|
|
412
|
+
const sym = index.symbols.get(symName)?.[0];
|
|
413
|
+
if (sym) {
|
|
414
|
+
related.sharedCallees.push({
|
|
415
|
+
name: symName,
|
|
416
|
+
file: sym.relativePath,
|
|
417
|
+
line: sym.startLine,
|
|
418
|
+
sharedCalleeCount: count
|
|
419
|
+
});
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
return related;
|
|
426
|
+
} finally { index._endOp(); }
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* Impact analysis — what call sites need updating if a function changes.
|
|
431
|
+
*
|
|
432
|
+
* @param {object} index - ProjectIndex instance
|
|
433
|
+
* @param {string} name - Function name
|
|
434
|
+
* @param {object} options - { file, className, exclude, top }
|
|
435
|
+
* @returns {object|null}
|
|
436
|
+
*/
|
|
437
|
+
function impact(index, name, options = {}) {
|
|
438
|
+
index._beginOp();
|
|
439
|
+
try {
|
|
440
|
+
const { def } = index.resolveSymbol(name, { file: options.file, className: options.className });
|
|
441
|
+
if (!def) {
|
|
442
|
+
return null;
|
|
443
|
+
}
|
|
444
|
+
const defIsMethod = def.isMethod || def.type === 'method' || def.className || def.receiver;
|
|
445
|
+
|
|
446
|
+
// Use findCallers for className-scoped or method queries (sophisticated binding resolution)
|
|
447
|
+
// Fall back to usages-based approach for simple function queries (backward compatible)
|
|
448
|
+
let callSites;
|
|
449
|
+
if (options.className || defIsMethod) {
|
|
450
|
+
// findCallers has proper method call resolution (self/this, binding IDs, receiver checks)
|
|
451
|
+
let callerResults = index.findCallers(name, {
|
|
452
|
+
includeMethods: true,
|
|
453
|
+
includeUncertain: false,
|
|
454
|
+
targetDefinitions: [def],
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
// When the target definition has a className (including Go/Rust methods which
|
|
458
|
+
// now get className from receiver), filter out method calls whose receiver
|
|
459
|
+
// clearly belongs to a different type. This helps with common method names
|
|
460
|
+
// like .close(), .get() etc. where many types have the same method.
|
|
461
|
+
if (def.className) {
|
|
462
|
+
const targetClassName = def.className;
|
|
463
|
+
// Pre-compute how many types share this method name
|
|
464
|
+
const _impMethodDefs = index.symbols.get(name);
|
|
465
|
+
const _impClassNames = new Set();
|
|
466
|
+
if (_impMethodDefs) {
|
|
467
|
+
for (const d of _impMethodDefs) {
|
|
468
|
+
if (d.className) _impClassNames.add(d.className);
|
|
469
|
+
else if (d.receiver) _impClassNames.add(d.receiver.replace(/^\*/, ''));
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
callerResults = callerResults.filter(c => {
|
|
473
|
+
// Keep non-method calls and self/this/cls calls (already resolved by findCallers)
|
|
474
|
+
if (!c.isMethod) return true;
|
|
475
|
+
const r = c.receiver;
|
|
476
|
+
if (r && ['self', 'cls', 'this', 'super'].includes(r)) return true;
|
|
477
|
+
// Use receiverType from findCallers when available (Go/Java/Rust type inference)
|
|
478
|
+
if (c.receiverType) {
|
|
479
|
+
return c.receiverType === targetClassName;
|
|
480
|
+
}
|
|
481
|
+
// No receiver (chained/complex expression): only include if method is
|
|
482
|
+
// unique or rare across types — otherwise too many false positives
|
|
483
|
+
if (!r) {
|
|
484
|
+
return _impClassNames.size <= 1;
|
|
485
|
+
}
|
|
486
|
+
// Check if receiver matches the target class name (case-insensitive camelCase convention)
|
|
487
|
+
if (r.toLowerCase().includes(targetClassName.toLowerCase())) return true;
|
|
488
|
+
// Check if receiver is an instance of the target class using local variable type inference
|
|
489
|
+
if (c.callerFile) {
|
|
490
|
+
const callerDef = c.callerStartLine ? { file: c.callerFile, startLine: c.callerStartLine, endLine: c.callerEndLine } : null;
|
|
491
|
+
if (callerDef) {
|
|
492
|
+
const callerCalls = index.getCachedCalls(c.callerFile);
|
|
493
|
+
if (callerCalls && Array.isArray(callerCalls)) {
|
|
494
|
+
const localTypes = new Map();
|
|
495
|
+
for (const call of callerCalls) {
|
|
496
|
+
if (call.line >= callerDef.startLine && call.line <= callerDef.endLine) {
|
|
497
|
+
if (!call.isMethod && !call.receiver) {
|
|
498
|
+
const syms = index.symbols.get(call.name);
|
|
499
|
+
if (syms && syms.some(s => s.type === 'class')) {
|
|
500
|
+
// Found a constructor call — check for assignment pattern
|
|
501
|
+
const fileEntry = index.files.get(c.callerFile);
|
|
502
|
+
if (fileEntry) {
|
|
503
|
+
const content = index._readFile(c.callerFile);
|
|
504
|
+
const lines = content.split('\n');
|
|
505
|
+
const line = lines[call.line - 1] || '';
|
|
506
|
+
// Match "var = ClassName(...)" or "var = new ClassName(...)" or "Type var = new ClassName<>(...)"
|
|
507
|
+
const m = line.match(/(\w+)\s*=\s*(?:await\s+)?(?:new\s+)?(\w+)\s*(?:<[^>]*>)?\s*\(/);
|
|
508
|
+
if (m && m[2] === call.name) {
|
|
509
|
+
localTypes.set(m[1], call.name);
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
const receiverType = localTypes.get(r);
|
|
517
|
+
if (receiverType) {
|
|
518
|
+
return receiverType === targetClassName;
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
// Check class field declarations for receiver type: private DataService service
|
|
524
|
+
if (c.callerFile) {
|
|
525
|
+
const callerEnclosing = index.findEnclosingFunction(c.callerFile, c.line, true);
|
|
526
|
+
if (callerEnclosing?.className) {
|
|
527
|
+
const classSyms = index.symbols.get(callerEnclosing.className);
|
|
528
|
+
if (classSyms) {
|
|
529
|
+
const classDef = classSyms.find(s => s.type === 'class' || s.type === 'struct' || s.type === 'interface');
|
|
530
|
+
if (classDef) {
|
|
531
|
+
const content = index._readFile(c.callerFile);
|
|
532
|
+
const lines = content.split('\n');
|
|
533
|
+
// Scan class body for field declarations matching the receiver
|
|
534
|
+
for (let li = classDef.startLine - 1; li < (classDef.endLine || classDef.startLine + 50) && li < lines.length; li++) {
|
|
535
|
+
const line = lines[li];
|
|
536
|
+
// Match Java/TS field: [modifiers] TypeName<...> receiverName [= ...]
|
|
537
|
+
const fieldMatch = line.match(new RegExp(`\\b(\\w+)(?:<[^>]*>)?\\s+${r.replace(/[.*+?^${}()|[\]\\]/g, '\\\\$&')}\\s*[;=]`));
|
|
538
|
+
if (fieldMatch) {
|
|
539
|
+
const fieldType = fieldMatch[1];
|
|
540
|
+
if (fieldType === targetClassName) return true;
|
|
541
|
+
break;
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
// Check parameter type annotations: def foo(tracker: SourceTracker) → tracker.record()
|
|
549
|
+
if (c.callerFile && c.callerStartLine) {
|
|
550
|
+
const callerSymbol = index.findEnclosingFunction(c.callerFile, c.line, true);
|
|
551
|
+
if (callerSymbol && callerSymbol.paramsStructured) {
|
|
552
|
+
for (const param of callerSymbol.paramsStructured) {
|
|
553
|
+
if (param.name === r && param.type) {
|
|
554
|
+
// Check if the type annotation contains the target class name
|
|
555
|
+
const typeMatches = param.type.match(/\b([A-Za-z_]\w*)\b/g);
|
|
556
|
+
if (typeMatches && typeMatches.some(t => t === targetClassName)) {
|
|
557
|
+
return true;
|
|
558
|
+
}
|
|
559
|
+
// Type annotation exists but doesn't match target class — filter out
|
|
560
|
+
return false;
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
// Unique method heuristic: if the called method exists on exactly one class/type
|
|
566
|
+
// and it matches the target, include the call (no other class could match)
|
|
567
|
+
if (_impClassNames.size === 1 && _impClassNames.has(targetClassName)) {
|
|
568
|
+
return true;
|
|
569
|
+
}
|
|
570
|
+
// Type-scoped query but receiver type unknown — filter it out.
|
|
571
|
+
// Unknown receivers are likely unrelated.
|
|
572
|
+
return false;
|
|
573
|
+
});
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
callSites = [];
|
|
577
|
+
for (const c of callerResults) {
|
|
578
|
+
const analysis = index.analyzeCallSite(
|
|
579
|
+
{ file: c.file, relativePath: c.relativePath, line: c.line, content: c.content },
|
|
580
|
+
name
|
|
581
|
+
);
|
|
582
|
+
callSites.push({
|
|
583
|
+
file: c.relativePath,
|
|
584
|
+
line: c.line,
|
|
585
|
+
expression: c.content.trim(),
|
|
586
|
+
callerName: c.callerName,
|
|
587
|
+
...analysis
|
|
588
|
+
});
|
|
589
|
+
}
|
|
590
|
+
index._clearTreeCache();
|
|
591
|
+
} else {
|
|
592
|
+
// Use findCallers (benefits from callee index) instead of usages() for speed
|
|
593
|
+
const callerResults = index.findCallers(name, {
|
|
594
|
+
includeMethods: false,
|
|
595
|
+
includeUncertain: false,
|
|
596
|
+
targetDefinitions: [def],
|
|
597
|
+
});
|
|
598
|
+
const targetBindingId = def.bindingId;
|
|
599
|
+
// Convert findCallers results to the format expected by analyzeCallSite
|
|
600
|
+
const calls = callerResults.map(c => ({
|
|
601
|
+
file: c.file,
|
|
602
|
+
relativePath: c.relativePath,
|
|
603
|
+
line: c.line,
|
|
604
|
+
content: c.content,
|
|
605
|
+
usageType: 'call',
|
|
606
|
+
callerName: c.callerName,
|
|
607
|
+
}));
|
|
608
|
+
// Keep the same binding filter for backward compat (findCallers already handles this,
|
|
609
|
+
// but cross-check with usages-based binding filter for safety)
|
|
610
|
+
const filteredCalls = calls.filter(u => {
|
|
611
|
+
const fileEntry = index.files.get(u.file);
|
|
612
|
+
if (fileEntry && targetBindingId) {
|
|
613
|
+
let localBindings = (fileEntry.bindings || []).filter(b => b.name === name);
|
|
614
|
+
if (localBindings.length === 0 && langTraits(fileEntry.language)?.packageScope === 'directory') {
|
|
615
|
+
const dir = path.dirname(u.file);
|
|
616
|
+
for (const [fp, fe] of index.files) {
|
|
617
|
+
if (fp !== u.file && path.dirname(fp) === dir) {
|
|
618
|
+
const sibling = (fe.bindings || []).filter(b => b.name === name);
|
|
619
|
+
localBindings = localBindings.concat(sibling);
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
if (localBindings.length > 0 && !localBindings.some(b => b.id === targetBindingId)) {
|
|
624
|
+
return false;
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
return true;
|
|
628
|
+
});
|
|
629
|
+
// (findCallers already handles binding resolution and scope-aware filtering)
|
|
630
|
+
|
|
631
|
+
// Analyze each call site, filtering out method calls for non-method definitions
|
|
632
|
+
callSites = [];
|
|
633
|
+
const defFileEntry = index.files.get(def.file);
|
|
634
|
+
const defLang = defFileEntry?.language;
|
|
635
|
+
const targetDir = defLang === 'go' ? path.basename(path.dirname(def.file)) : null;
|
|
636
|
+
for (const call of filteredCalls) {
|
|
637
|
+
const analysis = index.analyzeCallSite(call, name);
|
|
638
|
+
// Skip method calls (obj.parse()) when target is a standalone function (parse())
|
|
639
|
+
// For Go, allow calls where receiver matches the package directory name
|
|
640
|
+
// (e.g., controller.FilterActive() where file is in pkg/controller/)
|
|
641
|
+
if (analysis.isMethodCall && !defIsMethod) {
|
|
642
|
+
if (targetDir) {
|
|
643
|
+
// Get receiver from parsed calls cache
|
|
644
|
+
const parsedCalls = index.getCachedCalls(call.file);
|
|
645
|
+
const matchedCall = parsedCalls?.find(c => c.name === name && c.line === call.line);
|
|
646
|
+
if (matchedCall?.receiver === targetDir) {
|
|
647
|
+
// Receiver matches package directory — keep it
|
|
648
|
+
} else {
|
|
649
|
+
continue;
|
|
650
|
+
}
|
|
651
|
+
} else {
|
|
652
|
+
continue;
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
callSites.push({
|
|
656
|
+
file: call.relativePath,
|
|
657
|
+
line: call.line,
|
|
658
|
+
expression: call.content.trim(),
|
|
659
|
+
callerName: call.callerName || index.findEnclosingFunction(call.file, call.line),
|
|
660
|
+
...analysis
|
|
661
|
+
});
|
|
662
|
+
}
|
|
663
|
+
index._clearTreeCache();
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
// Apply exclude filter
|
|
667
|
+
let filteredSites = callSites;
|
|
668
|
+
if (options.exclude && options.exclude.length > 0) {
|
|
669
|
+
filteredSites = callSites.filter(s => index.matchesFilters(s.file, { exclude: options.exclude }));
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
// Apply top limit if specified (limits total call sites shown)
|
|
673
|
+
const totalBeforeLimit = filteredSites.length;
|
|
674
|
+
if (options.top && options.top > 0 && filteredSites.length > options.top) {
|
|
675
|
+
filteredSites = filteredSites.slice(0, options.top);
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
// Group by file
|
|
679
|
+
const byFile = new Map();
|
|
680
|
+
for (const site of filteredSites) {
|
|
681
|
+
if (!byFile.has(site.file)) {
|
|
682
|
+
byFile.set(site.file, []);
|
|
683
|
+
}
|
|
684
|
+
byFile.get(site.file).push(site);
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
// Identify patterns
|
|
688
|
+
const patterns = index.identifyCallPatterns(filteredSites, name);
|
|
689
|
+
|
|
690
|
+
// Detect scope pollution: multiple class definitions for the same method name
|
|
691
|
+
let scopeWarning = null;
|
|
692
|
+
if (defIsMethod) {
|
|
693
|
+
const allDefs = index.symbols.get(name);
|
|
694
|
+
if (allDefs && allDefs.length > 1) {
|
|
695
|
+
const classNames = [...new Set(allDefs
|
|
696
|
+
.filter(d => d.className && d.className !== def.className)
|
|
697
|
+
.map(d => d.className))];
|
|
698
|
+
if (classNames.length > 0 && !options.className && !options.file) {
|
|
699
|
+
scopeWarning = {
|
|
700
|
+
targetClass: def.className || '(unknown)',
|
|
701
|
+
otherClasses: classNames,
|
|
702
|
+
hint: `Results may include calls to ${classNames.join(', ')}.${name}(). Use file= or className= to narrow scope.`
|
|
703
|
+
};
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
return {
|
|
709
|
+
function: name,
|
|
710
|
+
file: def.relativePath,
|
|
711
|
+
startLine: def.startLine,
|
|
712
|
+
signature: index.formatSignature(def),
|
|
713
|
+
params: def.params,
|
|
714
|
+
paramsStructured: def.paramsStructured,
|
|
715
|
+
totalCallSites: totalBeforeLimit,
|
|
716
|
+
shownCallSites: filteredSites.length,
|
|
717
|
+
byFile: Array.from(byFile.entries()).map(([file, sites]) => ({
|
|
718
|
+
file,
|
|
719
|
+
count: sites.length,
|
|
720
|
+
sites
|
|
721
|
+
})),
|
|
722
|
+
patterns,
|
|
723
|
+
scopeWarning
|
|
724
|
+
};
|
|
725
|
+
} finally { index._endOp(); }
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
/**
|
|
729
|
+
* About: comprehensive symbol metadata — definition, usages, callers, callees, tests, code.
|
|
730
|
+
*
|
|
731
|
+
* @param {object} index - ProjectIndex instance
|
|
732
|
+
* @param {string} name - Symbol name
|
|
733
|
+
* @param {object} options - { file, className, all, maxCallers, maxCallees, withCode, withTypes,
|
|
734
|
+
* includeMethods, includeUncertain, includeTests, exclude, minConfidence }
|
|
735
|
+
* @returns {object|null}
|
|
736
|
+
*/
|
|
737
|
+
function about(index, name, options = {}) {
|
|
738
|
+
index._beginOp();
|
|
739
|
+
try {
|
|
740
|
+
const maxCallers = options.all ? Infinity : (options.maxCallers || 10);
|
|
741
|
+
const maxCallees = options.all ? Infinity : (options.maxCallees || 10);
|
|
742
|
+
|
|
743
|
+
// Find symbol definition(s) — skip counts since about() computes its own via usages()
|
|
744
|
+
const definitions = index.find(name, { exact: true, file: options.file, className: options.className, skipCounts: true });
|
|
745
|
+
if (definitions.length === 0) {
|
|
746
|
+
// Try fuzzy match (needs counts for suggestion ranking)
|
|
747
|
+
const fuzzy = index.find(name, { file: options.file, className: options.className });
|
|
748
|
+
if (fuzzy.length === 0) {
|
|
749
|
+
return null;
|
|
750
|
+
}
|
|
751
|
+
// Return suggestion
|
|
752
|
+
return {
|
|
753
|
+
found: false,
|
|
754
|
+
suggestions: (options.all ? fuzzy : fuzzy.slice(0, 5)).map(s => ({
|
|
755
|
+
name: s.name,
|
|
756
|
+
file: s.relativePath,
|
|
757
|
+
line: s.startLine,
|
|
758
|
+
type: s.type,
|
|
759
|
+
usageCount: s.usageCount
|
|
760
|
+
}))
|
|
761
|
+
};
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
// Use resolveSymbol for consistent primary selection (prefers non-test files)
|
|
765
|
+
const { def: resolved } = index.resolveSymbol(name, { file: options.file, className: options.className });
|
|
766
|
+
const primary = resolved || definitions[0];
|
|
767
|
+
const others = definitions.filter(d =>
|
|
768
|
+
d.relativePath !== primary.relativePath || d.startLine !== primary.startLine
|
|
769
|
+
);
|
|
770
|
+
|
|
771
|
+
// Use the actual symbol name (may differ from query if fuzzy matched)
|
|
772
|
+
const symbolName = primary.name;
|
|
773
|
+
|
|
774
|
+
// Default includeMethods: true when target is a class method (method calls are the primary way
|
|
775
|
+
// class methods are invoked), false for standalone functions (reduces noise from unrelated obj.fn() calls)
|
|
776
|
+
const isMethod = !!(primary.isMethod || primary.type === 'method' || primary.className);
|
|
777
|
+
const includeMethods = options.includeMethods ?? isMethod;
|
|
778
|
+
|
|
779
|
+
// Get usage counts by type (fast path uses callee index, no file reads)
|
|
780
|
+
// Exclude test files by default (matching usages command behavior)
|
|
781
|
+
const countExclude = !options.includeTests ? addTestExclusions(options.exclude) : options.exclude;
|
|
782
|
+
const usagesByType = index.countSymbolUsages(primary, { exclude: countExclude });
|
|
783
|
+
|
|
784
|
+
// Get callers and callees (only for functions)
|
|
785
|
+
let callers = [];
|
|
786
|
+
let callees = [];
|
|
787
|
+
let allCallers = null;
|
|
788
|
+
let allCallees = null;
|
|
789
|
+
let aboutConfFiltered = 0;
|
|
790
|
+
if (primary.type === 'function' || primary.params !== undefined) {
|
|
791
|
+
// Use maxResults to limit file iteration (with buffer for exclude filtering)
|
|
792
|
+
const callerCap = maxCallers === Infinity ? undefined : maxCallers * 3;
|
|
793
|
+
allCallers = index.findCallers(symbolName, { includeMethods, includeUncertain: options.includeUncertain, targetDefinitions: [primary], maxResults: callerCap });
|
|
794
|
+
// Apply exclude filter before slicing
|
|
795
|
+
if (options.exclude && options.exclude.length > 0) {
|
|
796
|
+
allCallers = allCallers.filter(c => index.matchesFilters(c.relativePath, { exclude: options.exclude }));
|
|
797
|
+
}
|
|
798
|
+
// Apply confidence filtering before slicing
|
|
799
|
+
if (options.minConfidence > 0) {
|
|
800
|
+
const { filterByConfidence } = require('./confidence');
|
|
801
|
+
const callerResult = filterByConfidence(allCallers, options.minConfidence);
|
|
802
|
+
allCallers = callerResult.kept;
|
|
803
|
+
aboutConfFiltered += callerResult.filtered;
|
|
804
|
+
}
|
|
805
|
+
callers = allCallers.slice(0, maxCallers).map(c => ({
|
|
806
|
+
file: c.relativePath,
|
|
807
|
+
line: c.line,
|
|
808
|
+
expression: c.content.trim(),
|
|
809
|
+
callerName: c.callerName,
|
|
810
|
+
confidence: c.confidence,
|
|
811
|
+
resolution: c.resolution,
|
|
812
|
+
}));
|
|
813
|
+
|
|
814
|
+
allCallees = index.findCallees(primary, { includeMethods, includeUncertain: options.includeUncertain });
|
|
815
|
+
// Apply exclude filter before slicing
|
|
816
|
+
if (options.exclude && options.exclude.length > 0) {
|
|
817
|
+
allCallees = allCallees.filter(c => index.matchesFilters(c.relativePath, { exclude: options.exclude }));
|
|
818
|
+
}
|
|
819
|
+
// Apply confidence filtering before slicing
|
|
820
|
+
if (options.minConfidence > 0) {
|
|
821
|
+
const { filterByConfidence } = require('./confidence');
|
|
822
|
+
const calleeResult = filterByConfidence(allCallees, options.minConfidence);
|
|
823
|
+
allCallees = calleeResult.kept;
|
|
824
|
+
aboutConfFiltered += calleeResult.filtered;
|
|
825
|
+
}
|
|
826
|
+
callees = allCallees.slice(0, maxCallees).map(c => ({
|
|
827
|
+
name: c.name,
|
|
828
|
+
file: c.relativePath,
|
|
829
|
+
line: c.startLine,
|
|
830
|
+
startLine: c.startLine,
|
|
831
|
+
endLine: c.endLine,
|
|
832
|
+
weight: c.weight,
|
|
833
|
+
callCount: c.callCount,
|
|
834
|
+
confidence: c.confidence,
|
|
835
|
+
resolution: c.resolution,
|
|
836
|
+
}));
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
// Find tests
|
|
840
|
+
const tests = index.tests(symbolName);
|
|
841
|
+
const testSummary = {
|
|
842
|
+
fileCount: tests.length,
|
|
843
|
+
totalMatches: tests.reduce((sum, t) => sum + t.matches.length, 0),
|
|
844
|
+
files: (options.all ? tests : tests.slice(0, 3)).map(t => t.file)
|
|
845
|
+
};
|
|
846
|
+
|
|
847
|
+
// Extract code if requested (default: true)
|
|
848
|
+
let code = null;
|
|
849
|
+
if (options.withCode !== false) {
|
|
850
|
+
code = index.extractCode(primary);
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
// Get type definitions if requested
|
|
854
|
+
let types = [];
|
|
855
|
+
if (options.withTypes) {
|
|
856
|
+
const TYPE_KINDS = ['type', 'interface', 'class', 'struct'];
|
|
857
|
+
const seen = new Set();
|
|
858
|
+
|
|
859
|
+
const addType = (typeName) => {
|
|
860
|
+
if (seen.has(typeName)) return;
|
|
861
|
+
seen.add(typeName);
|
|
862
|
+
const typeSymbols = index.symbols.get(typeName);
|
|
863
|
+
if (typeSymbols) {
|
|
864
|
+
for (const sym of typeSymbols) {
|
|
865
|
+
if (TYPE_KINDS.includes(sym.type)) {
|
|
866
|
+
types.push({
|
|
867
|
+
name: sym.name,
|
|
868
|
+
type: sym.type,
|
|
869
|
+
file: sym.relativePath,
|
|
870
|
+
line: sym.startLine
|
|
871
|
+
});
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
};
|
|
876
|
+
|
|
877
|
+
// From signature annotations
|
|
878
|
+
const typeNames = index.extractTypeNames(primary);
|
|
879
|
+
for (const typeName of typeNames) addType(typeName);
|
|
880
|
+
|
|
881
|
+
// From callee signatures — types used by functions this function calls
|
|
882
|
+
if (allCallees) {
|
|
883
|
+
for (const callee of allCallees) {
|
|
884
|
+
const calleeTypeNames = index.extractTypeNames(callee);
|
|
885
|
+
for (const tn of calleeTypeNames) addType(tn);
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
const result = {
|
|
891
|
+
found: true,
|
|
892
|
+
symbol: {
|
|
893
|
+
name: primary.name,
|
|
894
|
+
type: primary.type,
|
|
895
|
+
file: primary.relativePath,
|
|
896
|
+
startLine: primary.startLine,
|
|
897
|
+
endLine: primary.endLine,
|
|
898
|
+
params: primary.params,
|
|
899
|
+
returnType: primary.returnType,
|
|
900
|
+
modifiers: primary.modifiers,
|
|
901
|
+
docstring: primary.docstring,
|
|
902
|
+
signature: index.formatSignature(primary)
|
|
903
|
+
},
|
|
904
|
+
usages: usagesByType,
|
|
905
|
+
totalUsages: usagesByType.calls + usagesByType.imports + usagesByType.references,
|
|
906
|
+
callers: {
|
|
907
|
+
total: allCallers?.length ?? 0,
|
|
908
|
+
top: callers
|
|
909
|
+
},
|
|
910
|
+
callees: {
|
|
911
|
+
total: allCallees?.length ?? 0,
|
|
912
|
+
top: callees
|
|
913
|
+
},
|
|
914
|
+
tests: testSummary,
|
|
915
|
+
otherDefinitions: (options.all ? others : others.slice(0, 3)).map(d => ({
|
|
916
|
+
file: d.relativePath,
|
|
917
|
+
line: d.startLine,
|
|
918
|
+
usageCount: d.usageCount ?? index.countSymbolUsages(d).total
|
|
919
|
+
})),
|
|
920
|
+
types,
|
|
921
|
+
code,
|
|
922
|
+
includeMethods,
|
|
923
|
+
...(aboutConfFiltered > 0 && { confidenceFiltered: aboutConfFiltered }),
|
|
924
|
+
completeness: detectCompleteness(index)
|
|
925
|
+
};
|
|
926
|
+
|
|
927
|
+
return result;
|
|
928
|
+
} finally { index._endOp(); }
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
/**
|
|
932
|
+
* Diff-based impact analysis: find which functions changed and who calls them.
|
|
933
|
+
*
|
|
934
|
+
* @param {object} index - ProjectIndex instance
|
|
935
|
+
* @param {object} options - { base, staged, file }
|
|
936
|
+
* @returns {object}
|
|
937
|
+
*/
|
|
938
|
+
function diffImpact(index, options = {}) {
|
|
939
|
+
index._beginOp();
|
|
940
|
+
try {
|
|
941
|
+
const { base = 'HEAD', staged = false, file } = options;
|
|
942
|
+
|
|
943
|
+
// Validate base ref format to prevent argument injection
|
|
944
|
+
if (base && !/^[a-zA-Z0-9._\-~\/^@{}:]+$/.test(base)) { // eslint-disable-line no-useless-escape
|
|
945
|
+
throw new Error(`Invalid git ref format: ${base}`);
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
// Verify git repo
|
|
949
|
+
let gitRoot;
|
|
950
|
+
try {
|
|
951
|
+
gitRoot = execFileSync('git', ['rev-parse', '--show-toplevel'], { cwd: index.root, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
|
|
952
|
+
} catch (e) {
|
|
953
|
+
throw new Error('Not a git repository. diff-impact requires git.', { cause: e });
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
// Build git diff command (use execFileSync to avoid shell expansion)
|
|
957
|
+
const diffArgs = ['diff', '--unified=0'];
|
|
958
|
+
if (staged) {
|
|
959
|
+
diffArgs.push('--staged');
|
|
960
|
+
} else {
|
|
961
|
+
diffArgs.push(base);
|
|
962
|
+
}
|
|
963
|
+
if (file) {
|
|
964
|
+
diffArgs.push('--', file);
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
let diffText;
|
|
968
|
+
try {
|
|
969
|
+
diffText = execFileSync('git', diffArgs, { cwd: index.root, encoding: 'utf-8', maxBuffer: 10 * 1024 * 1024 });
|
|
970
|
+
} catch (e) {
|
|
971
|
+
// git diff exits non-zero when there are diff errors, but also for invalid refs
|
|
972
|
+
if (e.stdout) {
|
|
973
|
+
diffText = e.stdout;
|
|
974
|
+
} else {
|
|
975
|
+
throw new Error(`git diff failed: ${e.message}`, { cause: e });
|
|
976
|
+
}
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
if (!diffText || !diffText.trim()) {
|
|
980
|
+
return {
|
|
981
|
+
base: staged ? '(staged)' : base,
|
|
982
|
+
functions: [],
|
|
983
|
+
moduleLevelChanges: [],
|
|
984
|
+
newFunctions: [],
|
|
985
|
+
deletedFunctions: [],
|
|
986
|
+
summary: { modifiedFunctions: 0, deletedFunctions: 0, newFunctions: 0, totalCallSites: 0, affectedFiles: 0 }
|
|
987
|
+
};
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
// Diff paths are git-root-relative. Resolve to index.root for file lookup.
|
|
991
|
+
// Normalize both through realpath to handle macOS /var → /private/var symlinks.
|
|
992
|
+
let realGitRoot, realProjectRoot;
|
|
993
|
+
try { realGitRoot = fs.realpathSync(gitRoot); } catch (_) { realGitRoot = gitRoot; }
|
|
994
|
+
try { realProjectRoot = fs.realpathSync(index.root); } catch (_) { realProjectRoot = index.root; }
|
|
995
|
+
const projectPrefix = realGitRoot === realProjectRoot
|
|
996
|
+
? ''
|
|
997
|
+
: path.relative(realGitRoot, realProjectRoot);
|
|
998
|
+
|
|
999
|
+
const rawChanges = parseDiff(diffText, gitRoot);
|
|
1000
|
+
// Filter to files under index.root and remap paths.
|
|
1001
|
+
// Preserve gitRelativePath (repo-relative) for git show commands.
|
|
1002
|
+
const changes = [];
|
|
1003
|
+
for (const c of rawChanges) {
|
|
1004
|
+
if (projectPrefix && !c.relativePath.startsWith(projectPrefix + '/')) continue;
|
|
1005
|
+
const localRel = projectPrefix ? c.relativePath.slice(projectPrefix.length + 1) : c.relativePath;
|
|
1006
|
+
changes.push({ ...c, gitRelativePath: c.relativePath, filePath: path.join(index.root, localRel), relativePath: localRel });
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
const functions = [];
|
|
1010
|
+
const moduleLevelChanges = [];
|
|
1011
|
+
const newFunctions = [];
|
|
1012
|
+
const deletedFunctions = [];
|
|
1013
|
+
const callerFileSet = new Set();
|
|
1014
|
+
let totalCallSites = 0;
|
|
1015
|
+
|
|
1016
|
+
for (const change of changes) {
|
|
1017
|
+
const lang = detectLanguage(change.filePath);
|
|
1018
|
+
if (!lang) continue;
|
|
1019
|
+
|
|
1020
|
+
const fileEntry = index.files.get(change.filePath);
|
|
1021
|
+
|
|
1022
|
+
// Handle deleted files: entire file was removed, all functions are deleted
|
|
1023
|
+
if (!fileEntry) {
|
|
1024
|
+
if (change.isDeleted && change.deletedLines.length > 0) {
|
|
1025
|
+
const ref = staged ? 'HEAD' : base;
|
|
1026
|
+
try {
|
|
1027
|
+
const oldContent = execFileSync(
|
|
1028
|
+
'git', ['show', `${ref}:${change.gitRelativePath}`],
|
|
1029
|
+
{ cwd: index.root, encoding: 'utf-8', maxBuffer: 10 * 1024 * 1024, stdio: ['ignore', 'pipe', 'ignore'] }
|
|
1030
|
+
);
|
|
1031
|
+
const oldParsed = parse(oldContent, lang);
|
|
1032
|
+
for (const oldFn of extractCallableSymbols(oldParsed)) {
|
|
1033
|
+
deletedFunctions.push({
|
|
1034
|
+
name: oldFn.name,
|
|
1035
|
+
filePath: change.filePath,
|
|
1036
|
+
relativePath: change.relativePath,
|
|
1037
|
+
startLine: oldFn.startLine
|
|
1038
|
+
});
|
|
1039
|
+
}
|
|
1040
|
+
} catch (e) {
|
|
1041
|
+
// git show failed — skip
|
|
1042
|
+
}
|
|
1043
|
+
}
|
|
1044
|
+
continue;
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
// Track which functions are affected by added/modified lines
|
|
1048
|
+
const affectedSymbols = new Map(); // symbolName -> { symbol, addedLines, deletedLines }
|
|
1049
|
+
|
|
1050
|
+
for (const line of change.addedLines) {
|
|
1051
|
+
const symbol = index.findEnclosingFunction(change.filePath, line, true);
|
|
1052
|
+
if (symbol) {
|
|
1053
|
+
const key = `${symbol.name}:${symbol.startLine}`;
|
|
1054
|
+
if (!affectedSymbols.has(key)) {
|
|
1055
|
+
affectedSymbols.set(key, { symbol, addedLines: [], deletedLines: [] });
|
|
1056
|
+
}
|
|
1057
|
+
affectedSymbols.get(key).addedLines.push(line);
|
|
1058
|
+
} else {
|
|
1059
|
+
// Module-level change
|
|
1060
|
+
const existing = moduleLevelChanges.find(m => m.filePath === change.filePath);
|
|
1061
|
+
if (existing) {
|
|
1062
|
+
existing.addedLines.push(line);
|
|
1063
|
+
} else {
|
|
1064
|
+
moduleLevelChanges.push({
|
|
1065
|
+
filePath: change.filePath,
|
|
1066
|
+
relativePath: change.relativePath,
|
|
1067
|
+
addedLines: [line],
|
|
1068
|
+
deletedLines: []
|
|
1069
|
+
});
|
|
1070
|
+
}
|
|
1071
|
+
}
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
for (const line of change.deletedLines) {
|
|
1075
|
+
// For deleted lines, we can't use findEnclosingFunction on the current file
|
|
1076
|
+
// since those lines no longer exist. Track as module-level unless they map
|
|
1077
|
+
// to a function that still exists (the function was modified, not deleted).
|
|
1078
|
+
// We approximate: if a deleted line is within the range of a known symbol, it's a modification.
|
|
1079
|
+
let matched = false;
|
|
1080
|
+
for (const symbol of fileEntry.symbols) {
|
|
1081
|
+
if (NON_CALLABLE_TYPES.has(symbol.type)) continue;
|
|
1082
|
+
// Use a generous range — deleted lines near a function likely belong to it
|
|
1083
|
+
if (line >= symbol.startLine - 2 && line <= symbol.endLine + 2) {
|
|
1084
|
+
const key = `${symbol.name}:${symbol.startLine}`;
|
|
1085
|
+
if (!affectedSymbols.has(key)) {
|
|
1086
|
+
affectedSymbols.set(key, { symbol, addedLines: [], deletedLines: [] });
|
|
1087
|
+
}
|
|
1088
|
+
affectedSymbols.get(key).deletedLines.push(line);
|
|
1089
|
+
matched = true;
|
|
1090
|
+
break;
|
|
1091
|
+
}
|
|
1092
|
+
}
|
|
1093
|
+
if (!matched) {
|
|
1094
|
+
const existing = moduleLevelChanges.find(m => m.filePath === change.filePath);
|
|
1095
|
+
if (existing) {
|
|
1096
|
+
existing.deletedLines.push(line);
|
|
1097
|
+
} else {
|
|
1098
|
+
moduleLevelChanges.push({
|
|
1099
|
+
filePath: change.filePath,
|
|
1100
|
+
relativePath: change.relativePath,
|
|
1101
|
+
addedLines: [],
|
|
1102
|
+
deletedLines: [line]
|
|
1103
|
+
});
|
|
1104
|
+
}
|
|
1105
|
+
}
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
// Detect new functions: all added lines are within a single function range
|
|
1109
|
+
// and the function didn't exist before (approximation: all lines in the function are added)
|
|
1110
|
+
for (const [key, data] of affectedSymbols) {
|
|
1111
|
+
const { symbol, addedLines } = data;
|
|
1112
|
+
const fnLineCount = symbol.endLine - symbol.startLine + 1;
|
|
1113
|
+
if (addedLines.length >= fnLineCount * 0.8 && data.deletedLines.length === 0) {
|
|
1114
|
+
newFunctions.push({
|
|
1115
|
+
name: symbol.name,
|
|
1116
|
+
filePath: change.filePath,
|
|
1117
|
+
relativePath: change.relativePath,
|
|
1118
|
+
startLine: symbol.startLine,
|
|
1119
|
+
endLine: symbol.endLine,
|
|
1120
|
+
signature: index.formatSignature(symbol)
|
|
1121
|
+
});
|
|
1122
|
+
affectedSymbols.delete(key);
|
|
1123
|
+
}
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
// Detect deleted functions: compare old file symbols with current by identity.
|
|
1127
|
+
// Uses name+className counts to handle overloads (e.g. Java method overloading).
|
|
1128
|
+
if (change.deletedLines.length > 0) {
|
|
1129
|
+
const ref = staged ? 'HEAD' : base;
|
|
1130
|
+
try {
|
|
1131
|
+
const oldContent = execFileSync(
|
|
1132
|
+
'git', ['show', `${ref}:${change.gitRelativePath}`],
|
|
1133
|
+
{ cwd: index.root, encoding: 'utf-8', maxBuffer: 10 * 1024 * 1024, stdio: ['ignore', 'pipe', 'ignore'] }
|
|
1134
|
+
);
|
|
1135
|
+
const fileLang = detectLanguage(change.filePath);
|
|
1136
|
+
if (fileLang) {
|
|
1137
|
+
const oldParsed = parse(oldContent, fileLang);
|
|
1138
|
+
// Count current symbols by identity (name + className)
|
|
1139
|
+
const currentCounts = new Map();
|
|
1140
|
+
for (const s of fileEntry.symbols) {
|
|
1141
|
+
if (NON_CALLABLE_TYPES.has(s.type)) continue;
|
|
1142
|
+
const key = `${s.name}\0${s.className || ''}`;
|
|
1143
|
+
currentCounts.set(key, (currentCounts.get(key) || 0) + 1);
|
|
1144
|
+
}
|
|
1145
|
+
// Count old symbols by identity and detect deletions
|
|
1146
|
+
const oldCounts = new Map();
|
|
1147
|
+
const oldSymbols = extractCallableSymbols(oldParsed);
|
|
1148
|
+
for (const oldFn of oldSymbols) {
|
|
1149
|
+
const key = `${oldFn.name}\0${oldFn.className || ''}`;
|
|
1150
|
+
oldCounts.set(key, (oldCounts.get(key) || 0) + 1);
|
|
1151
|
+
}
|
|
1152
|
+
// For each identity, if old count > current count, the difference are deletions
|
|
1153
|
+
for (const [key, oldCount] of oldCounts) {
|
|
1154
|
+
const curCount = currentCounts.get(key) || 0;
|
|
1155
|
+
if (oldCount > curCount) {
|
|
1156
|
+
// Find the specific old symbols with this identity that were deleted
|
|
1157
|
+
const matching = oldSymbols.filter(s => `${s.name}\0${s.className || ''}` === key);
|
|
1158
|
+
// Report the extra ones (by startLine descending — later ones more likely deleted)
|
|
1159
|
+
const toReport = matching.slice(curCount);
|
|
1160
|
+
for (const oldFn of toReport) {
|
|
1161
|
+
deletedFunctions.push({
|
|
1162
|
+
name: oldFn.name,
|
|
1163
|
+
filePath: change.filePath,
|
|
1164
|
+
relativePath: change.relativePath,
|
|
1165
|
+
startLine: oldFn.startLine
|
|
1166
|
+
});
|
|
1167
|
+
}
|
|
1168
|
+
}
|
|
1169
|
+
}
|
|
1170
|
+
}
|
|
1171
|
+
} catch (e) {
|
|
1172
|
+
// File didn't exist in base, or git error — skip
|
|
1173
|
+
}
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
// For each affected function, find callers
|
|
1177
|
+
for (const [, data] of affectedSymbols) {
|
|
1178
|
+
const { symbol, addedLines: aLines, deletedLines: dLines } = data;
|
|
1179
|
+
|
|
1180
|
+
// Get the specific definitions matching this symbol
|
|
1181
|
+
const allDefs = index.symbols.get(symbol.name) || [];
|
|
1182
|
+
const targetDefs = allDefs.filter(d => d.file === change.filePath && d.startLine === symbol.startLine);
|
|
1183
|
+
|
|
1184
|
+
let callers = index.findCallers(symbol.name, {
|
|
1185
|
+
targetDefinitions: targetDefs.length > 0 ? targetDefs : undefined,
|
|
1186
|
+
includeMethods: true,
|
|
1187
|
+
includeUncertain: false,
|
|
1188
|
+
});
|
|
1189
|
+
|
|
1190
|
+
// For Go/Java/Rust methods with a className, filter callers whose
|
|
1191
|
+
// receiver clearly belongs to a different type (same logic as impact()).
|
|
1192
|
+
const targetDef = targetDefs[0] || symbol;
|
|
1193
|
+
if (targetDef.className && langTraits(lang)?.typeSystem === 'nominal') {
|
|
1194
|
+
const targetClassName = targetDef.className;
|
|
1195
|
+
// Pre-compute how many types share this method name
|
|
1196
|
+
const methodDefs = index.symbols.get(symbol.name);
|
|
1197
|
+
const classNames = new Set();
|
|
1198
|
+
if (methodDefs) {
|
|
1199
|
+
for (const d of methodDefs) {
|
|
1200
|
+
if (d.className) classNames.add(d.className);
|
|
1201
|
+
else if (d.receiver) classNames.add(d.receiver.replace(/^\*/, ''));
|
|
1202
|
+
}
|
|
1203
|
+
}
|
|
1204
|
+
const isWidelyShared = classNames.size > 3;
|
|
1205
|
+
callers = callers.filter(c => {
|
|
1206
|
+
if (!c.isMethod) return true;
|
|
1207
|
+
const r = c.receiver;
|
|
1208
|
+
if (r && ['self', 'cls', 'this', 'super'].includes(r)) return true;
|
|
1209
|
+
// No receiver (chained/complex expression): only include if method is
|
|
1210
|
+
// unique or rare across types — otherwise too many false positives
|
|
1211
|
+
if (!r) {
|
|
1212
|
+
return classNames.size <= 1;
|
|
1213
|
+
}
|
|
1214
|
+
// Use receiverType from findCallers when available
|
|
1215
|
+
if (c.receiverType) {
|
|
1216
|
+
return c.receiverType === targetClassName ||
|
|
1217
|
+
c.receiverType === targetDef.receiver?.replace(/^\*/, '');
|
|
1218
|
+
}
|
|
1219
|
+
// Unique method heuristic: if the method exists on exactly one class/type, include
|
|
1220
|
+
if (classNames.size === 1 && classNames.has(targetClassName)) return true;
|
|
1221
|
+
// For widely shared method names (Get, Set, Run, etc.), require same-package
|
|
1222
|
+
// evidence when receiver type is unknown
|
|
1223
|
+
if (isWidelyShared) {
|
|
1224
|
+
const callerFile = c.file || '';
|
|
1225
|
+
const targetDir = path.dirname(change.filePath);
|
|
1226
|
+
return path.dirname(callerFile) === targetDir;
|
|
1227
|
+
}
|
|
1228
|
+
// Unknown receiver + multiple classes with this method → filter out
|
|
1229
|
+
return false;
|
|
1230
|
+
});
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1233
|
+
for (const c of callers) {
|
|
1234
|
+
callerFileSet.add(c.file);
|
|
1235
|
+
}
|
|
1236
|
+
totalCallSites += callers.length;
|
|
1237
|
+
|
|
1238
|
+
functions.push({
|
|
1239
|
+
name: symbol.name,
|
|
1240
|
+
filePath: change.filePath,
|
|
1241
|
+
relativePath: change.relativePath,
|
|
1242
|
+
startLine: symbol.startLine,
|
|
1243
|
+
endLine: symbol.endLine,
|
|
1244
|
+
signature: index.formatSignature(symbol),
|
|
1245
|
+
addedLines: aLines,
|
|
1246
|
+
deletedLines: dLines,
|
|
1247
|
+
callers: callers.map(c => ({
|
|
1248
|
+
file: c.file,
|
|
1249
|
+
relativePath: c.relativePath,
|
|
1250
|
+
line: c.line,
|
|
1251
|
+
callerName: c.callerName,
|
|
1252
|
+
content: c.content.trim()
|
|
1253
|
+
}))
|
|
1254
|
+
});
|
|
1255
|
+
}
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
return {
|
|
1259
|
+
base: staged ? '(staged)' : base,
|
|
1260
|
+
functions,
|
|
1261
|
+
moduleLevelChanges,
|
|
1262
|
+
newFunctions,
|
|
1263
|
+
deletedFunctions,
|
|
1264
|
+
summary: {
|
|
1265
|
+
modifiedFunctions: functions.length,
|
|
1266
|
+
deletedFunctions: deletedFunctions.length,
|
|
1267
|
+
newFunctions: newFunctions.length,
|
|
1268
|
+
totalCallSites,
|
|
1269
|
+
affectedFiles: callerFileSet.size
|
|
1270
|
+
}
|
|
1271
|
+
};
|
|
1272
|
+
} finally { index._endOp(); }
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1275
|
+
// ========================================================================
|
|
1276
|
+
// STANDALONE HELPERS (used by diffImpact and parseDiff)
|
|
1277
|
+
// ========================================================================
|
|
1278
|
+
|
|
1279
|
+
/**
|
|
1280
|
+
* Extract all callable symbols (functions + class methods) from a parse result,
|
|
1281
|
+
* matching how indexFile builds the symbol list. Methods get className added.
|
|
1282
|
+
* @param {object} parsed - Result from parse()
|
|
1283
|
+
* @returns {Array<{name, className, startLine}>}
|
|
1284
|
+
*/
|
|
1285
|
+
function extractCallableSymbols(parsed) {
|
|
1286
|
+
const symbols = [];
|
|
1287
|
+
for (const fn of parsed.functions) {
|
|
1288
|
+
symbols.push({ name: fn.name, className: fn.className || '', startLine: fn.startLine });
|
|
1289
|
+
}
|
|
1290
|
+
for (const cls of parsed.classes) {
|
|
1291
|
+
if (cls.members) {
|
|
1292
|
+
for (const m of cls.members) {
|
|
1293
|
+
symbols.push({ name: m.name, className: cls.name, startLine: m.startLine });
|
|
1294
|
+
}
|
|
1295
|
+
}
|
|
1296
|
+
}
|
|
1297
|
+
return symbols;
|
|
1298
|
+
}
|
|
1299
|
+
|
|
1300
|
+
/**
|
|
1301
|
+
* Unquote a git diff path: unescape C-style backslash sequences and strip tab metadata.
|
|
1302
|
+
* Git quotes paths containing special chars as "a/path\"with\"quotes".
|
|
1303
|
+
* @param {string} raw - Raw path string (may contain backslash escapes)
|
|
1304
|
+
* @returns {string} Unquoted path
|
|
1305
|
+
*/
|
|
1306
|
+
function unquoteDiffPath(raw) {
|
|
1307
|
+
const ESCAPES = { '\\\\': '\\', '\\"': '"', '\\n': '\n', '\\t': '\t' };
|
|
1308
|
+
return raw
|
|
1309
|
+
.split('\t')[0]
|
|
1310
|
+
.replace(/\\[\\"nt]/g, m => ESCAPES[m]);
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1313
|
+
/**
|
|
1314
|
+
* Parse unified diff output into structured change data
|
|
1315
|
+
* @param {string} diffText - Output from `git diff --unified=0`
|
|
1316
|
+
* @param {string} root - Project root directory
|
|
1317
|
+
* @returns {Array<{ filePath, relativePath, addedLines, deletedLines }>}
|
|
1318
|
+
*/
|
|
1319
|
+
function parseDiff(diffText, root) {
|
|
1320
|
+
const changes = [];
|
|
1321
|
+
let currentFile = null;
|
|
1322
|
+
let pendingOldPath = null; // Track --- a/ path for deleted files
|
|
1323
|
+
|
|
1324
|
+
for (const line of diffText.split('\n')) {
|
|
1325
|
+
// Track old file path from --- header for deleted-file detection
|
|
1326
|
+
// Handles both unquoted (--- a/path) and quoted (--- "a/path") formats
|
|
1327
|
+
const oldMatch = line.match(/^--- (?:"a\/((?:[^"\\]|\\.)*)"|a\/(.+?))\s*$/);
|
|
1328
|
+
if (oldMatch) {
|
|
1329
|
+
const raw = oldMatch[1] !== undefined ? oldMatch[1] : oldMatch[2];
|
|
1330
|
+
pendingOldPath = unquoteDiffPath(raw);
|
|
1331
|
+
continue;
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1334
|
+
// Match file header: +++ b/path or +++ "b/path" or +++ /dev/null
|
|
1335
|
+
if (line.startsWith('+++ ')) {
|
|
1336
|
+
let relativePath;
|
|
1337
|
+
const isDevNull = line.startsWith('+++ /dev/null');
|
|
1338
|
+
if (isDevNull) {
|
|
1339
|
+
// File was deleted — use the --- a/ path
|
|
1340
|
+
if (!pendingOldPath) continue;
|
|
1341
|
+
relativePath = pendingOldPath;
|
|
1342
|
+
} else {
|
|
1343
|
+
const newMatch = line.match(/^\+\+\+ (?:"b\/((?:[^"\\]|\\.)*)"|b\/(.+?))\s*$/);
|
|
1344
|
+
if (!newMatch) continue;
|
|
1345
|
+
const raw = newMatch[1] !== undefined ? newMatch[1] : newMatch[2];
|
|
1346
|
+
relativePath = unquoteDiffPath(raw);
|
|
1347
|
+
}
|
|
1348
|
+
pendingOldPath = null;
|
|
1349
|
+
currentFile = {
|
|
1350
|
+
filePath: path.join(root, relativePath),
|
|
1351
|
+
relativePath,
|
|
1352
|
+
addedLines: [],
|
|
1353
|
+
deletedLines: [],
|
|
1354
|
+
...(isDevNull && { isDeleted: true })
|
|
1355
|
+
};
|
|
1356
|
+
changes.push(currentFile);
|
|
1357
|
+
continue;
|
|
1358
|
+
}
|
|
1359
|
+
|
|
1360
|
+
// Match hunk header: @@ -old,count +new,count @@
|
|
1361
|
+
if (line.startsWith('@@') && currentFile) {
|
|
1362
|
+
const match = line.match(/@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/);
|
|
1363
|
+
if (match) {
|
|
1364
|
+
const oldStart = parseInt(match[1], 10);
|
|
1365
|
+
const oldCount = parseInt(match[2] || '1', 10);
|
|
1366
|
+
const newStart = parseInt(match[3], 10);
|
|
1367
|
+
const newCount = parseInt(match[4] || '1', 10);
|
|
1368
|
+
|
|
1369
|
+
// Deleted lines (from old file)
|
|
1370
|
+
if (oldCount > 0) {
|
|
1371
|
+
for (let i = 0; i < oldCount; i++) {
|
|
1372
|
+
currentFile.deletedLines.push(oldStart + i);
|
|
1373
|
+
}
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1376
|
+
// Added lines (in new file)
|
|
1377
|
+
if (newCount > 0) {
|
|
1378
|
+
for (let i = 0; i < newCount; i++) {
|
|
1379
|
+
currentFile.addedLines.push(newStart + i);
|
|
1380
|
+
}
|
|
1381
|
+
}
|
|
1382
|
+
}
|
|
1383
|
+
}
|
|
1384
|
+
}
|
|
1385
|
+
|
|
1386
|
+
return changes;
|
|
1387
|
+
}
|
|
1388
|
+
|
|
1389
|
+
module.exports = {
|
|
1390
|
+
context,
|
|
1391
|
+
smart,
|
|
1392
|
+
detectCompleteness,
|
|
1393
|
+
related,
|
|
1394
|
+
impact,
|
|
1395
|
+
about,
|
|
1396
|
+
diffImpact,
|
|
1397
|
+
parseDiff,
|
|
1398
|
+
extractCallableSymbols,
|
|
1399
|
+
unquoteDiffPath,
|
|
1400
|
+
};
|