@bfra.me/workspace-analyzer 0.1.0 → 0.2.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.
@@ -0,0 +1,226 @@
1
+ /**
2
+ * Type definitions for dependency graph visualization.
3
+ *
4
+ * These types define the data structures used to transform a DependencyGraph
5
+ * into a format suitable for interactive D3.js visualization.
6
+ */
7
+
8
+ import type {Severity} from '../types/index'
9
+
10
+ /**
11
+ * A node in the visualization graph representing a module.
12
+ */
13
+ export interface VisualizationNode {
14
+ /** Unique identifier (typically the normalized file path) */
15
+ readonly id: string
16
+ /** Display name for the node */
17
+ readonly name: string
18
+ /** Full file path of the module */
19
+ readonly filePath: string
20
+ /** Package name if the module belongs to a workspace package */
21
+ readonly packageName: string | undefined
22
+ /** Architectural layer (e.g., 'domain', 'application', 'infrastructure') */
23
+ readonly layer: string | undefined
24
+ /** Number of modules this node imports */
25
+ readonly importsCount: number
26
+ /** Number of modules that import this node */
27
+ readonly importedByCount: number
28
+ /** Whether this node is part of a dependency cycle */
29
+ readonly isInCycle: boolean
30
+ /** Violations associated with this node */
31
+ readonly violations: readonly VisualizationViolation[]
32
+ /** Highest severity level among violations (undefined if no violations) */
33
+ readonly highestViolationSeverity: Severity | undefined
34
+ }
35
+
36
+ /**
37
+ * An edge in the visualization graph representing an import relationship.
38
+ */
39
+ export interface VisualizationEdge {
40
+ /** Source node ID (the importing module) */
41
+ readonly source: string
42
+ /** Target node ID (the imported module) */
43
+ readonly target: string
44
+ /** Import type classification */
45
+ readonly type: 'static' | 'dynamic' | 'type-only' | 'require'
46
+ /** Whether this edge is part of a dependency cycle */
47
+ readonly isInCycle: boolean
48
+ /** ID of the cycle this edge belongs to (undefined if not in a cycle) */
49
+ readonly cycleId: string | undefined
50
+ }
51
+
52
+ /**
53
+ * A violation displayed on a node or edge in the visualization.
54
+ */
55
+ export interface VisualizationViolation {
56
+ /** Unique identifier for this violation */
57
+ readonly id: string
58
+ /** Human-readable message describing the violation */
59
+ readonly message: string
60
+ /** Severity level of the violation */
61
+ readonly severity: Severity
62
+ /** ID of the rule that generated this violation */
63
+ readonly ruleId: string
64
+ }
65
+
66
+ /**
67
+ * Cycle information for visualization highlighting.
68
+ */
69
+ export interface VisualizationCycle {
70
+ /** Unique identifier for this cycle */
71
+ readonly id: string
72
+ /** Node IDs participating in the cycle, in order */
73
+ readonly nodes: readonly string[]
74
+ /** Edges forming the cycle path */
75
+ readonly edges: readonly {readonly from: string; readonly to: string}[]
76
+ /** Number of nodes in the cycle */
77
+ readonly length: number
78
+ /** Human-readable description of the cycle path */
79
+ readonly description: string
80
+ }
81
+
82
+ /**
83
+ * Statistics summary for the visualization.
84
+ */
85
+ export interface VisualizationStatistics {
86
+ /** Total number of nodes in the graph */
87
+ readonly totalNodes: number
88
+ /** Total number of edges (import relationships) */
89
+ readonly totalEdges: number
90
+ /** Total number of dependency cycles detected */
91
+ readonly totalCycles: number
92
+ /** Count of nodes grouped by architectural layer */
93
+ readonly nodesByLayer: Readonly<Record<string, number>>
94
+ /** Count of violations grouped by severity level */
95
+ readonly violationsBySeverity: Readonly<Record<Severity, number>>
96
+ /** Number of workspace packages analyzed */
97
+ readonly packagesAnalyzed: number
98
+ /** Number of source files analyzed */
99
+ readonly filesAnalyzed: number
100
+ }
101
+
102
+ /**
103
+ * Metadata about the visualization generation.
104
+ */
105
+ export interface VisualizationMetadata {
106
+ /** Root path of the analyzed workspace */
107
+ readonly workspacePath: string
108
+ /** ISO 8601 timestamp when the visualization was generated */
109
+ readonly generatedAt: string
110
+ /** Version of the workspace-analyzer that generated this data */
111
+ readonly analyzerVersion: string
112
+ }
113
+
114
+ /**
115
+ * Layer definition for architectural boundary visualization.
116
+ */
117
+ export interface VisualizationLayerDefinition {
118
+ /** Layer name (e.g., 'domain', 'application') */
119
+ readonly name: string
120
+ /** Layers this layer is allowed to depend on */
121
+ readonly allowedDependencies: readonly string[]
122
+ }
123
+
124
+ /**
125
+ * Complete visualization data ready for rendering.
126
+ */
127
+ export interface VisualizationData {
128
+ /** All nodes in the visualization graph */
129
+ readonly nodes: readonly VisualizationNode[]
130
+ /** All edges (import relationships) in the graph */
131
+ readonly edges: readonly VisualizationEdge[]
132
+ /** Detected dependency cycles */
133
+ readonly cycles: readonly VisualizationCycle[]
134
+ /** Summary statistics */
135
+ readonly statistics: VisualizationStatistics
136
+ /** Architectural layer definitions for filtering */
137
+ readonly layers: readonly VisualizationLayerDefinition[]
138
+ /** Generation metadata */
139
+ readonly metadata: VisualizationMetadata
140
+ }
141
+
142
+ /**
143
+ * Filter configuration for the visualization UI.
144
+ */
145
+ export interface VisualizationFilters {
146
+ /** Layers to display (empty = all layers) */
147
+ readonly layers: readonly string[]
148
+ /** Severity levels to display (empty = all severities) */
149
+ readonly severities: readonly Severity[]
150
+ /** Package scopes to display (e.g., '@bfra.me/*') */
151
+ readonly packages: readonly string[]
152
+ /** Show only nodes that are part of cycles */
153
+ readonly showCyclesOnly: boolean
154
+ /** Show only nodes with violations */
155
+ readonly showViolationsOnly: boolean
156
+ }
157
+
158
+ /**
159
+ * Options for configuring visualization generation.
160
+ */
161
+ export interface VisualizerOptions {
162
+ /** Output path for the generated HTML file */
163
+ readonly outputPath: string
164
+ /** Output format(s) to generate */
165
+ readonly format: 'html' | 'json' | 'both'
166
+ /** Whether to auto-open the generated file in the browser */
167
+ readonly autoOpen: boolean
168
+ /** Title displayed in the visualization */
169
+ readonly title: string
170
+ /** Pre-applied filters for the initial visualization state */
171
+ readonly filters: Partial<VisualizationFilters>
172
+ /** Include type-only imports in the graph */
173
+ readonly includeTypeImports: boolean
174
+ /** Maximum number of nodes to render (for performance) */
175
+ readonly maxNodes: number
176
+ }
177
+
178
+ /**
179
+ * Default visualization options.
180
+ */
181
+ export const DEFAULT_VISUALIZER_OPTIONS: VisualizerOptions = {
182
+ outputPath: './workspace-graph.html',
183
+ format: 'html',
184
+ autoOpen: true,
185
+ title: 'Workspace Dependency Graph',
186
+ filters: {},
187
+ includeTypeImports: true,
188
+ maxNodes: 1000,
189
+ }
190
+
191
+ /**
192
+ * Severity levels ordered by importance (highest first).
193
+ */
194
+ export const SEVERITY_ORDER: readonly Severity[] = ['critical', 'error', 'warning', 'info'] as const
195
+
196
+ /**
197
+ * Error type for visualization generation failures.
198
+ */
199
+ export interface VisualizationError {
200
+ /** Error code for programmatic handling */
201
+ readonly code: 'INVALID_GRAPH' | 'TRANSFORM_FAILED' | 'LIMIT_EXCEEDED'
202
+ /** Human-readable error message */
203
+ readonly message: string
204
+ /** Optional details about the error */
205
+ readonly details?: Record<string, unknown>
206
+ }
207
+
208
+ /**
209
+ * Gets the highest severity from a list of severities.
210
+ *
211
+ * @param severities - Array of severity levels
212
+ * @returns The highest severity, or undefined if the array is empty
213
+ */
214
+ export function getHighestSeverity(severities: readonly Severity[]): Severity | undefined {
215
+ if (severities.length === 0) {
216
+ return undefined
217
+ }
218
+
219
+ for (const level of SEVERITY_ORDER) {
220
+ if (severities.includes(level)) {
221
+ return level
222
+ }
223
+ }
224
+
225
+ return severities[0]
226
+ }
@@ -0,0 +1,246 @@
1
+ /**
2
+ * Violation collection utilities for gathering architectural issues from RuleEngine.
3
+ *
4
+ * Provides functions to collect violations from the rule engine and associate them
5
+ * with visualization nodes for display in the interactive graph.
6
+ */
7
+
8
+ import type {Project, SourceFile} from 'ts-morph'
9
+
10
+ import type {createRuleEngine, RuleContext} from '../rules/rule-engine'
11
+ import type {WorkspacePackage} from '../scanner/workspace-scanner'
12
+ import type {Issue} from '../types/index'
13
+ import type {Result} from '../types/result'
14
+ import type {VisualizationError, VisualizationNode} from './types'
15
+
16
+ import path from 'node:path'
17
+
18
+ import {createProject} from '@bfra.me/doc-sync'
19
+ import {err, isErr, ok} from '@bfra.me/es/result'
20
+
21
+ import {getHighestSeverity} from './types'
22
+
23
+ /**
24
+ * Context for collecting violations from the workspace.
25
+ */
26
+ export interface ViolationCollectionContext {
27
+ /** The rule engine instance to use for evaluation */
28
+ readonly ruleEngine: ReturnType<typeof createRuleEngine>
29
+ /** All packages in the workspace */
30
+ readonly packages: readonly WorkspacePackage[]
31
+ /** Root path of the workspace */
32
+ readonly workspacePath: string
33
+ /** Optional tsconfig path mappings */
34
+ readonly tsconfigPaths?: Readonly<Record<string, readonly string[]>>
35
+ /** Optional progress reporting callback */
36
+ readonly reportProgress?: (message: string) => void
37
+ }
38
+
39
+ /**
40
+ * Options for violation collection.
41
+ */
42
+ export interface ViolationCollectionOptions {
43
+ /** Whether to include info-level issues */
44
+ readonly includeInfo: boolean
45
+ /** Maximum number of issues to collect (for performance) */
46
+ readonly maxIssues: number
47
+ /** File patterns to exclude from analysis */
48
+ readonly excludePatterns: readonly string[]
49
+ }
50
+
51
+ /**
52
+ * Default options for violation collection.
53
+ */
54
+ export const DEFAULT_VIOLATION_COLLECTION_OPTIONS: ViolationCollectionOptions = {
55
+ includeInfo: true,
56
+ maxIssues: 10000,
57
+ excludePatterns: ['**/*.test.ts', '**/*.spec.ts', '**/node_modules/**'],
58
+ }
59
+
60
+ /**
61
+ * Collects architectural violations using the RuleEngine.
62
+ *
63
+ * Evaluates all source files in the workspace packages against the configured
64
+ * architectural rules and collects the resulting issues for visualization.
65
+ *
66
+ * @param context - Context containing rule engine and workspace information
67
+ * @param options - Options for controlling collection behavior
68
+ * @returns Result containing collected issues or an error
69
+ */
70
+ export async function collectVisualizationViolations(
71
+ context: ViolationCollectionContext,
72
+ options: Partial<ViolationCollectionOptions> = {},
73
+ ): Promise<Result<readonly Issue[], VisualizationError>> {
74
+ const opts: ViolationCollectionOptions = {
75
+ ...DEFAULT_VIOLATION_COLLECTION_OPTIONS,
76
+ ...options,
77
+ }
78
+
79
+ const {ruleEngine, packages, workspacePath, tsconfigPaths, reportProgress} = context
80
+
81
+ const allIssues: Issue[] = []
82
+
83
+ try {
84
+ for (const pkg of packages) {
85
+ reportProgress?.(`Collecting violations from ${pkg.name}...`)
86
+
87
+ const tsconfigPath = path.join(pkg.packagePath, 'tsconfig.json')
88
+
89
+ let project: Project
90
+ try {
91
+ project = createProject({
92
+ tsConfigPath: tsconfigPath,
93
+ })
94
+ } catch {
95
+ continue
96
+ }
97
+
98
+ const sourceFiles = getSourceFiles(project)
99
+
100
+ for (const sourceFile of sourceFiles) {
101
+ const filePath = sourceFile.getFilePath()
102
+
103
+ if (shouldSkipFile(filePath, opts.excludePatterns)) {
104
+ continue
105
+ }
106
+
107
+ const ruleContext: RuleContext = {
108
+ sourceFile,
109
+ pkg,
110
+ workspacePath,
111
+ allPackages: packages,
112
+ tsconfigPaths,
113
+ }
114
+
115
+ const result = await ruleEngine.evaluateFile(ruleContext)
116
+
117
+ if (isErr(result)) {
118
+ continue
119
+ }
120
+
121
+ const issues = result.data
122
+
123
+ const filteredIssues = opts.includeInfo
124
+ ? issues
125
+ : issues.filter(issue => issue.severity !== 'info')
126
+
127
+ allIssues.push(...filteredIssues)
128
+
129
+ if (allIssues.length >= opts.maxIssues) {
130
+ reportProgress?.(`Reached maximum issue limit (${opts.maxIssues})`)
131
+ break
132
+ }
133
+ }
134
+
135
+ if (allIssues.length >= opts.maxIssues) {
136
+ break
137
+ }
138
+ }
139
+
140
+ return ok(allIssues)
141
+ } catch (error) {
142
+ return err({
143
+ code: 'TRANSFORM_FAILED',
144
+ message: `Error collecting violations: ${error instanceof Error ? error.message : String(error)}`,
145
+ })
146
+ }
147
+ }
148
+
149
+ /**
150
+ * Maps issues to their corresponding visualization nodes.
151
+ *
152
+ * Associates each issue with the node(s) it affects by matching file paths.
153
+ * Updates nodes with their violations and highest severity level.
154
+ *
155
+ * @param nodes - Visualization nodes to annotate with violations
156
+ * @param issues - Issues collected from rule engine
157
+ * @returns Updated nodes with violation information
158
+ */
159
+ export function mapIssuesToNodes(
160
+ nodes: readonly VisualizationNode[],
161
+ issues: readonly Issue[],
162
+ ): readonly VisualizationNode[] {
163
+ const nodesByPath = new Map<string, VisualizationNode>()
164
+
165
+ for (const node of nodes) {
166
+ const normalizedPath = normalizePath(node.filePath)
167
+ nodesByPath.set(normalizedPath, node)
168
+ }
169
+
170
+ const issuesByPath = new Map<string, Issue[]>()
171
+ for (const issue of issues) {
172
+ const normalizedPath = normalizePath(issue.location.filePath)
173
+ const existing = issuesByPath.get(normalizedPath) ?? []
174
+ existing.push(issue)
175
+ issuesByPath.set(normalizedPath, existing)
176
+ }
177
+
178
+ return nodes.map(node => {
179
+ const normalizedPath = normalizePath(node.filePath)
180
+ const nodeIssues = issuesByPath.get(normalizedPath) ?? []
181
+
182
+ if (nodeIssues.length === 0) {
183
+ return node
184
+ }
185
+
186
+ const violations = nodeIssues.map((issue, index) => ({
187
+ id: `${issue.id}-${index}`,
188
+ message: issue.description,
189
+ severity: issue.severity,
190
+ ruleId: issue.id,
191
+ }))
192
+
193
+ const severities = violations.map(v => v.severity)
194
+ const highestSeverity = getHighestSeverity(severities)
195
+
196
+ return {
197
+ ...node,
198
+ violations,
199
+ highestViolationSeverity: highestSeverity,
200
+ }
201
+ })
202
+ }
203
+
204
+ /**
205
+ * Gets source files from a TypeScript project.
206
+ *
207
+ * @param project - The ts-morph project
208
+ * @returns Array of source files for analysis
209
+ */
210
+ function getSourceFiles(project: Project): readonly SourceFile[] {
211
+ return project.getSourceFiles().filter(sf => {
212
+ const filePath = sf.getFilePath()
213
+ return (
214
+ (filePath.endsWith('.ts') ||
215
+ filePath.endsWith('.tsx') ||
216
+ filePath.endsWith('.js') ||
217
+ filePath.endsWith('.jsx')) &&
218
+ !filePath.includes('node_modules') &&
219
+ !filePath.includes('.test.') &&
220
+ !filePath.includes('.spec.')
221
+ )
222
+ })
223
+ }
224
+
225
+ /**
226
+ * Determines if a file should be skipped based on exclude patterns.
227
+ */
228
+ function shouldSkipFile(filePath: string, excludePatterns: readonly string[]): boolean {
229
+ if (excludePatterns.length === 0) {
230
+ return false
231
+ }
232
+
233
+ const normalized = normalizePath(filePath)
234
+
235
+ return excludePatterns.some(pattern => {
236
+ const normalizedPattern = pattern.replaceAll('\\', '/').toLowerCase()
237
+ return normalized.includes(normalizedPattern.replaceAll('**', '').replaceAll('*', ''))
238
+ })
239
+ }
240
+
241
+ /**
242
+ * Normalizes a file path for comparison.
243
+ */
244
+ function normalizePath(filePath: string): string {
245
+ return filePath.replaceAll('\\', '/').toLowerCase()
246
+ }