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/.claude/skills/ucn/SKILL.md +49 -3
- package/.claude/skills/ucn/references/commands.md +3 -1
- package/README.md +158 -533
- package/cli/index.js +71 -10
- package/core/cache.js +22 -13
- package/core/callers.js +51 -52
- package/core/execute.js +24 -6
- package/core/graph.js +167 -35
- package/core/index-ir.js +12 -9
- package/core/output/graph.js +60 -11
- package/core/output/lines.js +259 -0
- package/core/output/public.js +15 -0
- package/core/output/reporting.js +9 -2
- package/core/output-budget.js +7 -4
- package/core/project.js +15 -2
- package/core/registry.js +7 -6
- package/core/reporting.js +159 -14
- package/languages/javascript.js +213 -6
- package/languages/python.js +115 -12
- package/mcp/server.js +3 -1
- package/package.json +2 -2
- package/assets/demo.svg +0 -31
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
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
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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),
|
package/core/output/graph.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
437
|
-
|
|
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
|
-
|
|
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'}
|
|
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');
|