@ebowwa/dependency-graph 1.0.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/src/builder.ts ADDED
@@ -0,0 +1,366 @@
1
+ /**
2
+ * @ebowwa/dependency-graph - Graph Builder
3
+ *
4
+ * Builds dependency graphs from package.json and import analysis
5
+ */
6
+
7
+ import { readdirSync, readFileSync, existsSync } from "node:fs";
8
+ import { join, dirname, relative } from "node:path";
9
+ import type {
10
+ DependencyGraph,
11
+ DependencyNode,
12
+ DependencyEdge,
13
+ BuildOptions,
14
+ } from "./types.js";
15
+
16
+ interface PackageInfo {
17
+ name: string;
18
+ path: string;
19
+ version: string;
20
+ }
21
+
22
+ export class DependencyGraphBuilder {
23
+ private monorepoRoot: string;
24
+ private graph: DependencyGraph;
25
+ private packageCache: Map<string, { path: string; pkg: Record<string, unknown> }> = new Map();
26
+
27
+ constructor(monorepoRoot: string) {
28
+ this.monorepoRoot = monorepoRoot;
29
+ this.graph = {
30
+ nodes: new Map(),
31
+ edges: [],
32
+ reverseEdges: new Map(),
33
+ };
34
+ }
35
+
36
+ /**
37
+ * Build the complete dependency graph
38
+ */
39
+ async build(options: BuildOptions = {}): Promise<DependencyGraph> {
40
+ const {
41
+ includeDevDependencies = false,
42
+ analyzeImports = true,
43
+ excludePatterns = [],
44
+ } = options;
45
+
46
+ // Discover all packages in the monorepo
47
+ const packages = await this.discoverPackages();
48
+
49
+ // Add nodes for all packages
50
+ for (const pkg of packages) {
51
+ this.addNode(pkg.name, pkg.path, "workspace", pkg.version);
52
+ }
53
+
54
+ // Analyze dependencies for each package
55
+ for (const pkg of packages) {
56
+ await this.analyzePackageDependencies(
57
+ pkg,
58
+ includeDevDependencies,
59
+ excludePatterns
60
+ );
61
+
62
+ if (analyzeImports) {
63
+ await this.analyzeImports(pkg);
64
+ }
65
+ }
66
+
67
+ // Build reverse edges for impact analysis
68
+ this.buildReverseEdges();
69
+
70
+ return this.graph;
71
+ }
72
+
73
+ /**
74
+ * Discover all package.json files in the monorepo
75
+ */
76
+ private async discoverPackages(): Promise<PackageInfo[]> {
77
+ const packages: PackageInfo[] = [];
78
+ const seen = new Set<string>();
79
+
80
+ // Search in common locations
81
+ const searchPaths = [
82
+ this.monorepoRoot,
83
+ join(this.monorepoRoot, "packages"),
84
+ join(this.monorepoRoot, "packages/src"),
85
+ join(this.monorepoRoot, "apps"),
86
+ join(this.monorepoRoot, "MCP/packages"),
87
+ ];
88
+
89
+ for (const searchPath of searchPaths) {
90
+ if (!existsSync(searchPath)) continue;
91
+ await this.searchDirectory(searchPath, packages, seen, 0, 4);
92
+ }
93
+
94
+ return packages;
95
+ }
96
+
97
+ /**
98
+ * Recursively search for package.json files
99
+ */
100
+ private async searchDirectory(
101
+ dir: string,
102
+ packages: PackageInfo[],
103
+ seen: Set<string>,
104
+ depth: number,
105
+ maxDepth: number
106
+ ): Promise<void> {
107
+ if (depth > maxDepth) return;
108
+
109
+ try {
110
+ const entries = readdirSync(dir, { withFileTypes: true });
111
+
112
+ for (const entry of entries) {
113
+ // Skip node_modules and hidden dirs
114
+ if (entry.name === "node_modules" || entry.name.startsWith("."))
115
+ continue;
116
+ if (entry.name === "dist" || entry.name === "build") continue;
117
+
118
+ const fullPath = join(dir, entry.name);
119
+
120
+ if (entry.isDirectory()) {
121
+ await this.searchDirectory(
122
+ fullPath,
123
+ packages,
124
+ seen,
125
+ depth + 1,
126
+ maxDepth
127
+ );
128
+ } else if (entry.name === "package.json") {
129
+ const packageDir = dirname(fullPath);
130
+ const relativePath = relative(this.monorepoRoot, packageDir);
131
+
132
+ if (seen.has(relativePath)) continue;
133
+ seen.add(relativePath);
134
+
135
+ try {
136
+ const pkg = JSON.parse(readFileSync(fullPath, "utf-8"));
137
+ if (pkg.name && !pkg.private) {
138
+ packages.push({
139
+ name: pkg.name,
140
+ path: relativePath,
141
+ version: pkg.version || "0.0.0",
142
+ });
143
+ this.packageCache.set(pkg.name, { path: relativePath, pkg });
144
+ }
145
+ } catch {
146
+ // Invalid package.json, skip
147
+ }
148
+ }
149
+ }
150
+ } catch {
151
+ // Directory not accessible, skip
152
+ }
153
+ }
154
+
155
+ /**
156
+ * Analyze package.json dependencies
157
+ */
158
+ private async analyzePackageDependencies(
159
+ pkg: PackageInfo,
160
+ includeDev: boolean,
161
+ excludePatterns: string[]
162
+ ): Promise<void> {
163
+ const pkgPath = join(this.monorepoRoot, pkg.path, "package.json");
164
+ if (!existsSync(pkgPath)) return;
165
+
166
+ const packageJson = JSON.parse(readFileSync(pkgPath, "utf-8"));
167
+
168
+ const depTypes = ["dependencies"];
169
+ if (includeDev)
170
+ depTypes.push("devDependencies", "peerDependencies", "optionalDependencies");
171
+
172
+ for (const depType of depTypes) {
173
+ const deps = packageJson[depType];
174
+ if (!deps) continue;
175
+
176
+ for (const [depName, depVersion] of Object.entries(
177
+ deps as Record<string, string>
178
+ )) {
179
+ // Check if this matches any exclude pattern
180
+ if (excludePatterns.some((pattern) => depName.match(pattern))) continue;
181
+
182
+ const isWorkspace =
183
+ depVersion === "workspace:*" ||
184
+ depVersion === "workspace:^" ||
185
+ depVersion === "workspace:~";
186
+
187
+ if (isWorkspace) {
188
+ // This is a workspace dependency
189
+ const targetPkg = this.packageCache.get(depName);
190
+ if (targetPkg) {
191
+ this.addEdge(pkg.name, depName, "workspace");
192
+ }
193
+ } else if (this.packageCache.has(depName)) {
194
+ // It's a local package but not using workspace: protocol
195
+ this.addEdge(pkg.name, depName, "workspace");
196
+ } else {
197
+ // External dependency
198
+ this.addNode(depName, "", "external", depVersion as string);
199
+ this.addEdge(pkg.name, depName, "external");
200
+ }
201
+ }
202
+ }
203
+ }
204
+
205
+ /**
206
+ * Analyze TypeScript/JavaScript imports
207
+ */
208
+ private async analyzeImports(pkg: PackageInfo): Promise<void> {
209
+ const pkgDir = join(this.monorepoRoot, pkg.path);
210
+
211
+ // Common source directories
212
+ const sourceDirs = ["src", "lib", ""];
213
+
214
+ for (const sourceDir of sourceDirs) {
215
+ const searchPath = sourceDir ? join(pkgDir, sourceDir) : pkgDir;
216
+ if (!existsSync(searchPath)) continue;
217
+ await this.analyzeImportsInDirectory(pkg.name, searchPath);
218
+ }
219
+ }
220
+
221
+ /**
222
+ * Recursively analyze imports in a directory
223
+ */
224
+ private async analyzeImportsInDirectory(
225
+ packageName: string,
226
+ dir: string
227
+ ): Promise<void> {
228
+ try {
229
+ const entries = readdirSync(dir, { withFileTypes: true });
230
+
231
+ for (const entry of entries) {
232
+ if (entry.name === "node_modules" || entry.name.startsWith(".")) continue;
233
+ if (entry.name === "dist" || entry.name === "build") continue;
234
+
235
+ const fullPath = join(dir, entry.name);
236
+
237
+ if (entry.isDirectory()) {
238
+ await this.analyzeImportsInDirectory(packageName, fullPath);
239
+ } else if (
240
+ entry.name.endsWith(".ts") ||
241
+ entry.name.endsWith(".tsx") ||
242
+ entry.name.endsWith(".js") ||
243
+ entry.name.endsWith(".jsx") ||
244
+ entry.name.endsWith(".mjs")
245
+ ) {
246
+ await this.analyzeImportsInFile(packageName, fullPath);
247
+ }
248
+ }
249
+ } catch {
250
+ // Directory not accessible
251
+ }
252
+ }
253
+
254
+ /**
255
+ * Analyze imports in a single file
256
+ */
257
+ private async analyzeImportsInFile(
258
+ packageName: string,
259
+ filePath: string
260
+ ): Promise<void> {
261
+ try {
262
+ const content = readFileSync(filePath, "utf-8");
263
+
264
+ // Match various import patterns
265
+ const importPatterns = [
266
+ // ES imports: import ... from '...' | import ... from "..."
267
+ /import\s+(?:(?:\{[^}]*\}|\*\s+as\s+\w+|\w+)\s*,?\s*)*\s+from\s+['"]([^'"]+)['"]/g,
268
+ // Dynamic imports: import('...')
269
+ /import\(['"]([^'"]+)['"]\)/g,
270
+ // require(): require('...') | require("...")
271
+ /require\(['"]([^'"]+)['"]\)/g,
272
+ // Export from: export ... from '...'
273
+ /export\s+(?:(?:\{[^}]*\}|\*\s+as\s+\w+)\s+from\s+)?['"]([^'"]+)['"]/g,
274
+ ];
275
+
276
+ for (const pattern of importPatterns) {
277
+ let match;
278
+ while ((match = pattern.exec(content)) !== null) {
279
+ const importPath = match[1];
280
+
281
+ // Skip relative imports
282
+ if (importPath.startsWith(".") || importPath.startsWith("/")) continue;
283
+
284
+ // Check if this is a known workspace package
285
+ for (const [pkgName, pkgData] of this.packageCache) {
286
+ if (
287
+ importPath === pkgName ||
288
+ importPath.startsWith(pkgName + "/")
289
+ ) {
290
+ this.addEdge(packageName, pkgName, "import", importPath);
291
+ break;
292
+ }
293
+ }
294
+ }
295
+ }
296
+ } catch {
297
+ // File not readable
298
+ }
299
+ }
300
+
301
+ /**
302
+ * Add a node to the graph
303
+ */
304
+ private addNode(
305
+ name: string,
306
+ path: string,
307
+ type: "package" | "workspace" | "external",
308
+ version?: string
309
+ ): void {
310
+ if (!this.graph.nodes.has(name)) {
311
+ this.graph.nodes.set(name, { name, path, type, version });
312
+ this.graph.reverseEdges.set(name, new Set());
313
+ }
314
+ }
315
+
316
+ /**
317
+ * Add an edge to the graph
318
+ */
319
+ private addEdge(
320
+ from: string,
321
+ to: string,
322
+ type: "workspace" | "external" | "import",
323
+ importPath?: string
324
+ ): void {
325
+ // Skip self-references
326
+ if (from === to) return;
327
+
328
+ // Check if edge already exists
329
+ const existing = this.graph.edges.find((e) => e.from === from && e.to === to);
330
+ if (existing) return;
331
+
332
+ this.graph.edges.push({ from, to, type, importPath });
333
+
334
+ // Update reverse edges
335
+ if (!this.graph.reverseEdges.has(to)) {
336
+ this.graph.reverseEdges.set(to, new Set());
337
+ }
338
+ this.graph.reverseEdges.get(to)!.add(from);
339
+ }
340
+
341
+ /**
342
+ * Build reverse edges for impact analysis
343
+ */
344
+ private buildReverseEdges(): void {
345
+ for (const [to, dependents] of this.graph.reverseEdges) {
346
+ // Ensure node exists
347
+ if (!this.graph.nodes.has(to)) {
348
+ this.graph.nodes.set(to, { name: to, path: "", type: "external" });
349
+ }
350
+ }
351
+ }
352
+
353
+ /**
354
+ * Get the built graph
355
+ */
356
+ getGraph(): DependencyGraph {
357
+ return this.graph;
358
+ }
359
+
360
+ /**
361
+ * Get the monorepo root
362
+ */
363
+ getMonorepoRoot(): string {
364
+ return this.monorepoRoot;
365
+ }
366
+ }
@@ -0,0 +1,191 @@
1
+ /**
2
+ * @ebowwa/dependency-graph - Output Formatters
3
+ *
4
+ * Format dependency graphs for various output formats
5
+ */
6
+
7
+ import type { DependencyGraph, OutputFormat } from "./types.js";
8
+
9
+ /**
10
+ * Format the graph for output
11
+ */
12
+ export function formatGraph(graph: DependencyGraph, format: OutputFormat): string {
13
+ switch (format) {
14
+ case "mermaid":
15
+ return formatAsMermaid(graph);
16
+ case "dot":
17
+ return formatAsDot(graph);
18
+ case "tree":
19
+ return formatAsTree(graph);
20
+ case "json":
21
+ default:
22
+ return formatAsJson(graph);
23
+ }
24
+ }
25
+
26
+ /**
27
+ * Format as JSON
28
+ */
29
+ export function formatAsJson(graph: DependencyGraph): string {
30
+ return JSON.stringify(
31
+ {
32
+ nodes: Array.from(graph.nodes.values()),
33
+ edges: graph.edges,
34
+ },
35
+ null,
36
+ 2
37
+ );
38
+ }
39
+
40
+ /**
41
+ * Format as Mermaid diagram
42
+ */
43
+ export function formatAsMermaid(graph: DependencyGraph): string {
44
+ const lines = ["graph TD"];
45
+
46
+ // Add nodes
47
+ for (const [name, node] of graph.nodes) {
48
+ const label = node.type === "workspace" ? `📦 ${name}` : `📚 ${name}`;
49
+ lines.push(` ${sanitizeId(name)}["${label}"]`);
50
+ }
51
+
52
+ // Add edges
53
+ for (const edge of graph.edges) {
54
+ const from = sanitizeId(edge.from);
55
+ const to = sanitizeId(edge.to);
56
+ const label =
57
+ edge.type === "workspace"
58
+ ? "workspace"
59
+ : edge.type === "import"
60
+ ? "imports"
61
+ : "external";
62
+ lines.push(` ${from} -->|${label}| ${to}`);
63
+ }
64
+
65
+ // Add styles
66
+ lines.push(" classDef workspace fill:#e1f5fe");
67
+ lines.push(" classDef external fill:#f5f5f5");
68
+ lines.push(" classDef import fill:#fff3e0");
69
+
70
+ for (const [name, node] of graph.nodes) {
71
+ const id = sanitizeId(name);
72
+ if (node.type === "workspace") {
73
+ lines.push(` class ${id} workspace`);
74
+ } else if (node.type === "external") {
75
+ lines.push(` class ${id} external`);
76
+ }
77
+ }
78
+
79
+ return lines.join("\n");
80
+ }
81
+
82
+ /**
83
+ * Format as Graphviz DOT
84
+ */
85
+ export function formatAsDot(graph: DependencyGraph): string {
86
+ const lines = ["digraph dependencies {"];
87
+ lines.push(" rankdir=LR;");
88
+ lines.push(" node [shape=box];");
89
+
90
+ // Add nodes
91
+ for (const [name, node] of graph.nodes) {
92
+ const color = node.type === "workspace" ? "lightblue" : "lightgray";
93
+ lines.push(` "${name}" [fillcolor=${color}, style=filled];`);
94
+ }
95
+
96
+ // Add edges
97
+ for (const edge of graph.edges) {
98
+ const style = edge.type === "workspace" ? "solid" : "dashed";
99
+ lines.push(
100
+ ` "${edge.from}" -> "${edge.to}" [style=${style}, label="${edge.type}"];`
101
+ );
102
+ }
103
+
104
+ lines.push("}");
105
+ return lines.join("\n");
106
+ }
107
+
108
+ /**
109
+ * Format as ASCII tree
110
+ */
111
+ export function formatAsTree(graph: DependencyGraph): string {
112
+ const lines: string[] = [];
113
+ const seen = new Set<string>();
114
+
115
+ // Find root nodes (no incoming edges from other workspace packages)
116
+ const workspaceNodes = Array.from(graph.nodes.values()).filter(
117
+ (n) => n.type === "workspace"
118
+ );
119
+ const incomingEdges = new Map<string, number>();
120
+
121
+ for (const node of workspaceNodes) {
122
+ incomingEdges.set(node.name, 0);
123
+ }
124
+
125
+ for (const edge of graph.edges) {
126
+ if (graph.nodes.get(edge.to)?.type === "workspace") {
127
+ incomingEdges.set(edge.to, (incomingEdges.get(edge.to) || 0) + 1);
128
+ }
129
+ }
130
+
131
+ // Start from roots
132
+ for (const [name, node] of graph.nodes) {
133
+ if (node.type === "workspace" && (incomingEdges.get(name) || 0) === 0) {
134
+ lines.push(`📦 ${name}`);
135
+ printTree(graph, name, "", seen, lines);
136
+ }
137
+ }
138
+
139
+ // Add any disconnected nodes
140
+ for (const [name, node] of graph.nodes) {
141
+ if (!seen.has(name) && node.type === "workspace") {
142
+ lines.push(`📦 ${name} (disconnected)`);
143
+ }
144
+ }
145
+
146
+ return lines.join("\n");
147
+ }
148
+
149
+ function printTree(
150
+ graph: DependencyGraph,
151
+ nodeName: string,
152
+ prefix: string,
153
+ seen: Set<string>,
154
+ lines: string[]
155
+ ): void {
156
+ seen.add(nodeName);
157
+
158
+ // Find outgoing edges to other workspace packages
159
+ const outgoing = graph.edges.filter(
160
+ (e) =>
161
+ e.from === nodeName && graph.nodes.get(e.to)?.type === "workspace"
162
+ );
163
+
164
+ for (let i = 0; i < outgoing.length; i++) {
165
+ const edge = outgoing[i];
166
+ const isLast = i === outgoing.length - 1;
167
+ const connector = isLast ? "└──" : "├──";
168
+ const childPrefix = prefix + (isLast ? " " : "│ ");
169
+
170
+ lines.push(`${prefix}${connector} 📦 ${edge.to} [${edge.type}]`);
171
+ if (!seen.has(edge.to)) {
172
+ printTree(graph, edge.to, childPrefix, seen, lines);
173
+ }
174
+ }
175
+
176
+ // Show external dependencies count
177
+ const externals = graph.edges.filter(
178
+ (e) =>
179
+ e.from === nodeName && graph.nodes.get(e.to)?.type === "external"
180
+ );
181
+ if (externals.length > 0) {
182
+ lines.push(`${prefix}└── 📚 ${externals.length} external dependencies`);
183
+ }
184
+ }
185
+
186
+ /**
187
+ * Sanitize ID for Mermaid/DOT formats
188
+ */
189
+ function sanitizeId(name: string): string {
190
+ return name.replace(/[^a-zA-Z0-9]/g, "_");
191
+ }
package/src/index.ts ADDED
@@ -0,0 +1,56 @@
1
+ /**
2
+ * @ebowwa/dependency-graph
3
+ *
4
+ * Core dependency graph analysis and visualization for monorepos
5
+ *
6
+ * @example
7
+ * ```typescript
8
+ * import { DependencyGraphBuilder, formatGraph, analyzeImpact, findCircularDependencies } from '@ebowwa/dependency-graph';
9
+ *
10
+ * const builder = new DependencyGraphBuilder('/path/to/monorepo');
11
+ * const graph = await builder.build({ analyzeImports: true });
12
+ *
13
+ * // Format as Mermaid diagram
14
+ * const mermaid = formatGraph(graph, 'mermaid');
15
+ *
16
+ * // Find circular dependencies
17
+ * const cycles = findCircularDependencies(graph);
18
+ *
19
+ * // Analyze impact of changing a package
20
+ * const impact = analyzeImpact(graph, '@ebowwa/terminal');
21
+ * ```
22
+ */
23
+
24
+ // Types
25
+ export type {
26
+ DependencyNode,
27
+ DependencyEdge,
28
+ DependencyGraph,
29
+ ImportInfo,
30
+ BuildOptions,
31
+ ImpactResult,
32
+ OutputFormat,
33
+ PackageInfo,
34
+ } from "./types.js";
35
+
36
+ // Builder
37
+ export { DependencyGraphBuilder } from "./builder.js";
38
+
39
+ // Analysis
40
+ export {
41
+ findCircularDependencies,
42
+ analyzeImpact,
43
+ findUnusedPackages,
44
+ getPackageInfo,
45
+ getWorkspacePackages,
46
+ getExternalDependencies,
47
+ } from "./analysis.js";
48
+
49
+ // Formatters
50
+ export {
51
+ formatGraph,
52
+ formatAsJson,
53
+ formatAsMermaid,
54
+ formatAsDot,
55
+ formatAsTree,
56
+ } from "./formatters.js";
package/src/types.ts ADDED
@@ -0,0 +1,58 @@
1
+ /**
2
+ * @ebowwa/dependency-graph - Core Types
3
+ *
4
+ * Type definitions for dependency graph analysis
5
+ */
6
+
7
+ export interface DependencyNode {
8
+ name: string;
9
+ path: string;
10
+ type: "package" | "workspace" | "external";
11
+ version?: string;
12
+ }
13
+
14
+ export interface DependencyEdge {
15
+ from: string;
16
+ to: string;
17
+ type: "workspace" | "external" | "import";
18
+ importPath?: string;
19
+ }
20
+
21
+ export interface DependencyGraph {
22
+ nodes: Map<string, DependencyNode>;
23
+ edges: DependencyEdge[];
24
+ reverseEdges: Map<string, Set<string>>;
25
+ }
26
+
27
+ export interface ImportInfo {
28
+ from: string;
29
+ imports: string[];
30
+ file: string;
31
+ }
32
+
33
+ export interface BuildOptions {
34
+ includeDevDependencies?: boolean;
35
+ analyzeImports?: boolean;
36
+ excludePatterns?: string[];
37
+ }
38
+
39
+ export interface ImpactResult {
40
+ direct: string[];
41
+ transitive: string[];
42
+ all: string[];
43
+ }
44
+
45
+ export type OutputFormat = "json" | "mermaid" | "dot" | "tree";
46
+
47
+ export interface PackageInfo {
48
+ name: string;
49
+ type: "package" | "workspace" | "external";
50
+ path: string;
51
+ version?: string;
52
+ dependencies: Array<{
53
+ name: string;
54
+ type: "workspace" | "external" | "import";
55
+ version?: string;
56
+ }>;
57
+ dependents: string[];
58
+ }