@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.
Files changed (73) hide show
  1. package/README.md +402 -0
  2. package/lib/chunk-4LSFAAZW.js +1 -0
  3. package/lib/chunk-JDF7DQ4V.js +27 -0
  4. package/lib/chunk-WOJ4C7N7.js +7122 -0
  5. package/lib/cli.d.ts +1 -0
  6. package/lib/cli.js +318 -0
  7. package/lib/index.d.ts +3701 -0
  8. package/lib/index.js +1262 -0
  9. package/lib/types/index.d.ts +146 -0
  10. package/lib/types/index.js +28 -0
  11. package/package.json +89 -0
  12. package/src/analyzers/analyzer.ts +201 -0
  13. package/src/analyzers/architectural-analyzer.ts +304 -0
  14. package/src/analyzers/build-config-analyzer.ts +334 -0
  15. package/src/analyzers/circular-import-analyzer.ts +463 -0
  16. package/src/analyzers/config-consistency-analyzer.ts +335 -0
  17. package/src/analyzers/dead-code-analyzer.ts +565 -0
  18. package/src/analyzers/duplicate-code-analyzer.ts +626 -0
  19. package/src/analyzers/duplicate-dependency-analyzer.ts +381 -0
  20. package/src/analyzers/eslint-config-analyzer.ts +281 -0
  21. package/src/analyzers/exports-field-analyzer.ts +324 -0
  22. package/src/analyzers/index.ts +388 -0
  23. package/src/analyzers/large-dependency-analyzer.ts +535 -0
  24. package/src/analyzers/package-json-analyzer.ts +349 -0
  25. package/src/analyzers/peer-dependency-analyzer.ts +275 -0
  26. package/src/analyzers/tree-shaking-analyzer.ts +623 -0
  27. package/src/analyzers/tsconfig-analyzer.ts +382 -0
  28. package/src/analyzers/unused-dependency-analyzer.ts +356 -0
  29. package/src/analyzers/version-alignment-analyzer.ts +308 -0
  30. package/src/api/analyze-workspace.ts +245 -0
  31. package/src/api/index.ts +11 -0
  32. package/src/cache/cache-manager.ts +495 -0
  33. package/src/cache/cache-schema.ts +247 -0
  34. package/src/cache/change-detector.ts +169 -0
  35. package/src/cache/file-hasher.ts +65 -0
  36. package/src/cache/index.ts +47 -0
  37. package/src/cli/commands/analyze.ts +240 -0
  38. package/src/cli/commands/index.ts +5 -0
  39. package/src/cli/index.ts +61 -0
  40. package/src/cli/types.ts +65 -0
  41. package/src/cli/ui.ts +213 -0
  42. package/src/cli.ts +9 -0
  43. package/src/config/defaults.ts +183 -0
  44. package/src/config/index.ts +81 -0
  45. package/src/config/loader.ts +270 -0
  46. package/src/config/merger.ts +229 -0
  47. package/src/config/schema.ts +263 -0
  48. package/src/core/incremental-analyzer.ts +462 -0
  49. package/src/core/index.ts +34 -0
  50. package/src/core/orchestrator.ts +416 -0
  51. package/src/graph/dependency-graph.ts +408 -0
  52. package/src/graph/index.ts +19 -0
  53. package/src/index.ts +417 -0
  54. package/src/parser/config-parser.ts +491 -0
  55. package/src/parser/import-extractor.ts +340 -0
  56. package/src/parser/index.ts +54 -0
  57. package/src/parser/typescript-parser.ts +95 -0
  58. package/src/performance/bundle-estimator.ts +444 -0
  59. package/src/performance/index.ts +27 -0
  60. package/src/reporters/console-reporter.ts +355 -0
  61. package/src/reporters/index.ts +49 -0
  62. package/src/reporters/json-reporter.ts +273 -0
  63. package/src/reporters/markdown-reporter.ts +349 -0
  64. package/src/reporters/reporter.ts +399 -0
  65. package/src/rules/builtin-rules.ts +709 -0
  66. package/src/rules/index.ts +52 -0
  67. package/src/rules/rule-engine.ts +409 -0
  68. package/src/scanner/index.ts +18 -0
  69. package/src/scanner/workspace-scanner.ts +403 -0
  70. package/src/types/index.ts +176 -0
  71. package/src/types/result.ts +19 -0
  72. package/src/utils/index.ts +7 -0
  73. 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
+ }