@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/README.md +55 -57
- package/dist/index.js +250 -151
- package/dist/index.js.map +1 -1
- package/package.json +5 -4
- package/src/index.ts +259 -167
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.
|
|
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": "^
|
|
43
|
+
"@types/jest": "^30.0.0",
|
|
44
44
|
"@types/node": "^26.0.0",
|
|
45
45
|
"eslint": "^10.5.0",
|
|
46
|
-
"jest": "^
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 (
|
|
44
|
-
if (node.importClause
|
|
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
|
|
48
|
-
|
|
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')
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
if (
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
|
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
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
213
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
334
|
+
namespaceImportName = clause.namedBindings.name.text;
|
|
261
335
|
} else if (ts.isNamedImports(clause.namedBindings)) {
|
|
262
336
|
for (const el of clause.namedBindings.elements) {
|
|
263
|
-
|
|
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)
|
|
280
|
-
|
|
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
|
-
|
|
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
|
|
313
|
-
ts.isImportSpecifier(parent) ||
|
|
314
|
-
ts.isImportClause(parent) ||
|
|
315
|
-
ts.isNamespaceImport(parent)
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
)
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
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
|
-
|
|
334
|
-
|
|
335
|
-
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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
|
}
|