@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,709 @@
1
+ /**
2
+ * Built-in architectural rules for workspace analysis.
3
+ *
4
+ * Provides rules for detecting common architectural anti-patterns:
5
+ * - Layer violations (cross-layer imports)
6
+ * - Barrel export misuse (export * in application code)
7
+ * - Public API validation (explicit exports)
8
+ * - Side effects in module initialization
9
+ * - Import path alias violations
10
+ * - Package boundary enforcement
11
+ */
12
+
13
+ import type {SourceFile} from 'ts-morph'
14
+
15
+ import type {
16
+ LayerConfiguration,
17
+ Rule,
18
+ RuleContext,
19
+ RuleMetadata,
20
+ RuleOptions,
21
+ RuleResult,
22
+ RuleViolation,
23
+ } from './rule-engine'
24
+
25
+ import {SyntaxKind} from 'ts-morph'
26
+
27
+ import {extractImports, isRelativeImport} from '../parser/import-extractor'
28
+ import {matchAnyPattern} from '../utils/pattern-matcher'
29
+
30
+ import {
31
+ BUILTIN_RULE_IDS,
32
+ DEFAULT_LAYER_CONFIG,
33
+ getFileLayer,
34
+ isLayerImportAllowed,
35
+ } from './rule-engine'
36
+
37
+ /**
38
+ * Options for LayerViolationRule.
39
+ */
40
+ export interface LayerViolationRuleOptions extends RuleOptions {
41
+ readonly options?: {
42
+ /** Custom layer configuration */
43
+ readonly layerConfig?: LayerConfiguration
44
+ /** Whether to report violations for unrecognized layers */
45
+ readonly reportUnknownLayers?: boolean
46
+ }
47
+ }
48
+
49
+ export const layerViolationRuleMetadata: RuleMetadata = {
50
+ id: BUILTIN_RULE_IDS.LAYER_VIOLATION,
51
+ name: 'Layer Violation',
52
+ description: 'Detects imports that violate architectural layer boundaries',
53
+ defaultSeverity: 'warning',
54
+ category: 'layer-violation',
55
+ }
56
+
57
+ /**
58
+ * Creates a rule that detects layer boundary violations.
59
+ *
60
+ * @example
61
+ * ```ts
62
+ * const rule = createLayerViolationRule({
63
+ * options: {
64
+ * layerConfig: {
65
+ * layers: [
66
+ * {name: 'domain', allowedDependencies: []},
67
+ * {name: 'application', allowedDependencies: ['domain']},
68
+ * ],
69
+ * patterns: [
70
+ * {pattern: '**\/domain\/**', layer: 'domain'},
71
+ * ],
72
+ * },
73
+ * },
74
+ * })
75
+ * ```
76
+ */
77
+ export function createLayerViolationRule(options: LayerViolationRuleOptions = {}): Rule {
78
+ const layerConfig = options.options?.layerConfig ?? DEFAULT_LAYER_CONFIG
79
+ const reportUnknownLayers = options.options?.reportUnknownLayers ?? false
80
+
81
+ return {
82
+ metadata: layerViolationRuleMetadata,
83
+ evaluate: async (context: RuleContext): Promise<RuleResult> => {
84
+ const violations: RuleViolation[] = []
85
+ const {sourceFile} = context
86
+ const filePath = sourceFile.getFilePath()
87
+ const config = context.layerConfig ?? layerConfig
88
+
89
+ const sourceLayer = getFileLayer(filePath, config)
90
+
91
+ if (sourceLayer === undefined) {
92
+ if (reportUnknownLayers) {
93
+ return {
94
+ violations: [
95
+ {
96
+ ruleId: BUILTIN_RULE_IDS.LAYER_VIOLATION,
97
+ location: {filePath, line: 1, column: 1},
98
+ message: 'File does not belong to any recognized architectural layer',
99
+ suggestion: 'Organize file into an appropriate layer directory',
100
+ },
101
+ ],
102
+ success: true,
103
+ }
104
+ }
105
+ return {violations: [], success: true}
106
+ }
107
+
108
+ const importResult = extractImports(sourceFile, {
109
+ workspacePrefixes: ['@bfra.me/'],
110
+ includeTypeImports: false,
111
+ })
112
+
113
+ for (const imp of importResult.imports) {
114
+ if (!imp.isRelative) continue
115
+
116
+ const resolvedPath = resolveRelativeImportPath(filePath, imp.moduleSpecifier)
117
+ const targetLayer = getFileLayer(resolvedPath, config)
118
+
119
+ if (targetLayer !== undefined && !isLayerImportAllowed(sourceLayer, targetLayer, config)) {
120
+ violations.push({
121
+ ruleId: BUILTIN_RULE_IDS.LAYER_VIOLATION,
122
+ location: {
123
+ filePath,
124
+ line: imp.line,
125
+ column: imp.column,
126
+ },
127
+ message: `Layer violation: '${sourceLayer}' cannot import from '${targetLayer}' (via '${imp.moduleSpecifier}')`,
128
+ suggestion: `Move shared code to a common layer or invert the dependency direction`,
129
+ relatedLocations: [{filePath: resolvedPath}],
130
+ metadata: {
131
+ sourceLayer,
132
+ targetLayer,
133
+ moduleSpecifier: imp.moduleSpecifier,
134
+ },
135
+ })
136
+ }
137
+ }
138
+
139
+ return {violations, success: true}
140
+ },
141
+ }
142
+ }
143
+
144
+ /**
145
+ * Options for BarrelExportRule.
146
+ */
147
+ export interface BarrelExportRuleOptions extends RuleOptions {
148
+ readonly options?: {
149
+ /** Allow export * in these file patterns (e.g., index.ts in libs) */
150
+ readonly allowedPatterns?: readonly string[]
151
+ /** Whether to allow export * from workspace packages */
152
+ readonly allowWorkspaceReexports?: boolean
153
+ }
154
+ }
155
+
156
+ export const barrelExportRuleMetadata: RuleMetadata = {
157
+ id: BUILTIN_RULE_IDS.BARREL_EXPORT,
158
+ name: 'Barrel Export',
159
+ description: 'Detects `export *` usage which can break tree-shaking and obscure the public API',
160
+ defaultSeverity: 'warning',
161
+ category: 'barrel-export',
162
+ }
163
+
164
+ /**
165
+ * Creates a rule that detects export * (barrel export) misuse.
166
+ *
167
+ * @example
168
+ * ```ts
169
+ * const rule = createBarrelExportRule({
170
+ * options: {
171
+ * allowedPatterns: ['**\/index.ts'],
172
+ * allowWorkspaceReexports: false,
173
+ * },
174
+ * })
175
+ * ```
176
+ */
177
+ export function createBarrelExportRule(options: BarrelExportRuleOptions = {}): Rule {
178
+ const allowedPatterns = options.options?.allowedPatterns ?? []
179
+ const allowWorkspaceReexports = options.options?.allowWorkspaceReexports ?? false
180
+
181
+ return {
182
+ metadata: barrelExportRuleMetadata,
183
+ evaluate: async (context: RuleContext): Promise<RuleResult> => {
184
+ const violations: RuleViolation[] = []
185
+ const {sourceFile} = context
186
+ const filePath = sourceFile.getFilePath()
187
+
188
+ if (matchAnyPattern(filePath, allowedPatterns)) {
189
+ return {violations: [], success: true}
190
+ }
191
+
192
+ for (const exportDecl of sourceFile.getExportDeclarations()) {
193
+ if (!exportDecl.isNamespaceExport()) continue
194
+
195
+ const moduleSpecifier = exportDecl.getModuleSpecifierValue()
196
+ if (moduleSpecifier === undefined) continue
197
+
198
+ if (allowWorkspaceReexports && isWorkspacePackage(moduleSpecifier)) {
199
+ continue
200
+ }
201
+
202
+ const {line, column} = sourceFile.getLineAndColumnAtPos(exportDecl.getStart())
203
+
204
+ violations.push({
205
+ ruleId: BUILTIN_RULE_IDS.BARREL_EXPORT,
206
+ location: {filePath, line, column},
207
+ message: `Avoid \`export * from '${moduleSpecifier}'\` - it breaks tree-shaking and obscures the public API`,
208
+ suggestion: 'Use explicit named exports to maintain a clear public API surface',
209
+ metadata: {
210
+ moduleSpecifier,
211
+ exportType: 'namespace',
212
+ },
213
+ })
214
+ }
215
+
216
+ return {violations, success: true}
217
+ },
218
+ }
219
+ }
220
+
221
+ /**
222
+ * Options for PublicApiRule.
223
+ */
224
+ export interface PublicApiRuleOptions extends RuleOptions {
225
+ readonly options?: {
226
+ /** Entry point files that define the public API */
227
+ readonly entryPoints?: readonly string[]
228
+ /** Require all exports to be re-exported from entry points */
229
+ readonly requireReexport?: boolean
230
+ }
231
+ }
232
+
233
+ export const publicApiRuleMetadata: RuleMetadata = {
234
+ id: BUILTIN_RULE_IDS.PUBLIC_API,
235
+ name: 'Public API',
236
+ description: 'Validates that the public API surface is explicitly defined via entry points',
237
+ defaultSeverity: 'info',
238
+ category: 'public-api',
239
+ }
240
+
241
+ /**
242
+ * Creates a rule that validates public API surface definition.
243
+ *
244
+ * @example
245
+ * ```ts
246
+ * const rule = createPublicApiRule({
247
+ * options: {
248
+ * entryPoints: ['src/index.ts'],
249
+ * requireReexport: true,
250
+ * },
251
+ * })
252
+ * ```
253
+ */
254
+ export function createPublicApiRule(options: PublicApiRuleOptions = {}): Rule {
255
+ const entryPoints = options.options?.entryPoints ?? ['src/index.ts', 'index.ts']
256
+ const requireReexport = options.options?.requireReexport ?? false
257
+
258
+ return {
259
+ metadata: publicApiRuleMetadata,
260
+ evaluate: async (context: RuleContext): Promise<RuleResult> => {
261
+ const violations: RuleViolation[] = []
262
+ const {sourceFile} = context
263
+ const filePath = sourceFile.getFilePath()
264
+
265
+ const isEntryPoint = entryPoints.some(ep => filePath.endsWith(ep))
266
+
267
+ if (isEntryPoint) {
268
+ const hasExportStar = sourceFile.getExportDeclarations().some(e => e.isNamespaceExport())
269
+
270
+ if (hasExportStar) {
271
+ const exportStarDecl = sourceFile.getExportDeclarations().find(e => e.isNamespaceExport())
272
+ if (exportStarDecl !== undefined) {
273
+ const {line, column} = sourceFile.getLineAndColumnAtPos(exportStarDecl.getStart())
274
+ violations.push({
275
+ ruleId: BUILTIN_RULE_IDS.PUBLIC_API,
276
+ location: {filePath, line, column},
277
+ message: 'Entry point should use explicit named exports instead of `export *`',
278
+ suggestion:
279
+ 'Replace `export *` with explicit named exports to document the public API',
280
+ })
281
+ }
282
+ }
283
+ }
284
+
285
+ if (requireReexport && !isEntryPoint) {
286
+ const exports = sourceFile.getExportedDeclarations()
287
+
288
+ for (const [name, declarations] of exports) {
289
+ for (const decl of declarations) {
290
+ if (decl.getSourceFile() === sourceFile) {
291
+ const {line, column} = sourceFile.getLineAndColumnAtPos(decl.getStart())
292
+ violations.push({
293
+ ruleId: BUILTIN_RULE_IDS.PUBLIC_API,
294
+ location: {filePath, line, column},
295
+ message: `Export '${name}' should be re-exported from an entry point file`,
296
+ suggestion: `Add this export to one of the entry points: ${entryPoints.join(', ')}`,
297
+ metadata: {exportName: name},
298
+ })
299
+ }
300
+ }
301
+ }
302
+ }
303
+
304
+ return {violations, success: true}
305
+ },
306
+ }
307
+ }
308
+
309
+ /**
310
+ * Options for SideEffectRule.
311
+ */
312
+ export interface SideEffectRuleOptions extends RuleOptions {
313
+ readonly options?: {
314
+ /** Allow side effects in these file patterns */
315
+ readonly allowedPatterns?: readonly string[]
316
+ /** Check for console.log/warn/error at module level */
317
+ readonly checkConsoleCalls?: boolean
318
+ /** Check for assignments to global objects */
319
+ readonly checkGlobalAssignments?: boolean
320
+ }
321
+ }
322
+
323
+ export const sideEffectRuleMetadata: RuleMetadata = {
324
+ id: BUILTIN_RULE_IDS.SIDE_EFFECT,
325
+ name: 'Side Effect',
326
+ description: 'Detects side effects in module initialization that can break tree-shaking',
327
+ defaultSeverity: 'warning',
328
+ category: 'side-effect',
329
+ }
330
+
331
+ /**
332
+ * Creates a rule that detects side effects at module initialization.
333
+ *
334
+ * @example
335
+ * ```ts
336
+ * const rule = createSideEffectRule({
337
+ * options: {
338
+ * allowedPatterns: ['**\/polyfills.ts'],
339
+ * checkConsoleCalls: true,
340
+ * },
341
+ * })
342
+ * ```
343
+ */
344
+ export function createSideEffectRule(options: SideEffectRuleOptions = {}): Rule {
345
+ const allowedPatterns = options.options?.allowedPatterns ?? []
346
+ const checkConsoleCalls = options.options?.checkConsoleCalls ?? true
347
+ const checkGlobalAssignments = options.options?.checkGlobalAssignments ?? true
348
+
349
+ return {
350
+ metadata: sideEffectRuleMetadata,
351
+ evaluate: async (context: RuleContext): Promise<RuleResult> => {
352
+ const violations: RuleViolation[] = []
353
+ const {sourceFile} = context
354
+ const filePath = sourceFile.getFilePath()
355
+
356
+ if (matchAnyPattern(filePath, allowedPatterns)) {
357
+ return {violations: [], success: true}
358
+ }
359
+
360
+ checkTopLevelSideEffects(sourceFile, filePath, violations, {
361
+ checkConsoleCalls,
362
+ checkGlobalAssignments,
363
+ })
364
+
365
+ return {violations, success: true}
366
+ },
367
+ }
368
+ }
369
+
370
+ /**
371
+ * Options for PathAliasRule.
372
+ */
373
+ export interface PathAliasRuleOptions extends RuleOptions {
374
+ readonly options?: {
375
+ /** Require path aliases for deep imports */
376
+ readonly requireAliasForDeepImports?: boolean
377
+ /** Depth threshold for requiring aliases */
378
+ readonly deepImportThreshold?: number
379
+ }
380
+ }
381
+
382
+ export const pathAliasRuleMetadata: RuleMetadata = {
383
+ id: BUILTIN_RULE_IDS.PATH_ALIAS,
384
+ name: 'Path Alias',
385
+ description: 'Validates import paths against tsconfig path aliases',
386
+ defaultSeverity: 'info',
387
+ category: 'layer-violation',
388
+ }
389
+
390
+ /**
391
+ * Creates a rule that validates import path aliases against tsconfig.
392
+ *
393
+ * @example
394
+ * ```ts
395
+ * const rule = createPathAliasRule({
396
+ * options: {
397
+ * requireAliasForDeepImports: true,
398
+ * deepImportThreshold: 3,
399
+ * },
400
+ * })
401
+ * ```
402
+ */
403
+ export function createPathAliasRule(options: PathAliasRuleOptions = {}): Rule {
404
+ const requireAliasForDeepImports = options.options?.requireAliasForDeepImports ?? false
405
+ const deepImportThreshold = options.options?.deepImportThreshold ?? 3
406
+
407
+ return {
408
+ metadata: pathAliasRuleMetadata,
409
+ evaluate: async (context: RuleContext): Promise<RuleResult> => {
410
+ const violations: RuleViolation[] = []
411
+ const {sourceFile, tsconfigPaths} = context
412
+ const filePath = sourceFile.getFilePath()
413
+
414
+ if (tsconfigPaths === undefined) {
415
+ return {violations: [], success: true}
416
+ }
417
+
418
+ const importResult = extractImports(sourceFile, {
419
+ workspacePrefixes: ['@bfra.me/'],
420
+ includeTypeImports: true,
421
+ })
422
+
423
+ for (const imp of importResult.imports) {
424
+ if (!imp.isRelative) continue
425
+
426
+ const pathDepth = imp.moduleSpecifier.split('/').filter(p => p === '..').length
427
+
428
+ if (requireAliasForDeepImports && pathDepth >= deepImportThreshold) {
429
+ const {line, column} = {line: imp.line, column: imp.column}
430
+
431
+ const suggestedAlias = findMatchingAlias(imp.moduleSpecifier, tsconfigPaths)
432
+
433
+ violations.push({
434
+ ruleId: BUILTIN_RULE_IDS.PATH_ALIAS,
435
+ location: {filePath, line, column},
436
+ message: `Deep relative import '${imp.moduleSpecifier}' (${pathDepth} levels up) should use a path alias`,
437
+ suggestion:
438
+ suggestedAlias === undefined
439
+ ? 'Consider adding a path alias in tsconfig.json'
440
+ : `Use path alias '${suggestedAlias}' instead`,
441
+ metadata: {
442
+ moduleSpecifier: imp.moduleSpecifier,
443
+ pathDepth,
444
+ suggestedAlias,
445
+ },
446
+ })
447
+ }
448
+
449
+ if (!isValidPathAlias(imp.moduleSpecifier, tsconfigPaths)) {
450
+ const invalidAlias = imp.moduleSpecifier.split('/')[0]
451
+ if (
452
+ invalidAlias !== undefined &&
453
+ !imp.isRelative &&
454
+ invalidAlias.startsWith('@') &&
455
+ !imp.isWorkspacePackage
456
+ ) {
457
+ violations.push({
458
+ ruleId: BUILTIN_RULE_IDS.PATH_ALIAS,
459
+ location: {filePath, line: imp.line, column: imp.column},
460
+ message: `Import '${imp.moduleSpecifier}' uses an undefined path alias`,
461
+ suggestion: `Define '${invalidAlias}' in tsconfig.json paths or use a valid import path`,
462
+ metadata: {
463
+ moduleSpecifier: imp.moduleSpecifier,
464
+ invalidAlias,
465
+ },
466
+ })
467
+ }
468
+ }
469
+ }
470
+
471
+ return {violations, success: true}
472
+ },
473
+ }
474
+ }
475
+
476
+ /**
477
+ * Options for PackageBoundaryRule.
478
+ */
479
+ export interface PackageBoundaryRuleOptions extends RuleOptions {
480
+ readonly options?: {
481
+ /** Allowed cross-package import patterns */
482
+ readonly allowedCrossPackagePatterns?: readonly string[]
483
+ /** Packages that can be imported from anywhere */
484
+ readonly sharedPackages?: readonly string[]
485
+ /** Enforce importing only from package entry points */
486
+ readonly enforceEntryPointImports?: boolean
487
+ }
488
+ }
489
+
490
+ export const packageBoundaryRuleMetadata: RuleMetadata = {
491
+ id: BUILTIN_RULE_IDS.PACKAGE_BOUNDARY,
492
+ name: 'Package Boundary',
493
+ description: 'Enforces proper package boundaries in monorepo imports',
494
+ defaultSeverity: 'warning',
495
+ category: 'boundary',
496
+ }
497
+
498
+ /**
499
+ * Creates a rule that enforces monorepo package boundaries.
500
+ *
501
+ * @example
502
+ * ```ts
503
+ * const rule = createPackageBoundaryRule({
504
+ * options: {
505
+ * sharedPackages: ['@bfra.me/es', '@bfra.me/tsconfig'],
506
+ * enforceEntryPointImports: true,
507
+ * },
508
+ * })
509
+ * ```
510
+ */
511
+ export function createPackageBoundaryRule(options: PackageBoundaryRuleOptions = {}): Rule {
512
+ const sharedPackages = options.options?.sharedPackages ?? []
513
+ const enforceEntryPointImports = options.options?.enforceEntryPointImports ?? true
514
+
515
+ return {
516
+ metadata: packageBoundaryRuleMetadata,
517
+ evaluate: async (context: RuleContext): Promise<RuleResult> => {
518
+ const violations: RuleViolation[] = []
519
+ const {sourceFile, pkg, allPackages} = context
520
+ const filePath = sourceFile.getFilePath()
521
+
522
+ const importResult = extractImports(sourceFile, {
523
+ workspacePrefixes: ['@bfra.me/'],
524
+ includeTypeImports: true,
525
+ })
526
+
527
+ for (const imp of importResult.imports) {
528
+ if (!imp.isWorkspacePackage) continue
529
+
530
+ const packageName = getPackageNameFromImport(imp.moduleSpecifier)
531
+ if (packageName === undefined) continue
532
+
533
+ if (sharedPackages.includes(packageName)) continue
534
+
535
+ if (enforceEntryPointImports) {
536
+ const hasSubpath = imp.moduleSpecifier !== packageName
537
+
538
+ if (hasSubpath) {
539
+ const importedPkg = allPackages.find(p => p.name === packageName)
540
+
541
+ if (importedPkg !== undefined) {
542
+ const subpath = imp.moduleSpecifier.slice(packageName.length + 1)
543
+ const isValidExport = isExportedSubpath(importedPkg, subpath)
544
+
545
+ if (!isValidExport) {
546
+ violations.push({
547
+ ruleId: BUILTIN_RULE_IDS.PACKAGE_BOUNDARY,
548
+ location: {filePath, line: imp.line, column: imp.column},
549
+ message: `Import '${imp.moduleSpecifier}' accesses internal module - use a documented export`,
550
+ suggestion: `Import from '${packageName}' entry point or request the subpath be exported`,
551
+ metadata: {
552
+ sourcePackage: pkg.name,
553
+ targetPackage: packageName,
554
+ subpath,
555
+ },
556
+ })
557
+ }
558
+ }
559
+ }
560
+ }
561
+ }
562
+
563
+ return {violations, success: true}
564
+ },
565
+ }
566
+ }
567
+
568
+ function resolveRelativeImportPath(fromPath: string, specifier: string): string {
569
+ const dir = fromPath.slice(0, Math.max(0, fromPath.lastIndexOf('/')))
570
+ const parts = [...dir.split('/')]
571
+
572
+ for (const segment of specifier.split('/')) {
573
+ if (segment === '..') {
574
+ parts.pop()
575
+ } else if (segment !== '.') {
576
+ parts.push(segment)
577
+ }
578
+ }
579
+
580
+ return parts.join('/')
581
+ }
582
+
583
+ function isWorkspacePackage(specifier: string): boolean {
584
+ return specifier.startsWith('@bfra.me/')
585
+ }
586
+
587
+ function checkTopLevelSideEffects(
588
+ sourceFile: SourceFile,
589
+ filePath: string,
590
+ violations: RuleViolation[],
591
+ options: {checkConsoleCalls: boolean; checkGlobalAssignments: boolean},
592
+ ): void {
593
+ const statements = sourceFile.getStatements()
594
+
595
+ for (const stmt of statements) {
596
+ if (stmt.getKind() === SyntaxKind.ExpressionStatement) {
597
+ const expr = stmt.getChildAtIndex(0)
598
+ if (expr === undefined) continue
599
+
600
+ if (options.checkConsoleCalls && expr.getKind() === SyntaxKind.CallExpression) {
601
+ const text = expr.getText()
602
+ if (
603
+ text.startsWith('console.') ||
604
+ text.includes('console.log') ||
605
+ text.includes('console.warn') ||
606
+ text.includes('console.error')
607
+ ) {
608
+ const {line, column} = sourceFile.getLineAndColumnAtPos(stmt.getStart())
609
+ violations.push({
610
+ ruleId: BUILTIN_RULE_IDS.SIDE_EFFECT,
611
+ location: {filePath, line, column},
612
+ message: 'Console call at module initialization level is a side effect',
613
+ suggestion: 'Move console calls inside functions or wrap in conditional checks',
614
+ })
615
+ }
616
+ }
617
+
618
+ if (options.checkGlobalAssignments && expr.getKind() === SyntaxKind.BinaryExpression) {
619
+ const text = expr.getText()
620
+ if (text.includes('window.') || text.includes('globalThis.') || text.includes('global.')) {
621
+ const {line, column} = sourceFile.getLineAndColumnAtPos(stmt.getStart())
622
+ violations.push({
623
+ ruleId: BUILTIN_RULE_IDS.SIDE_EFFECT,
624
+ location: {filePath, line, column},
625
+ message: 'Global assignment at module initialization level is a side effect',
626
+ suggestion: 'Wrap global assignments in initialization functions',
627
+ })
628
+ }
629
+ }
630
+ }
631
+
632
+ if (stmt.getKind() === SyntaxKind.ImportDeclaration) {
633
+ const importDecl = stmt.asKind(SyntaxKind.ImportDeclaration)
634
+ if (importDecl !== undefined) {
635
+ const namedBindings = importDecl.getImportClause()?.getNamedBindings()
636
+ const defaultImport = importDecl.getImportClause()?.getDefaultImport()
637
+
638
+ if (namedBindings === undefined && defaultImport === undefined) {
639
+ const {line, column} = sourceFile.getLineAndColumnAtPos(stmt.getStart())
640
+ const moduleSpecifier = importDecl.getModuleSpecifierValue()
641
+ violations.push({
642
+ ruleId: BUILTIN_RULE_IDS.SIDE_EFFECT,
643
+ location: {filePath, line, column},
644
+ message: `Side-effect only import '${moduleSpecifier}' may affect tree-shaking`,
645
+ suggestion:
646
+ 'Ensure this import is necessary and consider if it could be moved to an entry point',
647
+ metadata: {moduleSpecifier},
648
+ })
649
+ }
650
+ }
651
+ }
652
+ }
653
+ }
654
+
655
+ function findMatchingAlias(
656
+ specifier: string,
657
+ paths: Readonly<Record<string, readonly string[]>>,
658
+ ): string | undefined {
659
+ for (const [alias, targets] of Object.entries(paths)) {
660
+ for (const target of targets) {
661
+ const normalizedTarget = target.replaceAll('*', '')
662
+ if (specifier.includes(normalizedTarget)) {
663
+ // eslint-disable-next-line unicorn/prefer-string-replace-all
664
+ return alias.replace(/\*/g, specifier.replace(normalizedTarget, ''))
665
+ }
666
+ }
667
+ }
668
+ return undefined
669
+ }
670
+
671
+ function isValidPathAlias(
672
+ specifier: string,
673
+ paths: Readonly<Record<string, readonly string[]>>,
674
+ ): boolean {
675
+ if (isRelativeImport(specifier)) return true
676
+ if (specifier.startsWith('@') && !Object.keys(paths).some(p => specifier.startsWith(p))) {
677
+ return false
678
+ }
679
+ return true
680
+ }
681
+
682
+ function getPackageNameFromImport(specifier: string): string | undefined {
683
+ if (specifier.startsWith('@')) {
684
+ const parts = specifier.split('/')
685
+ if (parts.length >= 2) {
686
+ return `${parts[0]}/${parts[1]}`
687
+ }
688
+ }
689
+ return specifier.split('/')[0]
690
+ }
691
+
692
+ function isExportedSubpath(pkg: {packageJson: {exports?: unknown}}, subpath: string): boolean {
693
+ const exports = pkg.packageJson.exports
694
+
695
+ if (exports === null || exports === undefined) {
696
+ return true
697
+ }
698
+
699
+ if (typeof exports === 'string') {
700
+ return subpath === ''
701
+ }
702
+
703
+ if (typeof exports === 'object') {
704
+ const exportKey = `./${subpath}`
705
+ return exportKey in (exports as Record<string, unknown>)
706
+ }
707
+
708
+ return false
709
+ }