@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,349 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PackageJsonAnalyzer - Validates package.json configuration for best practices.
|
|
3
|
+
*
|
|
4
|
+
* Detects issues such as:
|
|
5
|
+
* - Missing required fields (types, exports, etc.)
|
|
6
|
+
* - Inconsistent entry points
|
|
7
|
+
* - Invalid dependency versions
|
|
8
|
+
* - Missing peer dependency declarations
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type {ParsedPackageJson} from '../parser/config-parser'
|
|
12
|
+
import type {WorkspacePackage} from '../scanner/workspace-scanner'
|
|
13
|
+
import type {Issue, IssueLocation} from '../types/index'
|
|
14
|
+
import type {Result} from '../types/result'
|
|
15
|
+
import type {AnalysisContext, Analyzer, AnalyzerError, AnalyzerMetadata} from './analyzer'
|
|
16
|
+
|
|
17
|
+
import path from 'node:path'
|
|
18
|
+
|
|
19
|
+
import {ok} from '@bfra.me/es/result'
|
|
20
|
+
|
|
21
|
+
import {createIssue, filterIssues} from './analyzer'
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Configuration options specific to PackageJsonAnalyzer.
|
|
25
|
+
*/
|
|
26
|
+
export interface PackageJsonAnalyzerOptions {
|
|
27
|
+
/** Whether to require types field for TypeScript packages */
|
|
28
|
+
readonly requireTypes?: boolean
|
|
29
|
+
/** Whether to require exports field for ESM packages */
|
|
30
|
+
readonly requireExports?: boolean
|
|
31
|
+
/** Whether to check for common scripts like build, test, lint */
|
|
32
|
+
readonly checkScripts?: boolean
|
|
33
|
+
/** Package names exempt from certain checks */
|
|
34
|
+
readonly exemptPackages?: readonly string[]
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const DEFAULT_OPTIONS: PackageJsonAnalyzerOptions = {
|
|
38
|
+
requireTypes: true,
|
|
39
|
+
requireExports: true,
|
|
40
|
+
checkScripts: true,
|
|
41
|
+
exemptPackages: [],
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const METADATA: AnalyzerMetadata = {
|
|
45
|
+
id: 'package-json',
|
|
46
|
+
name: 'Package.json Analyzer',
|
|
47
|
+
description: 'Validates package.json configuration for best practices and consistency',
|
|
48
|
+
categories: ['configuration'],
|
|
49
|
+
defaultSeverity: 'warning',
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Creates a PackageJsonAnalyzer instance.
|
|
54
|
+
*/
|
|
55
|
+
export function createPackageJsonAnalyzer(options: PackageJsonAnalyzerOptions = {}): Analyzer {
|
|
56
|
+
const resolvedOptions = {...DEFAULT_OPTIONS, ...options}
|
|
57
|
+
|
|
58
|
+
return {
|
|
59
|
+
metadata: METADATA,
|
|
60
|
+
analyze: async (context: AnalysisContext): Promise<Result<readonly Issue[], AnalyzerError>> => {
|
|
61
|
+
const issues: Issue[] = []
|
|
62
|
+
|
|
63
|
+
for (const pkg of context.packages) {
|
|
64
|
+
if (isExemptPackage(pkg.name, resolvedOptions.exemptPackages)) {
|
|
65
|
+
continue
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const packageIssues = analyzePackage(pkg, resolvedOptions)
|
|
69
|
+
issues.push(...packageIssues)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return ok(filterIssues(issues, context.config))
|
|
73
|
+
},
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function isExemptPackage(name: string, exemptPackages: readonly string[] | undefined): boolean {
|
|
78
|
+
return exemptPackages?.includes(name) ?? false
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function createLocation(pkg: WorkspacePackage): IssueLocation {
|
|
82
|
+
return {
|
|
83
|
+
filePath: pkg.packageJsonPath,
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function analyzePackage(pkg: WorkspacePackage, options: PackageJsonAnalyzerOptions): Issue[] {
|
|
88
|
+
const issues: Issue[] = []
|
|
89
|
+
const pkgJson = pkg.packageJson as ParsedPackageJson
|
|
90
|
+
|
|
91
|
+
// Check for types field in TypeScript packages
|
|
92
|
+
if (options.requireTypes && pkg.hasTsConfig) {
|
|
93
|
+
issues.push(...checkTypesField(pkg, pkgJson))
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Check for exports field in ESM packages
|
|
97
|
+
if (options.requireExports) {
|
|
98
|
+
issues.push(...checkExportsField(pkg, pkgJson))
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Check for essential scripts
|
|
102
|
+
if (options.checkScripts) {
|
|
103
|
+
issues.push(...checkScripts(pkg, pkgJson))
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Check for invalid dependencies
|
|
107
|
+
issues.push(...checkDependencies(pkg, pkgJson))
|
|
108
|
+
|
|
109
|
+
// Check entry point consistency
|
|
110
|
+
issues.push(...checkEntryPoints(pkg, pkgJson))
|
|
111
|
+
|
|
112
|
+
return issues
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function checkTypesField(pkg: WorkspacePackage, pkgJson: ParsedPackageJson): Issue[] {
|
|
116
|
+
const issues: Issue[] = []
|
|
117
|
+
|
|
118
|
+
// Check main types field
|
|
119
|
+
if (pkgJson.types === undefined) {
|
|
120
|
+
// Check if types are declared in exports
|
|
121
|
+
const hasTypesInExports = hasTypesExport(pkgJson.exports)
|
|
122
|
+
|
|
123
|
+
if (!hasTypesInExports) {
|
|
124
|
+
issues.push(
|
|
125
|
+
createIssue({
|
|
126
|
+
id: 'missing-types',
|
|
127
|
+
title: 'Missing types field',
|
|
128
|
+
description: `Package "${pkg.name}" is a TypeScript package but does not declare a "types" field or types in exports`,
|
|
129
|
+
severity: 'warning',
|
|
130
|
+
category: 'configuration',
|
|
131
|
+
location: createLocation(pkg),
|
|
132
|
+
suggestion:
|
|
133
|
+
'Add "types" field pointing to your .d.ts entry point, or add "types" to exports',
|
|
134
|
+
}),
|
|
135
|
+
)
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return issues
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function hasTypesExport(exports: Record<string, unknown> | undefined): boolean {
|
|
143
|
+
if (exports === undefined) {
|
|
144
|
+
return false
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Check if root export has types
|
|
148
|
+
const rootExport = exports['.']
|
|
149
|
+
if (typeof rootExport === 'object' && rootExport !== null && 'types' in rootExport) {
|
|
150
|
+
return true
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Check nested exports
|
|
154
|
+
for (const value of Object.values(exports)) {
|
|
155
|
+
if (typeof value === 'object' && value !== null && 'types' in value) {
|
|
156
|
+
return true
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return false
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function checkExportsField(pkg: WorkspacePackage, pkgJson: ParsedPackageJson): Issue[] {
|
|
164
|
+
const issues: Issue[] = []
|
|
165
|
+
|
|
166
|
+
// Check if package uses ESM
|
|
167
|
+
const isEsm = pkgJson.type === 'module'
|
|
168
|
+
|
|
169
|
+
if (isEsm && pkgJson.exports === undefined) {
|
|
170
|
+
issues.push(
|
|
171
|
+
createIssue({
|
|
172
|
+
id: 'missing-exports',
|
|
173
|
+
title: 'ESM package missing exports field',
|
|
174
|
+
description: `Package "${pkg.name}" uses ESM ("type": "module") but does not declare an "exports" field`,
|
|
175
|
+
severity: 'warning',
|
|
176
|
+
category: 'configuration',
|
|
177
|
+
location: createLocation(pkg),
|
|
178
|
+
suggestion: 'Add "exports" field with explicit entry points for better Node.js resolution',
|
|
179
|
+
}),
|
|
180
|
+
)
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Check for wildcard exports in non-library packages
|
|
184
|
+
if (pkgJson.exports !== undefined) {
|
|
185
|
+
issues.push(...checkWildcardExports(pkg, pkgJson.exports))
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
return issues
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function checkWildcardExports(pkg: WorkspacePackage, exports: Record<string, unknown>): Issue[] {
|
|
192
|
+
const issues: Issue[] = []
|
|
193
|
+
|
|
194
|
+
for (const [key, value] of Object.entries(exports)) {
|
|
195
|
+
if (key.includes('*')) {
|
|
196
|
+
issues.push(
|
|
197
|
+
createIssue({
|
|
198
|
+
id: 'wildcard-export',
|
|
199
|
+
title: 'Wildcard export pattern detected',
|
|
200
|
+
description: `Package "${pkg.name}" uses wildcard export pattern "${key}" which may expose internal modules`,
|
|
201
|
+
severity: 'info',
|
|
202
|
+
category: 'configuration',
|
|
203
|
+
location: createLocation(pkg),
|
|
204
|
+
suggestion:
|
|
205
|
+
'Consider using explicit export paths for better control over public API surface',
|
|
206
|
+
metadata: {exportKey: key, exportValue: value},
|
|
207
|
+
}),
|
|
208
|
+
)
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
return issues
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function checkScripts(pkg: WorkspacePackage, pkgJson: ParsedPackageJson): Issue[] {
|
|
216
|
+
const issues: Issue[] = []
|
|
217
|
+
const scripts = pkgJson.scripts ?? {}
|
|
218
|
+
|
|
219
|
+
// Check for build script
|
|
220
|
+
if (scripts.build === undefined && pkg.hasTsConfig) {
|
|
221
|
+
issues.push(
|
|
222
|
+
createIssue({
|
|
223
|
+
id: 'missing-build-script',
|
|
224
|
+
title: 'Missing build script',
|
|
225
|
+
description: `Package "${pkg.name}" has TypeScript config but no "build" script`,
|
|
226
|
+
severity: 'info',
|
|
227
|
+
category: 'configuration',
|
|
228
|
+
location: createLocation(pkg),
|
|
229
|
+
suggestion: 'Add a "build" script to compile TypeScript sources',
|
|
230
|
+
}),
|
|
231
|
+
)
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
return issues
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function checkDependencies(pkg: WorkspacePackage, pkgJson: ParsedPackageJson): Issue[] {
|
|
238
|
+
const issues: Issue[] = []
|
|
239
|
+
|
|
240
|
+
// Check for deprecated version formats
|
|
241
|
+
const allDeps = {
|
|
242
|
+
...(pkgJson.dependencies ?? {}),
|
|
243
|
+
...(pkgJson.devDependencies ?? {}),
|
|
244
|
+
...(pkgJson.peerDependencies ?? {}),
|
|
245
|
+
...(pkgJson.optionalDependencies ?? {}),
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
for (const [depName, version] of Object.entries(allDeps)) {
|
|
249
|
+
// Check for file: protocol
|
|
250
|
+
if (version.startsWith('file:')) {
|
|
251
|
+
issues.push(
|
|
252
|
+
createIssue({
|
|
253
|
+
id: 'file-dependency',
|
|
254
|
+
title: 'File protocol dependency',
|
|
255
|
+
description: `Package "${pkg.name}" has a file: dependency on "${depName}"`,
|
|
256
|
+
severity: 'warning',
|
|
257
|
+
category: 'dependency',
|
|
258
|
+
location: createLocation(pkg),
|
|
259
|
+
suggestion: 'Use workspace: protocol for monorepo packages or publish as a package',
|
|
260
|
+
metadata: {dependency: depName, version},
|
|
261
|
+
}),
|
|
262
|
+
)
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// Check for git: protocol in production deps
|
|
266
|
+
if (version.startsWith('git:') || version.includes('github:')) {
|
|
267
|
+
const isDev = pkgJson.devDependencies !== undefined && depName in pkgJson.devDependencies
|
|
268
|
+
if (!isDev) {
|
|
269
|
+
issues.push(
|
|
270
|
+
createIssue({
|
|
271
|
+
id: 'git-dependency',
|
|
272
|
+
title: 'Git protocol dependency in production',
|
|
273
|
+
description: `Package "${pkg.name}" has a git dependency on "${depName}" in production dependencies`,
|
|
274
|
+
severity: 'warning',
|
|
275
|
+
category: 'dependency',
|
|
276
|
+
location: createLocation(pkg),
|
|
277
|
+
suggestion: 'Use a published npm package version for production dependencies',
|
|
278
|
+
metadata: {dependency: depName, version},
|
|
279
|
+
}),
|
|
280
|
+
)
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// Check for latest or * versions
|
|
285
|
+
if (version === 'latest' || version === '*') {
|
|
286
|
+
issues.push(
|
|
287
|
+
createIssue({
|
|
288
|
+
id: 'unpinned-dependency',
|
|
289
|
+
title: 'Unpinned dependency version',
|
|
290
|
+
description: `Package "${pkg.name}" has an unpinned dependency "${depName}" with version "${version}"`,
|
|
291
|
+
severity: 'error',
|
|
292
|
+
category: 'dependency',
|
|
293
|
+
location: createLocation(pkg),
|
|
294
|
+
suggestion: 'Use a specific version or range for reproducible builds',
|
|
295
|
+
metadata: {dependency: depName, version},
|
|
296
|
+
}),
|
|
297
|
+
)
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
return issues
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function checkEntryPoints(pkg: WorkspacePackage, pkgJson: ParsedPackageJson): Issue[] {
|
|
305
|
+
const issues: Issue[] = []
|
|
306
|
+
|
|
307
|
+
// Check if main and module both exist but point to same format
|
|
308
|
+
if (pkgJson.main !== undefined && pkgJson.module !== undefined) {
|
|
309
|
+
const mainExt = path.extname(pkgJson.main)
|
|
310
|
+
const moduleExt = path.extname(pkgJson.module)
|
|
311
|
+
|
|
312
|
+
// Both pointing to same extension might indicate misconfiguration
|
|
313
|
+
if (mainExt === moduleExt && mainExt !== '') {
|
|
314
|
+
issues.push(
|
|
315
|
+
createIssue({
|
|
316
|
+
id: 'duplicate-entry-format',
|
|
317
|
+
title: 'Entry points may have same format',
|
|
318
|
+
description: `Package "${pkg.name}" has both "main" and "module" pointing to ${mainExt} files`,
|
|
319
|
+
severity: 'info',
|
|
320
|
+
category: 'configuration',
|
|
321
|
+
location: createLocation(pkg),
|
|
322
|
+
suggestion:
|
|
323
|
+
'"main" should typically point to CJS format (.cjs) and "module" to ESM format (.mjs or .js)',
|
|
324
|
+
metadata: {main: pkgJson.main, module: pkgJson.module},
|
|
325
|
+
}),
|
|
326
|
+
)
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// Check for ESM package without module field
|
|
331
|
+
if (pkgJson.type === 'module' && pkgJson.module === undefined && pkgJson.exports === undefined) {
|
|
332
|
+
issues.push(
|
|
333
|
+
createIssue({
|
|
334
|
+
id: 'esm-without-module-or-exports',
|
|
335
|
+
title: 'ESM package without module or exports field',
|
|
336
|
+
description: `Package "${pkg.name}" is ESM but has neither "module" nor "exports" field`,
|
|
337
|
+
severity: 'warning',
|
|
338
|
+
category: 'configuration',
|
|
339
|
+
location: createLocation(pkg),
|
|
340
|
+
suggestion:
|
|
341
|
+
'Add "exports" field for modern ESM packages, or "module" for backwards compatibility',
|
|
342
|
+
}),
|
|
343
|
+
)
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
return issues
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
export {METADATA as packageJsonAnalyzerMetadata}
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PeerDependencyAnalyzer - Validates peer dependency declarations in workspace packages.
|
|
3
|
+
*
|
|
4
|
+
* Ensures workspace packages correctly declare peer dependencies for:
|
|
5
|
+
* - Packages that should be provided by consumers
|
|
6
|
+
* - Plugin architectures where host version matters
|
|
7
|
+
* - Framework dependencies that consumers must provide
|
|
8
|
+
*
|
|
9
|
+
* Reports:
|
|
10
|
+
* - Missing peer dependency declarations
|
|
11
|
+
* - Peer dependency version mismatches
|
|
12
|
+
* - Invalid peer dependency ranges
|
|
13
|
+
* - Workspace packages not declared as peers when they should be
|
|
14
|
+
*/
|
|
15
|
+
|
|
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 {ok} from '@bfra.me/es/result'
|
|
22
|
+
|
|
23
|
+
import {createIssue, filterIssues} from './analyzer'
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Configuration options specific to PeerDependencyAnalyzer.
|
|
27
|
+
*/
|
|
28
|
+
export interface PeerDependencyAnalyzerOptions {
|
|
29
|
+
/** Packages that should always be declared as peer dependencies */
|
|
30
|
+
readonly requiredPeers?: readonly string[]
|
|
31
|
+
/** Packages that should never be peer dependencies */
|
|
32
|
+
readonly disallowedPeers?: readonly string[]
|
|
33
|
+
/** Whether to check for workspace packages that should be peers */
|
|
34
|
+
readonly checkWorkspacePeers?: boolean
|
|
35
|
+
/** Workspace package prefixes */
|
|
36
|
+
readonly workspacePrefixes?: readonly string[]
|
|
37
|
+
/** Check for peer dependencies that are also in dependencies (dual declaration) */
|
|
38
|
+
readonly checkDualDeclarations?: boolean
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const DEFAULT_OPTIONS: Required<PeerDependencyAnalyzerOptions> = {
|
|
42
|
+
requiredPeers: [],
|
|
43
|
+
disallowedPeers: [],
|
|
44
|
+
checkWorkspacePeers: true,
|
|
45
|
+
workspacePrefixes: ['@bfra.me/'],
|
|
46
|
+
checkDualDeclarations: true,
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export const peerDependencyAnalyzerMetadata: AnalyzerMetadata = {
|
|
50
|
+
id: 'peer-dependency',
|
|
51
|
+
name: 'Peer Dependency Analyzer',
|
|
52
|
+
description: 'Validates peer dependency declarations for proper package consumption',
|
|
53
|
+
categories: ['dependency'],
|
|
54
|
+
defaultSeverity: 'warning',
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Creates a PeerDependencyAnalyzer instance.
|
|
59
|
+
*
|
|
60
|
+
* @example
|
|
61
|
+
* ```ts
|
|
62
|
+
* const analyzer = createPeerDependencyAnalyzer({
|
|
63
|
+
* requiredPeers: ['react', 'typescript'],
|
|
64
|
+
* checkDualDeclarations: true,
|
|
65
|
+
* })
|
|
66
|
+
* const result = await analyzer.analyze(context)
|
|
67
|
+
* ```
|
|
68
|
+
*/
|
|
69
|
+
export function createPeerDependencyAnalyzer(
|
|
70
|
+
options: PeerDependencyAnalyzerOptions = {},
|
|
71
|
+
): Analyzer {
|
|
72
|
+
const resolvedOptions = {...DEFAULT_OPTIONS, ...options}
|
|
73
|
+
|
|
74
|
+
return {
|
|
75
|
+
metadata: peerDependencyAnalyzerMetadata,
|
|
76
|
+
analyze: async (context: AnalysisContext): Promise<Result<readonly Issue[], AnalyzerError>> => {
|
|
77
|
+
const issues: Issue[] = []
|
|
78
|
+
|
|
79
|
+
const workspacePackageNames = new Set(context.packages.map(pkg => pkg.name))
|
|
80
|
+
|
|
81
|
+
for (const pkg of context.packages) {
|
|
82
|
+
context.reportProgress?.(`Analyzing peer dependencies for ${pkg.name}...`)
|
|
83
|
+
|
|
84
|
+
const packageIssues = analyzePackagePeerDependencies(
|
|
85
|
+
pkg,
|
|
86
|
+
workspacePackageNames,
|
|
87
|
+
resolvedOptions,
|
|
88
|
+
)
|
|
89
|
+
issues.push(...packageIssues)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return ok(filterIssues(issues, context.config))
|
|
93
|
+
},
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Analyzes peer dependencies for a single package.
|
|
99
|
+
*/
|
|
100
|
+
function analyzePackagePeerDependencies(
|
|
101
|
+
pkg: WorkspacePackage,
|
|
102
|
+
workspacePackageNames: Set<string>,
|
|
103
|
+
options: Required<PeerDependencyAnalyzerOptions>,
|
|
104
|
+
): Issue[] {
|
|
105
|
+
const issues: Issue[] = []
|
|
106
|
+
const pkgJson = pkg.packageJson
|
|
107
|
+
|
|
108
|
+
const dependencies = pkgJson.dependencies ?? {}
|
|
109
|
+
const peerDependencies = pkgJson.peerDependencies ?? {}
|
|
110
|
+
const devDependencies = pkgJson.devDependencies ?? {}
|
|
111
|
+
|
|
112
|
+
// Check for required peer dependencies that are missing
|
|
113
|
+
for (const requiredPeer of options.requiredPeers) {
|
|
114
|
+
const isDep = requiredPeer in dependencies
|
|
115
|
+
const isDevDep = requiredPeer in devDependencies
|
|
116
|
+
const isPeer = requiredPeer in peerDependencies
|
|
117
|
+
|
|
118
|
+
if ((isDep || isDevDep) && !isPeer) {
|
|
119
|
+
issues.push(
|
|
120
|
+
createIssue({
|
|
121
|
+
id: 'missing-peer-declaration',
|
|
122
|
+
title: `Missing peer dependency: ${requiredPeer}`,
|
|
123
|
+
description: `Package "${pkg.name}" uses "${requiredPeer}" but does not declare it as a peer dependency`,
|
|
124
|
+
severity: 'warning',
|
|
125
|
+
category: 'dependency',
|
|
126
|
+
location: createLocation(pkg),
|
|
127
|
+
suggestion: `Add "${requiredPeer}" to peerDependencies with an appropriate version range`,
|
|
128
|
+
metadata: {
|
|
129
|
+
packageName: pkg.name,
|
|
130
|
+
missingPeer: requiredPeer,
|
|
131
|
+
},
|
|
132
|
+
}),
|
|
133
|
+
)
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Check for disallowed peer dependencies
|
|
138
|
+
for (const disallowed of options.disallowedPeers) {
|
|
139
|
+
if (disallowed in peerDependencies) {
|
|
140
|
+
issues.push(
|
|
141
|
+
createIssue({
|
|
142
|
+
id: 'disallowed-peer-dependency',
|
|
143
|
+
title: `Disallowed peer dependency: ${disallowed}`,
|
|
144
|
+
description: `Package "${pkg.name}" should not declare "${disallowed}" as a peer dependency`,
|
|
145
|
+
severity: 'error',
|
|
146
|
+
category: 'dependency',
|
|
147
|
+
location: createLocation(pkg),
|
|
148
|
+
suggestion: `Remove "${disallowed}" from peerDependencies`,
|
|
149
|
+
metadata: {
|
|
150
|
+
packageName: pkg.name,
|
|
151
|
+
disallowedPeer: disallowed,
|
|
152
|
+
},
|
|
153
|
+
}),
|
|
154
|
+
)
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Check for dual declarations (dependency + peer dependency)
|
|
159
|
+
if (options.checkDualDeclarations) {
|
|
160
|
+
for (const peerName of Object.keys(peerDependencies)) {
|
|
161
|
+
if (peerName in dependencies) {
|
|
162
|
+
issues.push(
|
|
163
|
+
createIssue({
|
|
164
|
+
id: 'dual-dependency-declaration',
|
|
165
|
+
title: `Dual declaration: ${peerName}`,
|
|
166
|
+
description: `Package "${pkg.name}" declares "${peerName}" in both dependencies and peerDependencies`,
|
|
167
|
+
severity: 'info',
|
|
168
|
+
category: 'dependency',
|
|
169
|
+
location: createLocation(pkg),
|
|
170
|
+
suggestion: `Remove "${peerName}" from dependencies if consumers should provide it, or from peerDependencies if you bundle it`,
|
|
171
|
+
metadata: {
|
|
172
|
+
packageName: pkg.name,
|
|
173
|
+
dualDependency: peerName,
|
|
174
|
+
dependencyVersion: dependencies[peerName],
|
|
175
|
+
peerVersion: peerDependencies[peerName],
|
|
176
|
+
},
|
|
177
|
+
}),
|
|
178
|
+
)
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Check workspace packages used as dependencies that might need to be peers
|
|
184
|
+
if (options.checkWorkspacePeers) {
|
|
185
|
+
for (const [depName, depVersion] of Object.entries(dependencies)) {
|
|
186
|
+
const isWorkspaceDep =
|
|
187
|
+
workspacePackageNames.has(depName) ||
|
|
188
|
+
options.workspacePrefixes.some(prefix => depName.startsWith(prefix))
|
|
189
|
+
const isWorkspaceVersion = depVersion.startsWith('workspace:')
|
|
190
|
+
const isPeer = depName in peerDependencies
|
|
191
|
+
|
|
192
|
+
// Workspace dependencies with non-workspace versions might indicate a peer dep need
|
|
193
|
+
if (isWorkspaceDep && !isWorkspaceVersion && !isPeer) {
|
|
194
|
+
issues.push(
|
|
195
|
+
createIssue({
|
|
196
|
+
id: 'workspace-peer-candidate',
|
|
197
|
+
title: `Workspace package should be peer: ${depName}`,
|
|
198
|
+
description: `Package "${pkg.name}" uses workspace package "${depName}" with version "${depVersion}" instead of workspace: protocol`,
|
|
199
|
+
severity: 'info',
|
|
200
|
+
category: 'dependency',
|
|
201
|
+
location: createLocation(pkg),
|
|
202
|
+
suggestion: `Consider using "workspace:*" for internal packages or declaring as peerDependency if consumers should provide it`,
|
|
203
|
+
metadata: {
|
|
204
|
+
packageName: pkg.name,
|
|
205
|
+
dependencyName: depName,
|
|
206
|
+
currentVersion: depVersion,
|
|
207
|
+
},
|
|
208
|
+
}),
|
|
209
|
+
)
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Check peer dependency version ranges for validity
|
|
215
|
+
for (const [peerName, peerVersion] of Object.entries(peerDependencies)) {
|
|
216
|
+
if (!isValidVersionRange(peerVersion)) {
|
|
217
|
+
issues.push(
|
|
218
|
+
createIssue({
|
|
219
|
+
id: 'invalid-peer-version',
|
|
220
|
+
title: `Invalid peer version range: ${peerName}`,
|
|
221
|
+
description: `Package "${pkg.name}" has an invalid version range "${peerVersion}" for peer dependency "${peerName}"`,
|
|
222
|
+
severity: 'error',
|
|
223
|
+
category: 'dependency',
|
|
224
|
+
location: createLocation(pkg),
|
|
225
|
+
suggestion: `Use a valid semver range like ">=1.0.0", "^2.0.0", or ">=1.0.0 <3.0.0"`,
|
|
226
|
+
metadata: {
|
|
227
|
+
packageName: pkg.name,
|
|
228
|
+
peerName,
|
|
229
|
+
invalidVersion: peerVersion,
|
|
230
|
+
},
|
|
231
|
+
}),
|
|
232
|
+
)
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
return issues
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Creates a location pointing to the package.json file.
|
|
241
|
+
*/
|
|
242
|
+
function createLocation(pkg: WorkspacePackage): IssueLocation {
|
|
243
|
+
return {
|
|
244
|
+
filePath: pkg.packageJsonPath,
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Basic validation for semver version ranges.
|
|
250
|
+
*/
|
|
251
|
+
function isValidVersionRange(version: string): boolean {
|
|
252
|
+
if (version.length === 0) {
|
|
253
|
+
return false
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// workspace: protocol is valid
|
|
257
|
+
if (version.startsWith('workspace:')) {
|
|
258
|
+
return true
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// Common invalid patterns
|
|
262
|
+
if (version === 'latest' || version === '*' || version === '') {
|
|
263
|
+
return false
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// Basic semver-like patterns (use non-capturing groups for validation only)
|
|
267
|
+
// Avoid catastrophic backtracking by removing ambiguous optional matches/repetitions
|
|
268
|
+
// Accept major, major.minor, major.minor.patch. Use up to two dot-separated numbers.
|
|
269
|
+
const semverPattern =
|
|
270
|
+
/^[\^~>=<]?\d+(?:\.\d+){0,2}(?:-[\w.]+)?(?:\+[\w.]+)?(?:\s*(?:&&|\|\|)\s*[\^~>=<]?\d+(?:\.\d+){0,2}(?:-[\w.]+)?(?:\+[\w.]+)?)*$/
|
|
271
|
+
const rangePattern = /^>=?\d+(?:\.\d+){0,2}\s+<?=?\d+(?:\.\d+){0,2}$/
|
|
272
|
+
const xRangePattern = /^\d+(?:\.\d+)?\.x$/
|
|
273
|
+
|
|
274
|
+
return semverPattern.test(version) || rangePattern.test(version) || xRangePattern.test(version)
|
|
275
|
+
}
|