@reactgraph/cli 0.1.1

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 (178) hide show
  1. package/README.md +319 -0
  2. package/bun.lock +527 -0
  3. package/dist/cli/components/IndexProgress.d.ts +18 -0
  4. package/dist/cli/components/IndexProgress.d.ts.map +1 -0
  5. package/dist/cli/components/IndexProgress.js +26 -0
  6. package/dist/cli/components/IndexProgress.js.map +1 -0
  7. package/dist/cli/components/InitResult.d.ts +7 -0
  8. package/dist/cli/components/InitResult.d.ts.map +1 -0
  9. package/dist/cli/components/InitResult.js +6 -0
  10. package/dist/cli/components/InitResult.js.map +1 -0
  11. package/dist/cli/index-cmd.d.ts +7 -0
  12. package/dist/cli/index-cmd.d.ts.map +1 -0
  13. package/dist/cli/index-cmd.js +28 -0
  14. package/dist/cli/index-cmd.js.map +1 -0
  15. package/dist/cli/index.d.ts +3 -0
  16. package/dist/cli/index.d.ts.map +1 -0
  17. package/dist/cli/index.js +81 -0
  18. package/dist/cli/index.js.map +1 -0
  19. package/dist/cli/init.d.ts +8 -0
  20. package/dist/cli/init.d.ts.map +1 -0
  21. package/dist/cli/init.js +77 -0
  22. package/dist/cli/init.js.map +1 -0
  23. package/dist/cli/serve.d.ts +2 -0
  24. package/dist/cli/serve.d.ts.map +1 -0
  25. package/dist/cli/serve.js +28 -0
  26. package/dist/cli/serve.js.map +1 -0
  27. package/dist/cli/unused.d.ts +2 -0
  28. package/dist/cli/unused.d.ts.map +1 -0
  29. package/dist/cli/unused.js +56 -0
  30. package/dist/cli/unused.js.map +1 -0
  31. package/dist/graph/graph.d.ts +30 -0
  32. package/dist/graph/graph.d.ts.map +1 -0
  33. package/dist/graph/graph.js +166 -0
  34. package/dist/graph/graph.js.map +1 -0
  35. package/dist/graph/index.d.ts +5 -0
  36. package/dist/graph/index.d.ts.map +1 -0
  37. package/dist/graph/index.js +5 -0
  38. package/dist/graph/index.js.map +1 -0
  39. package/dist/graph/schema.d.ts +33 -0
  40. package/dist/graph/schema.d.ts.map +1 -0
  41. package/dist/graph/schema.js +3 -0
  42. package/dist/graph/schema.js.map +1 -0
  43. package/dist/graph/serialize.d.ts +7 -0
  44. package/dist/graph/serialize.d.ts.map +1 -0
  45. package/dist/graph/serialize.js +39 -0
  46. package/dist/graph/serialize.js.map +1 -0
  47. package/dist/graph/traverse.d.ts +14 -0
  48. package/dist/graph/traverse.d.ts.map +1 -0
  49. package/dist/graph/traverse.js +50 -0
  50. package/dist/graph/traverse.js.map +1 -0
  51. package/dist/mcp/formatter.d.ts +26 -0
  52. package/dist/mcp/formatter.d.ts.map +1 -0
  53. package/dist/mcp/formatter.js +691 -0
  54. package/dist/mcp/formatter.js.map +1 -0
  55. package/dist/mcp/server.d.ts +2 -0
  56. package/dist/mcp/server.d.ts.map +1 -0
  57. package/dist/mcp/server.js +45 -0
  58. package/dist/mcp/server.js.map +1 -0
  59. package/dist/mcp/tools.d.ts +9 -0
  60. package/dist/mcp/tools.d.ts.map +1 -0
  61. package/dist/mcp/tools.js +136 -0
  62. package/dist/mcp/tools.js.map +1 -0
  63. package/dist/output/ai-context.d.ts +7 -0
  64. package/dist/output/ai-context.d.ts.map +1 -0
  65. package/dist/output/ai-context.js +26 -0
  66. package/dist/output/ai-context.js.map +1 -0
  67. package/dist/parser/extractors/api-calls.d.ts +15 -0
  68. package/dist/parser/extractors/api-calls.d.ts.map +1 -0
  69. package/dist/parser/extractors/api-calls.js +168 -0
  70. package/dist/parser/extractors/api-calls.js.map +1 -0
  71. package/dist/parser/extractors/components.d.ts +5 -0
  72. package/dist/parser/extractors/components.d.ts.map +1 -0
  73. package/dist/parser/extractors/components.js +236 -0
  74. package/dist/parser/extractors/components.js.map +1 -0
  75. package/dist/parser/extractors/context.d.ts +14 -0
  76. package/dist/parser/extractors/context.d.ts.map +1 -0
  77. package/dist/parser/extractors/context.js +196 -0
  78. package/dist/parser/extractors/context.js.map +1 -0
  79. package/dist/parser/extractors/effects.d.ts +14 -0
  80. package/dist/parser/extractors/effects.d.ts.map +1 -0
  81. package/dist/parser/extractors/effects.js +175 -0
  82. package/dist/parser/extractors/effects.js.map +1 -0
  83. package/dist/parser/extractors/hooks.d.ts +5 -0
  84. package/dist/parser/extractors/hooks.d.ts.map +1 -0
  85. package/dist/parser/extractors/hooks.js +242 -0
  86. package/dist/parser/extractors/hooks.js.map +1 -0
  87. package/dist/parser/extractors/imports.d.ts +6 -0
  88. package/dist/parser/extractors/imports.d.ts.map +1 -0
  89. package/dist/parser/extractors/imports.js +148 -0
  90. package/dist/parser/extractors/imports.js.map +1 -0
  91. package/dist/parser/extractors/index.d.ts +12 -0
  92. package/dist/parser/extractors/index.d.ts.map +1 -0
  93. package/dist/parser/extractors/index.js +11 -0
  94. package/dist/parser/extractors/index.js.map +1 -0
  95. package/dist/parser/extractors/jsx-tree.d.ts +5 -0
  96. package/dist/parser/extractors/jsx-tree.d.ts.map +1 -0
  97. package/dist/parser/extractors/jsx-tree.js +226 -0
  98. package/dist/parser/extractors/jsx-tree.js.map +1 -0
  99. package/dist/parser/extractors/routes.d.ts +13 -0
  100. package/dist/parser/extractors/routes.d.ts.map +1 -0
  101. package/dist/parser/extractors/routes.js +275 -0
  102. package/dist/parser/extractors/routes.js.map +1 -0
  103. package/dist/parser/extractors/state.d.ts +14 -0
  104. package/dist/parser/extractors/state.d.ts.map +1 -0
  105. package/dist/parser/extractors/state.js +368 -0
  106. package/dist/parser/extractors/state.js.map +1 -0
  107. package/dist/parser/extractors/types.d.ts +22 -0
  108. package/dist/parser/extractors/types.d.ts.map +1 -0
  109. package/dist/parser/extractors/types.js +51 -0
  110. package/dist/parser/extractors/types.js.map +1 -0
  111. package/dist/parser/indexer.d.ts +14 -0
  112. package/dist/parser/indexer.d.ts.map +1 -0
  113. package/dist/parser/indexer.js +167 -0
  114. package/dist/parser/indexer.js.map +1 -0
  115. package/dist/parser/pipeline.d.ts +16 -0
  116. package/dist/parser/pipeline.d.ts.map +1 -0
  117. package/dist/parser/pipeline.js +63 -0
  118. package/dist/parser/pipeline.js.map +1 -0
  119. package/dist/parser/setup.d.ts +4 -0
  120. package/dist/parser/setup.d.ts.map +1 -0
  121. package/dist/parser/setup.js +29 -0
  122. package/dist/parser/setup.js.map +1 -0
  123. package/dist/parser/walker.d.ts +6 -0
  124. package/dist/parser/walker.d.ts.map +1 -0
  125. package/dist/parser/walker.js +45 -0
  126. package/dist/parser/walker.js.map +1 -0
  127. package/dist/watcher.d.ts +12 -0
  128. package/dist/watcher.d.ts.map +1 -0
  129. package/dist/watcher.js +72 -0
  130. package/dist/watcher.js.map +1 -0
  131. package/package.json +51 -0
  132. package/src/cli/components/IndexProgress.tsx +79 -0
  133. package/src/cli/components/InitResult.tsx +28 -0
  134. package/src/cli/index-cmd.ts +41 -0
  135. package/src/cli/index.ts +92 -0
  136. package/src/cli/init.ts +97 -0
  137. package/src/cli/serve.ts +29 -0
  138. package/src/cli/unused.ts +88 -0
  139. package/src/graph/graph.ts +179 -0
  140. package/src/graph/index.ts +4 -0
  141. package/src/graph/schema.ts +68 -0
  142. package/src/graph/serialize.ts +40 -0
  143. package/src/graph/traverse.ts +66 -0
  144. package/src/mcp/formatter.ts +757 -0
  145. package/src/mcp/server.ts +59 -0
  146. package/src/mcp/tools.ts +154 -0
  147. package/src/output/ai-context.ts +29 -0
  148. package/src/parser/extractors/api-calls.ts +192 -0
  149. package/src/parser/extractors/components.ts +273 -0
  150. package/src/parser/extractors/context.ts +216 -0
  151. package/src/parser/extractors/effects.ts +205 -0
  152. package/src/parser/extractors/hooks.ts +268 -0
  153. package/src/parser/extractors/imports.ts +192 -0
  154. package/src/parser/extractors/index.ts +11 -0
  155. package/src/parser/extractors/jsx-tree.ts +271 -0
  156. package/src/parser/extractors/routes.ts +331 -0
  157. package/src/parser/extractors/state.ts +392 -0
  158. package/src/parser/extractors/types.ts +71 -0
  159. package/src/parser/indexer.ts +197 -0
  160. package/src/parser/pipeline.ts +89 -0
  161. package/src/parser/setup.ts +33 -0
  162. package/src/parser/walker.ts +61 -0
  163. package/src/watcher.ts +91 -0
  164. package/templates/CLAUDE.md +7 -0
  165. package/tests/extractors.test.ts +164 -0
  166. package/tests/fixtures/basic/src/App.tsx +12 -0
  167. package/tests/fixtures/basic/src/components/Dashboard.tsx +24 -0
  168. package/tests/fixtures/basic/src/components/MetricsCard.tsx +15 -0
  169. package/tests/fixtures/basic/src/components/Sidebar.tsx +20 -0
  170. package/tests/fixtures/basic/src/contexts/ThemeContext.tsx +16 -0
  171. package/tests/fixtures/basic/src/hooks/useAuth.ts +25 -0
  172. package/tests/fixtures/basic/src/stores/authStore.ts +15 -0
  173. package/tests/fixtures/basic/src/utils.ts +7 -0
  174. package/tests/graph.test.ts +91 -0
  175. package/tests/phase2.test.ts +309 -0
  176. package/tests/smoke.test.ts +77 -0
  177. package/tsconfig.json +20 -0
  178. package/vitest.config.ts +8 -0
@@ -0,0 +1,97 @@
1
+ import { existsSync, readFileSync, writeFileSync, appendFileSync, mkdirSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import type { ReactGraphConfig } from '../graph/schema.js';
4
+
5
+ export interface InitResult {
6
+ framework: string;
7
+ srcDirs: string[];
8
+ configCreated: boolean;
9
+ gitignoreUpdated: boolean;
10
+ }
11
+
12
+ export function runInit(projectDir: string): InitResult {
13
+ const framework = detectFramework(projectDir);
14
+ const srcDirs = detectSrcDirs(projectDir);
15
+
16
+ // Create .reactgraph/ directory
17
+ const rgDir = join(projectDir, '.reactgraph');
18
+ mkdirSync(rgDir, { recursive: true });
19
+
20
+ // Write config
21
+ const config: ReactGraphConfig = {
22
+ srcDirs,
23
+ framework,
24
+ exclude: ['**/*.test.*', '**/*.spec.*', '**/*.stories.*'],
25
+ };
26
+ writeFileSync(join(rgDir, 'config.json'), JSON.stringify(config, null, 2));
27
+
28
+ // Update .gitignore
29
+ const gitignoreUpdated = ensureGitignore(projectDir);
30
+
31
+ return {
32
+ framework,
33
+ srcDirs,
34
+ configCreated: true,
35
+ gitignoreUpdated,
36
+ };
37
+ }
38
+
39
+ function detectFramework(projectDir: string): ReactGraphConfig['framework'] {
40
+ // Next.js
41
+ if (
42
+ existsSync(join(projectDir, 'next.config.js')) ||
43
+ existsSync(join(projectDir, 'next.config.mjs')) ||
44
+ existsSync(join(projectDir, 'next.config.ts'))
45
+ ) {
46
+ return 'next';
47
+ }
48
+
49
+ // Vite
50
+ if (
51
+ existsSync(join(projectDir, 'vite.config.ts')) ||
52
+ existsSync(join(projectDir, 'vite.config.js'))
53
+ ) {
54
+ return 'vite';
55
+ }
56
+
57
+ // Remix
58
+ if (existsSync(join(projectDir, 'remix.config.js'))) {
59
+ return 'remix';
60
+ }
61
+
62
+ // CRA (react-scripts in package.json)
63
+ try {
64
+ const pkg = JSON.parse(readFileSync(join(projectDir, 'package.json'), 'utf-8'));
65
+ if (pkg.dependencies?.['react-scripts'] || pkg.devDependencies?.['react-scripts']) {
66
+ return 'cra';
67
+ }
68
+ } catch {
69
+ // no package.json
70
+ }
71
+
72
+ return 'unknown';
73
+ }
74
+
75
+ function detectSrcDirs(projectDir: string): string[] {
76
+ const candidates = ['src', 'app', 'pages', 'lib'];
77
+ const found = candidates.filter(d => existsSync(join(projectDir, d)));
78
+ return found.length > 0 ? found : ['.'];
79
+ }
80
+
81
+ function ensureGitignore(projectDir: string): boolean {
82
+ const gitignorePath = join(projectDir, '.gitignore');
83
+ const entry = '.reactgraph/';
84
+
85
+ try {
86
+ if (existsSync(gitignorePath)) {
87
+ const content = readFileSync(gitignorePath, 'utf-8');
88
+ if (content.includes(entry)) return false;
89
+ appendFileSync(gitignorePath, `\n# ReactGraph cache\n${entry}\n`);
90
+ } else {
91
+ writeFileSync(gitignorePath, `# ReactGraph cache\n${entry}\n`);
92
+ }
93
+ return true;
94
+ } catch {
95
+ return false;
96
+ }
97
+ }
@@ -0,0 +1,29 @@
1
+ import { loadGraph } from '../graph/serialize.js';
2
+ import { startServer } from '../mcp/server.js';
3
+ import { startWatcher } from '../watcher.js';
4
+
5
+ export async function runServe(projectDir: string, watchMode: boolean = false): Promise<void> {
6
+ if (watchMode) {
7
+ const graph = await loadGraph(projectDir);
8
+ if (!graph) {
9
+ console.error('No graph found. Run "reactgraph index" first.');
10
+ process.exit(1);
11
+ }
12
+
13
+ startWatcher({
14
+ projectDir,
15
+ graph,
16
+ onUpdate: (file, stats) => {
17
+ process.stderr.write(`[reactgraph] Updated: ${file} (${stats.nodes} nodes, ${stats.edges} edges)\n`);
18
+ },
19
+ onError: (file, err) => {
20
+ process.stderr.write(`[reactgraph] Error parsing ${file}: ${err.message}\n`);
21
+ },
22
+ });
23
+
24
+ // Start MCP server with the live graph
25
+ await startServer(projectDir, graph);
26
+ } else {
27
+ await startServer(projectDir);
28
+ }
29
+ }
@@ -0,0 +1,88 @@
1
+ import { render } from 'ink';
2
+ import React from 'react';
3
+ import { Box, Text } from 'ink';
4
+ import { loadGraph } from '../graph/serialize.js';
5
+ import type { ReactGraph } from '../graph/graph.js';
6
+ import type { GraphNode } from '../graph/schema.js';
7
+
8
+ export async function runUnused(projectDir: string): Promise<void> {
9
+ const graph = await loadGraph(projectDir);
10
+ if (!graph) {
11
+ console.error('No graph found. Run "reactgraph index" first.');
12
+ process.exit(1);
13
+ }
14
+
15
+ const orphanComponents = findOrphanComponents(graph);
16
+ const unusedHooks = findUnusedHooks(graph);
17
+ const unusedExports = findUnusedExports(graph);
18
+
19
+ const App = () => React.createElement(Box, { flexDirection: 'column', paddingTop: 1 },
20
+ React.createElement(Text, { bold: true }, 'ReactGraph — Unused Analysis'),
21
+ React.createElement(Text, null, ''),
22
+
23
+ // Orphan components
24
+ orphanComponents.length > 0
25
+ ? React.createElement(Box, { flexDirection: 'column' },
26
+ React.createElement(Text, { color: 'yellow' }, `Orphan components (${orphanComponents.length}) — rendered by nothing:`),
27
+ ...orphanComponents.map(c =>
28
+ React.createElement(Text, { key: c.id, dimColor: true }, ` ${c.name} [${c.file}:${c.line}]`)
29
+ )
30
+ )
31
+ : React.createElement(Text, { color: 'green' }, '✓ No orphan components'),
32
+
33
+ React.createElement(Text, null, ''),
34
+
35
+ // Unused hooks
36
+ unusedHooks.length > 0
37
+ ? React.createElement(Box, { flexDirection: 'column' },
38
+ React.createElement(Text, { color: 'yellow' }, `Unused hooks (${unusedHooks.length}) — never called:`),
39
+ ...unusedHooks.map(h =>
40
+ React.createElement(Text, { key: h.id, dimColor: true }, ` ${h.name} [${h.file}:${h.line}]`)
41
+ )
42
+ )
43
+ : React.createElement(Text, { color: 'green' }, '✓ No unused hooks'),
44
+
45
+ React.createElement(Text, null, ''),
46
+
47
+ // Unused exports
48
+ unusedExports.length > 0
49
+ ? React.createElement(Box, { flexDirection: 'column' },
50
+ React.createElement(Text, { color: 'yellow' }, `Unused exports (${unusedExports.length}) — never imported:`),
51
+ ...unusedExports.map(e =>
52
+ React.createElement(Text, { key: e.id, dimColor: true }, ` ${e.name} [${e.file}:${e.line}]`)
53
+ )
54
+ )
55
+ : React.createElement(Text, { color: 'green' }, '✓ No unused exports'),
56
+ );
57
+
58
+ const { unmount } = render(React.createElement(App));
59
+ setTimeout(() => unmount(), 100);
60
+ }
61
+
62
+ function findOrphanComponents(graph: ReactGraph): GraphNode[] {
63
+ return graph.getNodesByKind('Component').filter(c => {
64
+ // A component is orphan if nothing renders it and it's not a route target
65
+ const renderedBy = graph.getEdgesTo(c.id, ['renders']);
66
+ return renderedBy.length === 0;
67
+ });
68
+ }
69
+
70
+ function findUnusedHooks(graph: ReactGraph): GraphNode[] {
71
+ return graph.getNodesByKind('Hook').filter(h => {
72
+ const usedBy = graph.getEdgesTo(h.id, ['uses_hook']);
73
+ return usedBy.length === 0;
74
+ });
75
+ }
76
+
77
+ function findUnusedExports(graph: ReactGraph): GraphNode[] {
78
+ // Find exported nodes that are never imported by anything
79
+ const allNodes = graph.getAllNodes();
80
+ return allNodes.filter(n => {
81
+ if (n.exportType === 'none') return false;
82
+ if (n.kind === 'Module') return false; // Modules themselves aren't "used"
83
+
84
+ // Check if any edge points to this node
85
+ const incoming = graph.getEdgesTo(n.id);
86
+ return incoming.length === 0;
87
+ });
88
+ }
@@ -0,0 +1,179 @@
1
+ import type { GraphNode, GraphEdge, NodeKind, EdgeKind } from './schema.js';
2
+
3
+ export class ReactGraph {
4
+ private nodes = new Map<string, GraphNode>();
5
+ private edges: GraphEdge[] = [];
6
+
7
+ // Secondary indexes
8
+ private byKind = new Map<NodeKind, Set<string>>();
9
+ private byFile = new Map<string, Set<string>>();
10
+ private byName = new Map<string, string[]>();
11
+ private edgesBySource = new Map<string, GraphEdge[]>();
12
+ private edgesByTarget = new Map<string, GraphEdge[]>();
13
+ private edgesByKind = new Map<EdgeKind, GraphEdge[]>();
14
+
15
+ addNode(node: GraphNode): void {
16
+ this.nodes.set(node.id, node);
17
+
18
+ // Index by kind
19
+ if (!this.byKind.has(node.kind)) this.byKind.set(node.kind, new Set());
20
+ this.byKind.get(node.kind)!.add(node.id);
21
+
22
+ // Index by file
23
+ if (!this.byFile.has(node.file)) this.byFile.set(node.file, new Set());
24
+ this.byFile.get(node.file)!.add(node.id);
25
+
26
+ // Index by name
27
+ if (!this.byName.has(node.name)) this.byName.set(node.name, []);
28
+ this.byName.get(node.name)!.push(node.id);
29
+ }
30
+
31
+ addEdge(edge: GraphEdge): void {
32
+ this.edges.push(edge);
33
+
34
+ if (!this.edgesBySource.has(edge.source)) this.edgesBySource.set(edge.source, []);
35
+ this.edgesBySource.get(edge.source)!.push(edge);
36
+
37
+ if (!this.edgesByTarget.has(edge.target)) this.edgesByTarget.set(edge.target, []);
38
+ this.edgesByTarget.get(edge.target)!.push(edge);
39
+
40
+ if (!this.edgesByKind.has(edge.kind)) this.edgesByKind.set(edge.kind, []);
41
+ this.edgesByKind.get(edge.kind)!.push(edge);
42
+ }
43
+
44
+ getNode(id: string): GraphNode | undefined {
45
+ return this.nodes.get(id);
46
+ }
47
+
48
+ getAllNodes(): GraphNode[] {
49
+ return Array.from(this.nodes.values());
50
+ }
51
+
52
+ getAllEdges(): GraphEdge[] {
53
+ return this.edges;
54
+ }
55
+
56
+ getNodesByKind(kind: NodeKind): GraphNode[] {
57
+ const ids = this.byKind.get(kind);
58
+ if (!ids) return [];
59
+ return Array.from(ids).map(id => this.nodes.get(id)!);
60
+ }
61
+
62
+ getNodesByFile(file: string): GraphNode[] {
63
+ const ids = this.byFile.get(file);
64
+ if (!ids) return [];
65
+ return Array.from(ids).map(id => this.nodes.get(id)!);
66
+ }
67
+
68
+ getNodesByName(name: string): GraphNode[] {
69
+ const ids = this.byName.get(name);
70
+ if (!ids) return [];
71
+ return ids.map(id => this.nodes.get(id)!);
72
+ }
73
+
74
+ getEdgesFrom(nodeId: string, kinds?: EdgeKind[]): GraphEdge[] {
75
+ const edges = this.edgesBySource.get(nodeId) ?? [];
76
+ if (!kinds) return edges;
77
+ return edges.filter(e => kinds.includes(e.kind));
78
+ }
79
+
80
+ getEdgesTo(nodeId: string, kinds?: EdgeKind[]): GraphEdge[] {
81
+ const edges = this.edgesByTarget.get(nodeId) ?? [];
82
+ if (!kinds) return edges;
83
+ return edges.filter(e => kinds.includes(e.kind));
84
+ }
85
+
86
+ getEdgesByKind(kind: EdgeKind): GraphEdge[] {
87
+ return this.edgesByKind.get(kind) ?? [];
88
+ }
89
+
90
+ removeNodesByFile(file: string): void {
91
+ const ids = this.byFile.get(file);
92
+ if (!ids) return;
93
+
94
+ // Remove edges referencing these nodes
95
+ const idSet = new Set(ids);
96
+ this.edges = this.edges.filter(e => !idSet.has(e.source) && !idSet.has(e.target));
97
+
98
+ // Rebuild edge indexes
99
+ this.edgesBySource.clear();
100
+ this.edgesByTarget.clear();
101
+ this.edgesByKind.clear();
102
+ for (const edge of this.edges) {
103
+ if (!this.edgesBySource.has(edge.source)) this.edgesBySource.set(edge.source, []);
104
+ this.edgesBySource.get(edge.source)!.push(edge);
105
+ if (!this.edgesByTarget.has(edge.target)) this.edgesByTarget.set(edge.target, []);
106
+ this.edgesByTarget.get(edge.target)!.push(edge);
107
+ if (!this.edgesByKind.has(edge.kind)) this.edgesByKind.set(edge.kind, []);
108
+ this.edgesByKind.get(edge.kind)!.push(edge);
109
+ }
110
+
111
+ // Remove nodes
112
+ for (const id of ids) {
113
+ const node = this.nodes.get(id)!;
114
+ this.nodes.delete(id);
115
+
116
+ // Clean kind index
117
+ this.byKind.get(node.kind)?.delete(id);
118
+
119
+ // Clean name index
120
+ const nameIds = this.byName.get(node.name);
121
+ if (nameIds) {
122
+ const idx = nameIds.indexOf(id);
123
+ if (idx >= 0) nameIds.splice(idx, 1);
124
+ if (nameIds.length === 0) this.byName.delete(node.name);
125
+ }
126
+ }
127
+
128
+ this.byFile.delete(file);
129
+ }
130
+
131
+ clear(): void {
132
+ this.nodes.clear();
133
+ this.edges = [];
134
+ this.byKind.clear();
135
+ this.byFile.clear();
136
+ this.byName.clear();
137
+ this.edgesBySource.clear();
138
+ this.edgesByTarget.clear();
139
+ this.edgesByKind.clear();
140
+ }
141
+
142
+ /** Find a node by name with fuzzy matching: exact → case-insensitive → partial */
143
+ findNode(name: string): GraphNode | undefined {
144
+ // Exact match
145
+ const exact = this.byName.get(name);
146
+ if (exact?.length) return this.nodes.get(exact[0]);
147
+
148
+ // Case-insensitive
149
+ const lower = name.toLowerCase();
150
+ for (const [key, ids] of this.byName) {
151
+ if (key.toLowerCase() === lower && ids.length) return this.nodes.get(ids[0]);
152
+ }
153
+
154
+ // Partial match
155
+ for (const [key, ids] of this.byName) {
156
+ if (key.toLowerCase().includes(lower) && ids.length) return this.nodes.get(ids[0]);
157
+ }
158
+
159
+ return undefined;
160
+ }
161
+
162
+ /** Count of in+out edges for a node */
163
+ connectivity(nodeId: string): number {
164
+ return (this.edgesBySource.get(nodeId)?.length ?? 0) +
165
+ (this.edgesByTarget.get(nodeId)?.length ?? 0);
166
+ }
167
+
168
+ stats(): Record<string, number> {
169
+ const s: Record<string, number> = {
170
+ totalNodes: this.nodes.size,
171
+ totalEdges: this.edges.length,
172
+ files: this.byFile.size,
173
+ };
174
+ for (const [kind, ids] of this.byKind) {
175
+ s[kind] = ids.size;
176
+ }
177
+ return s;
178
+ }
179
+ }
@@ -0,0 +1,4 @@
1
+ export { ReactGraph } from './graph.js';
2
+ export * from './schema.js';
3
+ export * from './serialize.js';
4
+ export * from './traverse.js';
@@ -0,0 +1,68 @@
1
+ // ---- Node Types ----
2
+
3
+ export type NodeKind =
4
+ | 'Component'
5
+ | 'Hook'
6
+ | 'BuiltinHook'
7
+ | 'Context'
8
+ | 'Route'
9
+ | 'Module'
10
+ | 'Store'
11
+ | 'ApiEndpoint'
12
+ | 'Type'
13
+ | 'Util'
14
+ | 'Constant';
15
+
16
+ export interface GraphNode {
17
+ id: string;
18
+ kind: NodeKind;
19
+ name: string;
20
+ file: string; // relative path
21
+ line: number;
22
+ exportType: 'default' | 'named' | 'none';
23
+ props?: string[];
24
+ returns?: string;
25
+ doc?: string;
26
+ meta: Record<string, unknown>;
27
+ }
28
+
29
+ // ---- Edge Types ----
30
+
31
+ export type EdgeKind =
32
+ | 'imports'
33
+ | 'renders'
34
+ | 'passes_prop'
35
+ | 'uses_hook'
36
+ | 'provides'
37
+ | 'consumes'
38
+ | 'calls'
39
+ | 'fetches'
40
+ | 'reads_store'
41
+ | 'writes_store'
42
+ | 'routes_to'
43
+ | 'type_of';
44
+
45
+ export interface GraphEdge {
46
+ source: string; // node id
47
+ target: string; // node id
48
+ kind: EdgeKind;
49
+ meta: Record<string, unknown>;
50
+ }
51
+
52
+ // ---- Serialization ----
53
+
54
+ export interface SerializedGraph {
55
+ version: number;
56
+ timestamp: string;
57
+ nodes: GraphNode[];
58
+ edges: GraphEdge[];
59
+ }
60
+
61
+ // ---- Config ----
62
+
63
+ export interface ReactGraphConfig {
64
+ srcDirs: string[];
65
+ framework: 'next' | 'vite' | 'cra' | 'remix' | 'unknown';
66
+ exclude: string[];
67
+ aliases?: Record<string, string>;
68
+ }
@@ -0,0 +1,40 @@
1
+ import { readFile, writeFile, mkdir } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import type { SerializedGraph } from './schema.js';
4
+ import { ReactGraph } from './graph.js';
5
+
6
+ const GRAPH_VERSION = 1;
7
+
8
+ export function serializeGraph(graph: ReactGraph): SerializedGraph {
9
+ return {
10
+ version: GRAPH_VERSION,
11
+ timestamp: new Date().toISOString(),
12
+ nodes: graph.getAllNodes(),
13
+ edges: graph.getAllEdges(),
14
+ };
15
+ }
16
+
17
+ export function deserializeGraph(data: SerializedGraph): ReactGraph {
18
+ const graph = new ReactGraph();
19
+ for (const node of data.nodes) graph.addNode(node);
20
+ for (const edge of data.edges) graph.addEdge(edge);
21
+ return graph;
22
+ }
23
+
24
+ export async function saveGraph(graph: ReactGraph, projectDir: string): Promise<void> {
25
+ const dir = join(projectDir, '.reactgraph');
26
+ await mkdir(dir, { recursive: true });
27
+ const data = serializeGraph(graph);
28
+ await writeFile(join(dir, 'graph.json'), JSON.stringify(data, null, 2));
29
+ }
30
+
31
+ export async function loadGraph(projectDir: string): Promise<ReactGraph | null> {
32
+ try {
33
+ const raw = await readFile(join(projectDir, '.reactgraph', 'graph.json'), 'utf-8');
34
+ const data: SerializedGraph = JSON.parse(raw);
35
+ if (data.version !== GRAPH_VERSION) return null;
36
+ return deserializeGraph(data);
37
+ } catch {
38
+ return null;
39
+ }
40
+ }
@@ -0,0 +1,66 @@
1
+ import type { GraphNode, GraphEdge, EdgeKind } from './schema.js';
2
+ import { ReactGraph } from './graph.js';
3
+
4
+ export interface SubgraphResult {
5
+ nodes: GraphNode[];
6
+ edges: GraphEdge[];
7
+ root: GraphNode;
8
+ }
9
+
10
+ /** BFS from a node, collecting all connected nodes/edges up to depth */
11
+ export function getSubgraph(graph: ReactGraph, nodeId: string, depth: number = 2): SubgraphResult | null {
12
+ const root = graph.getNode(nodeId);
13
+ if (!root) return null;
14
+
15
+ const visitedNodes = new Set<string>([nodeId]);
16
+ const resultEdges: GraphEdge[] = [];
17
+ let frontier = [nodeId];
18
+
19
+ for (let d = 0; d < depth && frontier.length > 0; d++) {
20
+ const nextFrontier: string[] = [];
21
+
22
+ for (const id of frontier) {
23
+ // Outgoing edges
24
+ for (const edge of graph.getEdgesFrom(id)) {
25
+ resultEdges.push(edge);
26
+ if (!visitedNodes.has(edge.target)) {
27
+ visitedNodes.add(edge.target);
28
+ nextFrontier.push(edge.target);
29
+ }
30
+ }
31
+
32
+ // Incoming edges
33
+ for (const edge of graph.getEdgesTo(id)) {
34
+ resultEdges.push(edge);
35
+ if (!visitedNodes.has(edge.source)) {
36
+ visitedNodes.add(edge.source);
37
+ nextFrontier.push(edge.source);
38
+ }
39
+ }
40
+ }
41
+
42
+ frontier = nextFrontier;
43
+ }
44
+
45
+ const nodes = Array.from(visitedNodes)
46
+ .map(id => graph.getNode(id))
47
+ .filter((n): n is GraphNode => n !== undefined);
48
+
49
+ return { nodes, edges: resultEdges, root };
50
+ }
51
+
52
+ /** Get all nodes pointing to this node */
53
+ export function getIncoming(graph: ReactGraph, nodeId: string, edgeKinds?: EdgeKind[]): GraphNode[] {
54
+ const edges = graph.getEdgesTo(nodeId, edgeKinds);
55
+ return edges
56
+ .map(e => graph.getNode(e.source))
57
+ .filter((n): n is GraphNode => n !== undefined);
58
+ }
59
+
60
+ /** Get all nodes this node points to */
61
+ export function getOutgoing(graph: ReactGraph, nodeId: string, edgeKinds?: EdgeKind[]): GraphNode[] {
62
+ const edges = graph.getEdgesFrom(nodeId, edgeKinds);
63
+ return edges
64
+ .map(e => graph.getNode(e.target))
65
+ .filter((n): n is GraphNode => n !== undefined);
66
+ }