@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,381 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DuplicateDependencyAnalyzer - Detects duplicate and inconsistent dependencies across workspace packages.
|
|
3
|
+
*
|
|
4
|
+
* Identifies:
|
|
5
|
+
* - Same dependency with different versions across packages
|
|
6
|
+
* - Dependencies that could be hoisted to workspace root
|
|
7
|
+
* - Redundant devDependencies that match production dependencies
|
|
8
|
+
* - Version conflicts that may cause runtime issues
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type {WorkspacePackage} from '../scanner/workspace-scanner'
|
|
12
|
+
import type {Issue, IssueLocation, Severity} from '../types/index'
|
|
13
|
+
import type {Result} from '../types/result'
|
|
14
|
+
import type {AnalysisContext, Analyzer, AnalyzerError, AnalyzerMetadata} from './analyzer'
|
|
15
|
+
|
|
16
|
+
import {ok} from '@bfra.me/es/result'
|
|
17
|
+
|
|
18
|
+
import {createIssue, filterIssues} from './analyzer'
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Configuration options specific to DuplicateDependencyAnalyzer.
|
|
22
|
+
*/
|
|
23
|
+
export interface DuplicateDependencyAnalyzerOptions {
|
|
24
|
+
/** Ignore version differences for these packages */
|
|
25
|
+
readonly ignorePackages?: readonly string[]
|
|
26
|
+
/** Severity for version conflicts */
|
|
27
|
+
readonly conflictSeverity?: Severity
|
|
28
|
+
/** Whether to suggest hoisting common dependencies */
|
|
29
|
+
readonly suggestHoisting?: boolean
|
|
30
|
+
/** Minimum number of packages using a dependency to suggest hoisting */
|
|
31
|
+
readonly hoistingThreshold?: number
|
|
32
|
+
/** Check for redundant dev dependencies */
|
|
33
|
+
readonly checkRedundantDev?: boolean
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const DEFAULT_OPTIONS: Required<DuplicateDependencyAnalyzerOptions> = {
|
|
37
|
+
ignorePackages: [],
|
|
38
|
+
conflictSeverity: 'warning',
|
|
39
|
+
suggestHoisting: true,
|
|
40
|
+
hoistingThreshold: 3,
|
|
41
|
+
checkRedundantDev: true,
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export const duplicateDependencyAnalyzerMetadata: AnalyzerMetadata = {
|
|
45
|
+
id: 'duplicate-dependency',
|
|
46
|
+
name: 'Duplicate Dependency Analyzer',
|
|
47
|
+
description: 'Detects duplicate and version-inconsistent dependencies across workspace packages',
|
|
48
|
+
categories: ['dependency'],
|
|
49
|
+
defaultSeverity: 'warning',
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Information about a dependency occurrence.
|
|
54
|
+
*/
|
|
55
|
+
interface DependencyOccurrence {
|
|
56
|
+
readonly packageName: string
|
|
57
|
+
readonly version: string
|
|
58
|
+
readonly type: 'dependencies' | 'devDependencies' | 'peerDependencies' | 'optionalDependencies'
|
|
59
|
+
readonly packageJsonPath: string
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Aggregated dependency information across all packages.
|
|
64
|
+
*/
|
|
65
|
+
interface DependencyInfo {
|
|
66
|
+
readonly dependencyName: string
|
|
67
|
+
readonly occurrences: readonly DependencyOccurrence[]
|
|
68
|
+
readonly versions: readonly string[]
|
|
69
|
+
readonly hasConflict: boolean
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Creates a DuplicateDependencyAnalyzer instance.
|
|
74
|
+
*
|
|
75
|
+
* @example
|
|
76
|
+
* ```ts
|
|
77
|
+
* const analyzer = createDuplicateDependencyAnalyzer({
|
|
78
|
+
* conflictSeverity: 'error',
|
|
79
|
+
* suggestHoisting: true,
|
|
80
|
+
* })
|
|
81
|
+
* const result = await analyzer.analyze(context)
|
|
82
|
+
* ```
|
|
83
|
+
*/
|
|
84
|
+
export function createDuplicateDependencyAnalyzer(
|
|
85
|
+
options: DuplicateDependencyAnalyzerOptions = {},
|
|
86
|
+
): Analyzer {
|
|
87
|
+
const resolvedOptions = {...DEFAULT_OPTIONS, ...options}
|
|
88
|
+
|
|
89
|
+
return {
|
|
90
|
+
metadata: duplicateDependencyAnalyzerMetadata,
|
|
91
|
+
analyze: async (context: AnalysisContext): Promise<Result<readonly Issue[], AnalyzerError>> => {
|
|
92
|
+
const issues: Issue[] = []
|
|
93
|
+
|
|
94
|
+
context.reportProgress?.('Collecting dependency information across workspace...')
|
|
95
|
+
|
|
96
|
+
const dependencyMap = collectDependencies(context.packages)
|
|
97
|
+
|
|
98
|
+
context.reportProgress?.('Analyzing for duplicates and conflicts...')
|
|
99
|
+
|
|
100
|
+
// Check for version conflicts
|
|
101
|
+
for (const [depName, info] of dependencyMap) {
|
|
102
|
+
if (isIgnored(depName, resolvedOptions.ignorePackages)) {
|
|
103
|
+
continue
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (info.hasConflict) {
|
|
107
|
+
issues.push(createVersionConflictIssue(info, resolvedOptions))
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Suggest hoisting if same version used in multiple packages
|
|
111
|
+
if (
|
|
112
|
+
resolvedOptions.suggestHoisting &&
|
|
113
|
+
info.occurrences.length >= resolvedOptions.hoistingThreshold &&
|
|
114
|
+
!info.hasConflict
|
|
115
|
+
) {
|
|
116
|
+
issues.push(createHoistingSuggestion(info, resolvedOptions))
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Check for redundant dev dependencies
|
|
121
|
+
if (resolvedOptions.checkRedundantDev) {
|
|
122
|
+
for (const pkg of context.packages) {
|
|
123
|
+
issues.push(...checkRedundantDevDependencies(pkg))
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return ok(filterIssues(issues, context.config))
|
|
128
|
+
},
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Collects all dependencies from all packages into a unified map.
|
|
134
|
+
*/
|
|
135
|
+
function collectDependencies(packages: readonly WorkspacePackage[]): Map<string, DependencyInfo> {
|
|
136
|
+
const depMap = new Map<string, DependencyOccurrence[]>()
|
|
137
|
+
|
|
138
|
+
for (const pkg of packages) {
|
|
139
|
+
const pkgJson = pkg.packageJson
|
|
140
|
+
|
|
141
|
+
collectFromObject(depMap, pkg, pkgJson.dependencies, 'dependencies')
|
|
142
|
+
collectFromObject(depMap, pkg, pkgJson.devDependencies, 'devDependencies')
|
|
143
|
+
collectFromObject(depMap, pkg, pkgJson.peerDependencies, 'peerDependencies')
|
|
144
|
+
collectFromObject(depMap, pkg, pkgJson.optionalDependencies, 'optionalDependencies')
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const result = new Map<string, DependencyInfo>()
|
|
148
|
+
|
|
149
|
+
for (const [depName, occurrences] of depMap) {
|
|
150
|
+
const versions = [...new Set(occurrences.map(o => normalizeVersion(o.version)))].sort()
|
|
151
|
+
const hasConflict = versions.length > 1
|
|
152
|
+
|
|
153
|
+
result.set(depName, {
|
|
154
|
+
dependencyName: depName,
|
|
155
|
+
occurrences,
|
|
156
|
+
versions,
|
|
157
|
+
hasConflict,
|
|
158
|
+
})
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return result
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Collects dependencies from a dependency object.
|
|
166
|
+
*/
|
|
167
|
+
function collectFromObject(
|
|
168
|
+
depMap: Map<string, DependencyOccurrence[]>,
|
|
169
|
+
pkg: WorkspacePackage,
|
|
170
|
+
deps: Readonly<Record<string, string>> | undefined,
|
|
171
|
+
type: DependencyOccurrence['type'],
|
|
172
|
+
): void {
|
|
173
|
+
if (deps === undefined) {
|
|
174
|
+
return
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
for (const [depName, version] of Object.entries(deps)) {
|
|
178
|
+
const occurrence: DependencyOccurrence = {
|
|
179
|
+
packageName: pkg.name,
|
|
180
|
+
version,
|
|
181
|
+
type,
|
|
182
|
+
packageJsonPath: pkg.packageJsonPath,
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const existing = depMap.get(depName)
|
|
186
|
+
if (existing === undefined) {
|
|
187
|
+
depMap.set(depName, [occurrence])
|
|
188
|
+
} else {
|
|
189
|
+
existing.push(occurrence)
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Normalizes a version string for comparison.
|
|
196
|
+
* Removes workspace: prefix and leading ^ or ~ for basic comparison.
|
|
197
|
+
*/
|
|
198
|
+
function normalizeVersion(version: string): string {
|
|
199
|
+
let normalized = version
|
|
200
|
+
|
|
201
|
+
// Handle workspace protocol
|
|
202
|
+
if (normalized.startsWith('workspace:')) {
|
|
203
|
+
normalized = normalized.slice('workspace:'.length)
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Keep the original if it's a range operator we want to compare
|
|
207
|
+
// Only normalize simple prefixes for exact matching
|
|
208
|
+
if (normalized.startsWith('^') || normalized.startsWith('~')) {
|
|
209
|
+
return normalized
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
return normalized
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Checks if a dependency should be ignored.
|
|
217
|
+
*/
|
|
218
|
+
function isIgnored(depName: string, ignorePackages: readonly string[]): boolean {
|
|
219
|
+
return ignorePackages.some(pattern => {
|
|
220
|
+
if (pattern.includes('*')) {
|
|
221
|
+
const regex = new RegExp(`^${pattern.replaceAll('*', '.*')}$`)
|
|
222
|
+
return regex.test(depName)
|
|
223
|
+
}
|
|
224
|
+
return depName === pattern
|
|
225
|
+
})
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Creates an issue for a version conflict.
|
|
230
|
+
*/
|
|
231
|
+
function createVersionConflictIssue(
|
|
232
|
+
info: DependencyInfo,
|
|
233
|
+
options: Required<DuplicateDependencyAnalyzerOptions>,
|
|
234
|
+
): Issue {
|
|
235
|
+
const firstOccurrence = info.occurrences[0]
|
|
236
|
+
const location: IssueLocation = {
|
|
237
|
+
filePath: firstOccurrence?.packageJsonPath ?? '',
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const relatedLocations: IssueLocation[] = info.occurrences.slice(1).map(o => ({
|
|
241
|
+
filePath: o.packageJsonPath,
|
|
242
|
+
}))
|
|
243
|
+
|
|
244
|
+
const versionList = info.occurrences
|
|
245
|
+
.map(o => ` - ${o.packageName}: ${o.version} (${o.type})`)
|
|
246
|
+
.join('\n')
|
|
247
|
+
|
|
248
|
+
return createIssue({
|
|
249
|
+
id: 'version-conflict',
|
|
250
|
+
title: `Version conflict: ${info.dependencyName}`,
|
|
251
|
+
description: `Dependency "${info.dependencyName}" has ${info.versions.length} different versions across packages:\n${versionList}`,
|
|
252
|
+
severity: options.conflictSeverity,
|
|
253
|
+
category: 'dependency',
|
|
254
|
+
location,
|
|
255
|
+
relatedLocations,
|
|
256
|
+
suggestion: `Align all packages to use the same version of "${info.dependencyName}" to avoid potential runtime issues`,
|
|
257
|
+
metadata: {
|
|
258
|
+
dependencyName: info.dependencyName,
|
|
259
|
+
versions: info.versions,
|
|
260
|
+
packages: info.occurrences.map(o => o.packageName),
|
|
261
|
+
},
|
|
262
|
+
})
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Creates a suggestion to hoist a dependency.
|
|
267
|
+
*/
|
|
268
|
+
function createHoistingSuggestion(
|
|
269
|
+
info: DependencyInfo,
|
|
270
|
+
_options: Required<DuplicateDependencyAnalyzerOptions>,
|
|
271
|
+
): Issue {
|
|
272
|
+
const firstOccurrence = info.occurrences[0]
|
|
273
|
+
const location: IssueLocation = {
|
|
274
|
+
filePath: firstOccurrence?.packageJsonPath ?? '',
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const packageCount = info.occurrences.length
|
|
278
|
+
const packageNames = info.occurrences.map(o => o.packageName).join(', ')
|
|
279
|
+
|
|
280
|
+
return createIssue({
|
|
281
|
+
id: 'hoisting-candidate',
|
|
282
|
+
title: `Hoisting candidate: ${info.dependencyName}`,
|
|
283
|
+
description: `Dependency "${info.dependencyName}" is used in ${packageCount} packages (${packageNames}) with the same version`,
|
|
284
|
+
severity: 'info',
|
|
285
|
+
category: 'dependency',
|
|
286
|
+
location,
|
|
287
|
+
suggestion: `Consider hoisting "${info.dependencyName}" to the workspace root package.json to reduce duplication`,
|
|
288
|
+
metadata: {
|
|
289
|
+
dependencyName: info.dependencyName,
|
|
290
|
+
version: info.versions[0],
|
|
291
|
+
packageCount,
|
|
292
|
+
packages: info.occurrences.map(o => o.packageName),
|
|
293
|
+
},
|
|
294
|
+
})
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Checks for redundant dev dependencies (same package in both deps and devDeps).
|
|
299
|
+
*/
|
|
300
|
+
function checkRedundantDevDependencies(pkg: WorkspacePackage): Issue[] {
|
|
301
|
+
const issues: Issue[] = []
|
|
302
|
+
const pkgJson = pkg.packageJson
|
|
303
|
+
|
|
304
|
+
if (pkgJson.dependencies === undefined || pkgJson.devDependencies === undefined) {
|
|
305
|
+
return issues
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
for (const [depName, devVersion] of Object.entries(pkgJson.devDependencies)) {
|
|
309
|
+
if (depName in pkgJson.dependencies) {
|
|
310
|
+
const prodVersion = pkgJson.dependencies[depName]
|
|
311
|
+
|
|
312
|
+
issues.push(
|
|
313
|
+
createIssue({
|
|
314
|
+
id: 'redundant-dev-dependency',
|
|
315
|
+
title: `Redundant dev dependency: ${depName}`,
|
|
316
|
+
description: `Package "${pkg.name}" has "${depName}" in both dependencies (${prodVersion}) and devDependencies (${devVersion})`,
|
|
317
|
+
severity: 'info',
|
|
318
|
+
category: 'dependency',
|
|
319
|
+
location: {filePath: pkg.packageJsonPath},
|
|
320
|
+
suggestion: `Remove "${depName}" from devDependencies as it's already in dependencies`,
|
|
321
|
+
metadata: {
|
|
322
|
+
packageName: pkg.name,
|
|
323
|
+
dependencyName: depName,
|
|
324
|
+
prodVersion,
|
|
325
|
+
devVersion,
|
|
326
|
+
},
|
|
327
|
+
}),
|
|
328
|
+
)
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
return issues
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Aggregates duplicate dependency statistics.
|
|
337
|
+
*/
|
|
338
|
+
export interface DuplicateDependencyStats {
|
|
339
|
+
/** Total unique dependencies across workspace */
|
|
340
|
+
readonly totalUniqueDependencies: number
|
|
341
|
+
/** Number of dependencies with version conflicts */
|
|
342
|
+
readonly conflictingDependencies: number
|
|
343
|
+
/** Number of packages with redundant dev dependencies */
|
|
344
|
+
readonly packagesWithRedundantDev: number
|
|
345
|
+
/** Dependencies appearing in most packages */
|
|
346
|
+
readonly mostCommon: readonly {readonly name: string; readonly count: number}[]
|
|
347
|
+
/** Dependencies with the most version variants */
|
|
348
|
+
readonly mostVariants: readonly {readonly name: string; readonly versionCount: number}[]
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Computes statistics about dependency duplicates.
|
|
353
|
+
*/
|
|
354
|
+
export function computeDuplicateStats(
|
|
355
|
+
dependencyMap: ReadonlyMap<string, DependencyInfo>,
|
|
356
|
+
topN = 10,
|
|
357
|
+
): DuplicateDependencyStats {
|
|
358
|
+
let conflictingDependencies = 0
|
|
359
|
+
const commonDeps: {name: string; count: number}[] = []
|
|
360
|
+
const variantDeps: {name: string; versionCount: number}[] = []
|
|
361
|
+
|
|
362
|
+
for (const [name, info] of dependencyMap) {
|
|
363
|
+
if (info.hasConflict) {
|
|
364
|
+
conflictingDependencies++
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
commonDeps.push({name, count: info.occurrences.length})
|
|
368
|
+
variantDeps.push({name, versionCount: info.versions.length})
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
commonDeps.sort((a, b) => b.count - a.count)
|
|
372
|
+
variantDeps.sort((a, b) => b.versionCount - a.versionCount)
|
|
373
|
+
|
|
374
|
+
return {
|
|
375
|
+
totalUniqueDependencies: dependencyMap.size,
|
|
376
|
+
conflictingDependencies,
|
|
377
|
+
packagesWithRedundantDev: 0, // Computed separately per-package
|
|
378
|
+
mostCommon: commonDeps.slice(0, topN),
|
|
379
|
+
mostVariants: variantDeps.filter(d => d.versionCount > 1).slice(0, topN),
|
|
380
|
+
}
|
|
381
|
+
}
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* EslintConfigAnalyzer - Validates ESLint configuration for consistency and best practices.
|
|
3
|
+
*
|
|
4
|
+
* Detects issues such as:
|
|
5
|
+
* - Missing ESLint configuration files
|
|
6
|
+
* - Inconsistent config patterns across packages
|
|
7
|
+
* - Missing recommended plugins for TypeScript packages
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type {WorkspacePackage} from '../scanner/workspace-scanner'
|
|
11
|
+
import type {Issue, IssueLocation} from '../types/index'
|
|
12
|
+
import type {Result} from '../types/result'
|
|
13
|
+
import type {AnalysisContext, Analyzer, AnalyzerError, AnalyzerMetadata} from './analyzer'
|
|
14
|
+
|
|
15
|
+
import fs from 'node:fs/promises'
|
|
16
|
+
import path from 'node:path'
|
|
17
|
+
|
|
18
|
+
import {ok} from '@bfra.me/es/result'
|
|
19
|
+
|
|
20
|
+
import {createIssue, filterIssues} from './analyzer'
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Configuration options specific to EslintConfigAnalyzer.
|
|
24
|
+
*/
|
|
25
|
+
export interface EslintConfigAnalyzerOptions {
|
|
26
|
+
/** Whether to require ESLint config for all packages */
|
|
27
|
+
readonly requireConfig?: boolean
|
|
28
|
+
/** Expected config file pattern (e.g., 'eslint.config.ts') */
|
|
29
|
+
readonly expectedConfigFile?: string
|
|
30
|
+
/** Whether to check for flat config usage */
|
|
31
|
+
readonly requireFlatConfig?: boolean
|
|
32
|
+
/** Package names exempt from checks */
|
|
33
|
+
readonly exemptPackages?: readonly string[]
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const DEFAULT_OPTIONS: EslintConfigAnalyzerOptions = {
|
|
37
|
+
requireConfig: true,
|
|
38
|
+
expectedConfigFile: 'eslint.config.ts',
|
|
39
|
+
requireFlatConfig: true,
|
|
40
|
+
exemptPackages: [],
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const METADATA: AnalyzerMetadata = {
|
|
44
|
+
id: 'eslint-config',
|
|
45
|
+
name: 'ESLint Config Analyzer',
|
|
46
|
+
description: 'Validates ESLint configuration for consistency and best practices',
|
|
47
|
+
categories: ['configuration'],
|
|
48
|
+
defaultSeverity: 'warning',
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const ESLINT_CONFIG_FILES = [
|
|
52
|
+
'eslint.config.ts',
|
|
53
|
+
'eslint.config.mts',
|
|
54
|
+
'eslint.config.cts',
|
|
55
|
+
'eslint.config.js',
|
|
56
|
+
'eslint.config.mjs',
|
|
57
|
+
'eslint.config.cjs',
|
|
58
|
+
'.eslintrc.json',
|
|
59
|
+
'.eslintrc.js',
|
|
60
|
+
'.eslintrc.cjs',
|
|
61
|
+
'.eslintrc.yaml',
|
|
62
|
+
'.eslintrc.yml',
|
|
63
|
+
'.eslintrc',
|
|
64
|
+
] as const
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Creates an EslintConfigAnalyzer instance.
|
|
68
|
+
*/
|
|
69
|
+
export function createEslintConfigAnalyzer(options: EslintConfigAnalyzerOptions = {}): Analyzer {
|
|
70
|
+
const resolvedOptions = {...DEFAULT_OPTIONS, ...options}
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
metadata: METADATA,
|
|
74
|
+
analyze: async (context: AnalysisContext): Promise<Result<readonly Issue[], AnalyzerError>> => {
|
|
75
|
+
const issues: Issue[] = []
|
|
76
|
+
|
|
77
|
+
for (const pkg of context.packages) {
|
|
78
|
+
if (isExemptPackage(pkg.name, resolvedOptions.exemptPackages)) {
|
|
79
|
+
continue
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const packageIssues = await analyzePackageEslint(pkg, resolvedOptions)
|
|
83
|
+
issues.push(...packageIssues)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return ok(filterIssues(issues, context.config))
|
|
87
|
+
},
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function isExemptPackage(name: string, exemptPackages: readonly string[] | undefined): boolean {
|
|
92
|
+
return exemptPackages?.includes(name) ?? false
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function createLocation(filePath: string): IssueLocation {
|
|
96
|
+
return {filePath}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function analyzePackageEslint(
|
|
100
|
+
pkg: WorkspacePackage,
|
|
101
|
+
options: EslintConfigAnalyzerOptions,
|
|
102
|
+
): Promise<Issue[]> {
|
|
103
|
+
const issues: Issue[] = []
|
|
104
|
+
|
|
105
|
+
// Find existing ESLint config
|
|
106
|
+
const configResult = await findEslintConfig(pkg.packagePath)
|
|
107
|
+
|
|
108
|
+
if (!configResult.found) {
|
|
109
|
+
if (options.requireConfig) {
|
|
110
|
+
issues.push(
|
|
111
|
+
createIssue({
|
|
112
|
+
id: 'eslint-no-config',
|
|
113
|
+
title: 'Missing ESLint configuration',
|
|
114
|
+
description: `Package "${pkg.name}" has no ESLint configuration file`,
|
|
115
|
+
severity: 'warning',
|
|
116
|
+
category: 'configuration',
|
|
117
|
+
location: createLocation(path.join(pkg.packagePath, 'package.json')),
|
|
118
|
+
suggestion: `Create "${options.expectedConfigFile ?? 'eslint.config.ts'}" for code quality enforcement`,
|
|
119
|
+
}),
|
|
120
|
+
)
|
|
121
|
+
}
|
|
122
|
+
return issues
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Check for legacy config files
|
|
126
|
+
if (options.requireFlatConfig && configResult.isLegacy) {
|
|
127
|
+
issues.push(
|
|
128
|
+
createIssue({
|
|
129
|
+
id: 'eslint-legacy-config',
|
|
130
|
+
title: 'Using legacy ESLint configuration',
|
|
131
|
+
description: `Package "${pkg.name}" uses legacy config format "${configResult.fileName}"`,
|
|
132
|
+
severity: 'info',
|
|
133
|
+
category: 'configuration',
|
|
134
|
+
location: createLocation(configResult.filePath),
|
|
135
|
+
suggestion: 'Migrate to flat config format (eslint.config.ts) for ESLint v9+',
|
|
136
|
+
}),
|
|
137
|
+
)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Check if expected config file is used
|
|
141
|
+
if (
|
|
142
|
+
options.expectedConfigFile !== undefined &&
|
|
143
|
+
configResult.fileName !== options.expectedConfigFile &&
|
|
144
|
+
!configResult.isLegacy
|
|
145
|
+
) {
|
|
146
|
+
issues.push(
|
|
147
|
+
createIssue({
|
|
148
|
+
id: 'eslint-unexpected-config-file',
|
|
149
|
+
title: 'Unexpected ESLint config file name',
|
|
150
|
+
description: `Package "${pkg.name}" uses "${configResult.fileName}" instead of "${options.expectedConfigFile}"`,
|
|
151
|
+
severity: 'info',
|
|
152
|
+
category: 'configuration',
|
|
153
|
+
location: createLocation(configResult.filePath),
|
|
154
|
+
suggestion: `Consider renaming to "${options.expectedConfigFile}" for consistency`,
|
|
155
|
+
}),
|
|
156
|
+
)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Analyze config content for common issues
|
|
160
|
+
if (!configResult.isLegacy) {
|
|
161
|
+
const contentIssues = await analyzeEslintConfigContent(pkg, configResult.filePath)
|
|
162
|
+
issues.push(...contentIssues)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return issues
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
interface EslintConfigResult {
|
|
169
|
+
found: boolean
|
|
170
|
+
fileName: string
|
|
171
|
+
filePath: string
|
|
172
|
+
isLegacy: boolean
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async function findEslintConfig(packagePath: string): Promise<EslintConfigResult> {
|
|
176
|
+
for (const configFile of ESLINT_CONFIG_FILES) {
|
|
177
|
+
const configPath = path.join(packagePath, configFile)
|
|
178
|
+
if (await fileExists(configPath)) {
|
|
179
|
+
return {
|
|
180
|
+
found: true,
|
|
181
|
+
fileName: configFile,
|
|
182
|
+
filePath: configPath,
|
|
183
|
+
isLegacy: isLegacyConfig(configFile),
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
return {
|
|
189
|
+
found: false,
|
|
190
|
+
fileName: '',
|
|
191
|
+
filePath: '',
|
|
192
|
+
isLegacy: false,
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function isLegacyConfig(fileName: string): boolean {
|
|
197
|
+
return fileName.startsWith('.eslintrc')
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
async function fileExists(filePath: string): Promise<boolean> {
|
|
201
|
+
try {
|
|
202
|
+
await fs.access(filePath)
|
|
203
|
+
return true
|
|
204
|
+
} catch {
|
|
205
|
+
return false
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
async function analyzeEslintConfigContent(
|
|
210
|
+
pkg: WorkspacePackage,
|
|
211
|
+
configPath: string,
|
|
212
|
+
): Promise<Issue[]> {
|
|
213
|
+
const issues: Issue[] = []
|
|
214
|
+
|
|
215
|
+
let content: string
|
|
216
|
+
try {
|
|
217
|
+
content = await fs.readFile(configPath, 'utf-8')
|
|
218
|
+
} catch {
|
|
219
|
+
return issues
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Check for defineConfig usage
|
|
223
|
+
if (!content.includes('defineConfig')) {
|
|
224
|
+
issues.push(
|
|
225
|
+
createIssue({
|
|
226
|
+
id: 'eslint-no-define-config',
|
|
227
|
+
title: 'ESLint config not using defineConfig',
|
|
228
|
+
description: `Package "${pkg.name}" ESLint config may not be using defineConfig() helper`,
|
|
229
|
+
severity: 'info',
|
|
230
|
+
category: 'configuration',
|
|
231
|
+
location: createLocation(configPath),
|
|
232
|
+
suggestion: 'Use defineConfig() from your ESLint config package for better type safety',
|
|
233
|
+
}),
|
|
234
|
+
)
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Check for TypeScript plugin in TS packages
|
|
238
|
+
if (pkg.hasTsConfig) {
|
|
239
|
+
const hasTypescriptPlugin =
|
|
240
|
+
content.includes('typescript-eslint') ||
|
|
241
|
+
content.includes('@typescript-eslint') ||
|
|
242
|
+
content.includes('tseslint')
|
|
243
|
+
|
|
244
|
+
if (!hasTypescriptPlugin) {
|
|
245
|
+
issues.push(
|
|
246
|
+
createIssue({
|
|
247
|
+
id: 'eslint-no-typescript-plugin',
|
|
248
|
+
title: 'ESLint config missing TypeScript support',
|
|
249
|
+
description: `Package "${pkg.name}" is a TypeScript package but ESLint config may not include TypeScript plugin`,
|
|
250
|
+
severity: 'warning',
|
|
251
|
+
category: 'configuration',
|
|
252
|
+
location: createLocation(configPath),
|
|
253
|
+
suggestion: 'Add @typescript-eslint/eslint-plugin for TypeScript-specific linting rules',
|
|
254
|
+
}),
|
|
255
|
+
)
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// Check for Prettier integration
|
|
260
|
+
const hasPrettierConfig =
|
|
261
|
+
content.includes('prettier') || content.includes('eslint-config-prettier')
|
|
262
|
+
|
|
263
|
+
if (!hasPrettierConfig) {
|
|
264
|
+
issues.push(
|
|
265
|
+
createIssue({
|
|
266
|
+
id: 'eslint-no-prettier',
|
|
267
|
+
title: 'ESLint config may not include Prettier integration',
|
|
268
|
+
description: `Package "${pkg.name}" ESLint config may not disable Prettier-conflicting rules`,
|
|
269
|
+
severity: 'info',
|
|
270
|
+
category: 'configuration',
|
|
271
|
+
location: createLocation(configPath),
|
|
272
|
+
suggestion:
|
|
273
|
+
'Consider adding eslint-config-prettier to avoid formatting conflicts with Prettier',
|
|
274
|
+
}),
|
|
275
|
+
)
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
return issues
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
export {METADATA as eslintConfigAnalyzerMetadata}
|