@bfra.me/workspace-analyzer 0.1.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/README.md +402 -0
- package/lib/chunk-4LSFAAZW.js +1 -0
- package/lib/chunk-JDF7DQ4V.js +27 -0
- package/lib/chunk-WOJ4C7N7.js +7122 -0
- package/lib/cli.d.ts +1 -0
- package/lib/cli.js +318 -0
- package/lib/index.d.ts +3701 -0
- package/lib/index.js +1262 -0
- package/lib/types/index.d.ts +146 -0
- package/lib/types/index.js +28 -0
- package/package.json +89 -0
- package/src/analyzers/analyzer.ts +201 -0
- package/src/analyzers/architectural-analyzer.ts +304 -0
- package/src/analyzers/build-config-analyzer.ts +334 -0
- package/src/analyzers/circular-import-analyzer.ts +463 -0
- package/src/analyzers/config-consistency-analyzer.ts +335 -0
- package/src/analyzers/dead-code-analyzer.ts +565 -0
- package/src/analyzers/duplicate-code-analyzer.ts +626 -0
- package/src/analyzers/duplicate-dependency-analyzer.ts +381 -0
- package/src/analyzers/eslint-config-analyzer.ts +281 -0
- package/src/analyzers/exports-field-analyzer.ts +324 -0
- package/src/analyzers/index.ts +388 -0
- package/src/analyzers/large-dependency-analyzer.ts +535 -0
- package/src/analyzers/package-json-analyzer.ts +349 -0
- package/src/analyzers/peer-dependency-analyzer.ts +275 -0
- package/src/analyzers/tree-shaking-analyzer.ts +623 -0
- package/src/analyzers/tsconfig-analyzer.ts +382 -0
- package/src/analyzers/unused-dependency-analyzer.ts +356 -0
- package/src/analyzers/version-alignment-analyzer.ts +308 -0
- package/src/api/analyze-workspace.ts +245 -0
- package/src/api/index.ts +11 -0
- package/src/cache/cache-manager.ts +495 -0
- package/src/cache/cache-schema.ts +247 -0
- package/src/cache/change-detector.ts +169 -0
- package/src/cache/file-hasher.ts +65 -0
- package/src/cache/index.ts +47 -0
- package/src/cli/commands/analyze.ts +240 -0
- package/src/cli/commands/index.ts +5 -0
- package/src/cli/index.ts +61 -0
- package/src/cli/types.ts +65 -0
- package/src/cli/ui.ts +213 -0
- package/src/cli.ts +9 -0
- package/src/config/defaults.ts +183 -0
- package/src/config/index.ts +81 -0
- package/src/config/loader.ts +270 -0
- package/src/config/merger.ts +229 -0
- package/src/config/schema.ts +263 -0
- package/src/core/incremental-analyzer.ts +462 -0
- package/src/core/index.ts +34 -0
- package/src/core/orchestrator.ts +416 -0
- package/src/graph/dependency-graph.ts +408 -0
- package/src/graph/index.ts +19 -0
- package/src/index.ts +417 -0
- package/src/parser/config-parser.ts +491 -0
- package/src/parser/import-extractor.ts +340 -0
- package/src/parser/index.ts +54 -0
- package/src/parser/typescript-parser.ts +95 -0
- package/src/performance/bundle-estimator.ts +444 -0
- package/src/performance/index.ts +27 -0
- package/src/reporters/console-reporter.ts +355 -0
- package/src/reporters/index.ts +49 -0
- package/src/reporters/json-reporter.ts +273 -0
- package/src/reporters/markdown-reporter.ts +349 -0
- package/src/reporters/reporter.ts +399 -0
- package/src/rules/builtin-rules.ts +709 -0
- package/src/rules/index.ts +52 -0
- package/src/rules/rule-engine.ts +409 -0
- package/src/scanner/index.ts +18 -0
- package/src/scanner/workspace-scanner.ts +403 -0
- package/src/types/index.ts +176 -0
- package/src/types/result.ts +19 -0
- package/src/utils/index.ts +7 -0
- package/src/utils/pattern-matcher.ts +48 -0
|
@@ -0,0 +1,623 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TreeShakingBlockerAnalyzer - Detects patterns that prevent effective tree-shaking.
|
|
3
|
+
*
|
|
4
|
+
* Identifies code patterns that block bundler tree-shaking optimizations:
|
|
5
|
+
* - CommonJS require() calls mixed with ES modules
|
|
6
|
+
* - Namespace imports (import * as X)
|
|
7
|
+
* - Side-effect imports without proper module configuration
|
|
8
|
+
* - Module.exports patterns
|
|
9
|
+
* - Dynamic requires with non-literal arguments
|
|
10
|
+
*
|
|
11
|
+
* Also detects type-only import enforcement opportunities (TASK-051) and
|
|
12
|
+
* provides dynamic import optimization suggestions (TASK-052).
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type {SourceFile} from 'ts-morph'
|
|
16
|
+
|
|
17
|
+
import type {WorkspacePackage} from '../scanner/workspace-scanner'
|
|
18
|
+
import type {Issue, IssueLocation, Severity} from '../types/index'
|
|
19
|
+
import type {Result} from '../types/result'
|
|
20
|
+
import type {AnalysisContext, Analyzer, AnalyzerError, AnalyzerMetadata} from './analyzer'
|
|
21
|
+
|
|
22
|
+
import {createProject} from '@bfra.me/doc-sync/parsers'
|
|
23
|
+
import {ok} from '@bfra.me/es/result'
|
|
24
|
+
import {SyntaxKind} from 'ts-morph'
|
|
25
|
+
|
|
26
|
+
import {createIssue, filterIssues} from './analyzer'
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Configuration options for TreeShakingBlockerAnalyzer.
|
|
30
|
+
*/
|
|
31
|
+
export interface TreeShakingBlockerAnalyzerOptions {
|
|
32
|
+
/** Report namespace imports (import * as X) */
|
|
33
|
+
readonly reportNamespaceImports?: boolean
|
|
34
|
+
/** Report CommonJS require() calls */
|
|
35
|
+
readonly reportRequireCalls?: boolean
|
|
36
|
+
/** Report module.exports patterns */
|
|
37
|
+
readonly reportModuleExports?: boolean
|
|
38
|
+
/** Report dynamic require with non-literal arguments */
|
|
39
|
+
readonly reportDynamicRequire?: boolean
|
|
40
|
+
/** Report type-only import opportunities */
|
|
41
|
+
readonly reportTypeOnlyOpportunities?: boolean
|
|
42
|
+
/** Report dynamic import optimization opportunities */
|
|
43
|
+
readonly reportDynamicImportOpportunities?: boolean
|
|
44
|
+
/** Severity for CommonJS interop issues */
|
|
45
|
+
readonly commonJsInteropSeverity?: Severity
|
|
46
|
+
/** Severity for namespace import issues */
|
|
47
|
+
readonly namespaceImportSeverity?: Severity
|
|
48
|
+
/** Severity for type-only import opportunities */
|
|
49
|
+
readonly typeOnlyImportSeverity?: Severity
|
|
50
|
+
/** File patterns to exclude from analysis */
|
|
51
|
+
readonly excludePatterns?: readonly string[]
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const DEFAULT_OPTIONS: Required<TreeShakingBlockerAnalyzerOptions> = {
|
|
55
|
+
reportNamespaceImports: true,
|
|
56
|
+
reportRequireCalls: true,
|
|
57
|
+
reportModuleExports: true,
|
|
58
|
+
reportDynamicRequire: true,
|
|
59
|
+
reportTypeOnlyOpportunities: true,
|
|
60
|
+
reportDynamicImportOpportunities: true,
|
|
61
|
+
commonJsInteropSeverity: 'warning',
|
|
62
|
+
namespaceImportSeverity: 'info',
|
|
63
|
+
typeOnlyImportSeverity: 'info',
|
|
64
|
+
excludePatterns: ['**/*.test.ts', '**/*.spec.ts', '**/__tests__/**', '**/__mocks__/**'],
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export const treeShakingBlockerAnalyzerMetadata: AnalyzerMetadata = {
|
|
68
|
+
id: 'tree-shaking-blocker',
|
|
69
|
+
name: 'Tree-Shaking Blocker Analyzer',
|
|
70
|
+
description: 'Detects patterns that prevent effective tree-shaking in bundlers',
|
|
71
|
+
categories: ['performance'],
|
|
72
|
+
defaultSeverity: 'warning',
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Creates a TreeShakingBlockerAnalyzer instance.
|
|
77
|
+
*/
|
|
78
|
+
export function createTreeShakingBlockerAnalyzer(
|
|
79
|
+
options: TreeShakingBlockerAnalyzerOptions = {},
|
|
80
|
+
): Analyzer {
|
|
81
|
+
const resolvedOptions = {...DEFAULT_OPTIONS, ...options}
|
|
82
|
+
|
|
83
|
+
return {
|
|
84
|
+
metadata: treeShakingBlockerAnalyzerMetadata,
|
|
85
|
+
analyze: async (context: AnalysisContext): Promise<Result<readonly Issue[], AnalyzerError>> => {
|
|
86
|
+
const issues: Issue[] = []
|
|
87
|
+
|
|
88
|
+
for (const pkg of context.packages) {
|
|
89
|
+
context.reportProgress?.(`Analyzing tree-shaking blockers in ${pkg.name}...`)
|
|
90
|
+
|
|
91
|
+
const packageIssues = await analyzePackageTreeShaking(
|
|
92
|
+
pkg,
|
|
93
|
+
context.workspacePath,
|
|
94
|
+
resolvedOptions,
|
|
95
|
+
)
|
|
96
|
+
issues.push(...packageIssues)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return ok(filterIssues(issues, context.config))
|
|
100
|
+
},
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Detected tree-shaking blocker pattern.
|
|
106
|
+
*/
|
|
107
|
+
export interface TreeShakingBlocker {
|
|
108
|
+
/** Type of blocker */
|
|
109
|
+
readonly type: TreeShakingBlockerType
|
|
110
|
+
/** Location in source */
|
|
111
|
+
readonly location: IssueLocation
|
|
112
|
+
/** Detailed description */
|
|
113
|
+
readonly description: string
|
|
114
|
+
/** Suggested fix */
|
|
115
|
+
readonly suggestion: string
|
|
116
|
+
/** Additional metadata */
|
|
117
|
+
readonly metadata?: Readonly<Record<string, unknown>>
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export type TreeShakingBlockerType =
|
|
121
|
+
| 'namespace-import'
|
|
122
|
+
| 'require-call'
|
|
123
|
+
| 'module-exports'
|
|
124
|
+
| 'dynamic-require'
|
|
125
|
+
| 'side-effect-import'
|
|
126
|
+
| 'type-only-opportunity'
|
|
127
|
+
| 'dynamic-import-opportunity'
|
|
128
|
+
|
|
129
|
+
async function analyzePackageTreeShaking(
|
|
130
|
+
pkg: WorkspacePackage,
|
|
131
|
+
_workspacePath: string,
|
|
132
|
+
options: Required<TreeShakingBlockerAnalyzerOptions>,
|
|
133
|
+
): Promise<Issue[]> {
|
|
134
|
+
const issues: Issue[] = []
|
|
135
|
+
|
|
136
|
+
const sourceFiles = filterSourceFiles(pkg.sourceFiles, options.excludePatterns)
|
|
137
|
+
if (sourceFiles.length === 0) {
|
|
138
|
+
return issues
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const project = createProject()
|
|
142
|
+
|
|
143
|
+
for (const filePath of sourceFiles) {
|
|
144
|
+
try {
|
|
145
|
+
const sourceFile = project.addSourceFileAtPath(filePath)
|
|
146
|
+
const fileIssues = analyzeFileTreeShaking(sourceFile, pkg, options)
|
|
147
|
+
issues.push(...fileIssues)
|
|
148
|
+
} catch {
|
|
149
|
+
// File may not be parseable
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return issues
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function analyzeFileTreeShaking(
|
|
157
|
+
sourceFile: SourceFile,
|
|
158
|
+
pkg: WorkspacePackage,
|
|
159
|
+
options: Required<TreeShakingBlockerAnalyzerOptions>,
|
|
160
|
+
): Issue[] {
|
|
161
|
+
const issues: Issue[] = []
|
|
162
|
+
const filePath = sourceFile.getFilePath()
|
|
163
|
+
|
|
164
|
+
// Check for namespace imports
|
|
165
|
+
if (options.reportNamespaceImports) {
|
|
166
|
+
const namespaceIssues = detectNamespaceImports(sourceFile, pkg, filePath, options)
|
|
167
|
+
issues.push(...namespaceIssues)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Check for CommonJS patterns
|
|
171
|
+
if (options.reportRequireCalls) {
|
|
172
|
+
const requireIssues = detectRequireCalls(sourceFile, pkg, filePath, options)
|
|
173
|
+
issues.push(...requireIssues)
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (options.reportModuleExports) {
|
|
177
|
+
const exportIssues = detectModuleExports(sourceFile, pkg, filePath, options)
|
|
178
|
+
issues.push(...exportIssues)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (options.reportDynamicRequire) {
|
|
182
|
+
const dynamicRequireIssues = detectDynamicRequire(sourceFile, pkg, filePath, options)
|
|
183
|
+
issues.push(...dynamicRequireIssues)
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Check for type-only import opportunities
|
|
187
|
+
if (options.reportTypeOnlyOpportunities) {
|
|
188
|
+
const typeOnlyIssues = detectTypeOnlyOpportunities(sourceFile, pkg, filePath, options)
|
|
189
|
+
issues.push(...typeOnlyIssues)
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Check for dynamic import opportunities
|
|
193
|
+
if (options.reportDynamicImportOpportunities) {
|
|
194
|
+
const dynamicImportIssues = detectDynamicImportOpportunities(sourceFile, pkg, filePath, options)
|
|
195
|
+
issues.push(...dynamicImportIssues)
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return issues
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function detectNamespaceImports(
|
|
202
|
+
sourceFile: SourceFile,
|
|
203
|
+
pkg: WorkspacePackage,
|
|
204
|
+
filePath: string,
|
|
205
|
+
options: Required<TreeShakingBlockerAnalyzerOptions>,
|
|
206
|
+
): Issue[] {
|
|
207
|
+
const issues: Issue[] = []
|
|
208
|
+
|
|
209
|
+
for (const importDecl of sourceFile.getImportDeclarations()) {
|
|
210
|
+
const namespaceImport = importDecl.getNamespaceImport()
|
|
211
|
+
if (namespaceImport === undefined) continue
|
|
212
|
+
|
|
213
|
+
const moduleSpecifier = importDecl.getModuleSpecifierValue()
|
|
214
|
+
const {line, column} = sourceFile.getLineAndColumnAtPos(importDecl.getStart())
|
|
215
|
+
|
|
216
|
+
// Skip relative imports (internal modules)
|
|
217
|
+
if (moduleSpecifier.startsWith('.')) continue
|
|
218
|
+
|
|
219
|
+
issues.push(
|
|
220
|
+
createIssue({
|
|
221
|
+
id: 'namespace-import',
|
|
222
|
+
title: `Namespace import from '${moduleSpecifier}'`,
|
|
223
|
+
description:
|
|
224
|
+
`Namespace imports (import * as ${namespaceImport.getText()}) include all exports from ` +
|
|
225
|
+
`'${moduleSpecifier}', preventing tree-shaking from removing unused exports.`,
|
|
226
|
+
severity: options.namespaceImportSeverity,
|
|
227
|
+
category: 'performance',
|
|
228
|
+
location: {filePath, line, column},
|
|
229
|
+
suggestion:
|
|
230
|
+
`Consider using named imports: import { specificExport } from '${moduleSpecifier}' ` +
|
|
231
|
+
`to allow tree-shaking of unused exports.`,
|
|
232
|
+
metadata: {
|
|
233
|
+
packageName: pkg.name,
|
|
234
|
+
moduleSpecifier,
|
|
235
|
+
namespaceName: namespaceImport.getText(),
|
|
236
|
+
blockerType: 'namespace-import' as const,
|
|
237
|
+
},
|
|
238
|
+
}),
|
|
239
|
+
)
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
return issues
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function detectRequireCalls(
|
|
246
|
+
sourceFile: SourceFile,
|
|
247
|
+
pkg: WorkspacePackage,
|
|
248
|
+
filePath: string,
|
|
249
|
+
options: Required<TreeShakingBlockerAnalyzerOptions>,
|
|
250
|
+
): Issue[] {
|
|
251
|
+
const issues: Issue[] = []
|
|
252
|
+
|
|
253
|
+
sourceFile.forEachDescendant(node => {
|
|
254
|
+
if (node.getKind() !== SyntaxKind.CallExpression) return
|
|
255
|
+
|
|
256
|
+
const callExpr = node.asKind(SyntaxKind.CallExpression)
|
|
257
|
+
if (callExpr === undefined) return
|
|
258
|
+
|
|
259
|
+
const expr = callExpr.getExpression()
|
|
260
|
+
if (expr.getKind() !== SyntaxKind.Identifier || expr.getText() !== 'require') return
|
|
261
|
+
|
|
262
|
+
const args = callExpr.getArguments()
|
|
263
|
+
if (args.length === 0) return
|
|
264
|
+
|
|
265
|
+
const firstArg = args[0]
|
|
266
|
+
if (firstArg === undefined) return
|
|
267
|
+
|
|
268
|
+
const {line, column} = sourceFile.getLineAndColumnAtPos(node.getStart())
|
|
269
|
+
|
|
270
|
+
if (firstArg.getKind() === SyntaxKind.StringLiteral) {
|
|
271
|
+
const stringLiteral = firstArg.asKind(SyntaxKind.StringLiteral)
|
|
272
|
+
const moduleSpecifier = stringLiteral?.getLiteralValue() ?? ''
|
|
273
|
+
|
|
274
|
+
issues.push(
|
|
275
|
+
createIssue({
|
|
276
|
+
id: 'commonjs-require',
|
|
277
|
+
title: `CommonJS require() call for '${moduleSpecifier}'`,
|
|
278
|
+
description:
|
|
279
|
+
`require() is CommonJS syntax that prevents ES module tree-shaking. ` +
|
|
280
|
+
`The entire module will be included in the bundle regardless of which exports are used.`,
|
|
281
|
+
severity: options.commonJsInteropSeverity,
|
|
282
|
+
category: 'performance',
|
|
283
|
+
location: {filePath, line, column},
|
|
284
|
+
suggestion:
|
|
285
|
+
`Convert to ES module import: import { ... } from '${moduleSpecifier}' ` +
|
|
286
|
+
`for better tree-shaking support.`,
|
|
287
|
+
metadata: {
|
|
288
|
+
packageName: pkg.name,
|
|
289
|
+
moduleSpecifier,
|
|
290
|
+
blockerType: 'require-call' as const,
|
|
291
|
+
},
|
|
292
|
+
}),
|
|
293
|
+
)
|
|
294
|
+
}
|
|
295
|
+
})
|
|
296
|
+
|
|
297
|
+
return issues
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function detectModuleExports(
|
|
301
|
+
sourceFile: SourceFile,
|
|
302
|
+
pkg: WorkspacePackage,
|
|
303
|
+
filePath: string,
|
|
304
|
+
options: Required<TreeShakingBlockerAnalyzerOptions>,
|
|
305
|
+
): Issue[] {
|
|
306
|
+
const issues: Issue[] = []
|
|
307
|
+
|
|
308
|
+
sourceFile.forEachDescendant(node => {
|
|
309
|
+
if (node.getKind() !== SyntaxKind.BinaryExpression) return
|
|
310
|
+
|
|
311
|
+
const binaryExpr = node.asKind(SyntaxKind.BinaryExpression)
|
|
312
|
+
if (binaryExpr === undefined) return
|
|
313
|
+
|
|
314
|
+
const left = binaryExpr.getLeft()
|
|
315
|
+
const leftText = left.getText()
|
|
316
|
+
|
|
317
|
+
// Check for module.exports = or exports.X =
|
|
318
|
+
if (!leftText.startsWith('module.exports') && !leftText.startsWith('exports.')) return
|
|
319
|
+
|
|
320
|
+
const {line, column} = sourceFile.getLineAndColumnAtPos(node.getStart())
|
|
321
|
+
|
|
322
|
+
issues.push(
|
|
323
|
+
createIssue({
|
|
324
|
+
id: 'module-exports',
|
|
325
|
+
title: `CommonJS ${leftText.split('=')[0]?.trim()} pattern`,
|
|
326
|
+
description:
|
|
327
|
+
`CommonJS module.exports/exports patterns prevent ES module tree-shaking. ` +
|
|
328
|
+
`The entire module will be bundled regardless of which exports are consumed.`,
|
|
329
|
+
severity: options.commonJsInteropSeverity,
|
|
330
|
+
category: 'performance',
|
|
331
|
+
location: {filePath, line, column},
|
|
332
|
+
suggestion:
|
|
333
|
+
`Convert to ES module exports: export { ... } or export default ... ` +
|
|
334
|
+
`for better tree-shaking support.`,
|
|
335
|
+
metadata: {
|
|
336
|
+
packageName: pkg.name,
|
|
337
|
+
exportPattern: leftText.split('=')[0]?.trim(),
|
|
338
|
+
blockerType: 'module-exports' as const,
|
|
339
|
+
},
|
|
340
|
+
}),
|
|
341
|
+
)
|
|
342
|
+
})
|
|
343
|
+
|
|
344
|
+
return issues
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function detectDynamicRequire(
|
|
348
|
+
sourceFile: SourceFile,
|
|
349
|
+
pkg: WorkspacePackage,
|
|
350
|
+
filePath: string,
|
|
351
|
+
options: Required<TreeShakingBlockerAnalyzerOptions>,
|
|
352
|
+
): Issue[] {
|
|
353
|
+
const issues: Issue[] = []
|
|
354
|
+
|
|
355
|
+
sourceFile.forEachDescendant(node => {
|
|
356
|
+
if (node.getKind() !== SyntaxKind.CallExpression) return
|
|
357
|
+
|
|
358
|
+
const callExpr = node.asKind(SyntaxKind.CallExpression)
|
|
359
|
+
if (callExpr === undefined) return
|
|
360
|
+
|
|
361
|
+
const expr = callExpr.getExpression()
|
|
362
|
+
if (expr.getKind() !== SyntaxKind.Identifier || expr.getText() !== 'require') return
|
|
363
|
+
|
|
364
|
+
const args = callExpr.getArguments()
|
|
365
|
+
if (args.length === 0) return
|
|
366
|
+
|
|
367
|
+
const firstArg = args[0]
|
|
368
|
+
if (firstArg === undefined) return
|
|
369
|
+
|
|
370
|
+
// Only report non-literal requires (dynamic)
|
|
371
|
+
if (firstArg.getKind() === SyntaxKind.StringLiteral) return
|
|
372
|
+
|
|
373
|
+
const {line, column} = sourceFile.getLineAndColumnAtPos(node.getStart())
|
|
374
|
+
|
|
375
|
+
issues.push(
|
|
376
|
+
createIssue({
|
|
377
|
+
id: 'dynamic-require',
|
|
378
|
+
title: 'Dynamic require() with non-literal argument',
|
|
379
|
+
description:
|
|
380
|
+
`Dynamic require() with computed module paths cannot be statically analyzed, ` +
|
|
381
|
+
`preventing any tree-shaking or code splitting optimization.`,
|
|
382
|
+
severity: options.commonJsInteropSeverity,
|
|
383
|
+
category: 'performance',
|
|
384
|
+
location: {filePath, line, column},
|
|
385
|
+
suggestion:
|
|
386
|
+
`Consider using a static require() or converting to dynamic import() ` +
|
|
387
|
+
`with explicit module paths for better bundler optimization.`,
|
|
388
|
+
metadata: {
|
|
389
|
+
packageName: pkg.name,
|
|
390
|
+
argumentText: firstArg.getText(),
|
|
391
|
+
blockerType: 'dynamic-require' as const,
|
|
392
|
+
},
|
|
393
|
+
}),
|
|
394
|
+
)
|
|
395
|
+
})
|
|
396
|
+
|
|
397
|
+
return issues
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* Detects opportunities to use type-only imports (TASK-051).
|
|
402
|
+
*
|
|
403
|
+
* Type-only imports (import type { X } from 'pkg') are completely removed during
|
|
404
|
+
* TypeScript compilation, resulting in smaller bundles and avoiding side effects.
|
|
405
|
+
*
|
|
406
|
+
* This uses a heuristic approach checking if imported names follow common type naming
|
|
407
|
+
* conventions (interfaces, type aliases, etc.) rather than full reference analysis.
|
|
408
|
+
*/
|
|
409
|
+
function detectTypeOnlyOpportunities(
|
|
410
|
+
sourceFile: SourceFile,
|
|
411
|
+
pkg: WorkspacePackage,
|
|
412
|
+
filePath: string,
|
|
413
|
+
options: Required<TreeShakingBlockerAnalyzerOptions>,
|
|
414
|
+
): Issue[] {
|
|
415
|
+
const issues: Issue[] = []
|
|
416
|
+
|
|
417
|
+
for (const importDecl of sourceFile.getImportDeclarations()) {
|
|
418
|
+
// Skip already type-only imports
|
|
419
|
+
if (importDecl.isTypeOnly()) continue
|
|
420
|
+
|
|
421
|
+
const namedImports = importDecl.getNamedImports()
|
|
422
|
+
if (namedImports.length === 0) continue
|
|
423
|
+
|
|
424
|
+
const potentialTypeOnlyNames: string[] = []
|
|
425
|
+
|
|
426
|
+
for (const namedImport of namedImports) {
|
|
427
|
+
// Skip imports already marked as type-only
|
|
428
|
+
if (namedImport.isTypeOnly()) continue
|
|
429
|
+
|
|
430
|
+
const name = namedImport.getName()
|
|
431
|
+
|
|
432
|
+
// Heuristic: Check if name follows common type naming patterns
|
|
433
|
+
// This is a conservative approach that catches obvious cases
|
|
434
|
+
if (isLikelyTypeName(name)) {
|
|
435
|
+
potentialTypeOnlyNames.push(name)
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
if (potentialTypeOnlyNames.length > 0) {
|
|
440
|
+
const {line, column} = sourceFile.getLineAndColumnAtPos(importDecl.getStart())
|
|
441
|
+
const moduleSpecifier = importDecl.getModuleSpecifierValue()
|
|
442
|
+
|
|
443
|
+
issues.push(
|
|
444
|
+
createIssue({
|
|
445
|
+
id: 'type-only-import-opportunity',
|
|
446
|
+
title: `Potential type-only import in '${moduleSpecifier}'`,
|
|
447
|
+
description:
|
|
448
|
+
`The import(s) { ${potentialTypeOnlyNames.join(', ')} } may be type-only based on naming conventions. ` +
|
|
449
|
+
`If these are only used as types, marking them as type-only reduces bundle size.`,
|
|
450
|
+
severity: options.typeOnlyImportSeverity,
|
|
451
|
+
category: 'performance',
|
|
452
|
+
location: {filePath, line, column},
|
|
453
|
+
suggestion:
|
|
454
|
+
`Review usage and consider: import type { ${potentialTypeOnlyNames.join(', ')} } from '${moduleSpecifier}' ` +
|
|
455
|
+
`or mark individual imports: import { type ${potentialTypeOnlyNames[0]} } from '${moduleSpecifier}'`,
|
|
456
|
+
metadata: {
|
|
457
|
+
packageName: pkg.name,
|
|
458
|
+
moduleSpecifier,
|
|
459
|
+
typeOnlyImports: potentialTypeOnlyNames,
|
|
460
|
+
blockerType: 'type-only-opportunity' as const,
|
|
461
|
+
},
|
|
462
|
+
}),
|
|
463
|
+
)
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
return issues
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/**
|
|
471
|
+
* Checks if a name follows common type naming conventions.
|
|
472
|
+
*
|
|
473
|
+
* Matches:
|
|
474
|
+
* - Names starting with 'I' followed by uppercase (IUser, IConfig)
|
|
475
|
+
* - Names ending with 'Type', 'Props', 'Options', 'Config', 'State', 'Context', 'Params'
|
|
476
|
+
* - Names ending with 'Interface' or starting with 'Abstract'
|
|
477
|
+
*/
|
|
478
|
+
function isLikelyTypeName(name: string): boolean {
|
|
479
|
+
// Interface naming convention: IUser, IConfig
|
|
480
|
+
if (/^I[A-Z]/.test(name)) return true
|
|
481
|
+
|
|
482
|
+
// Common type suffixes
|
|
483
|
+
const typeSuffixes = [
|
|
484
|
+
'Type',
|
|
485
|
+
'Types',
|
|
486
|
+
'Props',
|
|
487
|
+
'Options',
|
|
488
|
+
'Config',
|
|
489
|
+
'Configuration',
|
|
490
|
+
'State',
|
|
491
|
+
'Context',
|
|
492
|
+
'Params',
|
|
493
|
+
'Parameters',
|
|
494
|
+
'Interface',
|
|
495
|
+
'Enum',
|
|
496
|
+
'Kind',
|
|
497
|
+
'Metadata',
|
|
498
|
+
'Schema',
|
|
499
|
+
'Definition',
|
|
500
|
+
]
|
|
501
|
+
|
|
502
|
+
if (typeSuffixes.some(suffix => name.endsWith(suffix))) return true
|
|
503
|
+
|
|
504
|
+
// Abstract prefix
|
|
505
|
+
if (name.startsWith('Abstract')) return true
|
|
506
|
+
|
|
507
|
+
return false
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/**
|
|
511
|
+
* Detects opportunities for dynamic imports (TASK-052).
|
|
512
|
+
*
|
|
513
|
+
* Large modules that are not immediately needed on page load can be
|
|
514
|
+
* dynamically imported for code splitting and faster initial load.
|
|
515
|
+
*/
|
|
516
|
+
function detectDynamicImportOpportunities(
|
|
517
|
+
sourceFile: SourceFile,
|
|
518
|
+
pkg: WorkspacePackage,
|
|
519
|
+
filePath: string,
|
|
520
|
+
_options: Required<TreeShakingBlockerAnalyzerOptions>,
|
|
521
|
+
): Issue[] {
|
|
522
|
+
const issues: Issue[] = []
|
|
523
|
+
|
|
524
|
+
// Large packages that benefit from dynamic imports
|
|
525
|
+
const LARGE_PACKAGES = new Set([
|
|
526
|
+
'lodash',
|
|
527
|
+
'lodash-es',
|
|
528
|
+
'moment',
|
|
529
|
+
'd3',
|
|
530
|
+
'chart.js',
|
|
531
|
+
'three',
|
|
532
|
+
'@mui/material',
|
|
533
|
+
'antd',
|
|
534
|
+
'rxjs',
|
|
535
|
+
'@angular/core',
|
|
536
|
+
'monaco-editor',
|
|
537
|
+
'highlight.js',
|
|
538
|
+
'prismjs',
|
|
539
|
+
'marked',
|
|
540
|
+
'pdf-lib',
|
|
541
|
+
'xlsx',
|
|
542
|
+
'jszip',
|
|
543
|
+
])
|
|
544
|
+
|
|
545
|
+
for (const importDecl of sourceFile.getImportDeclarations()) {
|
|
546
|
+
const moduleSpecifier = importDecl.getModuleSpecifierValue()
|
|
547
|
+
const basePkg = getBasePackageName(moduleSpecifier)
|
|
548
|
+
|
|
549
|
+
if (!LARGE_PACKAGES.has(basePkg)) continue
|
|
550
|
+
|
|
551
|
+
// Check if this import could be lazily loaded
|
|
552
|
+
const {line, column} = sourceFile.getLineAndColumnAtPos(importDecl.getStart())
|
|
553
|
+
|
|
554
|
+
// Check if the import is used in conditional or lazy contexts
|
|
555
|
+
const namedImports = importDecl.getNamedImports()
|
|
556
|
+
const defaultImport = importDecl.getDefaultImport()
|
|
557
|
+
const namespaceImport = importDecl.getNamespaceImport()
|
|
558
|
+
|
|
559
|
+
const importNames =
|
|
560
|
+
namedImports.length > 0
|
|
561
|
+
? namedImports.map(n => n.getName()).join(', ')
|
|
562
|
+
: (defaultImport?.getText() ?? namespaceImport?.getText() ?? 'module')
|
|
563
|
+
|
|
564
|
+
issues.push(
|
|
565
|
+
createIssue({
|
|
566
|
+
id: 'dynamic-import-opportunity',
|
|
567
|
+
title: `Consider dynamic import for '${basePkg}'`,
|
|
568
|
+
description:
|
|
569
|
+
`'${basePkg}' is a large package (${importNames}) that could be dynamically imported ` +
|
|
570
|
+
`for code splitting. This can improve initial page load time by deferring the module load.`,
|
|
571
|
+
severity: 'info',
|
|
572
|
+
category: 'performance',
|
|
573
|
+
location: {filePath, line, column},
|
|
574
|
+
suggestion:
|
|
575
|
+
`If '${basePkg}' is not needed immediately, consider using dynamic import: ` +
|
|
576
|
+
`const ${importNames.split(',')[0]} = await import('${moduleSpecifier}')`,
|
|
577
|
+
metadata: {
|
|
578
|
+
packageName: pkg.name,
|
|
579
|
+
moduleSpecifier,
|
|
580
|
+
importNames,
|
|
581
|
+
blockerType: 'dynamic-import-opportunity' as const,
|
|
582
|
+
},
|
|
583
|
+
}),
|
|
584
|
+
)
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
return issues
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
function filterSourceFiles(
|
|
591
|
+
sourceFiles: readonly string[],
|
|
592
|
+
excludePatterns: readonly string[],
|
|
593
|
+
): string[] {
|
|
594
|
+
return sourceFiles.filter(filePath => {
|
|
595
|
+
const fileName = filePath.split('/').pop() ?? ''
|
|
596
|
+
|
|
597
|
+
return !excludePatterns.some(pattern => {
|
|
598
|
+
if (pattern.includes('**')) {
|
|
599
|
+
const regex = patternToRegex(pattern)
|
|
600
|
+
return regex.test(filePath)
|
|
601
|
+
}
|
|
602
|
+
return fileName.includes(pattern.replaceAll('*', ''))
|
|
603
|
+
})
|
|
604
|
+
})
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
function patternToRegex(pattern: string): RegExp {
|
|
608
|
+
const escaped = pattern
|
|
609
|
+
.replaceAll('.', String.raw`\.`)
|
|
610
|
+
.replaceAll('**', '.*')
|
|
611
|
+
.replaceAll('*', '[^/]*')
|
|
612
|
+
return new RegExp(escaped)
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
function getBasePackageName(specifier: string): string {
|
|
616
|
+
if (specifier.startsWith('@')) {
|
|
617
|
+
const parts = specifier.split('/')
|
|
618
|
+
if (parts.length >= 2) {
|
|
619
|
+
return `${parts[0]}/${parts[1]}`
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
return specifier.split('/')[0] ?? specifier
|
|
623
|
+
}
|