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.
Files changed (44) hide show
  1. package/.claude/skills/ucn/SKILL.md +3 -1
  2. package/.github/workflows/ci.yml +15 -3
  3. package/.github/workflows/publish.yml +20 -8
  4. package/README.md +1 -0
  5. package/cli/index.js +165 -246
  6. package/core/analysis.js +1400 -0
  7. package/core/build-worker.js +194 -0
  8. package/core/cache.js +105 -7
  9. package/core/callers.js +194 -64
  10. package/core/deadcode.js +22 -66
  11. package/core/discovery.js +9 -54
  12. package/core/execute.js +139 -54
  13. package/core/graph.js +615 -0
  14. package/core/output/analysis-ext.js +271 -0
  15. package/core/output/analysis.js +491 -0
  16. package/core/output/extraction.js +188 -0
  17. package/core/output/find.js +355 -0
  18. package/core/output/graph.js +399 -0
  19. package/core/output/refactoring.js +293 -0
  20. package/core/output/reporting.js +331 -0
  21. package/core/output/search.js +307 -0
  22. package/core/output/shared.js +271 -0
  23. package/core/output/tracing.js +416 -0
  24. package/core/output.js +15 -3293
  25. package/core/parallel-build.js +165 -0
  26. package/core/project.js +299 -3633
  27. package/core/registry.js +59 -0
  28. package/core/reporting.js +258 -0
  29. package/core/search.js +890 -0
  30. package/core/stacktrace.js +1 -1
  31. package/core/tracing.js +631 -0
  32. package/core/verify.js +10 -13
  33. package/eslint.config.js +43 -0
  34. package/jsconfig.json +10 -0
  35. package/languages/go.js +21 -2
  36. package/languages/html.js +8 -0
  37. package/languages/index.js +102 -40
  38. package/languages/java.js +13 -0
  39. package/languages/javascript.js +17 -1
  40. package/languages/python.js +14 -0
  41. package/languages/rust.js +13 -0
  42. package/languages/utils.js +1 -1
  43. package/mcp/server.js +45 -28
  44. 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 };