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