ucn 3.8.12 → 3.8.14
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 +15 -3
- package/.github/workflows/publish.yml +20 -8
- 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/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/graph.js
ADDED
|
@@ -0,0 +1,615 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* core/graph.js — Graph and file-dependency analysis
|
|
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 path = require('path');
|
|
11
|
+
const { extractImports, resolveImport } = require('./imports');
|
|
12
|
+
const { langTraits } = require('../languages');
|
|
13
|
+
const { isTestFile } = require('./discovery');
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Resolve imports in a file
|
|
17
|
+
* @param {object} index - ProjectIndex instance
|
|
18
|
+
* @param {string} filePath - File to analyze
|
|
19
|
+
* @returns {Array} Resolved imports
|
|
20
|
+
*/
|
|
21
|
+
function imports(index, filePath) {
|
|
22
|
+
const resolved = index.resolveFilePathForQuery(filePath);
|
|
23
|
+
if (typeof resolved !== 'string') return resolved;
|
|
24
|
+
|
|
25
|
+
const normalizedPath = resolved;
|
|
26
|
+
const fileEntry = index.files.get(normalizedPath);
|
|
27
|
+
if (!fileEntry) {
|
|
28
|
+
return { error: 'file-not-found', filePath };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
try {
|
|
32
|
+
const content = index._readFile(normalizedPath);
|
|
33
|
+
const { imports: rawImports } = extractImports(content, fileEntry.language);
|
|
34
|
+
|
|
35
|
+
const contentLines = content.split('\n');
|
|
36
|
+
|
|
37
|
+
return rawImports.map(imp => {
|
|
38
|
+
// Skip imports with null module (e.g. Rust include! with dynamic path)
|
|
39
|
+
if (!imp.module) {
|
|
40
|
+
return {
|
|
41
|
+
module: null,
|
|
42
|
+
names: imp.names,
|
|
43
|
+
type: imp.type,
|
|
44
|
+
resolved: null,
|
|
45
|
+
isExternal: false,
|
|
46
|
+
isDynamic: true,
|
|
47
|
+
line: null
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Dynamic imports with variable path (e.g. require(varName), import(varExpr)) can't be resolved.
|
|
52
|
+
// Only JS/TS require()/import() with dynamic=true has unresolvable paths.
|
|
53
|
+
// Go side-effect/dot imports and Rust glob uses also set dynamic=true but have valid module paths.
|
|
54
|
+
const isUnresolvableDynamic = imp.dynamic && (imp.type === 'require' || imp.type === 'dynamic');
|
|
55
|
+
if (isUnresolvableDynamic) {
|
|
56
|
+
let line = null;
|
|
57
|
+
for (let i = 0; i < contentLines.length; i++) {
|
|
58
|
+
if (contentLines[i].includes(imp.module || 'require')) {
|
|
59
|
+
line = i + 1;
|
|
60
|
+
break;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return {
|
|
64
|
+
module: imp.module,
|
|
65
|
+
names: imp.names,
|
|
66
|
+
type: imp.type,
|
|
67
|
+
resolved: null,
|
|
68
|
+
isExternal: false,
|
|
69
|
+
isDynamic: true,
|
|
70
|
+
line
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
let resolvedPath = resolveImport(imp.module, normalizedPath, {
|
|
75
|
+
aliases: index.config.aliases,
|
|
76
|
+
language: fileEntry.language,
|
|
77
|
+
root: index.root
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
// Java package imports: resolve by progressive suffix matching
|
|
81
|
+
// Handles regular, static (com.pkg.Class.method), and wildcard (com.pkg.Class.*) imports
|
|
82
|
+
if (!resolvedPath && fileEntry.language === 'java' && !imp.module.startsWith('.')) {
|
|
83
|
+
resolvedPath = index._resolveJavaPackageImport(imp.module);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Find line number of import
|
|
87
|
+
let line = null;
|
|
88
|
+
for (let i = 0; i < contentLines.length; i++) {
|
|
89
|
+
if (contentLines[i].includes(imp.module)) {
|
|
90
|
+
line = i + 1;
|
|
91
|
+
break;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return {
|
|
96
|
+
module: imp.module,
|
|
97
|
+
names: imp.names,
|
|
98
|
+
type: imp.type,
|
|
99
|
+
resolved: resolvedPath ? path.relative(index.root, resolvedPath) : null,
|
|
100
|
+
isExternal: !resolvedPath,
|
|
101
|
+
isDynamic: false,
|
|
102
|
+
line
|
|
103
|
+
};
|
|
104
|
+
});
|
|
105
|
+
} catch (e) {
|
|
106
|
+
return [];
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Get files that import a given file
|
|
112
|
+
* @param {object} index - ProjectIndex instance
|
|
113
|
+
* @param {string} filePath - File to check
|
|
114
|
+
* @returns {Array} Files that import this file
|
|
115
|
+
*/
|
|
116
|
+
function exporters(index, filePath) {
|
|
117
|
+
const resolved = index.resolveFilePathForQuery(filePath);
|
|
118
|
+
if (typeof resolved !== 'string') return resolved;
|
|
119
|
+
|
|
120
|
+
const targetPath = resolved;
|
|
121
|
+
|
|
122
|
+
const importers = index.exportGraph.get(targetPath) || [];
|
|
123
|
+
|
|
124
|
+
return importers.map(importerPath => {
|
|
125
|
+
const fileEntry = index.files.get(importerPath);
|
|
126
|
+
|
|
127
|
+
// Find the import line
|
|
128
|
+
let importLine = null;
|
|
129
|
+
try {
|
|
130
|
+
const content = index._readFile(importerPath);
|
|
131
|
+
const lines = content.split('\n');
|
|
132
|
+
let targetBasename = path.basename(targetPath, path.extname(targetPath));
|
|
133
|
+
|
|
134
|
+
// For __init__.py, search for the package name (parent dir)
|
|
135
|
+
// e.g., "from tools import X" → search for "tools" not "__init__"
|
|
136
|
+
if (targetBasename === '__init__') {
|
|
137
|
+
targetBasename = path.basename(path.dirname(targetPath));
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
for (let i = 0; i < lines.length; i++) {
|
|
141
|
+
if (lines[i].includes(targetBasename) &&
|
|
142
|
+
(lines[i].includes('import') || lines[i].includes('require') || lines[i].includes('from'))) {
|
|
143
|
+
importLine = i + 1;
|
|
144
|
+
break;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
} catch (e) {
|
|
148
|
+
// Skip
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return {
|
|
152
|
+
file: fileEntry ? fileEntry.relativePath : path.relative(index.root, importerPath),
|
|
153
|
+
importLine
|
|
154
|
+
};
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Get exports for a specific file
|
|
160
|
+
* @param {object} index - ProjectIndex instance
|
|
161
|
+
* @param {string} filePath - File path
|
|
162
|
+
* @param {Set} [_visited] - Internal visited set for re-export recursion
|
|
163
|
+
* @returns {Array} Exported symbols from that file
|
|
164
|
+
*/
|
|
165
|
+
function fileExports(index, filePath, _visited) {
|
|
166
|
+
const resolved = index.resolveFilePathForQuery(filePath);
|
|
167
|
+
if (typeof resolved !== 'string') return resolved;
|
|
168
|
+
|
|
169
|
+
const absPath = resolved;
|
|
170
|
+
const visited = _visited || new Set();
|
|
171
|
+
if (visited.has(absPath)) return [];
|
|
172
|
+
visited.add(absPath);
|
|
173
|
+
|
|
174
|
+
const fileEntry = index.files.get(absPath);
|
|
175
|
+
if (!fileEntry) {
|
|
176
|
+
return [];
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const results = [];
|
|
180
|
+
const exportedNames = new Set(fileEntry.exports);
|
|
181
|
+
|
|
182
|
+
for (const symbol of fileEntry.symbols) {
|
|
183
|
+
const isExported = exportedNames.has(symbol.name) ||
|
|
184
|
+
(symbol.modifiers && symbol.modifiers.includes('export')) ||
|
|
185
|
+
(symbol.modifiers && symbol.modifiers.includes('public')) ||
|
|
186
|
+
(langTraits(fileEntry.language)?.exportVisibility === 'capitalization' && /^[A-Z]/.test(symbol.name));
|
|
187
|
+
|
|
188
|
+
if (isExported) {
|
|
189
|
+
results.push({
|
|
190
|
+
name: symbol.name,
|
|
191
|
+
type: symbol.type,
|
|
192
|
+
file: fileEntry.relativePath,
|
|
193
|
+
startLine: symbol.startLine,
|
|
194
|
+
endLine: symbol.endLine,
|
|
195
|
+
params: symbol.params,
|
|
196
|
+
returnType: symbol.returnType,
|
|
197
|
+
signature: index.formatSignature(symbol)
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Add variable exports (export const/let/var) not matched to symbols
|
|
203
|
+
if (fileEntry.exportDetails) {
|
|
204
|
+
const matchedNames = new Set(results.map(r => r.name));
|
|
205
|
+
for (const exp of fileEntry.exportDetails) {
|
|
206
|
+
if (exp.isVariable && !matchedNames.has(exp.name)) {
|
|
207
|
+
const sig = `${exp.declKind} ${exp.name}${exp.typeAnnotation ? ': ' + exp.typeAnnotation : ''}`;
|
|
208
|
+
results.push({
|
|
209
|
+
name: exp.name,
|
|
210
|
+
type: 'variable',
|
|
211
|
+
file: fileEntry.relativePath,
|
|
212
|
+
startLine: exp.line,
|
|
213
|
+
endLine: exp.line,
|
|
214
|
+
params: undefined,
|
|
215
|
+
returnType: exp.typeAnnotation || null,
|
|
216
|
+
signature: sig
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Add re-exports: export { X } from './module'
|
|
222
|
+
// Resolve to the source file and look up the symbol there
|
|
223
|
+
for (const exp of fileEntry.exportDetails) {
|
|
224
|
+
if ((exp.type === 're-export' || exp.type === 're-export-all') && exp.source && !matchedNames.has(exp.name)) {
|
|
225
|
+
const resolvedSrc = resolveImport(exp.source, absPath, {
|
|
226
|
+
language: fileEntry.language,
|
|
227
|
+
root: index.root,
|
|
228
|
+
extensions: index.extensions
|
|
229
|
+
});
|
|
230
|
+
if (resolvedSrc) {
|
|
231
|
+
const sourceEntry = index.files.get(resolvedSrc);
|
|
232
|
+
if (sourceEntry) {
|
|
233
|
+
// For star re-exports, include all exported symbols from source
|
|
234
|
+
if (exp.type === 're-export-all') {
|
|
235
|
+
const sourceExportsResult = fileExports(index, resolvedSrc, visited);
|
|
236
|
+
for (const srcExp of sourceExportsResult) {
|
|
237
|
+
if (!matchedNames.has(srcExp.name)) {
|
|
238
|
+
matchedNames.add(srcExp.name);
|
|
239
|
+
results.push({ ...srcExp, file: fileEntry.relativePath, reExportedFrom: srcExp.file });
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
} else {
|
|
243
|
+
// Named re-export: find the specific symbol
|
|
244
|
+
const srcSymbol = sourceEntry.symbols.find(s => s.name === exp.name);
|
|
245
|
+
if (srcSymbol) {
|
|
246
|
+
matchedNames.add(exp.name);
|
|
247
|
+
results.push({
|
|
248
|
+
name: exp.name,
|
|
249
|
+
type: srcSymbol.type,
|
|
250
|
+
file: fileEntry.relativePath,
|
|
251
|
+
startLine: exp.line,
|
|
252
|
+
endLine: exp.line,
|
|
253
|
+
params: srcSymbol.params,
|
|
254
|
+
returnType: srcSymbol.returnType,
|
|
255
|
+
signature: index.formatSignature(srcSymbol),
|
|
256
|
+
reExportedFrom: sourceEntry.relativePath
|
|
257
|
+
});
|
|
258
|
+
} else {
|
|
259
|
+
// Symbol not found in source — still list it as a re-export
|
|
260
|
+
matchedNames.add(exp.name);
|
|
261
|
+
results.push({
|
|
262
|
+
name: exp.name,
|
|
263
|
+
type: 're-export',
|
|
264
|
+
file: fileEntry.relativePath,
|
|
265
|
+
startLine: exp.line,
|
|
266
|
+
endLine: exp.line,
|
|
267
|
+
params: undefined,
|
|
268
|
+
returnType: null,
|
|
269
|
+
signature: `re-export ${exp.name} from '${exp.source}'`,
|
|
270
|
+
reExportedFrom: sourceEntry.relativePath
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Python __all__ re-exports: names listed in __all__ that come from imports
|
|
281
|
+
// e.g. __init__.py: `from .utils import helper` + `__all__ = ["helper"]`
|
|
282
|
+
// `helper` is in fileEntry.exports but not in fileEntry.symbols
|
|
283
|
+
if (fileEntry.language === 'python' && fileEntry.exports.length > 0) {
|
|
284
|
+
const matchedNames = new Set(results.map(r => r.name));
|
|
285
|
+
const unmatched = fileEntry.exports.filter(name => !matchedNames.has(name));
|
|
286
|
+
if (unmatched.length > 0) {
|
|
287
|
+
// Re-extract raw imports to get name→module mapping (not stored in fileEntry)
|
|
288
|
+
try {
|
|
289
|
+
const content = index._readFile(absPath);
|
|
290
|
+
const { imports: rawImports } = extractImports(content, 'python');
|
|
291
|
+
// Build name→module map from raw imports
|
|
292
|
+
const nameToModule = new Map();
|
|
293
|
+
for (const imp of rawImports) {
|
|
294
|
+
if (imp.names) {
|
|
295
|
+
for (const name of imp.names) {
|
|
296
|
+
if (name !== '*') nameToModule.set(name, imp.module);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
for (const name of unmatched) {
|
|
301
|
+
const sourceModule = nameToModule.get(name);
|
|
302
|
+
if (!sourceModule) continue;
|
|
303
|
+
const resolvedSrc = resolveImport(sourceModule, absPath, {
|
|
304
|
+
language: 'python',
|
|
305
|
+
root: index.root,
|
|
306
|
+
extensions: index.extensions
|
|
307
|
+
});
|
|
308
|
+
if (!resolvedSrc) continue;
|
|
309
|
+
const sourceEntry = index.files.get(resolvedSrc);
|
|
310
|
+
const srcSymbol = sourceEntry && sourceEntry.symbols.find(s => s.name === name);
|
|
311
|
+
if (srcSymbol) {
|
|
312
|
+
matchedNames.add(name);
|
|
313
|
+
results.push({
|
|
314
|
+
name,
|
|
315
|
+
type: srcSymbol.type,
|
|
316
|
+
file: fileEntry.relativePath,
|
|
317
|
+
startLine: srcSymbol.startLine,
|
|
318
|
+
endLine: srcSymbol.endLine,
|
|
319
|
+
params: srcSymbol.params,
|
|
320
|
+
returnType: srcSymbol.returnType,
|
|
321
|
+
signature: index.formatSignature(srcSymbol),
|
|
322
|
+
reExportedFrom: sourceEntry.relativePath
|
|
323
|
+
});
|
|
324
|
+
} else {
|
|
325
|
+
// Source not indexed or symbol not found — still list it
|
|
326
|
+
matchedNames.add(name);
|
|
327
|
+
results.push({
|
|
328
|
+
name,
|
|
329
|
+
type: 're-export',
|
|
330
|
+
file: fileEntry.relativePath,
|
|
331
|
+
startLine: undefined,
|
|
332
|
+
endLine: undefined,
|
|
333
|
+
params: undefined,
|
|
334
|
+
returnType: null,
|
|
335
|
+
signature: `re-export ${name} from '${sourceModule}'`,
|
|
336
|
+
reExportedFrom: resolvedSrc
|
|
337
|
+
? (sourceEntry ? sourceEntry.relativePath : resolvedSrc)
|
|
338
|
+
: sourceModule
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
} catch (_) {
|
|
343
|
+
// File read failure — skip Python re-export resolution
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
return results;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Get all exported/public symbols
|
|
353
|
+
* @param {object} index - ProjectIndex instance
|
|
354
|
+
* @param {string} [filePath] - Optional file to limit to
|
|
355
|
+
* @param {object} [options] - { includeTests }
|
|
356
|
+
* @returns {Array} Exported symbols
|
|
357
|
+
*/
|
|
358
|
+
function api(index, filePath, options = {}) {
|
|
359
|
+
const results = [];
|
|
360
|
+
|
|
361
|
+
let fileIterator;
|
|
362
|
+
if (filePath) {
|
|
363
|
+
// Try exact resolution first
|
|
364
|
+
const resolved = index.resolveFilePathForQuery(filePath);
|
|
365
|
+
if (typeof resolved === 'string') {
|
|
366
|
+
const fileEntry = index.files.get(resolved);
|
|
367
|
+
if (!fileEntry) return { error: 'file-not-found', filePath };
|
|
368
|
+
fileIterator = [[resolved, fileEntry]];
|
|
369
|
+
} else {
|
|
370
|
+
// Fall back to pattern filter (substring match on relative path)
|
|
371
|
+
const matches = [];
|
|
372
|
+
for (const [absPath, fe] of index.files) {
|
|
373
|
+
if (fe.relativePath.includes(filePath)) {
|
|
374
|
+
matches.push([absPath, fe]);
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
if (matches.length === 0) return { error: 'file-not-found', filePath };
|
|
378
|
+
fileIterator = matches;
|
|
379
|
+
}
|
|
380
|
+
} else {
|
|
381
|
+
fileIterator = index.files.entries();
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
for (const [, fileEntry] of fileIterator) {
|
|
385
|
+
if (!fileEntry) continue;
|
|
386
|
+
|
|
387
|
+
// Skip test files by default (test classes aren't part of public API)
|
|
388
|
+
if (!options.includeTests && isTestFile(fileEntry.relativePath, fileEntry.language)) {
|
|
389
|
+
continue;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
const exportedNames = new Set(fileEntry.exports);
|
|
393
|
+
|
|
394
|
+
for (const symbol of fileEntry.symbols) {
|
|
395
|
+
const isExported = exportedNames.has(symbol.name) ||
|
|
396
|
+
(symbol.modifiers && symbol.modifiers.includes('export')) ||
|
|
397
|
+
(symbol.modifiers && symbol.modifiers.includes('public')) ||
|
|
398
|
+
(langTraits(fileEntry.language)?.exportVisibility === 'capitalization' && /^[A-Z]/.test(symbol.name));
|
|
399
|
+
|
|
400
|
+
if (isExported) {
|
|
401
|
+
results.push({
|
|
402
|
+
name: symbol.name,
|
|
403
|
+
type: symbol.type,
|
|
404
|
+
file: fileEntry.relativePath,
|
|
405
|
+
startLine: symbol.startLine,
|
|
406
|
+
endLine: symbol.endLine,
|
|
407
|
+
params: symbol.params,
|
|
408
|
+
returnType: symbol.returnType,
|
|
409
|
+
signature: index.formatSignature(symbol)
|
|
410
|
+
});
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// Add variable exports (export const/let/var) not matched to symbols
|
|
415
|
+
if (fileEntry.exportDetails) {
|
|
416
|
+
const matchedNames = new Set(results.filter(r => r.file === fileEntry.relativePath).map(r => r.name));
|
|
417
|
+
for (const exp of fileEntry.exportDetails) {
|
|
418
|
+
if (exp.isVariable && !matchedNames.has(exp.name)) {
|
|
419
|
+
const sig = `${exp.declKind} ${exp.name}${exp.typeAnnotation ? ': ' + exp.typeAnnotation : ''}`;
|
|
420
|
+
results.push({
|
|
421
|
+
name: exp.name,
|
|
422
|
+
type: 'variable',
|
|
423
|
+
file: fileEntry.relativePath,
|
|
424
|
+
startLine: exp.line,
|
|
425
|
+
endLine: exp.line,
|
|
426
|
+
params: undefined,
|
|
427
|
+
returnType: exp.typeAnnotation || null,
|
|
428
|
+
signature: sig
|
|
429
|
+
});
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
return results;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* Get dependency graph for a file
|
|
440
|
+
* @param {object} index - ProjectIndex instance
|
|
441
|
+
* @param {string} filePath - Starting file
|
|
442
|
+
* @param {object} options - { direction: 'imports' | 'importers' | 'both', maxDepth }
|
|
443
|
+
* @returns {object} - Graph structure with root, nodes, edges
|
|
444
|
+
*/
|
|
445
|
+
function graph(index, filePath, options = {}) {
|
|
446
|
+
const direction = options.direction || 'both';
|
|
447
|
+
// Sanitize depth: use default for null/undefined, clamp negative to 0
|
|
448
|
+
const rawDepth = options.maxDepth ?? 5;
|
|
449
|
+
const maxDepth = Math.max(0, rawDepth);
|
|
450
|
+
|
|
451
|
+
const resolved = index.resolveFilePathForQuery(filePath);
|
|
452
|
+
if (typeof resolved !== 'string') return resolved;
|
|
453
|
+
|
|
454
|
+
const targetPath = resolved;
|
|
455
|
+
|
|
456
|
+
const buildSubgraph = (dir) => {
|
|
457
|
+
const visited = new Set();
|
|
458
|
+
const nodes = [];
|
|
459
|
+
const edges = [];
|
|
460
|
+
|
|
461
|
+
const traverse = (file, depth) => {
|
|
462
|
+
if (visited.has(file)) return;
|
|
463
|
+
visited.add(file);
|
|
464
|
+
|
|
465
|
+
const fileEntry = index.files.get(file);
|
|
466
|
+
const relPath = fileEntry ? fileEntry.relativePath : path.relative(index.root, file);
|
|
467
|
+
nodes.push({ file, relativePath: relPath, depth });
|
|
468
|
+
|
|
469
|
+
// Stop traversal at max depth but still register the node above
|
|
470
|
+
if (depth >= maxDepth) return;
|
|
471
|
+
|
|
472
|
+
const neighbors = dir === 'imports'
|
|
473
|
+
? (index.importGraph.get(file) || [])
|
|
474
|
+
: (index.exportGraph.get(file) || []);
|
|
475
|
+
|
|
476
|
+
// Deduplicate neighbors (same file may be imported multiple times, e.g. Java inner classes)
|
|
477
|
+
const uniqueNeighbors = [...new Set(neighbors)];
|
|
478
|
+
|
|
479
|
+
for (const neighbor of uniqueNeighbors) {
|
|
480
|
+
edges.push({ from: file, to: neighbor });
|
|
481
|
+
traverse(neighbor, depth + 1);
|
|
482
|
+
}
|
|
483
|
+
};
|
|
484
|
+
|
|
485
|
+
traverse(targetPath, 0);
|
|
486
|
+
return { nodes, edges };
|
|
487
|
+
};
|
|
488
|
+
|
|
489
|
+
if (direction === 'both') {
|
|
490
|
+
// Build separate sub-graphs for imports and importers
|
|
491
|
+
const importsGraph = buildSubgraph('imports');
|
|
492
|
+
const importersGraph = buildSubgraph('importers');
|
|
493
|
+
|
|
494
|
+
return {
|
|
495
|
+
root: targetPath,
|
|
496
|
+
direction: 'both',
|
|
497
|
+
imports: { nodes: importsGraph.nodes, edges: importsGraph.edges },
|
|
498
|
+
importers: { nodes: importersGraph.nodes, edges: importersGraph.edges },
|
|
499
|
+
// Keep combined for backward compat
|
|
500
|
+
nodes: [...importsGraph.nodes, ...importersGraph.nodes.filter(n =>
|
|
501
|
+
!importsGraph.nodes.some(in_ => in_.file === n.file))],
|
|
502
|
+
edges: [...importsGraph.edges, ...importersGraph.edges]
|
|
503
|
+
};
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
const subgraph = buildSubgraph(direction);
|
|
507
|
+
return {
|
|
508
|
+
root: targetPath,
|
|
509
|
+
direction,
|
|
510
|
+
nodes: subgraph.nodes,
|
|
511
|
+
edges: subgraph.edges
|
|
512
|
+
};
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/**
|
|
516
|
+
* Detect circular dependencies in the import graph.
|
|
517
|
+
* Uses DFS with 3-color marking to find all cycles.
|
|
518
|
+
* @param {object} index - ProjectIndex instance
|
|
519
|
+
* @param {object} options - { file, exclude }
|
|
520
|
+
* @returns {object} - { cycles, totalFiles, summary }
|
|
521
|
+
*/
|
|
522
|
+
function circularDeps(index, options = {}) {
|
|
523
|
+
index._beginOp();
|
|
524
|
+
try {
|
|
525
|
+
const exclude = options.exclude || [];
|
|
526
|
+
const fileFilter = options.file || null;
|
|
527
|
+
|
|
528
|
+
const WHITE = 0, GRAY = 1, BLACK = 2;
|
|
529
|
+
const color = new Map();
|
|
530
|
+
const cycles = [];
|
|
531
|
+
const stack = [];
|
|
532
|
+
|
|
533
|
+
const shouldSkip = (file) => {
|
|
534
|
+
if (!index.files.has(file)) return true;
|
|
535
|
+
if (exclude.length > 0) {
|
|
536
|
+
const entry = index.files.get(file);
|
|
537
|
+
if (entry && !index.matchesFilters(entry.relativePath, { exclude })) return true;
|
|
538
|
+
}
|
|
539
|
+
return false;
|
|
540
|
+
};
|
|
541
|
+
|
|
542
|
+
const dfs = (file) => {
|
|
543
|
+
color.set(file, GRAY);
|
|
544
|
+
stack.push(file);
|
|
545
|
+
|
|
546
|
+
const neighbors = [...new Set(index.importGraph.get(file) || [])];
|
|
547
|
+
|
|
548
|
+
for (const neighbor of neighbors) {
|
|
549
|
+
if (neighbor === file) continue; // Skip self-imports (not a cycle)
|
|
550
|
+
if (shouldSkip(neighbor)) continue;
|
|
551
|
+
const nc = color.get(neighbor) || WHITE;
|
|
552
|
+
if (nc === GRAY) {
|
|
553
|
+
const idx = stack.indexOf(neighbor);
|
|
554
|
+
cycles.push(stack.slice(idx));
|
|
555
|
+
} else if (nc === WHITE) {
|
|
556
|
+
dfs(neighbor);
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
stack.pop();
|
|
561
|
+
color.set(file, BLACK);
|
|
562
|
+
};
|
|
563
|
+
|
|
564
|
+
for (const file of index.files.keys()) {
|
|
565
|
+
if ((color.get(file) || WHITE) === WHITE && !shouldSkip(file)) {
|
|
566
|
+
dfs(file);
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
// Convert to relative paths and deduplicate
|
|
571
|
+
const seen = new Set();
|
|
572
|
+
const uniqueCycles = [];
|
|
573
|
+
for (const cycle of cycles) {
|
|
574
|
+
const relCycle = cycle.map(f => index.files.get(f)?.relativePath || path.relative(index.root, f));
|
|
575
|
+
// Normalize: rotate so lexicographically smallest file is first
|
|
576
|
+
const sorted = relCycle.slice().sort();
|
|
577
|
+
const minIdx = relCycle.indexOf(sorted[0]);
|
|
578
|
+
const rotated = [...relCycle.slice(minIdx), ...relCycle.slice(0, minIdx)];
|
|
579
|
+
const key = rotated.join('\0');
|
|
580
|
+
if (!seen.has(key)) {
|
|
581
|
+
seen.add(key);
|
|
582
|
+
uniqueCycles.push({ files: rotated, length: rotated.length });
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
// Filter by file pattern
|
|
587
|
+
let result = uniqueCycles;
|
|
588
|
+
if (fileFilter) {
|
|
589
|
+
result = uniqueCycles.filter(c => c.files.some(f => f.includes(fileFilter)));
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
result.sort((a, b) => a.length - b.length || a.files[0].localeCompare(b.files[0]));
|
|
593
|
+
|
|
594
|
+
// Count files that participate in import graph (have edges)
|
|
595
|
+
let filesWithImports = 0;
|
|
596
|
+
for (const [, targets] of index.importGraph) {
|
|
597
|
+
if (targets && targets.length > 0) filesWithImports++;
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
return {
|
|
601
|
+
cycles: result,
|
|
602
|
+
totalFiles: index.files.size,
|
|
603
|
+
filesWithImports,
|
|
604
|
+
fileFilter: fileFilter || undefined,
|
|
605
|
+
summary: {
|
|
606
|
+
totalCycles: result.length,
|
|
607
|
+
filesInCycles: new Set(result.flatMap(c => c.files)).size,
|
|
608
|
+
}
|
|
609
|
+
};
|
|
610
|
+
} finally {
|
|
611
|
+
index._endOp();
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
module.exports = { imports, exporters, fileExports, api, graph, circularDeps };
|