ucn 5.2.2 → 5.3.0

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/core/graph.js CHANGED
@@ -13,6 +13,16 @@ const { extractImports, resolveImport } = require('./imports');
13
13
  const { langTraits } = require('../languages');
14
14
  const { isTestFile } = require('./discovery');
15
15
 
16
+ function importDeferralFields(imp, fileEntry) {
17
+ // A project-local typing module can expose a true TYPE_CHECKING flag.
18
+ // Parser spelling evidence alone cannot prove that guard runtime-false.
19
+ if (imp.deferredReason === 'type-checking' && fileEntry.moduleResolved?.typing) {
20
+ return { deferred: false };
21
+ }
22
+ return { deferred: !!imp.deferred,
23
+ ...(imp.deferredReason && { deferredReason: imp.deferredReason }) };
24
+ }
25
+
16
26
  /**
17
27
  * Resolve imports in a file
18
28
  * @param {object} index - ProjectIndex instance
@@ -49,7 +59,7 @@ function imports(index, filePath) {
49
59
  isExternal: false,
50
60
  isDynamic: true,
51
61
  line,
52
- deferred: !!imp.deferred,
62
+ ...importDeferralFields(imp, fileEntry),
53
63
  };
54
64
  }
55
65
 
@@ -66,7 +76,7 @@ function imports(index, filePath) {
66
76
  isExternal: false,
67
77
  isDynamic: true,
68
78
  line,
69
- deferred: !!imp.deferred,
79
+ ...importDeferralFields(imp, fileEntry),
70
80
  };
71
81
  }
72
82
 
@@ -97,7 +107,7 @@ function imports(index, filePath) {
97
107
  // `type: 'dynamic'` with `isDynamic: false` was a contradiction.
98
108
  isDynamic: imp.type === 'dynamic',
99
109
  line,
100
- deferred: !!imp.deferred,
110
+ ...importDeferralFields(imp, fileEntry),
101
111
  };
102
112
  });
103
113
  } catch (e) {
@@ -735,17 +745,15 @@ function graph(index, filePath, options = {}) {
735
745
  * @param {object} options - { file, exclude }
736
746
  * @returns {object} - { cycles, totalFiles, summary }
737
747
  */
748
+ const DEFAULT_CYCLE_LIMIT = 500;
749
+ const MAX_ENUMERATED_COMPONENT = 2000;
750
+
738
751
  function circularDeps(index, options = {}) {
739
752
  index._beginOp();
740
753
  try {
741
754
  const exclude = options.exclude || [];
742
755
  const fileFilter = options.file || null;
743
756
 
744
- const WHITE = 0, GRAY = 1, BLACK = 2;
745
- const color = new Map();
746
- const cycles = [];
747
- const stack = [];
748
-
749
757
  const shouldSkip = (file) => {
750
758
  if (!index.files.has(file)) return true;
751
759
  if (exclude.length > 0) {
@@ -755,37 +763,137 @@ function circularDeps(index, options = {}) {
755
763
  return false;
756
764
  };
757
765
 
758
- const dfs = (file) => {
759
- color.set(file, GRAY);
760
- stack.push(file);
761
-
762
- const neighbors = index.importGraph.get(file) || new Set();
763
-
764
- for (const neighbor of neighbors) {
765
- if (neighbor === file) continue; // Skip self-imports (not a cycle)
766
- if (shouldSkip(neighbor)) continue;
767
- const nc = color.get(neighbor) || WHITE;
768
- if (nc === GRAY) {
769
- const idx = stack.indexOf(neighbor);
770
- cycles.push(stack.slice(idx));
771
- } else if (nc === WHITE) {
772
- dfs(neighbor);
766
+ // Complete, order-independent enumeration (fix #339). The previous
767
+ // 3-color DFS reported only back-edge cycles: a cycle closing through
768
+ // an already-finished node was never emitted, so the answer depended
769
+ // on traversal order (click: 10 cycles in canonical order, 20 with
770
+ // reversed neighbor order, 4 shared). Tarjan strongly connected
771
+ // components + Johnson's elementary-circuit search over a canonically
772
+ // sorted adjacency — the same cycles for every build history — capped
773
+ // and disclosed (`summary.truncated`) so a dense tangle cannot explode.
774
+ const nodes = [...index.files.keys()].filter(f => !shouldSkip(f)).sort(codeUnitCompare);
775
+ const nodeIndex = new Map(nodes.map((f, i) => [f, i]));
776
+ const adj = nodes.map(f => {
777
+ const out = new Set();
778
+ for (const n of index.importGraph.get(f) || []) {
779
+ const j = nodeIndex.get(n);
780
+ if (j != null && n !== f) out.add(j); // self-imports are not cycles
781
+ }
782
+ return [...out].sort((a, b) => a - b);
783
+ });
784
+ const components = [];
785
+ {
786
+ let counter = 0;
787
+ const idxOf = new Array(nodes.length).fill(-1);
788
+ const low = new Array(nodes.length).fill(0);
789
+ const onStack = new Array(nodes.length).fill(false);
790
+ const tarjanStack = [];
791
+ for (let root = 0; root < nodes.length; root++) {
792
+ if (idxOf[root] !== -1) continue;
793
+ idxOf[root] = low[root] = counter++;
794
+ tarjanStack.push(root); onStack[root] = true;
795
+ const work = [[root, 0]];
796
+ while (work.length > 0) {
797
+ const frame = work[work.length - 1];
798
+ const v = frame[0];
799
+ if (frame[1] < adj[v].length) {
800
+ const w = adj[v][frame[1]++];
801
+ if (idxOf[w] === -1) {
802
+ idxOf[w] = low[w] = counter++;
803
+ tarjanStack.push(w); onStack[w] = true;
804
+ work.push([w, 0]);
805
+ } else if (onStack[w]) {
806
+ low[v] = Math.min(low[v], idxOf[w]);
807
+ }
808
+ } else {
809
+ work.pop();
810
+ if (work.length > 0) {
811
+ const u = work[work.length - 1][0];
812
+ low[u] = Math.min(low[u], low[v]);
813
+ }
814
+ if (low[v] === idxOf[v]) {
815
+ const comp = [];
816
+ let w;
817
+ do { w = tarjanStack.pop(); onStack[w] = false; comp.push(w); } while (w !== v);
818
+ components.push(comp.sort((a, b) => a - b));
819
+ }
820
+ }
773
821
  }
774
822
  }
775
-
776
- stack.pop();
777
- color.set(file, BLACK);
778
- };
779
-
780
- for (const file of index.files.keys()) {
781
- if ((color.get(file) || WHITE) === WHITE && !shouldSkip(file)) {
782
- dfs(file);
823
+ }
824
+ const cycleLimit = Number.isInteger(options.maxCycles) && options.maxCycles > 0
825
+ ? options.maxCycles : DEFAULT_CYCLE_LIMIT;
826
+ const cycles = [];
827
+ let truncated = false;
828
+ let limitReached = false;
829
+ const truncationReasons = new Set();
830
+ const cyclicComponents = components.filter(c => c.length >= 2).sort((a, b) => a[0] - b[0]);
831
+ for (const comp of cyclicComponents) {
832
+ if (limitReached) break;
833
+ if (comp.length > MAX_ENUMERATED_COMPONENT) {
834
+ truncated = true;
835
+ truncationReasons.add('component-size');
836
+ continue;
837
+ }
838
+ const inComp = new Set(comp);
839
+ for (const s of comp) {
840
+ if (limitReached) break;
841
+ const allowed = (v) => v >= s && inComp.has(v);
842
+ const blocked = new Map();
843
+ const blockedBy = new Map();
844
+ const trail = [];
845
+ const unblock = (u) => {
846
+ blocked.set(u, false);
847
+ const set = blockedBy.get(u);
848
+ if (!set) return;
849
+ blockedBy.set(u, new Set());
850
+ for (const w of set) if (blocked.get(w)) unblock(w);
851
+ };
852
+ const circuit = (v) => {
853
+ let found = false;
854
+ trail.push(v);
855
+ blocked.set(v, true);
856
+ for (const w of adj[v]) {
857
+ if (!allowed(w)) continue;
858
+ if (w === s) {
859
+ // Probe one extra cycle to distinguish a full list
860
+ // of exactly N from a genuinely truncated list.
861
+ if (cycles.length === cycleLimit) {
862
+ truncated = limitReached = true;
863
+ truncationReasons.add('cycle-limit');
864
+ break;
865
+ }
866
+ cycles.push(trail.map(i => nodes[i]));
867
+ found = true;
868
+ } else if (!blocked.get(w)) {
869
+ if (circuit(w)) found = true;
870
+ if (limitReached) break;
871
+ }
872
+ }
873
+ if (found) {
874
+ unblock(v);
875
+ } else {
876
+ for (const w of adj[v]) {
877
+ if (!allowed(w)) continue;
878
+ if (!blockedBy.has(w)) blockedBy.set(w, new Set());
879
+ blockedBy.get(w).add(v);
880
+ }
881
+ }
882
+ trail.pop();
883
+ return found;
884
+ };
885
+ circuit(s);
783
886
  }
784
887
  }
888
+ const componentSummaries = cyclicComponents.map(comp => ({
889
+ files: comp.map(i => index.files.get(nodes[i])?.relativePath || path.relative(index.root, nodes[i]))
890
+ .sort(codeUnitCompare),
891
+ size: comp.length,
892
+ }));
785
893
 
786
894
  const importEdgeDetails = (fromFile, toFile) => {
787
895
  const entry = index.files.get(fromFile);
788
- if (!entry || entry.language !== 'python') return [];
896
+ if (!entry) return [];
789
897
  const matches = [];
790
898
  for (const detail of entry.importDetails || []) {
791
899
  const specs = [detail.module];
@@ -804,7 +912,7 @@ function circularDeps(index, options = {}) {
804
912
  from: entry.relativePath,
805
913
  to: index.files.get(toFile)?.relativePath || path.relative(index.root, toFile),
806
914
  line: detail.line ?? null,
807
- deferred: !!detail.deferred,
915
+ ...importDeferralFields(detail, entry),
808
916
  });
809
917
  }
810
918
  return matches;
@@ -832,10 +940,14 @@ function circularDeps(index, options = {}) {
832
940
  const to = rotatedAbs[(edgeIndex + 1) % rotatedAbs.length];
833
941
  const details = importEdgeDetails(from, to);
834
942
  const deferred = details.length > 0 && details.every(detail => detail.deferred);
943
+ const deferredReasons = deferred
944
+ ? [...new Set(details.map(detail => detail.deferredReason).filter(Boolean))].sort()
945
+ : [];
835
946
  edges.push({
836
947
  from: rotated[edgeIndex],
837
948
  to: rotated[(edgeIndex + 1) % rotated.length],
838
949
  deferred,
950
+ ...(deferredReasons.length > 0 && { deferredReasons }),
839
951
  ...(details.length > 0 && {
840
952
  lines: [...new Set(details.map(detail => detail.line)
841
953
  .filter(line => line != null))].sort((a, b) => a - b),
@@ -847,6 +959,7 @@ function circularDeps(index, options = {}) {
847
959
  from: edge.from,
848
960
  to: edge.to,
849
961
  line: edge.lines?.[0] ?? null,
962
+ ...(edge.deferredReasons && { reasons: edge.deferredReasons }),
850
963
  ...(edge.lines?.length > 1 && { lines: edge.lines }),
851
964
  }));
852
965
  uniqueCycles.push({
@@ -865,7 +978,7 @@ function circularDeps(index, options = {}) {
865
978
  result = uniqueCycles.filter(c => c.files.some(f => f.includes(fileFilter)));
866
979
  }
867
980
 
868
- result.sort((a, b) => a.length - b.length || codeUnitCompare(a.files[0], b.files[0]));
981
+ result.sort((a, b) => a.length - b.length || codeUnitCompare(a.files.join('\0'), b.files.join('\0')));
869
982
 
870
983
  // Count files that participate in import graph (have edges)
871
984
  let filesWithImports = 0;
@@ -875,16 +988,35 @@ function circularDeps(index, options = {}) {
875
988
 
876
989
  const eagerCycles = result.filter(cycle => cycle.classification !== 'deferred').length;
877
990
  const deferredCycles = result.length - eagerCycles;
991
+ const groupOf = new Map();
992
+ componentSummaries.forEach((group, i) => { for (const f of group.files) groupOf.set(f, i); });
993
+ for (const group of componentSummaries) { group.eagerCycles = 0; group.deferredCycles = 0; }
994
+ for (const cycle of result) {
995
+ const group = componentSummaries[groupOf.get(cycle.files[0])];
996
+ if (!group) continue;
997
+ if (cycle.classification === 'deferred') group.deferredCycles++;
998
+ else group.eagerCycles++;
999
+ }
1000
+ const cycleGroups = fileFilter
1001
+ ? componentSummaries.filter(c => c.files.some(f => f.includes(fileFilter)))
1002
+ : componentSummaries;
878
1003
  return {
879
1004
  cycles: result,
1005
+ // Strongly connected groups: every file in a group sits on at
1006
+ // least one cycle with every other member — the refactor unit.
1007
+ components: cycleGroups,
880
1008
  totalFiles: index.files.size,
881
1009
  filesWithImports,
882
1010
  fileFilter: fileFilter || undefined,
883
1011
  summary: {
884
1012
  totalCycles: result.length,
885
- filesInCycles: new Set(result.flatMap(c => c.files)).size,
1013
+ filesInCycles: new Set(cycleGroups.flatMap(c => c.files)).size,
886
1014
  eagerCycles,
887
1015
  deferredCycles,
1016
+ componentCount: cycleGroups.length,
1017
+ ...(truncated && { truncated: true, cycleLimit,
1018
+ maxComponentSize: MAX_ENUMERATED_COMPONENT,
1019
+ truncationReasons: [...truncationReasons] }),
888
1020
  }
889
1021
  };
890
1022
  } finally {
package/core/index-ir.js CHANGED
@@ -21,6 +21,7 @@ function createImportBindings(imports) {
21
21
  ...(rename && { alias: rename.local }),
22
22
  ...(item.defaultLike && { defaultLike: true }),
23
23
  ...(item.deferred && { deferred: true }),
24
+ ...(item.dynamic && { dynamic: true }),
24
25
  };
25
26
  }));
26
27
  }
@@ -47,15 +48,17 @@ function createFileEntryFromIR({
47
48
  mtime,
48
49
  size,
49
50
  imports: imports.map(item => item.module),
50
- ...(ir.language === 'python' && {
51
- importDetails: imports.map(item => ({
52
- module: item.module,
53
- names: [...(item.names || [])],
54
- ...(item.type && { type: item.type }),
55
- ...(item.line != null && { line: item.line }),
56
- ...(item.deferred && { deferred: true }),
57
- })),
58
- }),
51
+ // Per-import detail records (fix #307, Python-only then; un-gated in
52
+ // fix #338 so JS/TS function-local require()/import() and type-only
53
+ // imports classify cycle edges the same way).
54
+ importDetails: imports.map(item => ({
55
+ module: item.module,
56
+ names: [...(item.names || [])],
57
+ ...(item.type && { type: item.type }),
58
+ ...(item.line != null && { line: item.line }),
59
+ ...(item.deferred && { deferred: true }),
60
+ ...(item.deferredReason && { deferredReason: item.deferredReason }),
61
+ })),
59
62
  globalImports: imports.filter(item => item.global).map(item => item.module),
60
63
  importNames: imports.flatMap(item => item.names || []),
61
64
  importBindings: createImportBindings(imports),
@@ -21,7 +21,7 @@ function formatImports(imports, filePath) {
21
21
  if (internal.length > 0) {
22
22
  lines.push('INTERNAL:');
23
23
  for (const imp of internal) {
24
- lines.push(` ${imp.module}${imp.deferred ? ' [function-local/deferred]' : ''}`);
24
+ lines.push(` ${imp.module}${deferredImportLabel(imp)}`);
25
25
  if (imp.resolved) {
26
26
  lines.push(` -> ${imp.resolved}${imp.indexed === false
27
27
  ? ' (not indexed; absent from dependency graph)'
@@ -37,7 +37,7 @@ function formatImports(imports, filePath) {
37
37
  if (internal.length > 0) lines.push('');
38
38
  lines.push('EXTERNAL:');
39
39
  for (const imp of external) {
40
- lines.push(` ${imp.module}${imp.deferred ? ' [function-local/deferred]' : ''}`);
40
+ lines.push(` ${imp.module}${deferredImportLabel(imp)}`);
41
41
  if (imp.names && imp.names.length > 0) {
42
42
  lines.push(` ${imp.names.join(', ')}`);
43
43
  }
@@ -48,7 +48,7 @@ function formatImports(imports, filePath) {
48
48
  if (internal.length > 0 || external.length > 0) lines.push('');
49
49
  lines.push('DYNAMIC (unresolved):');
50
50
  for (const imp of dynamic) {
51
- lines.push(` ${imp.module || '(variable)'}${imp.deferred ? ' [function-local/deferred]' : ''}`);
51
+ lines.push(` ${imp.module || '(variable)'}${deferredImportLabel(imp)}`);
52
52
  if (imp.names && imp.names.length > 0) {
53
53
  lines.push(` ${imp.names.join(', ')}`);
54
54
  }
@@ -407,6 +407,22 @@ function formatGraphJson(graph) {
407
407
  return JSON.stringify(result, null, 2);
408
408
  }
409
409
 
410
+ // fix #338: deferred-edge vocabulary shared by deps and cycle output.
411
+ const DEFERRED_REASON_LABELS = {
412
+ 'function-local': 'function-local import',
413
+ 'type-checking': 'TYPE_CHECKING-only import, never executed at runtime',
414
+ 'type-only': 'type-only import, erased at compile time',
415
+ };
416
+ function deferredReasonLabel(reason) {
417
+ return DEFERRED_REASON_LABELS[reason] || 'deferred import';
418
+ }
419
+ const EAGER_CYCLE_DISPLAY_LIMIT = 25;
420
+ const DEFERRED_CYCLE_DISPLAY_LIMIT = 10;
421
+ function deferredImportLabel(imp) {
422
+ if (!imp.deferred) return '';
423
+ return imp.deferredReason ? ` [deferred: ${imp.deferredReason}]` : ' [deferred]';
424
+ }
425
+
410
426
  function formatCircularDeps(result) {
411
427
  if (!result) return 'No results.';
412
428
  const lines = [];
@@ -420,7 +436,7 @@ function formatCircularDeps(result) {
420
436
 
421
437
  const scannedCount = result.filesWithImports != null ? result.filesWithImports : result.totalFiles;
422
438
 
423
- if (result.cycles.length === 0) {
439
+ if (result.cycles.length === 0 && !(result.components || []).length && !result.summary?.truncated) {
424
440
  lines.push('');
425
441
  lines.push('No circular dependencies found.');
426
442
  lines.push(`Scanned ${scannedCount} files with import relationships.`);
@@ -429,12 +445,32 @@ function formatCircularDeps(result) {
429
445
 
430
446
  const eager = result.cycles.filter(cycle => cycle.classification !== 'deferred');
431
447
  const deferred = result.cycles.filter(cycle => cycle.classification === 'deferred');
448
+
449
+ // Groups first: a strongly connected file set is the unit a refactor has
450
+ // to break, and a 14-file tangle can hold hundreds of elementary cycles.
451
+ const groups = result.components || [];
452
+ if (groups.length > 0) {
453
+ lines.push('');
454
+ lines.push(`CYCLE GROUPS (${groups.length}) — every member reaches every other member:`);
455
+ for (const group of groups) {
456
+ const counts = [];
457
+ if (group.eagerCycles != null) counts.push(`${group.eagerCycles} import-time`);
458
+ if (group.deferredCycles != null) counts.push(`${group.deferredCycles} deferred`);
459
+ const suffix = counts.length > 0 ? ` [${counts.join(', ')}${result.summary.truncated ? '; enumerated counts only' : ''}]` : '';
460
+ lines.push(` ${group.size} files: ${group.files.join(', ')}${suffix}`);
461
+ }
462
+ }
463
+
432
464
  let cycleNumber = 0;
433
- const renderGroup = (title, group, deferredGroup = false) => {
465
+ const renderGroup = (title, group, deferredGroup, displayLimit) => {
434
466
  if (group.length === 0) return;
467
+ const shown = group.slice(0, displayLimit);
435
468
  lines.push('');
436
- lines.push(`${title} (${group.length}):`);
437
- for (const cycle of group) {
469
+ const heading = shown.length < group.length
470
+ ? `${title} (${group.length}, showing ${shown.length} shortest):`
471
+ : `${title} (${group.length}):`;
472
+ lines.push(heading);
473
+ for (const cycle of shown) {
438
474
  cycleNumber++;
439
475
  lines.push('');
440
476
  lines.push(`Cycle ${cycleNumber} (${cycle.length} files):`);
@@ -442,19 +478,32 @@ function formatCircularDeps(result) {
442
478
  if (deferredGroup) {
443
479
  for (const edge of cycle.deferredEdges || []) {
444
480
  const at = edge.line != null ? `:${edge.line}` : '';
445
- lines.push(` deferred edge: ${edge.from}${at} → ${edge.to} (function-local import)`);
481
+ const why = (edge.reasons || []).map(deferredReasonLabel).join('; ') || 'function-local import';
482
+ lines.push(` deferred edge: ${edge.from}${at} → ${edge.to} (${why})`);
446
483
  }
447
484
  }
448
485
  }
486
+ if (shown.length < group.length) {
487
+ lines.push(` ... and ${group.length - shown.length} more (use --json for the full list)`);
488
+ }
449
489
  };
450
- renderGroup('IMPORT-TIME CYCLES', eager);
451
- renderGroup('DEFERRED CYCLES', deferred, true);
490
+ renderGroup('IMPORT-TIME CYCLES', eager, false, EAGER_CYCLE_DISPLAY_LIMIT);
491
+ renderGroup('DEFERRED CYCLES', deferred, true, DEFERRED_CYCLE_DISPLAY_LIMIT);
452
492
 
453
493
  lines.push('');
454
494
  const { totalCycles, filesInCycles } = result.summary;
455
495
  lines.push(`Summary: ${totalCycles} circular dependency chain${totalCycles !== 1 ? 's' : ''} involving ${filesInCycles} file${filesInCycles !== 1 ? 's' : ''} (${scannedCount} files with imports scanned).`);
496
+ if (result.summary.truncated) {
497
+ if (result.summary.truncationReasons?.includes('component-size')) {
498
+ lines.push(`Cycle enumeration skipped groups larger than ${result.summary.maxComponentSize} files.`);
499
+ }
500
+ if (!result.summary.truncationReasons || result.summary.truncationReasons.includes('cycle-limit')) {
501
+ lines.push(`Enumeration stopped at ${result.summary.cycleLimit} elementary cycles.`);
502
+ }
503
+ lines.push('The CYCLE GROUPS list and files-in-cycles count are complete; enumerated cycle counts are lower bounds.');
504
+ }
456
505
  if (deferred.length > 0) {
457
- lines.push(`${deferred.length} chain${deferred.length === 1 ? '' : 's'} contain a function-local import; they are not unconditional import-time cycles, but may still matter if invoked during initialization.`);
506
+ lines.push(`${deferred.length} chain${deferred.length === 1 ? '' : 's'} close only through deferred edges (function-local, TYPE_CHECKING-only, or type-only imports); they are not unconditional import-time cycles, but a function-local edge may still matter if invoked during initialization.`);
458
507
  }
459
508
 
460
509
  return lines.join('\n');