@ts-stack/cycle-detector 1.1.0 → 1.1.2

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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@ts-stack/cycle-detector",
3
3
  "type": "module",
4
- "version": "1.1.0",
4
+ "version": "1.1.2",
5
5
  "bin": {
6
6
  "cycle-detector": "./dist/index.js"
7
7
  },
@@ -21,7 +21,7 @@
21
21
  },
22
22
  "scripts": {
23
23
  "start": "npm run build && node dist/index.js",
24
- "test": "npm run build-test && npm run esm-jest",
24
+ "test": "npm run build-test && npm run esm-jest --",
25
25
  "esm-jest": "node --experimental-vm-modules --no-warnings=ExperimentalWarning node_modules/jest/bin/jest.js",
26
26
  "build": "tsc -b tsconfig.build.json",
27
27
  "build-test": "tsc -b tsconfig.unit.json",
@@ -40,14 +40,15 @@
40
40
  },
41
41
  "devDependencies": {
42
42
  "@eslint/js": "^10.0.1",
43
- "@types/jest": "^29.5.14",
43
+ "@types/jest": "^30.0.0",
44
44
  "@types/node": "^26.0.0",
45
45
  "eslint": "^10.5.0",
46
- "jest": "^29.7.0",
46
+ "jest": "^30.4.2",
47
47
  "nodemon": "^3.1.14",
48
48
  "prettier": "^3.8.4",
49
49
  "rimraf": "^5.0.10",
50
50
  "ts-node": "^10.9.2",
51
+ "type-fest": "^5.7.0",
51
52
  "typescript": "^5.9.3",
52
53
  "typescript-eslint": "^8.62.0"
53
54
  },
package/src/index.ts CHANGED
@@ -11,26 +11,21 @@ const graph = new Map<string, string[]>();
11
11
  const allUniqueCycles: string[][] = [];
12
12
  const globalDetectedCycles = new Set<string>();
13
13
 
14
- const packageMetaCache = new Map<string, { pkgDir: string; srcDirName: string; outDirName: string; } | null>();
15
14
  const compilerOptionsCache = new Map<string, ts.CompilerOptions>();
16
-
17
- let globalProjectPath: string | undefined;
15
+ const sourceFileCache = new Map<string, ts.SourceFile>();
16
+ const topLevelUsageCache = new Map<string, boolean>();
17
+ const exportedHoistedFunctionsCache = new Map<string, Set<string>>();
18
+ const resolvedSourceCache = new Map<string, string>(); // Performance cache for path mapping
18
19
 
19
20
  function parseArgs() {
20
21
  const args = [...process.argv.slice(2)];
21
- let projectPath: string | undefined;
22
22
  const entryPatterns: string[] = [];
23
23
 
24
24
  for (let i = 0; i < args.length; i++) {
25
- if (args[i] === '--project' || args[i] === '-p') {
26
- projectPath = args[i + 1];
27
- i++;
28
- } else {
29
- entryPatterns.push(args[i]);
30
- }
25
+ entryPatterns.push(args[i]);
31
26
  }
32
27
 
33
- return { entryPatterns, projectPath };
28
+ return { entryPatterns };
34
29
  }
35
30
 
36
31
  function isRuntimeImport(node: ts.ImportDeclaration | ts.ExportDeclaration): boolean {
@@ -40,12 +35,17 @@ function isRuntimeImport(node: ts.ImportDeclaration | ts.ExportDeclaration): boo
40
35
  }
41
36
 
42
37
  if (ts.isImportDeclaration(node)) {
43
- if (!node.importClause) return true;
44
- if (node.importClause.phaseModifier) return false;
38
+ if (node.importClause?.phaseModifier) return false;
39
+ if (!node.importClause) return true; // Side-effect import
40
+
41
+ if (node.importClause.name) return true; // Default import present
45
42
 
46
43
  const namedBindings = node.importClause.namedBindings;
47
- if (namedBindings && ts.isNamedImports(namedBindings)) {
48
- return !namedBindings.elements.every((el) => el.isTypeOnly);
44
+ if (namedBindings) {
45
+ if (ts.isNamespaceImport(namedBindings)) return true;
46
+ if (ts.isNamedImports(namedBindings)) {
47
+ return !namedBindings.elements.every((el) => el.isTypeOnly);
48
+ }
49
49
  }
50
50
  return true;
51
51
  }
@@ -55,18 +55,14 @@ function isRuntimeImport(node: ts.ImportDeclaration | ts.ExportDeclaration): boo
55
55
 
56
56
  function getCompilerOptionsForFile(filePath: string): ts.CompilerOptions {
57
57
  const currentDir = path.dirname(filePath);
58
-
59
58
  const cachedOptions = compilerOptionsCache.get(currentDir);
60
- if (cachedOptions !== undefined) {
61
- return cachedOptions;
62
- }
59
+ if (cachedOptions !== undefined) return cachedOptions;
63
60
 
64
- const configPath = ts.findConfigFile(currentDir, ts.sys.fileExists, 'tsconfig.json') || globalProjectPath;
61
+ const configPath = ts.findConfigFile(currentDir, ts.sys.fileExists, 'tsconfig.json');
65
62
 
66
63
  if (configPath) {
67
64
  const resolvedConfigPath = path.resolve(configPath);
68
65
  const configDir = path.dirname(resolvedConfigPath);
69
-
70
66
  const cachedConfig = compilerOptionsCache.get(resolvedConfigPath);
71
67
  if (cachedConfig !== undefined) {
72
68
  compilerOptionsCache.set(currentDir, cachedConfig);
@@ -77,7 +73,6 @@ function getCompilerOptionsForFile(filePath: string): ts.CompilerOptions {
77
73
  const configFile = ts.readConfigFile(resolvedConfigPath, ts.sys.readFile);
78
74
  const parsedConfig = ts.parseJsonConfigFileContent(configFile.config, ts.sys, configDir);
79
75
  const options = parsedConfig.options || {};
80
-
81
76
  compilerOptionsCache.set(resolvedConfigPath, options);
82
77
  compilerOptionsCache.set(currentDir, options);
83
78
  return options;
@@ -89,54 +84,78 @@ function getCompilerOptionsForFile(filePath: string): ts.CompilerOptions {
89
84
  return {};
90
85
  }
91
86
 
92
- function getPackageMeta(filePath: string) {
93
- let currentDir = path.dirname(filePath);
94
- const visitedDirs: string[] = [];
87
+ /**
88
+ * Universal resolution engine: safely maps compiled assets (.js, .d.ts) back to source files (.ts)
89
+ * Works flawlessly for standalone packages, polyrepos, and complex monorepos alike.
90
+ */
91
+ function convertToSourcePath(resolvedPath: string): string {
92
+ // If it's already a source file and not trapped inside a known build directory, return it directly
93
+ if (/\.(ts|tsx|mts|cts)$/.test(resolvedPath) && !/[\\/](dist|build|lib|out|cjs|esm|bin)[\\/]/i.test(resolvedPath)) {
94
+ return resolvedPath;
95
+ }
96
+
97
+ const extensions = ['.ts', '.tsx', '.mts', '.cts'];
98
+
99
+ // Strip execution/declaration extensions to get the base file signature
100
+ let baseName = resolvedPath;
101
+ if (baseName.endsWith('.d.ts')) baseName = baseName.slice(0, -5);
102
+ else if (baseName.endsWith('.d.mts')) baseName = baseName.slice(0, -6);
103
+ else if (baseName.endsWith('.d.cts')) baseName = baseName.slice(0, -6);
104
+ else if (baseName.endsWith('.js')) baseName = baseName.slice(0, -3);
105
+ else if (baseName.endsWith('.mjs')) baseName = baseName.slice(0, -4);
106
+ else if (baseName.endsWith('.cjs')) baseName = baseName.slice(0, -4);
107
+ else if (baseName.endsWith('.jsx')) baseName = baseName.slice(0, -4);
108
+
109
+ // Strategy 1: Direct Extension Swap (In-place builds or matching structures)
110
+ for (const ext of extensions) {
111
+ if (fs.existsSync(baseName + ext)) return baseName + ext;
112
+ }
95
113
 
96
- while (currentDir && currentDir !== path.parse(currentDir).root) {
97
- const cached = packageMetaCache.get(currentDir);
98
- if (cached !== undefined) {
99
- for (const d of visitedDirs) packageMetaCache.set(d, cached);
100
- return cached;
114
+ // Strategy 2: Adaptive Path Segment Replacement (Remaps build directories to source directories)
115
+ const buildDirs = ['dist', 'build', 'lib', 'out', 'cjs', 'esm', 'bin'];
116
+ const srcDirs = ['src', 'source', '.']; // '.' fallback for flat root architectures
117
+
118
+ for (const bDir of buildDirs) {
119
+ const regex = new RegExp(`([\\\\/])${bDir}([\\\\/])`, 'i');
120
+ if (regex.test(baseName)) {
121
+ for (const sDir of srcDirs) {
122
+ // Skip illegal mappings that loop 'lib' back onto 'lib'
123
+ if (bDir.toLowerCase() === sDir.toLowerCase()) continue;
124
+
125
+ const replacedBase = baseName.replace(regex, `$1${sDir}$2`);
126
+ for (const ext of extensions) {
127
+ if (fs.existsSync(replacedBase + ext)) return replacedBase + ext;
128
+ }
129
+ }
101
130
  }
131
+ }
102
132
 
103
- const pkgJsonPath = path.join(currentDir, 'package.json');
104
- if (fs.existsSync(pkgJsonPath)) {
105
- try {
106
- const content = fs.readFileSync(pkgJsonPath, 'utf8');
107
- const pkg = JSON.parse(content);
108
-
109
- let outDirName = 'dist';
110
- const mainField = pkg.main || pkg.types || pkg.typings || '';
111
- if (mainField) {
112
- const parts = path.normalize(mainField).split(path.sep);
113
- if (parts.length > 1 && parts[0] !== '.' && parts[0] !== '..') {
114
- outDirName = parts[0];
115
- } else if (parts.length > 2 && (parts[0] === '.' || parts[0] === '..')) {
116
- outDirName = parts[1];
133
+ // Strategy 3: Boundary-driven subpath mapping via closest package.json
134
+ let currentDir = path.dirname(resolvedPath);
135
+ while (currentDir && currentDir !== path.parse(currentDir).root) {
136
+ if (fs.existsSync(path.join(currentDir, 'package.json'))) {
137
+ for (const sDir of ['src', 'source']) {
138
+ const srcDirPath = path.join(currentDir, sDir);
139
+ if (fs.existsSync(srcDirPath)) {
140
+ const relativeToPackage = path.relative(currentDir, baseName);
141
+ const pathParts = relativeToPackage.split(path.sep);
142
+
143
+ if (pathParts.length > 1) {
144
+ // Drop the first segment (which is the output folder like 'dist' or 'lib')
145
+ const subPath = pathParts.slice(1).join(path.sep);
146
+ for (const ext of extensions) {
147
+ const targetFile = path.join(srcDirPath, subPath + ext);
148
+ if (fs.existsSync(targetFile)) return targetFile;
149
+ }
117
150
  }
118
151
  }
119
-
120
- let srcDirName = 'src';
121
- if (fs.existsSync(path.join(currentDir, 'source'))) srcDirName = 'source';
122
- else if (fs.existsSync(path.join(currentDir, 'lib'))) srcDirName = 'lib';
123
-
124
- const meta = { pkgDir: currentDir, srcDirName, outDirName };
125
- packageMetaCache.set(currentDir, meta);
126
- for (const d of visitedDirs) packageMetaCache.set(d, meta);
127
- return meta;
128
- } catch {
129
- packageMetaCache.set(currentDir, null);
130
- for (const d of visitedDirs) packageMetaCache.set(d, null);
131
- return null;
132
152
  }
153
+ break;
133
154
  }
134
-
135
- visitedDirs.push(currentDir);
136
155
  currentDir = path.dirname(currentDir);
137
156
  }
138
157
 
139
- return null;
158
+ return resolvedPath;
140
159
  }
141
160
 
142
161
  function resolveModule(moduleName: string, containingFile: string, options: ts.CompilerOptions): string | null {
@@ -145,47 +164,20 @@ function resolveModule(moduleName: string, containingFile: string, options: ts.C
145
164
 
146
165
  const resolvedFileName = path.resolve(result.resolvedModule.resolvedFileName);
147
166
 
148
- if (resolvedFileName.includes(`${path.sep}node_modules${path.sep}`)) {
149
- return null;
150
- }
151
-
152
- const meta = getPackageMeta(resolvedFileName);
153
- if (meta) {
154
- const { pkgDir, srcDirName, outDirName } = meta;
155
- const srcDirPath = path.join(pkgDir, srcDirName);
156
-
157
- if (resolvedFileName.startsWith(srcDirPath + path.sep)) {
158
- return resolvedFileName;
159
- }
160
-
161
- const outDirPath = path.join(pkgDir, outDirName);
162
- if (resolvedFileName.startsWith(outDirPath + path.sep) || resolvedFileName === outDirPath) {
163
- const relativeToOut = path.relative(outDirPath, resolvedFileName);
164
- let baseName = relativeToOut;
165
-
166
- if (baseName.endsWith('.d.ts')) baseName = baseName.slice(0, -5);
167
- else if (baseName.endsWith('.d.mts')) baseName = baseName.slice(0, -6);
168
- else if (baseName.endsWith('.d.cts')) baseName = baseName.slice(0, -6);
169
- else if (baseName.endsWith('.js')) baseName = baseName.slice(0, -3);
170
- else if (baseName.endsWith('.mjs')) baseName = baseName.slice(0, -4);
171
- else if (baseName.endsWith('.cjs')) baseName = baseName.slice(0, -4);
172
- else if (baseName.endsWith('.jsx')) baseName = baseName.slice(0, -4);
173
-
174
- const extensions = ['.ts', '.tsx', '.mts', '.cts'];
175
- for (const ext of extensions) {
176
- const targetSrcFile = path.join(srcDirPath, baseName + ext);
177
- if (fs.existsSync(targetSrcFile)) {
178
- return targetSrcFile;
179
- }
180
- }
181
- }
167
+ // Run universal mapping first
168
+ let sourcePath = resolvedSourceCache.get(resolvedFileName);
169
+ if (!sourcePath) {
170
+ sourcePath = convertToSourcePath(resolvedFileName);
171
+ resolvedSourceCache.set(resolvedFileName, sourcePath);
182
172
  }
183
173
 
184
- if (result.resolvedModule.isExternalLibraryImport) {
174
+ // Safety filter: Ignore true external node_modules.
175
+ // Symlinked monorepo packages will successfully map to their local paths above and bypass this check.
176
+ if (sourcePath.includes(`${path.sep}node_modules${path.sep}`)) {
185
177
  return null;
186
178
  }
187
179
 
188
- return resolvedFileName;
180
+ return result.resolvedModule.isExternalLibraryImport ? null : sourcePath;
189
181
  }
190
182
 
191
183
  function getCanonicalCycleKey(cycle: string[]): string {
@@ -194,9 +186,7 @@ function getCanonicalCycleKey(cycle: string[]): string {
194
186
 
195
187
  let minIdx = 0;
196
188
  for (let i = 1; i < nodes.length; i++) {
197
- if (nodes[i] < nodes[minIdx]) {
198
- minIdx = i;
199
- }
189
+ if (nodes[i] < nodes[minIdx]) minIdx = i;
200
190
  }
201
191
 
202
192
  const rotated = [...nodes.slice(minIdx), ...nodes.slice(0, minIdx)];
@@ -209,8 +199,14 @@ function parseFile(filePath: string) {
209
199
  graph.set(filePath, []);
210
200
 
211
201
  const options = getCompilerOptionsForFile(filePath);
212
- const content = fs.readFileSync(filePath, 'utf8');
213
- const sourceFile = ts.createSourceFile(filePath, content, ts.ScriptTarget.Latest, true);
202
+
203
+ let sourceFile = sourceFileCache.get(filePath);
204
+ if (!sourceFile) {
205
+ const content = fs.readFileSync(filePath, 'utf8');
206
+ sourceFile = ts.createSourceFile(filePath, content, ts.ScriptTarget.Latest, true);
207
+ sourceFileCache.set(filePath, sourceFile);
208
+ }
209
+
214
210
  const imports: string[] = [];
215
211
 
216
212
  function walk(node: ts.Node) {
@@ -232,17 +228,93 @@ function parseFile(filePath: string) {
232
228
  for (const dep of imports) parseFile(dep);
233
229
  }
234
230
 
235
- /**
236
- * Checks if a specific file import creates an immediate execution (top-level) risk.
237
- */
231
+ function getExportedHoistedFunctions(filePath: string): Set<string> {
232
+ if (exportedHoistedFunctionsCache.has(filePath)) {
233
+ return exportedHoistedFunctionsCache.get(filePath)!;
234
+ }
235
+
236
+ const hoisted = new Set<string>();
237
+ if (!fs.existsSync(filePath)) {
238
+ exportedHoistedFunctionsCache.set(filePath, hoisted);
239
+ return hoisted;
240
+ }
241
+
242
+ let sourceFile = sourceFileCache.get(filePath);
243
+ if (!sourceFile) {
244
+ try {
245
+ const content = fs.readFileSync(filePath, 'utf8');
246
+ sourceFile = ts.createSourceFile(filePath, content, ts.ScriptTarget.Latest, true);
247
+ sourceFileCache.set(filePath, sourceFile);
248
+ } catch {
249
+ exportedHoistedFunctionsCache.set(filePath, hoisted);
250
+ return hoisted;
251
+ }
252
+ }
253
+
254
+ const localHoistedFuncs = new Set<string>();
255
+
256
+ for (const statement of sourceFile.statements) {
257
+ if (ts.isFunctionDeclaration(statement)) {
258
+ if (statement.name) {
259
+ localHoistedFuncs.add(statement.name.text);
260
+ }
261
+ const hasExport = statement.modifiers?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword);
262
+ const hasDefault = statement.modifiers?.some((m) => m.kind === ts.SyntaxKind.DefaultKeyword);
263
+
264
+ if (hasExport) {
265
+ if (hasDefault) hoisted.add('default');
266
+ else if (statement.name) hoisted.add(statement.name.text);
267
+ }
268
+ }
269
+ }
270
+
271
+ for (const statement of sourceFile.statements) {
272
+ if (ts.isExportDeclaration(statement)) {
273
+ if (!statement.moduleSpecifier && statement.exportClause && ts.isNamedExports(statement.exportClause)) {
274
+ for (const el of statement.exportClause.elements) {
275
+ const localName = el.propertyName ? el.propertyName.text : el.name.text;
276
+ const exportedName = el.name.text;
277
+ if (localHoistedFuncs.has(localName)) {
278
+ hoisted.add(exportedName);
279
+ }
280
+ }
281
+ }
282
+ } else if (ts.isExportAssignment(statement)) {
283
+ if (!statement.isExportEquals && ts.isIdentifier(statement.expression)) {
284
+ if (localHoistedFuncs.has(statement.expression.text)) {
285
+ hoisted.add('default');
286
+ }
287
+ }
288
+ }
289
+ }
290
+
291
+ exportedHoistedFunctionsCache.set(filePath, hoisted);
292
+ return hoisted;
293
+ }
294
+
238
295
  function hasTopLevelUsage(fromFile: string, toFile: string): boolean {
239
296
  if (!fs.existsSync(fromFile)) return false;
240
297
 
298
+ const cacheKey = `${fromFile}-->${toFile}`;
299
+ if (topLevelUsageCache.has(cacheKey)) return topLevelUsageCache.get(cacheKey)!;
300
+
241
301
  const options = getCompilerOptionsForFile(fromFile);
242
- const content = fs.readFileSync(fromFile, 'utf8');
243
- const sourceFile = ts.createSourceFile(fromFile, content, ts.ScriptTarget.Latest, true);
244
302
 
245
- const importedSymbols = new Set<string>();
303
+ let sourceFile = sourceFileCache.get(fromFile);
304
+ if (!sourceFile) {
305
+ try {
306
+ const content = fs.readFileSync(fromFile, 'utf8');
307
+ sourceFile = ts.createSourceFile(fromFile, content, ts.ScriptTarget.Latest, true);
308
+ sourceFileCache.set(fromFile, sourceFile);
309
+ } catch {
310
+ topLevelUsageCache.set(cacheKey, false);
311
+ return false;
312
+ }
313
+ }
314
+
315
+ const hoistedExports = getExportedHoistedFunctions(toFile);
316
+ const localToExportedName = new Map<string, string>();
317
+ let namespaceImportName: string | null = null;
246
318
  let hasSideEffectOrReExport = false;
247
319
 
248
320
  function findImports(node: ts.Node) {
@@ -254,13 +326,18 @@ function hasTopLevelUsage(fromFile: string, toFile: string): boolean {
254
326
  if (resolved === toFile) {
255
327
  if (ts.isImportDeclaration(node) && node.importClause) {
256
328
  const clause = node.importClause;
257
- if (clause.name) importedSymbols.add(clause.name.text);
329
+ if (clause.name) {
330
+ localToExportedName.set(clause.name.text, 'default');
331
+ }
258
332
  if (clause.namedBindings) {
259
333
  if (ts.isNamespaceImport(clause.namedBindings)) {
260
- importedSymbols.add(clause.namedBindings.name.text);
334
+ namespaceImportName = clause.namedBindings.name.text;
261
335
  } else if (ts.isNamedImports(clause.namedBindings)) {
262
336
  for (const el of clause.namedBindings.elements) {
263
- importedSymbols.add(el.name.text);
337
+ if (el.isTypeOnly) continue;
338
+
339
+ const exportedName = el.propertyName ? el.propertyName.text : el.name.text;
340
+ localToExportedName.set(el.name.text, exportedName);
264
341
  }
265
342
  }
266
343
  }
@@ -276,8 +353,14 @@ function hasTopLevelUsage(fromFile: string, toFile: string): boolean {
276
353
 
277
354
  findImports(sourceFile);
278
355
 
279
- if (hasSideEffectOrReExport) return true;
280
- if (importedSymbols.size === 0) return false;
356
+ if (hasSideEffectOrReExport) {
357
+ topLevelUsageCache.set(cacheKey, true);
358
+ return true;
359
+ }
360
+ if (localToExportedName.size === 0 && !namespaceImportName) {
361
+ topLevelUsageCache.set(cacheKey, false);
362
+ return false;
363
+ }
281
364
 
282
365
  let dangerousTopLevelUsage = false;
283
366
 
@@ -299,41 +382,67 @@ function hasTopLevelUsage(fromFile: string, toFile: string): boolean {
299
382
  }
300
383
 
301
384
  if (ts.isPropertyDeclaration(node)) {
302
- const isStatic = node.modifiers?.some(m => m.kind === ts.SyntaxKind.StaticKeyword);
303
- if (!isStatic) {
304
- currentScopeLazy = true;
305
- }
385
+ const isStatic = node.modifiers?.some((m) => m.kind === ts.SyntaxKind.StaticKeyword);
386
+ if (!isStatic) currentScopeLazy = true;
306
387
  }
307
388
 
308
389
  if (!currentScopeLazy && ts.isIdentifier(node)) {
309
- if (importedSymbols.has(node.text)) {
390
+ const isImportedSymbol = localToExportedName.has(node.text);
391
+ const isNamespaceReference = namespaceImportName && node.text === namespaceImportName;
392
+
393
+ if (isImportedSymbol || isNamespaceReference) {
310
394
  const parent = node.parent;
311
-
312
- const isImportDeclarationRef =
313
- ts.isImportSpecifier(parent) ||
314
- ts.isImportClause(parent) ||
315
- ts.isNamespaceImport(parent);
316
-
317
- if (!isImportDeclarationRef) {
318
- let isInTypeContext = false;
319
- let checkParent: ts.Node | undefined = parent;
320
- while (checkParent && checkParent !== sourceFile) {
321
- if (
322
- ts.isTypeNode(checkParent) ||
323
- ts.isTypeReferenceNode(checkParent) ||
324
- ts.isTypeAliasDeclaration(checkParent) ||
325
- ts.isInterfaceDeclaration(checkParent)
326
- ) {
327
- isInTypeContext = true;
328
- break;
329
- }
330
- checkParent = checkParent.parent;
395
+
396
+ const isImportOrExportDeclarationRef =
397
+ ts.isImportSpecifier(parent) ||
398
+ ts.isImportClause(parent) ||
399
+ ts.isNamespaceImport(parent) ||
400
+ ts.isExportSpecifier(parent);
401
+
402
+ if (isImportOrExportDeclarationRef) return;
403
+ if (ts.isPropertyAccessExpression(parent) && parent.name === node) return;
404
+ if (ts.isPropertyAssignment(parent) && parent.name === node) return;
405
+ if (
406
+ (ts.isMethodDeclaration(parent) ||
407
+ ts.isPropertyDeclaration(parent) ||
408
+ ts.isClassDeclaration(parent) ||
409
+ ts.isInterfaceDeclaration(parent) ||
410
+ ts.isFunctionDeclaration(parent)) &&
411
+ parent.name === node
412
+ ) {
413
+ return;
414
+ }
415
+
416
+ if (isImportedSymbol) {
417
+ const exportedName = localToExportedName.get(node.text)!;
418
+ if (hoistedExports.has(exportedName)) return;
419
+ }
420
+
421
+ if (isNamespaceReference) {
422
+ if (ts.isPropertyAccessExpression(parent) && parent.expression === node) {
423
+ const propName = parent.name.text;
424
+ if (hoistedExports.has(propName)) return;
331
425
  }
426
+ }
332
427
 
333
- if (!isInTypeContext) {
334
- dangerousTopLevelUsage = true;
335
- return;
428
+ let isInTypeContext = false;
429
+ let checkParent: ts.Node | undefined = parent;
430
+ while (checkParent && checkParent !== sourceFile) {
431
+ if (
432
+ ts.isTypeNode(checkParent) ||
433
+ ts.isTypeReferenceNode(checkParent) ||
434
+ ts.isTypeAliasDeclaration(checkParent) ||
435
+ ts.isInterfaceDeclaration(checkParent)
436
+ ) {
437
+ isInTypeContext = true;
438
+ break;
336
439
  }
440
+ checkParent = checkParent.parent;
441
+ }
442
+
443
+ if (!isInTypeContext) {
444
+ dangerousTopLevelUsage = true;
445
+ return;
337
446
  }
338
447
  }
339
448
  }
@@ -342,6 +451,7 @@ function hasTopLevelUsage(fromFile: string, toFile: string): boolean {
342
451
  }
343
452
 
344
453
  checkNodeUsage(sourceFile, false);
454
+ topLevelUsageCache.set(cacheKey, dangerousTopLevelUsage);
345
455
  return dangerousTopLevelUsage;
346
456
  }
347
457
 
@@ -356,16 +466,13 @@ function canReach(start: string, target: string): boolean {
356
466
  seen.add(current);
357
467
 
358
468
  const deps = graph.get(current) || [];
359
- for (const dep of deps) {
360
- stack.push(dep);
361
- }
469
+ for (const dep of deps) stack.push(dep);
362
470
  }
363
471
  return false;
364
472
  }
365
473
 
366
474
  function main() {
367
- const { entryPatterns, projectPath } = parseArgs();
368
- globalProjectPath = projectPath;
475
+ const { entryPatterns } = parseArgs();
369
476
 
370
477
  if (entryPatterns.length === 0) {
371
478
  console.error('❌ Error: Please specify at least one entry point or glob pattern.');
@@ -432,36 +539,27 @@ function main() {
432
539
  }
433
540
 
434
541
  for (const entryPoint of entryPoints) {
435
- if (!visited.has(entryPoint)) {
436
- findCycles(entryPoint);
437
- }
542
+ if (!visited.has(entryPoint)) findCycles(entryPoint);
438
543
  }
439
544
 
440
545
  const criticalCycles: string[][] = [];
441
546
 
442
547
  for (const cycle of allUniqueCycles) {
443
548
  let isHarmfulCycle = false;
444
-
445
549
  for (let i = 0; i < cycle.length - 1; i++) {
446
550
  if (hasTopLevelUsage(cycle[i], cycle[i + 1])) {
447
551
  isHarmfulCycle = true;
448
552
  break;
449
553
  }
450
554
  }
451
-
452
- if (isHarmfulCycle) {
453
- criticalCycles.push(cycle);
454
- }
555
+ if (isHarmfulCycle) criticalCycles.push(cycle);
455
556
  }
456
557
 
457
558
  const entryPointCycles = new Map<string, string[][]>();
458
- for (const ep of entryPoints) {
459
- entryPointCycles.set(ep, []);
460
- }
559
+ for (const ep of entryPoints) entryPointCycles.set(ep, []);
461
560
 
462
561
  for (const cycle of criticalCycles) {
463
562
  const firstFile = cycle[0];
464
-
465
563
  const matchedEp = entryPoints.find((ep) => {
466
564
  const epDir = path.dirname(ep);
467
565
  return firstFile.startsWith(epDir + path.sep) || firstFile === ep;
@@ -471,15 +569,11 @@ function main() {
471
569
  entryPointCycles.get(matchedEp)!.push(cycle);
472
570
  } else {
473
571
  const reachingEp = entryPoints.find((ep) => canReach(ep, firstFile));
474
- if (reachingEp) {
475
- entryPointCycles.get(reachingEp)!.push(cycle);
476
- } else {
477
- entryPointCycles.get(entryPoints[0])!.push(cycle);
478
- }
572
+ if (reachingEp) entryPointCycles.get(reachingEp)!.push(cycle);
573
+ else entryPointCycles.get(entryPoints[0])!.push(cycle);
479
574
  }
480
575
  }
481
576
 
482
- // Phase 4: Output the clean, perfectly targeted report
483
577
  let globalHasCycles = false;
484
578
 
485
579
  for (const entryPoint of entryPoints) {
@@ -489,14 +583,12 @@ function main() {
489
583
  if (cycles.length > 0) {
490
584
  globalHasCycles = true;
491
585
  console.error(`❌ ${absoluteEntry} — Found ${cycles.length} critical circular dependencies:`);
492
-
586
+
493
587
  cycles.forEach((cycle, index) => {
494
588
  console.error(` ${index + 1})`, '-'.repeat(80));
495
-
496
589
  for (let i = 1; i < cycle.length; i++) {
497
- const nextFile = (i === cycle.length - 1) ? cycle[1] : cycle[i + 1];
590
+ const nextFile = i === cycle.length - 1 ? cycle[1] : cycle[i + 1];
498
591
  const isTopLevel = hasTopLevelUsage(cycle[i], nextFile);
499
-
500
592
  const prefix = isTopLevel ? ' 💥 [Top-level] ' : ' ⏳ [Lazy] ';
501
593
  console.error(`${prefix}${path.resolve(cycle[i])}`);
502
594
  }