ucn 5.2.2 → 5.3.1

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/callers.js CHANGED
@@ -592,10 +592,22 @@ function findCallers(index, name, options = {}) {
592
592
  // completion. Phase 2 still only enriches the first `maxResults` items —
593
593
  // file reads stay bounded, but the candidate count reflects the true total.
594
594
  const needsTotal = !!options.needsTotal;
595
- const localTypeCache = new Map(); // `${filePath}:${startLine}` -> localTypes Map or null
596
- const returnFlowCache = new Map(); // filePath -> return-type-flow map (see _buildReturnTypeFlowMap)
597
- const foldCtxCache = new Map(); // filePath -> chained-receiver fold context (fix #258)
598
- const pythonIndexedReceiverCache = new Map();
595
+ // Per-file query-time derivations that depend only on a file's immutable
596
+ // call records — never on the pinned target — so they live for the whole
597
+ // OPERATION, not one findCallers call (fix #340): stats --hot / repo run
598
+ // findCallers for hundreds of candidates over the same files, and
599
+ // rebuilding fold contexts (producer index, flow maps) and typed-local
600
+ // maps per candidate made grpc-go's `repo` cost 40s (1375 calls).
601
+ if (!index._opFindCallersCaches) {
602
+ index._opFindCallersCaches = {
603
+ localTypeCache: new Map(), // `${filePath}:${startLine}` -> localTypes Map or null
604
+ returnFlowCache: new Map(), // filePath -> return-type-flow map (see _buildReturnTypeFlowMap)
605
+ foldCtxCache: new Map(), // filePath -> chained-receiver fold context (fix #258)
606
+ pythonIndexedReceiverCache: new Map(),
607
+ };
608
+ }
609
+ const { localTypeCache, returnFlowCache, foldCtxCache, pythonIndexedReceiverCache } =
610
+ index._opFindCallersCaches;
599
611
 
600
612
  // Use inverted callee index to skip files that don't contain calls to this name
601
613
  let calleeFiles = index.getCalleeFiles(name);
@@ -1157,12 +1169,8 @@ function findCallers(index, name, options = {}) {
1157
1169
  receiverTypeFlowFile: path.join(index.root, project.rel),
1158
1170
  };
1159
1171
  } else {
1160
- const projectish = bindings.some(b => {
1161
- const mod = String(b.module || '');
1162
- const first = mod.split(/[./]/).filter(Boolean)[0];
1163
- return mod.startsWith('.') ||
1164
- (first && _projectTopLevelNames(index).has(first));
1165
- });
1172
+ const projectish = bindings.some(b =>
1173
+ _unresolvedModuleIsGap(index, b.module, b));
1166
1174
  const via = `${bindings[0].module}.${bindings[0].name}`;
1167
1175
  if (BUILTIN_RECEIVER_TYPES.has(call.receiverType)) {
1168
1176
  // Stable stdlib runtime classes (StringIO,
@@ -1657,10 +1665,7 @@ function findCallers(index, name, options = {}) {
1657
1665
  for (const binding of cbNameBindings) {
1658
1666
  const rel = fileEntry.moduleResolved?.[binding.module];
1659
1667
  if (!rel) {
1660
- const mod = String(binding.module || '');
1661
- const firstSeg = mod.split(/[./]/).filter(Boolean)[0];
1662
- if (mod.startsWith('.') ||
1663
- (firstSeg && _projectTopLevelNames(index).has(firstSeg))) {
1668
+ if (_unresolvedModuleIsGap(index, binding.module, binding)) {
1664
1669
  cbBindingUnknown = true;
1665
1670
  }
1666
1671
  continue;
@@ -2851,10 +2856,7 @@ function findCallers(index, name, options = {}) {
2851
2856
  // relative (project-internal by construction)
2852
2857
  // or its first segment names a project path
2853
2858
  // (resolution gap, not externality evidence)
2854
- const mod = String(b.module);
2855
- const firstSeg = mod.split(/[./]/).filter(Boolean)[0];
2856
- if (mod.startsWith('.') ||
2857
- (firstSeg && _projectTopLevelNames(index).has(firstSeg))) {
2859
+ if (_unresolvedModuleIsGap(index, b.module, b)) {
2858
2860
  undetermined = true;
2859
2861
  }
2860
2862
  continue;
@@ -3129,10 +3131,7 @@ function findCallers(index, name, options = {}) {
3129
3131
  const rel = recvSubmoduleRel ||
3130
3132
  (fileEntry.moduleResolved && fileEntry.moduleResolved[b.module]);
3131
3133
  if (!rel) {
3132
- const mod = String(b.module);
3133
- const firstSeg = mod.split(/[./]/).filter(Boolean)[0];
3134
- if (mod.startsWith('.') ||
3135
- (firstSeg && _projectTopLevelNames(index).has(firstSeg))) {
3134
+ if (_unresolvedModuleIsGap(index, b.module, b)) {
3136
3135
  projectish = true;
3137
3136
  undetermined = true;
3138
3137
  }
@@ -7956,10 +7955,7 @@ function _buildReturnTypeFlowMap(index, filePath, calls) {
7956
7955
  // project `info`) is not identity evidence. Same externality
7957
7956
  // test as #209 module ownership: relative or project-ish
7958
7957
  // modules are resolver gaps, never externality evidence.
7959
- const mod = String(binding.module);
7960
- const firstSeg = mod.split(/[./]/).filter(Boolean)[0];
7961
- if (!mod.startsWith('.') &&
7962
- !(firstSeg && _projectTopLevelNames(index).has(firstSeg))) {
7958
+ if (!_unresolvedModuleIsGap(index, binding.module, binding)) {
7963
7959
  const scope = call.enclosingFunction ? `${call.enclosingFunction.startLine}` : '';
7964
7960
  if (!map) map = new Map();
7965
7961
  const key = `${scope}:${call.assignedTo}`;
@@ -9103,9 +9099,7 @@ function _structuralQualifiedReceiverOrigin(index, fileEntry, qualifier, typeNam
9103
9099
  fromFile: path.join(index.root, rel),
9104
9100
  };
9105
9101
  }
9106
- const first = moduleName.split(/[./]/).filter(Boolean)[0];
9107
- if (moduleName.startsWith('.') ||
9108
- (first && _projectTopLevelNames(index).has(first))) {
9102
+ if (_unresolvedModuleIsGap(index, moduleName)) {
9109
9103
  projectish = true;
9110
9104
  }
9111
9105
  }
@@ -9296,10 +9290,7 @@ function _nameBindingReaches(index, startAbs, name, targetFiles, maxDepth = 4) {
9296
9290
  // Unresolved: relative or project-ish → resolver gap, not
9297
9291
  // a terminal; clearly external → that path pins outside
9298
9292
  // the project (dead end, consistent with #209c).
9299
- const mod = String(module);
9300
- const firstSeg = mod.split(/[./]/).filter(Boolean)[0];
9301
- if (mod.startsWith('.') ||
9302
- (firstSeg && _projectTopLevelNames(index).has(firstSeg))) unknown = true;
9293
+ if (_unresolvedModuleIsGap(index, module)) unknown = true;
9303
9294
  return;
9304
9295
  }
9305
9296
  next.push([path.join(index.root, rel), nextAttr]);
@@ -9860,6 +9851,25 @@ function _projectTopLevelNames(index) {
9860
9851
  return names;
9861
9852
  }
9862
9853
 
9854
+ /**
9855
+ * Is an UNRESOLVED module specifier a resolver gap rather than externality
9856
+ * evidence? (fix #337b) Relative specifiers and first segments naming a
9857
+ * project top-level path were already gaps (#209); a NON-LITERAL specifier —
9858
+ * `require(path.join(__dirname, ...))`, `require(name)`, template paths — is
9859
+ * one too: the parser records the expression text as the module, which can
9860
+ * never match a package name, so judging it external excluded true callers as
9861
+ * `other-definition-import`. Statically composable `__dirname` paths are
9862
+ * resolved parser-side; whatever stays dynamic must route 'unknown'.
9863
+ */
9864
+ function _unresolvedModuleIsGap(index, module, binding) {
9865
+ const mod = String(module || '');
9866
+ if (binding && binding.dynamic) return true;
9867
+ if (mod.startsWith('.')) return true;
9868
+ if (/[()$`{}+\s]/.test(mod)) return true;
9869
+ const firstSeg = mod.split(/[./]/).filter(Boolean)[0];
9870
+ return !!(firstSeg && _projectTopLevelNames(index).has(firstSeg));
9871
+ }
9872
+
9863
9873
  const IDENTITY_TYPE_KINDS = new Set(['class', 'struct', 'interface', 'trait', 'enum']);
9864
9874
 
9865
9875
  /**
@@ -10740,10 +10750,9 @@ function _goQualifierNamesImport(index, fieldFile, qualifier) {
10740
10750
  function _iterExternalProducerVia(index, fileEntry, call) {
10741
10751
  if (!fileEntry || langTraits(fileEntry.language)?.typeSystem === 'nominal') return null;
10742
10752
  const externalModule = (mod) => {
10743
- if (!mod || mod.startsWith('.')) return false;
10753
+ if (!mod) return false;
10744
10754
  if (fileEntry.moduleResolved?.[mod]) return false;
10745
- const firstSeg = mod.split(/[./]/).filter(Boolean)[0];
10746
- return !(firstSeg && _projectTopLevelNames(index).has(firstSeg));
10755
+ return !_unresolvedModuleIsGap(index, mod);
10747
10756
  };
10748
10757
  if (call.isMethod && call.receiverIsModule && call.receiver) {
10749
10758
  const binding = _structuralModuleBindings(fileEntry, call)[0];
@@ -10831,10 +10840,9 @@ function _structuralCompositeModuleOwnership(
10831
10840
 
10832
10841
  function _pythonBuiltinContractAllowed(index, fileEntry, moduleName) {
10833
10842
  const module = String(moduleName || '');
10834
- if (!module || module.startsWith('.')) return false;
10843
+ if (!module) return false;
10835
10844
  if (fileEntry.moduleResolved?.[module]) return false;
10836
- const first = module.split('.')[0];
10837
- return !first || !_projectTopLevelNames(index).has(first);
10845
+ return !_unresolvedModuleIsGap(index, module);
10838
10846
  }
10839
10847
 
10840
10848
  function _structuralImportedReceiverType(index, fileEntry, receiver) {
@@ -10957,10 +10965,7 @@ function _calleeStructuralBindingRoute(index, fileEntry, call, language, binding
10957
10965
  for (const binding of bindings) {
10958
10966
  const rel = fileEntry.moduleResolved && fileEntry.moduleResolved[binding.module];
10959
10967
  if (!rel) {
10960
- const mod = String(binding.module || '');
10961
- const firstSeg = mod.split(/[./]/).filter(Boolean)[0];
10962
- if (mod.startsWith('.') ||
10963
- (firstSeg && _projectTopLevelNames(index).has(firstSeg))) {
10968
+ if (_unresolvedModuleIsGap(index, binding.module, binding)) {
10964
10969
  sawProjectish = true;
10965
10970
  sawUnknown = true;
10966
10971
  }
@@ -11008,10 +11013,7 @@ function _calleeExportDefinitions(index, startAbs, exposedName, language, call,
11008
11013
  const enqueue = (module, nextAttr) => {
11009
11014
  const rel = fe.moduleResolved && fe.moduleResolved[module];
11010
11015
  if (!rel) {
11011
- const mod = String(module || '');
11012
- const firstSeg = mod.split(/[./]/).filter(Boolean)[0];
11013
- if (mod.startsWith('.') ||
11014
- (firstSeg && _projectTopLevelNames(index).has(firstSeg))) unknown = true;
11016
+ if (_unresolvedModuleIsGap(index, module)) unknown = true;
11015
11017
  return;
11016
11018
  }
11017
11019
  next.push([path.join(index.root, rel), nextAttr]);
@@ -15600,10 +15602,7 @@ function _typeOfCallResultFoldInner(index, fileEntry, filePath, record, ctx, con
15600
15602
  (binding && fileEntry.moduleResolved &&
15601
15603
  fileEntry.moduleResolved[binding.module]);
15602
15604
  if (binding && !rel) {
15603
- const mod = String(binding.module);
15604
- const firstSeg = mod.split(/[./]/).filter(Boolean)[0];
15605
- if (!mod.startsWith('.') &&
15606
- !(firstSeg && _projectTopLevelNames(index).has(firstSeg))) {
15605
+ if (!_unresolvedModuleIsGap(index, binding.module, binding)) {
15607
15606
  return {
15608
15607
  externalVia: `${record.receiver}.${name}`,
15609
15608
  ...(/^[A-Z]/.test(name) && { externalConcrete: true }),
@@ -16129,4 +16128,4 @@ function findCallbackUsages(index, name) {
16129
16128
  return usages;
16130
16129
  }
16131
16130
 
16132
- module.exports = { getCachedCalls, findCallers, findCallees, getInstanceAttributeTypes, findCallbackUsages, _nameBindingReaches, _moduleAttributeBindingReaches, _declaredFieldType, _projectTopLevelNames, _callArityCompatible, _closeCallableIdentityGroup, _overloadDiscipline, _overloadApplicable };
16131
+ module.exports = { _unresolvedModuleIsGap, _importReaches, _sameNominalPackageDir, getCachedCalls, findCallers, findCallees, getInstanceAttributeTypes, findCallbackUsages, _nameBindingReaches, _moduleAttributeBindingReaches, _declaredFieldType, _projectTopLevelNames, _callArityCompatible, _closeCallableIdentityGroup, _overloadDiscipline, _overloadApplicable };
package/core/check.js CHANGED
@@ -88,9 +88,9 @@ function check(index, options = {}) {
88
88
  const nonSourcePaths = dr?.nonSourcePaths || 0;
89
89
  let reason = 'no changes detected';
90
90
  if (changedPaths > 0 && nonSourcePaths === changedPaths) {
91
- reason = `${changedPaths} changed path(s), all outside supported source files`;
91
+ reason = `${changedPaths} changed path(s), all outside supported source files; untracked source files are included`;
92
92
  } else if (changedPaths > 0) {
93
- reason = 'no callable-symbol changes in the diff';
93
+ reason = 'no callable-symbol changes in the diff or untracked source files';
94
94
  }
95
95
  return {
96
96
  base: options.base || 'HEAD',
package/core/execute.js CHANGED
@@ -464,6 +464,14 @@ const PUBLIC_COMMAND_SET = new Set(CANONICAL_COMMANDS);
464
464
  * glob, file, and programmatic callers receive the same answer.
465
465
  */
466
466
  function validatePublicParams(command, p) {
467
+ if (p.raw && command !== 'source') return '--raw is supported only by source.';
468
+ if (p.lines && !['find', 'usages', 'search', 'show', 'impact'].includes(command)) {
469
+ return '--lines is supported by find, usages, search, show, and impact.';
470
+ }
471
+ if (p.lines && command === 'show' && p.sections) {
472
+ const parsed = parseSections(p.sections, ['callers'], new Set(['callers', 'callees']), 'show --lines');
473
+ if (parsed.error) return parsed.error;
474
+ }
467
475
  const hasName = typeof p.name === 'string' && p.name.trim() !== '';
468
476
  const gitScope = p.staged || (typeof p.base === 'string' && p.base.trim() !== '');
469
477
  if ((command === 'impact' || command === 'check') && hasName && gitScope) {
@@ -569,7 +577,7 @@ const HANDLERS = {
569
577
  } : query;
570
578
  const parsed = parseSections(
571
579
  p.sections,
572
- ['summary', 'callers', 'callees'],
580
+ p.lines ? ['callers'] : ['summary', 'callers', 'callees'],
573
581
  SHOW_SECTIONS,
574
582
  'show',
575
583
  );
@@ -693,6 +701,8 @@ const HANDLERS = {
693
701
  const notes = [
694
702
  ...(resolved.warnings || []).map(warning => warning.message),
695
703
  response.note,
704
+ ...(p.raw ? (response.result.entries || []).filter(entry => entry.truncated)
705
+ .map(entry => `Source truncated: ${entry.match.relativePath}:${entry.match.startLine}; showing ${entry.shownLines || entry.maxLines} of ${entry.totalLines} lines (--max-lines).`) : []),
696
706
  ].filter(Boolean);
697
707
  return notes.length > 0
698
708
  ? { ...response, note: combineNotes(notes) }
@@ -1174,6 +1184,9 @@ const HANDLERS = {
1174
1184
  file: p.file,
1175
1185
  className: p.className,
1176
1186
  exact: p.exact || false,
1187
+ // A shell definition listing renders no activity counts. Avoid a
1188
+ // full pinned caller query for every definition in a wildcard.
1189
+ skipCounts: !!p.lines,
1177
1190
  exclude,
1178
1191
  in: p.in,
1179
1192
  });
@@ -1430,7 +1443,7 @@ const HANDLERS = {
1430
1443
  exclude,
1431
1444
  in: p.in,
1432
1445
  file: p.file,
1433
- top: topVal || 50,
1446
+ top: topVal || (p.lines ? undefined : 50),
1434
1447
  });
1435
1448
  if (result.meta.error) return { ok: false, error: result.meta.error };
1436
1449
  const unsupported = (!p.regex && (p.term || p.name))
@@ -1456,7 +1469,7 @@ const HANDLERS = {
1456
1469
  // default at 500 and retain total/truncated metadata for controlled
1457
1470
  // expansion with an explicit top/limit.
1458
1471
  const topVal = num(p.top, undefined) || num(p.limit, undefined);
1459
- const effectiveLimit = topVal || 500;
1472
+ const effectiveLimit = topVal || (p.lines ? undefined : 500);
1460
1473
  const result = index.search(p.term, {
1461
1474
  codeOnly: p.codeOnly || false,
1462
1475
  context: num(p.context, 0),
@@ -2014,9 +2027,12 @@ const HANDLERS = {
2014
2027
 
2015
2028
  if (matches.length > 1 && p.all) {
2016
2029
  for (const m of matches) {
2017
- const code = readAndExtract(m);
2030
+ const fullCode = readAndExtract(m);
2018
2031
  const totalLines = m.endLine - m.startLine + 1;
2019
- entries.push({ match: m, code, totalLines, summaryMode: false, truncated: false });
2032
+ const truncated = !!maxLines && totalLines > maxLines;
2033
+ const code = truncated ? fullCode.split('\n').slice(0, maxLines).join('\n') : fullCode;
2034
+ entries.push({ match: m, code, totalLines, summaryMode: false, truncated,
2035
+ ...(truncated && { maxLines }) });
2020
2036
  }
2021
2037
  return { ok: true, result: { entries }, note: notes.length ? notes.map(n => 'Note: ' + n).join('\n') : undefined };
2022
2038
  }
@@ -2035,7 +2051,7 @@ const HANDLERS = {
2035
2051
  const totalLines = match.endLine - match.startLine + 1;
2036
2052
 
2037
2053
  // Large class summary mode (>200 lines, no maxLines)
2038
- if (totalLines > 200 && !maxLines) {
2054
+ if (totalLines > 200 && !maxLines && !p.raw) {
2039
2055
  const methods = index.findMethodsForType(match.name, match);
2040
2056
  entries.push({ match, code: null, methods, totalLines, summaryMode: true, truncated: false });
2041
2057
  return { ok: true, result: { entries }, note: notes.length ? notes.map(n => 'Note: ' + n).join('\n') : undefined };
@@ -2457,6 +2473,8 @@ function execute(index, command, params = {}) {
2457
2473
  // logged params object; a frozen object silently no-oped the
2458
2474
  // normalizers and returned wrong not-found answers).
2459
2475
  params = { ...params };
2476
+ // Listing completeness belongs to execution, including direct API calls.
2477
+ if (params.lines) params.all = true;
2460
2478
  try {
2461
2479
  if (PUBLIC_COMMAND_SET.has(command)) {
2462
2480
  const validationError = validatePublicParams(command, params);
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),