@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,356 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* UnusedDependencyAnalyzer - Detects unused dependencies in workspace packages.
|
|
3
|
+
*
|
|
4
|
+
* Compares declared dependencies in package.json against actual imports
|
|
5
|
+
* found in source files to identify unused packages that can be removed.
|
|
6
|
+
*
|
|
7
|
+
* Handles:
|
|
8
|
+
* - Static imports (import { x } from 'pkg')
|
|
9
|
+
* - Dynamic imports (await import('pkg'))
|
|
10
|
+
* - require() calls (const x = require('pkg'))
|
|
11
|
+
* - Type-only imports (import type { T } from 'pkg')
|
|
12
|
+
* - Dev vs production dependency classification
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type {ImportExtractionResult, ImportExtractorOptions} from '../parser/import-extractor'
|
|
16
|
+
import type {WorkspacePackage} from '../scanner/workspace-scanner'
|
|
17
|
+
import type {Issue, IssueLocation} from '../types/index'
|
|
18
|
+
import type {Result} from '../types/result'
|
|
19
|
+
import type {AnalysisContext, Analyzer, AnalyzerError, AnalyzerMetadata} from './analyzer'
|
|
20
|
+
|
|
21
|
+
import {createProject} from '@bfra.me/doc-sync/parsers'
|
|
22
|
+
import {ok} from '@bfra.me/es/result'
|
|
23
|
+
|
|
24
|
+
import {extractImports, getPackageNameFromSpecifier} from '../parser/import-extractor'
|
|
25
|
+
import {createIssue, filterIssues} from './analyzer'
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Configuration options specific to UnusedDependencyAnalyzer.
|
|
29
|
+
*/
|
|
30
|
+
export interface UnusedDependencyAnalyzerOptions {
|
|
31
|
+
/** Include devDependencies in analysis */
|
|
32
|
+
readonly checkDevDependencies?: boolean
|
|
33
|
+
/** Include peerDependencies in analysis */
|
|
34
|
+
readonly checkPeerDependencies?: boolean
|
|
35
|
+
/** Include optionalDependencies in analysis */
|
|
36
|
+
readonly checkOptionalDependencies?: boolean
|
|
37
|
+
/** Package names to ignore (regex patterns) */
|
|
38
|
+
readonly ignorePatterns?: readonly string[]
|
|
39
|
+
/** Packages known to be used implicitly (e.g., type packages, build tools) */
|
|
40
|
+
readonly implicitlyUsed?: readonly string[]
|
|
41
|
+
/** Workspace package prefixes for identifying workspace dependencies */
|
|
42
|
+
readonly workspacePrefixes?: readonly string[]
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const DEFAULT_OPTIONS: Required<UnusedDependencyAnalyzerOptions> = {
|
|
46
|
+
checkDevDependencies: true,
|
|
47
|
+
checkPeerDependencies: false,
|
|
48
|
+
checkOptionalDependencies: false,
|
|
49
|
+
ignorePatterns: [],
|
|
50
|
+
implicitlyUsed: [
|
|
51
|
+
// Common packages that are used implicitly or at build time
|
|
52
|
+
'typescript',
|
|
53
|
+
'@types/*',
|
|
54
|
+
'eslint',
|
|
55
|
+
'prettier',
|
|
56
|
+
'vitest',
|
|
57
|
+
'@vitest/*',
|
|
58
|
+
'tsup',
|
|
59
|
+
'vite',
|
|
60
|
+
'@vitejs/*',
|
|
61
|
+
],
|
|
62
|
+
workspacePrefixes: ['@bfra.me/'],
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export const unusedDependencyAnalyzerMetadata: AnalyzerMetadata = {
|
|
66
|
+
id: 'unused-dependency',
|
|
67
|
+
name: 'Unused Dependency Analyzer',
|
|
68
|
+
description: 'Detects dependencies declared in package.json that are not imported in source code',
|
|
69
|
+
categories: ['dependency'],
|
|
70
|
+
defaultSeverity: 'warning',
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Creates an UnusedDependencyAnalyzer instance.
|
|
75
|
+
*
|
|
76
|
+
* @example
|
|
77
|
+
* ```ts
|
|
78
|
+
* const analyzer = createUnusedDependencyAnalyzer({
|
|
79
|
+
* checkDevDependencies: true,
|
|
80
|
+
* ignorePatterns: ['@types/*'],
|
|
81
|
+
* })
|
|
82
|
+
* const result = await analyzer.analyze(context)
|
|
83
|
+
* ```
|
|
84
|
+
*/
|
|
85
|
+
export function createUnusedDependencyAnalyzer(
|
|
86
|
+
options: UnusedDependencyAnalyzerOptions = {},
|
|
87
|
+
): Analyzer {
|
|
88
|
+
const resolvedOptions = {...DEFAULT_OPTIONS, ...options}
|
|
89
|
+
|
|
90
|
+
return {
|
|
91
|
+
metadata: unusedDependencyAnalyzerMetadata,
|
|
92
|
+
analyze: async (context: AnalysisContext): Promise<Result<readonly Issue[], AnalyzerError>> => {
|
|
93
|
+
const issues: Issue[] = []
|
|
94
|
+
|
|
95
|
+
for (const pkg of context.packages) {
|
|
96
|
+
context.reportProgress?.(`Analyzing dependencies for ${pkg.name}...`)
|
|
97
|
+
|
|
98
|
+
const packageIssues = await analyzePackageDependencies(pkg, resolvedOptions)
|
|
99
|
+
issues.push(...packageIssues)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return ok(filterIssues(issues, context.config))
|
|
103
|
+
},
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Dependency classification for proper issue severity and messaging.
|
|
109
|
+
*/
|
|
110
|
+
type DependencyType =
|
|
111
|
+
| 'dependencies'
|
|
112
|
+
| 'devDependencies'
|
|
113
|
+
| 'peerDependencies'
|
|
114
|
+
| 'optionalDependencies'
|
|
115
|
+
|
|
116
|
+
interface DeclaredDependency {
|
|
117
|
+
readonly name: string
|
|
118
|
+
readonly version: string
|
|
119
|
+
readonly type: DependencyType
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Analyzes a single package for unused dependencies.
|
|
124
|
+
*/
|
|
125
|
+
async function analyzePackageDependencies(
|
|
126
|
+
pkg: WorkspacePackage,
|
|
127
|
+
options: Required<UnusedDependencyAnalyzerOptions>,
|
|
128
|
+
): Promise<Issue[]> {
|
|
129
|
+
const issues: Issue[] = []
|
|
130
|
+
|
|
131
|
+
const declaredDeps = collectDeclaredDependencies(pkg, options)
|
|
132
|
+
if (declaredDeps.length === 0) {
|
|
133
|
+
return issues
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const usedPackages = await collectUsedPackages(pkg, options)
|
|
137
|
+
|
|
138
|
+
for (const dep of declaredDeps) {
|
|
139
|
+
if (isIgnored(dep.name, options)) {
|
|
140
|
+
continue
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (isImplicitlyUsed(dep.name, options.implicitlyUsed)) {
|
|
144
|
+
continue
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (!usedPackages.has(dep.name)) {
|
|
148
|
+
issues.push(createUnusedDependencyIssue(pkg, dep))
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return issues
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Collects all declared dependencies from package.json based on configuration.
|
|
157
|
+
*/
|
|
158
|
+
function collectDeclaredDependencies(
|
|
159
|
+
pkg: WorkspacePackage,
|
|
160
|
+
options: Required<UnusedDependencyAnalyzerOptions>,
|
|
161
|
+
): DeclaredDependency[] {
|
|
162
|
+
const deps: DeclaredDependency[] = []
|
|
163
|
+
const pkgJson = pkg.packageJson
|
|
164
|
+
|
|
165
|
+
// Always check production dependencies
|
|
166
|
+
if (pkgJson.dependencies !== undefined) {
|
|
167
|
+
for (const [name, version] of Object.entries(pkgJson.dependencies)) {
|
|
168
|
+
deps.push({name, version, type: 'dependencies'})
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (options.checkDevDependencies && pkgJson.devDependencies !== undefined) {
|
|
173
|
+
for (const [name, version] of Object.entries(pkgJson.devDependencies)) {
|
|
174
|
+
deps.push({name, version, type: 'devDependencies'})
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (options.checkPeerDependencies && pkgJson.peerDependencies !== undefined) {
|
|
179
|
+
for (const [name, version] of Object.entries(pkgJson.peerDependencies)) {
|
|
180
|
+
deps.push({name, version, type: 'peerDependencies'})
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (options.checkOptionalDependencies && pkgJson.optionalDependencies !== undefined) {
|
|
185
|
+
for (const [name, version] of Object.entries(pkgJson.optionalDependencies)) {
|
|
186
|
+
deps.push({name, version, type: 'optionalDependencies'})
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return deps
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Collects all packages actually used in source files.
|
|
195
|
+
*/
|
|
196
|
+
async function collectUsedPackages(
|
|
197
|
+
pkg: WorkspacePackage,
|
|
198
|
+
options: Required<UnusedDependencyAnalyzerOptions>,
|
|
199
|
+
): Promise<Set<string>> {
|
|
200
|
+
const usedPackages = new Set<string>()
|
|
201
|
+
|
|
202
|
+
if (pkg.sourceFiles.length === 0) {
|
|
203
|
+
return usedPackages
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const project = createProject()
|
|
207
|
+
|
|
208
|
+
const extractorOptions: ImportExtractorOptions = {
|
|
209
|
+
workspacePrefixes: options.workspacePrefixes,
|
|
210
|
+
includeTypeImports: true,
|
|
211
|
+
includeDynamicImports: true,
|
|
212
|
+
includeRequireCalls: true,
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
for (const filePath of pkg.sourceFiles) {
|
|
216
|
+
try {
|
|
217
|
+
const sourceFile = project.addSourceFileAtPath(filePath)
|
|
218
|
+
const result = extractImports(sourceFile, extractorOptions)
|
|
219
|
+
|
|
220
|
+
for (const imp of result.imports) {
|
|
221
|
+
const packageName = getPackageNameFromSpecifier(imp.moduleSpecifier)
|
|
222
|
+
if (!isRelativeSpecifier(imp.moduleSpecifier)) {
|
|
223
|
+
usedPackages.add(packageName)
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
} catch {
|
|
227
|
+
// Skip files that can't be parsed (may be invalid syntax or not TypeScript)
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return usedPackages
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Checks if a module specifier is a relative import.
|
|
236
|
+
*/
|
|
237
|
+
function isRelativeSpecifier(specifier: string): boolean {
|
|
238
|
+
return specifier.startsWith('.') || specifier.startsWith('/')
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Checks if a dependency should be ignored based on patterns.
|
|
243
|
+
*/
|
|
244
|
+
function isIgnored(depName: string, options: Required<UnusedDependencyAnalyzerOptions>): boolean {
|
|
245
|
+
return options.ignorePatterns.some(pattern => {
|
|
246
|
+
if (pattern.includes('*')) {
|
|
247
|
+
const regex = new RegExp(`^${pattern.replaceAll('*', '.*')}$`)
|
|
248
|
+
return regex.test(depName)
|
|
249
|
+
}
|
|
250
|
+
return depName === pattern
|
|
251
|
+
})
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Checks if a dependency is implicitly used (build tools, type definitions, etc.).
|
|
256
|
+
*/
|
|
257
|
+
function isImplicitlyUsed(depName: string, implicitlyUsed: readonly string[]): boolean {
|
|
258
|
+
return implicitlyUsed.some(pattern => {
|
|
259
|
+
if (pattern.includes('*')) {
|
|
260
|
+
const regex = new RegExp(`^${pattern.replaceAll('*', '.*')}$`)
|
|
261
|
+
return regex.test(depName)
|
|
262
|
+
}
|
|
263
|
+
return depName === pattern
|
|
264
|
+
})
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Creates an issue for an unused dependency.
|
|
269
|
+
*/
|
|
270
|
+
function createUnusedDependencyIssue(pkg: WorkspacePackage, dep: DeclaredDependency): Issue {
|
|
271
|
+
const location: IssueLocation = {
|
|
272
|
+
filePath: pkg.packageJsonPath,
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const severityMap: Record<DependencyType, 'warning' | 'info'> = {
|
|
276
|
+
dependencies: 'warning',
|
|
277
|
+
devDependencies: 'info',
|
|
278
|
+
peerDependencies: 'info',
|
|
279
|
+
optionalDependencies: 'info',
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const typeDescription = getDependencyTypeDescription(dep.type)
|
|
283
|
+
|
|
284
|
+
return createIssue({
|
|
285
|
+
id: 'unused-dependency',
|
|
286
|
+
title: `Unused ${typeDescription}: ${dep.name}`,
|
|
287
|
+
description: `Package "${pkg.name}" declares "${dep.name}" in ${dep.type} but it is not imported in any source file`,
|
|
288
|
+
severity: severityMap[dep.type],
|
|
289
|
+
category: 'dependency',
|
|
290
|
+
location,
|
|
291
|
+
suggestion: `Remove "${dep.name}" from ${dep.type} in package.json, or verify it is used at runtime/build time`,
|
|
292
|
+
metadata: {
|
|
293
|
+
packageName: pkg.name,
|
|
294
|
+
dependencyName: dep.name,
|
|
295
|
+
dependencyVersion: dep.version,
|
|
296
|
+
dependencyType: dep.type,
|
|
297
|
+
},
|
|
298
|
+
})
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Gets a human-readable description for a dependency type.
|
|
303
|
+
*/
|
|
304
|
+
function getDependencyTypeDescription(type: DependencyType): string {
|
|
305
|
+
switch (type) {
|
|
306
|
+
case 'dependencies':
|
|
307
|
+
return 'dependency'
|
|
308
|
+
case 'devDependencies':
|
|
309
|
+
return 'dev dependency'
|
|
310
|
+
case 'peerDependencies':
|
|
311
|
+
return 'peer dependency'
|
|
312
|
+
case 'optionalDependencies':
|
|
313
|
+
return 'optional dependency'
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Aggregates import information from multiple packages for workspace-wide analysis.
|
|
319
|
+
*/
|
|
320
|
+
export function aggregatePackageImports(
|
|
321
|
+
packages: readonly WorkspacePackage[],
|
|
322
|
+
importResults: ReadonlyMap<string, readonly ImportExtractionResult[]>,
|
|
323
|
+
): {
|
|
324
|
+
readonly externalByPackage: ReadonlyMap<string, Set<string>>
|
|
325
|
+
readonly workspaceByPackage: ReadonlyMap<string, Set<string>>
|
|
326
|
+
} {
|
|
327
|
+
const externalByPackage = new Map<string, Set<string>>()
|
|
328
|
+
const workspaceByPackage = new Map<string, Set<string>>()
|
|
329
|
+
|
|
330
|
+
for (const pkg of packages) {
|
|
331
|
+
const results = importResults.get(pkg.name)
|
|
332
|
+
if (results === undefined) {
|
|
333
|
+
continue
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
const external = new Set<string>()
|
|
337
|
+
const workspace = new Set<string>()
|
|
338
|
+
|
|
339
|
+
for (const result of results) {
|
|
340
|
+
for (const dep of result.externalDependencies) {
|
|
341
|
+
external.add(dep)
|
|
342
|
+
}
|
|
343
|
+
for (const dep of result.workspaceDependencies) {
|
|
344
|
+
workspace.add(dep)
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
externalByPackage.set(pkg.name, external)
|
|
349
|
+
workspaceByPackage.set(pkg.name, workspace)
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
return {
|
|
353
|
+
externalByPackage,
|
|
354
|
+
workspaceByPackage,
|
|
355
|
+
}
|
|
356
|
+
}
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* VersionAlignmentAnalyzer - Checks for consistent dependency versions across workspace.
|
|
3
|
+
*
|
|
4
|
+
* Detects issues such as:
|
|
5
|
+
* - Different versions of the same dependency across packages
|
|
6
|
+
* - Workspace packages with mismatched versions
|
|
7
|
+
* - Invalid workspace: protocol references
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type {ParsedPackageJson} from '../parser/config-parser'
|
|
11
|
+
import type {WorkspacePackage} from '../scanner/workspace-scanner'
|
|
12
|
+
import type {Issue, IssueLocation} 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 VersionAlignmentAnalyzer.
|
|
22
|
+
*/
|
|
23
|
+
export interface VersionAlignmentAnalyzerOptions {
|
|
24
|
+
/** Whether to check for misaligned external dependency versions */
|
|
25
|
+
readonly checkExternalVersions?: boolean
|
|
26
|
+
/** Whether to check workspace protocol references */
|
|
27
|
+
readonly checkWorkspaceProtocol?: boolean
|
|
28
|
+
/** Dependencies to ignore in version alignment checks */
|
|
29
|
+
readonly ignoreDependencies?: readonly string[]
|
|
30
|
+
/** Package names exempt from checks */
|
|
31
|
+
readonly exemptPackages?: readonly string[]
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const DEFAULT_OPTIONS: VersionAlignmentAnalyzerOptions = {
|
|
35
|
+
checkExternalVersions: true,
|
|
36
|
+
checkWorkspaceProtocol: true,
|
|
37
|
+
ignoreDependencies: [],
|
|
38
|
+
exemptPackages: [],
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const METADATA: AnalyzerMetadata = {
|
|
42
|
+
id: 'version-alignment',
|
|
43
|
+
name: 'Version Alignment Analyzer',
|
|
44
|
+
description: 'Checks for consistent dependency versions across workspace packages',
|
|
45
|
+
categories: ['dependency'],
|
|
46
|
+
defaultSeverity: 'warning',
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Information about a dependency version usage.
|
|
51
|
+
*/
|
|
52
|
+
interface VersionUsage {
|
|
53
|
+
readonly packageName: string
|
|
54
|
+
readonly version: string
|
|
55
|
+
readonly type: 'prod' | 'dev' | 'peer' | 'optional'
|
|
56
|
+
readonly packageJsonPath: string
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Creates a VersionAlignmentAnalyzer instance.
|
|
61
|
+
*/
|
|
62
|
+
export function createVersionAlignmentAnalyzer(
|
|
63
|
+
options: VersionAlignmentAnalyzerOptions = {},
|
|
64
|
+
): Analyzer {
|
|
65
|
+
const resolvedOptions = {...DEFAULT_OPTIONS, ...options}
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
metadata: METADATA,
|
|
69
|
+
analyze: async (context: AnalysisContext): Promise<Result<readonly Issue[], AnalyzerError>> => {
|
|
70
|
+
const issues: Issue[] = []
|
|
71
|
+
|
|
72
|
+
// Build a map of all dependency versions across workspace
|
|
73
|
+
const dependencyMap = buildDependencyMap(context.packages, resolvedOptions)
|
|
74
|
+
|
|
75
|
+
// Check for version misalignment
|
|
76
|
+
if (resolvedOptions.checkExternalVersions) {
|
|
77
|
+
const alignmentIssues = checkVersionAlignment(dependencyMap, resolvedOptions)
|
|
78
|
+
issues.push(...alignmentIssues)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Check workspace protocol usage
|
|
82
|
+
if (resolvedOptions.checkWorkspaceProtocol) {
|
|
83
|
+
const workspaceIssues = checkWorkspaceProtocol(context.packages, resolvedOptions)
|
|
84
|
+
issues.push(...workspaceIssues)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return ok(filterIssues(issues, context.config))
|
|
88
|
+
},
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function createLocation(filePath: string): IssueLocation {
|
|
93
|
+
return {filePath}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Builds a map of all dependencies and their versions across the workspace.
|
|
98
|
+
*/
|
|
99
|
+
function buildDependencyMap(
|
|
100
|
+
packages: readonly WorkspacePackage[],
|
|
101
|
+
options: VersionAlignmentAnalyzerOptions,
|
|
102
|
+
): Map<string, VersionUsage[]> {
|
|
103
|
+
const dependencyMap = new Map<string, VersionUsage[]>()
|
|
104
|
+
const ignoredDeps = new Set(options.ignoreDependencies ?? [])
|
|
105
|
+
const exemptPackages = new Set(options.exemptPackages ?? [])
|
|
106
|
+
|
|
107
|
+
for (const pkg of packages) {
|
|
108
|
+
if (exemptPackages.has(pkg.name)) {
|
|
109
|
+
continue
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const pkgJson = pkg.packageJson as ParsedPackageJson
|
|
113
|
+
|
|
114
|
+
// Process each dependency type
|
|
115
|
+
const depTypes = [
|
|
116
|
+
{deps: pkgJson.dependencies, type: 'prod' as const},
|
|
117
|
+
{deps: pkgJson.devDependencies, type: 'dev' as const},
|
|
118
|
+
{deps: pkgJson.peerDependencies, type: 'peer' as const},
|
|
119
|
+
{deps: pkgJson.optionalDependencies, type: 'optional' as const},
|
|
120
|
+
]
|
|
121
|
+
|
|
122
|
+
for (const {deps, type} of depTypes) {
|
|
123
|
+
if (deps === undefined) {
|
|
124
|
+
continue
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
for (const [depName, version] of Object.entries(deps)) {
|
|
128
|
+
// Skip workspace protocol and ignored dependencies
|
|
129
|
+
if (version.startsWith('workspace:') || ignoredDeps.has(depName)) {
|
|
130
|
+
continue
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const usages = dependencyMap.get(depName) ?? []
|
|
134
|
+
usages.push({
|
|
135
|
+
packageName: pkg.name,
|
|
136
|
+
version,
|
|
137
|
+
type,
|
|
138
|
+
packageJsonPath: pkg.packageJsonPath,
|
|
139
|
+
})
|
|
140
|
+
dependencyMap.set(depName, usages)
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return dependencyMap
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Checks for version alignment issues in the dependency map.
|
|
150
|
+
*/
|
|
151
|
+
function checkVersionAlignment(
|
|
152
|
+
dependencyMap: Map<string, VersionUsage[]>,
|
|
153
|
+
_options: VersionAlignmentAnalyzerOptions,
|
|
154
|
+
): Issue[] {
|
|
155
|
+
const issues: Issue[] = []
|
|
156
|
+
|
|
157
|
+
for (const [depName, usages] of dependencyMap) {
|
|
158
|
+
if (usages.length <= 1) {
|
|
159
|
+
continue
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Group by normalized version
|
|
163
|
+
const versionGroups = groupByVersion(usages)
|
|
164
|
+
|
|
165
|
+
if (versionGroups.size <= 1) {
|
|
166
|
+
continue
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Different versions detected
|
|
170
|
+
const versionList = Array.from(versionGroups.entries())
|
|
171
|
+
.map(([version, pkgs]) => `"${version}" in ${pkgs.join(', ')}`)
|
|
172
|
+
.join('; ')
|
|
173
|
+
|
|
174
|
+
// Find the most common version
|
|
175
|
+
let mostCommonVersion = ''
|
|
176
|
+
let maxCount = 0
|
|
177
|
+
for (const [version, pkgs] of versionGroups) {
|
|
178
|
+
if (pkgs.length > maxCount) {
|
|
179
|
+
maxCount = pkgs.length
|
|
180
|
+
mostCommonVersion = version
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Report issues for non-common versions
|
|
185
|
+
for (const [version, pkgs] of versionGroups) {
|
|
186
|
+
if (version === mostCommonVersion) {
|
|
187
|
+
continue
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
for (const packageName of pkgs) {
|
|
191
|
+
const usage = usages.find(u => u.packageName === packageName && u.version === version)
|
|
192
|
+
if (usage === undefined) {
|
|
193
|
+
continue
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
issues.push(
|
|
197
|
+
createIssue({
|
|
198
|
+
id: 'version-mismatch',
|
|
199
|
+
title: 'Dependency version mismatch',
|
|
200
|
+
description: `Dependency "${depName}" has inconsistent versions across workspace: ${versionList}`,
|
|
201
|
+
severity: 'warning',
|
|
202
|
+
category: 'dependency',
|
|
203
|
+
location: createLocation(usage.packageJsonPath),
|
|
204
|
+
suggestion: `Consider aligning to version "${mostCommonVersion}" used by ${maxCount} package(s)`,
|
|
205
|
+
metadata: {
|
|
206
|
+
dependency: depName,
|
|
207
|
+
currentVersion: version,
|
|
208
|
+
suggestedVersion: mostCommonVersion,
|
|
209
|
+
allVersions: Object.fromEntries(versionGroups),
|
|
210
|
+
},
|
|
211
|
+
}),
|
|
212
|
+
)
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
return issues
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Groups usages by their normalized version.
|
|
222
|
+
*/
|
|
223
|
+
function groupByVersion(usages: VersionUsage[]): Map<string, string[]> {
|
|
224
|
+
const groups = new Map<string, string[]>()
|
|
225
|
+
|
|
226
|
+
for (const usage of usages) {
|
|
227
|
+
// Normalize version for comparison (remove leading ^ or ~)
|
|
228
|
+
const normalizedVersion = usage.version
|
|
229
|
+
|
|
230
|
+
const packages = groups.get(normalizedVersion) ?? []
|
|
231
|
+
packages.push(usage.packageName)
|
|
232
|
+
groups.set(normalizedVersion, packages)
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
return groups
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Checks workspace protocol usage for issues.
|
|
240
|
+
*/
|
|
241
|
+
function checkWorkspaceProtocol(
|
|
242
|
+
packages: readonly WorkspacePackage[],
|
|
243
|
+
options: VersionAlignmentAnalyzerOptions,
|
|
244
|
+
): Issue[] {
|
|
245
|
+
const issues: Issue[] = []
|
|
246
|
+
const packageNames = new Set(packages.map(p => p.name))
|
|
247
|
+
const exemptPackages = new Set(options.exemptPackages ?? [])
|
|
248
|
+
|
|
249
|
+
for (const pkg of packages) {
|
|
250
|
+
if (exemptPackages.has(pkg.name)) {
|
|
251
|
+
continue
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const pkgJson = pkg.packageJson as ParsedPackageJson
|
|
255
|
+
|
|
256
|
+
// Check all dependency types
|
|
257
|
+
const depTypes = [
|
|
258
|
+
{deps: pkgJson.dependencies, type: 'dependencies'},
|
|
259
|
+
{deps: pkgJson.devDependencies, type: 'devDependencies'},
|
|
260
|
+
{deps: pkgJson.peerDependencies, type: 'peerDependencies'},
|
|
261
|
+
]
|
|
262
|
+
|
|
263
|
+
for (const {deps, type} of depTypes) {
|
|
264
|
+
if (deps === undefined) {
|
|
265
|
+
continue
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
for (const [depName, version] of Object.entries(deps)) {
|
|
269
|
+
// Check for workspace: protocol pointing to non-existent package
|
|
270
|
+
if (version.startsWith('workspace:') && !packageNames.has(depName)) {
|
|
271
|
+
issues.push(
|
|
272
|
+
createIssue({
|
|
273
|
+
id: 'workspace-invalid-ref',
|
|
274
|
+
title: 'Invalid workspace protocol reference',
|
|
275
|
+
description: `Package "${pkg.name}" references "${depName}" with workspace: protocol, but package does not exist in workspace`,
|
|
276
|
+
severity: 'error',
|
|
277
|
+
category: 'dependency',
|
|
278
|
+
location: createLocation(pkg.packageJsonPath),
|
|
279
|
+
suggestion: `Either add "${depName}" to the workspace or use a valid npm version`,
|
|
280
|
+
metadata: {dependency: depName, version, dependencyType: type},
|
|
281
|
+
}),
|
|
282
|
+
)
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// Check for workspace package without workspace: protocol
|
|
286
|
+
if (!version.startsWith('workspace:') && packageNames.has(depName)) {
|
|
287
|
+
// Not necessarily an error - might be using published version
|
|
288
|
+
issues.push(
|
|
289
|
+
createIssue({
|
|
290
|
+
id: 'workspace-missing-protocol',
|
|
291
|
+
title: 'Workspace package without workspace: protocol',
|
|
292
|
+
description: `Package "${pkg.name}" depends on workspace package "${depName}" but uses version "${version}" instead of workspace: protocol`,
|
|
293
|
+
severity: 'info',
|
|
294
|
+
category: 'dependency',
|
|
295
|
+
location: createLocation(pkg.packageJsonPath),
|
|
296
|
+
suggestion: `Consider using "workspace:*" to link to the local package`,
|
|
297
|
+
metadata: {dependency: depName, version, dependencyType: type},
|
|
298
|
+
}),
|
|
299
|
+
)
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
return issues
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
export {METADATA as versionAlignmentAnalyzerMetadata}
|