@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,463 @@
1
+ /**
2
+ * CircularImportAnalyzer - Detects circular import chains in workspace packages.
3
+ *
4
+ * Uses the dependency graph builder and Tarjan's algorithm to identify
5
+ * circular dependencies, providing full path reporting and cycle visualization.
6
+ *
7
+ * Reports:
8
+ * - Direct cycles (A → B → A)
9
+ * - Transitive cycles (A → B → C → A)
10
+ * - Self-imports (A → A)
11
+ * - Cycle severity based on depth and involvement
12
+ */
13
+
14
+ import type {DependencyCycle, DependencyGraph} from '../graph/dependency-graph'
15
+ import type {ImportExtractionResult, ImportExtractorOptions} from '../parser/import-extractor'
16
+ import type {WorkspacePackage} from '../scanner/workspace-scanner'
17
+ import type {Issue, IssueLocation, Severity} from '../types/index'
18
+ import type {Result} from '../types/result'
19
+ import type {AnalysisContext, Analyzer, AnalyzerError, AnalyzerMetadata} from './analyzer'
20
+
21
+ import path from 'node:path'
22
+
23
+ import {createProject} from '@bfra.me/doc-sync/parsers'
24
+ import {ok} from '@bfra.me/es/result'
25
+
26
+ import {buildDependencyGraph, findCycles} from '../graph/dependency-graph'
27
+ import {extractImports} from '../parser/import-extractor'
28
+ import {createIssue, filterIssues} from './analyzer'
29
+
30
+ /**
31
+ * Configuration options specific to CircularImportAnalyzer.
32
+ */
33
+ export interface CircularImportAnalyzerOptions {
34
+ /** Maximum cycle length to report (helps avoid reporting very large cycles) */
35
+ readonly maxCycleLength?: number
36
+ /** Whether to include type-only imports in cycle detection */
37
+ readonly includeTypeImports?: boolean
38
+ /** Severity for direct (2-node) cycles */
39
+ readonly directCycleSeverity?: Severity
40
+ /** Severity for transitive cycles */
41
+ readonly transitiveCycleSeverity?: Severity
42
+ /** File patterns to exclude from analysis */
43
+ readonly excludePatterns?: readonly string[]
44
+ /** Workspace package prefixes */
45
+ readonly workspacePrefixes?: readonly string[]
46
+ }
47
+
48
+ const DEFAULT_OPTIONS: Required<CircularImportAnalyzerOptions> = {
49
+ maxCycleLength: 20,
50
+ includeTypeImports: false,
51
+ directCycleSeverity: 'error',
52
+ transitiveCycleSeverity: 'warning',
53
+ excludePatterns: ['**/*.test.ts', '**/*.spec.ts', '**/__tests__/**', '**/__mocks__/**'],
54
+ workspacePrefixes: ['@bfra.me/'],
55
+ }
56
+
57
+ export const circularImportAnalyzerMetadata: AnalyzerMetadata = {
58
+ id: 'circular-import',
59
+ name: 'Circular Import Analyzer',
60
+ description: 'Detects circular import chains using dependency graph analysis',
61
+ categories: ['circular-import'],
62
+ defaultSeverity: 'warning',
63
+ }
64
+
65
+ /**
66
+ * Creates a CircularImportAnalyzer instance.
67
+ *
68
+ * @example
69
+ * ```ts
70
+ * const analyzer = createCircularImportAnalyzer({
71
+ * maxCycleLength: 10,
72
+ * directCycleSeverity: 'error',
73
+ * })
74
+ * const result = await analyzer.analyze(context)
75
+ * ```
76
+ */
77
+ export function createCircularImportAnalyzer(
78
+ options: CircularImportAnalyzerOptions = {},
79
+ ): Analyzer {
80
+ const resolvedOptions = {...DEFAULT_OPTIONS, ...options}
81
+
82
+ return {
83
+ metadata: circularImportAnalyzerMetadata,
84
+ analyze: async (context: AnalysisContext): Promise<Result<readonly Issue[], AnalyzerError>> => {
85
+ const issues: Issue[] = []
86
+
87
+ for (const pkg of context.packages) {
88
+ context.reportProgress?.(`Analyzing circular imports in ${pkg.name}...`)
89
+
90
+ const packageIssues = await analyzePackageCircularImports(
91
+ pkg,
92
+ context.workspacePath,
93
+ resolvedOptions,
94
+ )
95
+ issues.push(...packageIssues)
96
+ }
97
+
98
+ return ok(filterIssues(issues, context.config))
99
+ },
100
+ }
101
+ }
102
+
103
+ /**
104
+ * Visualization data for a detected cycle.
105
+ */
106
+ export interface CycleVisualization {
107
+ /** Nodes in the cycle with display names */
108
+ readonly nodes: readonly CycleNode[]
109
+ /** Edges in the cycle */
110
+ readonly edges: readonly CycleEdge[]
111
+ /** ASCII art representation of the cycle */
112
+ readonly ascii: string
113
+ /** Mermaid diagram code for rendering */
114
+ readonly mermaid: string
115
+ }
116
+
117
+ /**
118
+ * A node in the cycle visualization.
119
+ */
120
+ export interface CycleNode {
121
+ /** Unique identifier */
122
+ readonly id: string
123
+ /** Display name */
124
+ readonly name: string
125
+ /** Full file path */
126
+ readonly filePath: string
127
+ /** Whether this node starts the cycle */
128
+ readonly isStart: boolean
129
+ }
130
+
131
+ /**
132
+ * An edge in the cycle visualization.
133
+ */
134
+ export interface CycleEdge {
135
+ /** Source node ID */
136
+ readonly from: string
137
+ /** Target node ID */
138
+ readonly to: string
139
+ /** Import type */
140
+ readonly importType: string
141
+ }
142
+
143
+ /**
144
+ * Analyzes a single package for circular imports.
145
+ */
146
+ async function analyzePackageCircularImports(
147
+ pkg: WorkspacePackage,
148
+ workspacePath: string,
149
+ options: Required<CircularImportAnalyzerOptions>,
150
+ ): Promise<Issue[]> {
151
+ const issues: Issue[] = []
152
+
153
+ const sourceFiles = filterSourceFiles(pkg.sourceFiles, options.excludePatterns)
154
+ if (sourceFiles.length === 0) {
155
+ return issues
156
+ }
157
+
158
+ const importResults = await extractAllImports(sourceFiles, options)
159
+ if (importResults.length === 0) {
160
+ return issues
161
+ }
162
+
163
+ const graph = buildDependencyGraph(importResults, {
164
+ rootPath: workspacePath,
165
+ includeTypeImports: options.includeTypeImports,
166
+ resolveRelativeImports: true,
167
+ })
168
+
169
+ const cycles = findCycles(graph)
170
+
171
+ const filteredCycles = cycles.filter(cycle => cycle.nodes.length <= options.maxCycleLength)
172
+
173
+ const seenCycles = new Set<string>()
174
+ for (const cycle of filteredCycles) {
175
+ const cycleKey = normalizeCycleKey(cycle)
176
+ if (seenCycles.has(cycleKey)) {
177
+ continue
178
+ }
179
+ seenCycles.add(cycleKey)
180
+
181
+ const visualization = generateCycleVisualization(cycle, graph, workspacePath)
182
+ issues.push(createCycleIssue(pkg, cycle, visualization, options, workspacePath))
183
+ }
184
+
185
+ return issues
186
+ }
187
+
188
+ /**
189
+ * Filters source files based on exclude patterns.
190
+ */
191
+ function filterSourceFiles(
192
+ sourceFiles: readonly string[],
193
+ excludePatterns: readonly string[],
194
+ ): string[] {
195
+ return sourceFiles.filter(filePath => {
196
+ const fileName = path.basename(filePath)
197
+ const relativePath = filePath
198
+
199
+ return !excludePatterns.some(pattern => {
200
+ if (pattern.includes('**')) {
201
+ const parts = pattern.split('**')
202
+ if (parts.length === 2 && parts[0] !== undefined && parts[1] !== undefined) {
203
+ const prefix = parts[0].replaceAll('/', '')
204
+ const suffix = parts[1].replaceAll('/', '')
205
+ return (
206
+ (prefix.length === 0 || relativePath.includes(prefix)) &&
207
+ (suffix.length === 0 || relativePath.endsWith(suffix))
208
+ )
209
+ }
210
+ }
211
+ return fileName.includes(pattern.replaceAll('*', '')) || relativePath.includes(pattern)
212
+ })
213
+ })
214
+ }
215
+
216
+ /**
217
+ * Extracts imports from all source files.
218
+ */
219
+ async function extractAllImports(
220
+ sourceFiles: readonly string[],
221
+ options: Required<CircularImportAnalyzerOptions>,
222
+ ): Promise<ImportExtractionResult[]> {
223
+ const results: ImportExtractionResult[] = []
224
+ const project = createProject()
225
+
226
+ const extractorOptions: ImportExtractorOptions = {
227
+ workspacePrefixes: options.workspacePrefixes,
228
+ includeTypeImports: options.includeTypeImports,
229
+ includeDynamicImports: true,
230
+ includeRequireCalls: true,
231
+ }
232
+
233
+ for (const filePath of sourceFiles) {
234
+ try {
235
+ const sourceFile = project.addSourceFileAtPath(filePath)
236
+ const result = extractImports(sourceFile, extractorOptions)
237
+ results.push(result)
238
+ } catch {
239
+ // Skip files that can't be parsed
240
+ }
241
+ }
242
+
243
+ return results
244
+ }
245
+
246
+ /**
247
+ * Creates a normalized key for a cycle to detect duplicates.
248
+ * Sorts nodes to ensure A→B→C→A is considered the same as B→C→A→B.
249
+ */
250
+ function normalizeCycleKey(cycle: DependencyCycle): string {
251
+ const sorted = [...cycle.nodes].sort()
252
+ return sorted.join('|')
253
+ }
254
+
255
+ /**
256
+ * Generates visualization data for a cycle.
257
+ */
258
+ export function generateCycleVisualization(
259
+ cycle: DependencyCycle,
260
+ _graph: DependencyGraph,
261
+ workspacePath: string,
262
+ ): CycleVisualization {
263
+ const nodes: CycleNode[] = cycle.nodes.map((nodeId, index) => ({
264
+ id: nodeId,
265
+ name: getShortName(nodeId),
266
+ filePath: path.join(workspacePath, nodeId),
267
+ isStart: index === 0,
268
+ }))
269
+
270
+ const edges: CycleEdge[] = cycle.edges.map(edge => ({
271
+ from: edge.from,
272
+ to: edge.to,
273
+ importType: edge.type,
274
+ }))
275
+
276
+ const ascii = generateAsciiDiagram(cycle)
277
+ const mermaid = generateMermaidDiagram(nodes, edges)
278
+
279
+ return {nodes, edges, ascii, mermaid}
280
+ }
281
+
282
+ /**
283
+ * Gets a short display name for a node.
284
+ */
285
+ function getShortName(nodeId: string): string {
286
+ const parts = nodeId.split('/')
287
+ return parts.at(-1) ?? nodeId
288
+ }
289
+
290
+ /**
291
+ * Generates an ASCII art diagram of the cycle.
292
+ */
293
+ function generateAsciiDiagram(cycle: DependencyCycle): string {
294
+ const nodes = cycle.nodes
295
+ const lines: string[] = []
296
+
297
+ let isFirst = true
298
+ for (const node of nodes) {
299
+ const current = getShortName(node)
300
+
301
+ if (isFirst) {
302
+ lines.push(`┌─> ${current}`)
303
+ isFirst = false
304
+ } else {
305
+ lines.push(`│ ${current}`)
306
+ }
307
+ lines.push(`│ ↓`)
308
+ }
309
+
310
+ lines[lines.length - 1] = `└─────┘`
311
+
312
+ return lines.join('\n')
313
+ }
314
+
315
+ /**
316
+ * Generates a Mermaid diagram for the cycle.
317
+ */
318
+ function generateMermaidDiagram(nodes: readonly CycleNode[], edges: readonly CycleEdge[]): string {
319
+ const lines: string[] = ['graph LR']
320
+
321
+ for (const node of nodes) {
322
+ const sanitizedId = node.id.replaceAll('/', '_').replaceAll('.', '_')
323
+ lines.push(` ${sanitizedId}["${node.name}"]`)
324
+ }
325
+
326
+ for (const edge of edges) {
327
+ const fromId = edge.from.replaceAll('/', '_').replaceAll('.', '_')
328
+ const toId = edge.to.replaceAll('/', '_').replaceAll('.', '_')
329
+ lines.push(` ${fromId} --> ${toId}`)
330
+ }
331
+
332
+ return lines.join('\n')
333
+ }
334
+
335
+ /**
336
+ * Creates an issue for a detected cycle.
337
+ */
338
+ function createCycleIssue(
339
+ pkg: WorkspacePackage,
340
+ cycle: DependencyCycle,
341
+ visualization: CycleVisualization,
342
+ options: Required<CircularImportAnalyzerOptions>,
343
+ workspacePath: string,
344
+ ): Issue {
345
+ const cycleLength = cycle.nodes.length
346
+ const isDirectCycle = cycleLength === 2
347
+ const severity = isDirectCycle ? options.directCycleSeverity : options.transitiveCycleSeverity
348
+
349
+ const firstNode = cycle.nodes[0]
350
+ const location: IssueLocation = {
351
+ filePath: firstNode === undefined ? pkg.packagePath : path.join(workspacePath, firstNode),
352
+ }
353
+
354
+ const relatedLocations: IssueLocation[] = cycle.nodes.slice(1).map(nodeId => ({
355
+ filePath: path.join(workspacePath, nodeId),
356
+ }))
357
+
358
+ const cycleDescription =
359
+ cycleLength === 2
360
+ ? `Direct circular import between ${getShortName(cycle.nodes[0] ?? '')} and ${getShortName(cycle.nodes[1] ?? '')}`
361
+ : `Circular import chain of ${cycleLength} files`
362
+
363
+ return createIssue({
364
+ id: 'circular-import',
365
+ title: cycleDescription,
366
+ description: `Detected circular import: ${cycle.description}`,
367
+ severity,
368
+ category: 'circular-import',
369
+ location,
370
+ relatedLocations,
371
+ suggestion: generateFixSuggestion(cycle, cycleLength),
372
+ metadata: {
373
+ packageName: pkg.name,
374
+ cycleLength,
375
+ cycleNodes: cycle.nodes,
376
+ cycleDescription: cycle.description,
377
+ visualization: {
378
+ ascii: visualization.ascii,
379
+ mermaid: visualization.mermaid,
380
+ },
381
+ },
382
+ })
383
+ }
384
+
385
+ /**
386
+ * Generates a fix suggestion based on cycle characteristics.
387
+ */
388
+ function generateFixSuggestion(_cycle: DependencyCycle, cycleLength: number): string {
389
+ if (cycleLength === 2) {
390
+ return 'Consider extracting shared types/interfaces to a separate module, or restructuring to break the bidirectional dependency'
391
+ }
392
+
393
+ if (cycleLength <= 4) {
394
+ return 'Identify the most general module in the cycle and ensure other modules depend on it, not vice versa'
395
+ }
396
+
397
+ return 'This is a complex circular dependency. Consider using dependency inversion, extracting interfaces, or restructuring the module hierarchy'
398
+ }
399
+
400
+ /**
401
+ * Statistics about circular imports in a package.
402
+ */
403
+ export interface CircularImportStats {
404
+ /** Total number of cycles detected */
405
+ readonly totalCycles: number
406
+ /** Number of direct (2-node) cycles */
407
+ readonly directCycles: number
408
+ /** Number of transitive cycles */
409
+ readonly transitiveCycles: number
410
+ /** Files involved in cycles */
411
+ readonly affectedFiles: readonly string[]
412
+ /** Average cycle length */
413
+ readonly averageCycleLength: number
414
+ /** Maximum cycle length found */
415
+ readonly maxCycleLength: number
416
+ }
417
+
418
+ /**
419
+ * Computes statistics about detected cycles.
420
+ */
421
+ export function computeCycleStats(cycles: readonly DependencyCycle[]): CircularImportStats {
422
+ if (cycles.length === 0) {
423
+ return {
424
+ totalCycles: 0,
425
+ directCycles: 0,
426
+ transitiveCycles: 0,
427
+ affectedFiles: [],
428
+ averageCycleLength: 0,
429
+ maxCycleLength: 0,
430
+ }
431
+ }
432
+
433
+ let directCycles = 0
434
+ let transitiveCycles = 0
435
+ let totalLength = 0
436
+ let maxLength = 0
437
+ const affectedFiles = new Set<string>()
438
+
439
+ for (const cycle of cycles) {
440
+ const length = cycle.nodes.length
441
+ totalLength += length
442
+ maxLength = Math.max(maxLength, length)
443
+
444
+ if (length === 2) {
445
+ directCycles++
446
+ } else {
447
+ transitiveCycles++
448
+ }
449
+
450
+ for (const node of cycle.nodes) {
451
+ affectedFiles.add(node)
452
+ }
453
+ }
454
+
455
+ return {
456
+ totalCycles: cycles.length,
457
+ directCycles,
458
+ transitiveCycles,
459
+ affectedFiles: [...affectedFiles].sort(),
460
+ averageCycleLength: totalLength / cycles.length,
461
+ maxCycleLength: maxLength,
462
+ }
463
+ }