@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,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
|
+
}
|