@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,757 @@
1
+ import { ReactGraph } from '../graph/graph.js';
2
+ import { getSubgraph } from '../graph/traverse.js';
3
+ import type { GraphNode, GraphEdge, NodeKind } from '../graph/schema.js';
4
+
5
+ /**
6
+ * Generate the full project map — token-optimized structured text.
7
+ */
8
+ export function formatMap(graph: ReactGraph, maxComponents: number = 30): string {
9
+ const stats = graph.stats();
10
+ const lines: string[] = [];
11
+
12
+ const routeCount = stats.Route ?? 0;
13
+ const storeCount = stats.Store ?? 0;
14
+ const endpointCount = stats.ApiEndpoint ?? 0;
15
+
16
+ // Header
17
+ lines.push(`# REACTGRAPH MAP | ${stats.Component ?? 0} components, ${stats.Hook ?? 0} hooks, ${storeCount} stores, ${routeCount} routes`);
18
+ lines.push(`# Indexed: ${new Date().toISOString()} | ${stats.files} files`);
19
+ lines.push('');
20
+
21
+ // Routes section
22
+ const routes = graph.getNodesByKind('Route');
23
+ const endpoints = graph.getNodesByKind('ApiEndpoint');
24
+ if (routes.length > 0 || endpoints.length > 0) {
25
+ lines.push('## ROUTES');
26
+ for (const route of routes) {
27
+ const rendersEdges = graph.getEdgesFrom(route.id, ['renders']);
28
+ const component = rendersEdges.length > 0 ? resolveNodeName(graph, rendersEdges[0]!.target) : '';
29
+ const role = route.meta.role ? ` [${route.meta.role}]` : '';
30
+ lines.push(`${route.name.padEnd(25)} → ${component}${role} [${route.file}]`);
31
+ }
32
+ for (const ep of endpoints) {
33
+ lines.push(`${ep.name.padEnd(25)} [${ep.file}]`);
34
+ }
35
+ lines.push('');
36
+ }
37
+
38
+ // Components section
39
+ const components = graph.getNodesByKind('Component')
40
+ .map(c => ({ node: c, conn: graph.connectivity(c.id) }))
41
+ .sort((a, b) => b.conn - a.conn)
42
+ .slice(0, maxComponents);
43
+
44
+ if (components.length > 0) {
45
+ lines.push(`## COMPONENTS (top ${Math.min(maxComponents, components.length)} by connectivity)`);
46
+
47
+ for (const { node } of components) {
48
+ lines.push(`${node.name} [${node.file}:${node.line}]`);
49
+
50
+ // What it renders
51
+ const rendersEdges = graph.getEdgesFrom(node.id, ['renders']);
52
+ if (rendersEdges.length > 0) {
53
+ const rendered = rendersEdges
54
+ .map(e => resolveNodeName(graph, e.target))
55
+ .filter(Boolean);
56
+ lines.push(` renders: ${rendered.join(', ')}`);
57
+ }
58
+
59
+ // Hooks used
60
+ const hookEdges = graph.getEdgesFrom(node.id, ['uses_hook']);
61
+ if (hookEdges.length > 0) {
62
+ const hooks = hookEdges
63
+ .map(e => (e.meta.hookName as string) ?? resolveNodeName(graph, e.target))
64
+ .filter(h => !h.startsWith('builtin:'));
65
+ if (hooks.length > 0) {
66
+ lines.push(` hooks: ${hooks.join(', ')}`);
67
+ }
68
+ }
69
+
70
+ // Context
71
+ const provides = graph.getEdgesFrom(node.id, ['provides']);
72
+ const consumes = graph.getEdgesFrom(node.id, ['consumes']);
73
+ if (provides.length > 0 || consumes.length > 0) {
74
+ const parts: string[] = [];
75
+ for (const e of provides) parts.push(`${resolveNodeName(graph, e.target)}(provides)`);
76
+ for (const e of consumes) parts.push(`${resolveNodeName(graph, e.target)}(read)`);
77
+ lines.push(` context: ${parts.join(', ')}`);
78
+ }
79
+
80
+ // Store reads/writes
81
+ const reads = graph.getEdgesFrom(node.id, ['reads_store']);
82
+ const writes = graph.getEdgesFrom(node.id, ['writes_store']);
83
+ if (reads.length > 0 || writes.length > 0) {
84
+ const parts: string[] = [];
85
+ const seen = new Set<string>();
86
+ for (const e of reads) {
87
+ const name = resolveNodeName(graph, e.target);
88
+ if (!seen.has(name)) { parts.push(`${name}(read)`); seen.add(name); }
89
+ }
90
+ for (const e of writes) {
91
+ const name = resolveNodeName(graph, e.target);
92
+ if (!seen.has(name)) { parts.push(`${name}(write)`); seen.add(name); }
93
+ else {
94
+ // Upgrade to read/write
95
+ const idx = parts.findIndex(p => p.startsWith(name));
96
+ if (idx >= 0) parts[idx] = `${name}(read/write)`;
97
+ }
98
+ }
99
+ lines.push(` state: ${parts.join(', ')}`);
100
+ }
101
+
102
+ // API calls
103
+ const fetches = graph.getEdgesFrom(node.id, ['fetches']);
104
+ if (fetches.length > 0) {
105
+ const apis = fetches.map(e => `${e.meta.method} ${e.meta.url}`);
106
+ lines.push(` fetches: ${apis.join(', ')}`);
107
+ }
108
+
109
+ // Props
110
+ if (node.props && node.props.length > 0) {
111
+ lines.push(` props: ${node.props.join(', ')}`);
112
+ } else if (node.exportType === 'default') {
113
+ lines.push(` props: none (page component)`);
114
+ }
115
+
116
+ lines.push('');
117
+ }
118
+ }
119
+
120
+ // Hooks section
121
+ const hooks = graph.getNodesByKind('Hook');
122
+ if (hooks.length > 0) {
123
+ lines.push('## HOOKS');
124
+ for (const hook of hooks) {
125
+ lines.push(`${hook.name} [${hook.file}:${hook.line}]`);
126
+
127
+ if (hook.returns) lines.push(` returns: ${hook.returns}`);
128
+
129
+ // Store reads/writes
130
+ const reads = graph.getEdgesFrom(hook.id, ['reads_store']);
131
+ const writes = graph.getEdgesFrom(hook.id, ['writes_store']);
132
+ if (reads.length > 0 || writes.length > 0) {
133
+ const parts: string[] = [];
134
+ for (const e of reads) parts.push(`${resolveNodeName(graph, e.target)}(read)`);
135
+ for (const e of writes) parts.push(`${resolveNodeName(graph, e.target)}(write)`);
136
+ lines.push(` state: ${parts.join(', ')}`);
137
+ }
138
+
139
+ // API calls
140
+ const fetches = graph.getEdgesFrom(hook.id, ['fetches']);
141
+ if (fetches.length > 0) {
142
+ const apis = fetches.map(e => `${e.meta.method} ${e.meta.url}`);
143
+ lines.push(` fetches: ${apis.join(', ')}`);
144
+ }
145
+
146
+ // Who uses this hook
147
+ const usedBy = graph.getEdgesTo(hook.id, ['uses_hook']);
148
+ if (usedBy.length > 0) {
149
+ const users = usedBy.map(e => resolveNodeName(graph, e.source)).filter(Boolean);
150
+ const display = users.length > 5
151
+ ? `${users.slice(0, 5).join(', ')} (${users.length} total)`
152
+ : users.join(', ');
153
+ lines.push(` used-by: ${display}`);
154
+ }
155
+
156
+ // Hook deps
157
+ const deps = graph.getEdgesFrom(hook.id, ['uses_hook']);
158
+ const customDeps = deps.filter(e => !e.target.startsWith('builtin:'));
159
+ if (customDeps.length > 0) {
160
+ const depNames = customDeps
161
+ .map(e => (e.meta.hookName as string) ?? resolveNodeName(graph, e.target))
162
+ .filter(Boolean);
163
+ lines.push(` deps: ${depNames.join(', ')}`);
164
+ }
165
+
166
+ lines.push('');
167
+ }
168
+ }
169
+
170
+ // State stores section
171
+ const stores = graph.getNodesByKind('Store');
172
+ if (stores.length > 0) {
173
+ lines.push('## STATE STORES');
174
+ for (const store of stores) {
175
+ const lib = store.meta.library ? `[${store.meta.library}]` : '';
176
+ lines.push(`${store.name} ${lib} [${store.file}]`);
177
+
178
+ const shape = store.meta.shape as string[] | undefined;
179
+ if (shape && shape.length > 0) {
180
+ lines.push(` shape: { ${shape.join(', ')} }`);
181
+ }
182
+
183
+ const writers = graph.getEdgesTo(store.id, ['writes_store']);
184
+ if (writers.length > 0) {
185
+ lines.push(` writers: ${writers.map(e => resolveNodeName(graph, e.source)).join(', ')}`);
186
+ }
187
+
188
+ const readers = graph.getEdgesTo(store.id, ['reads_store']);
189
+ if (readers.length > 0) {
190
+ lines.push(` readers: ${readers.map(e => resolveNodeName(graph, e.source)).join(', ')}`);
191
+ }
192
+
193
+ lines.push('');
194
+ }
195
+ }
196
+
197
+ // Context providers section
198
+ const contexts = graph.getNodesByKind('Context');
199
+ if (contexts.length > 0) {
200
+ lines.push('## CONTEXT PROVIDERS');
201
+ for (const ctx of contexts) {
202
+ const providers = graph.getEdgesTo(ctx.id, ['provides']);
203
+ const providedAt = providers.length > 0
204
+ ? ` provided-at: ${providers.map(e => resolveNodeName(graph, e.source)).join(', ')}`
205
+ : '';
206
+ lines.push(`${ctx.name} [${ctx.file}]${providedAt}`);
207
+
208
+ const consumers = graph.getEdgesTo(ctx.id, ['consumes']);
209
+ if (consumers.length > 0) {
210
+ const names = consumers.map(e => resolveNodeName(graph, e.source));
211
+ const display = names.length > 5
212
+ ? `${names.slice(0, 5).join(', ')} (${names.length} total)`
213
+ : names.join(', ');
214
+ lines.push(` consumers: ${display}`);
215
+ }
216
+ lines.push('');
217
+ }
218
+ }
219
+
220
+ // Shared modules
221
+ const modules = graph.getNodesByKind('Module');
222
+ const highFanOut = modules
223
+ .map(m => ({ node: m, importers: graph.getEdgesTo(m.id, ['imports']).length }))
224
+ .filter(m => m.importers >= 5)
225
+ .sort((a, b) => b.importers - a.importers);
226
+
227
+ if (highFanOut.length > 0) {
228
+ lines.push('## SHARED MODULES (high fan-out)');
229
+ for (const { node, importers } of highFanOut) {
230
+ lines.push(`${node.name} [${node.file}] → ${importers} importers`);
231
+ }
232
+ lines.push('');
233
+ }
234
+
235
+ return lines.join('\n');
236
+ }
237
+
238
+ /**
239
+ * Format a subgraph around a specific node — tree-style rendering.
240
+ */
241
+ export function formatSubgraph(graph: ReactGraph, target: string, depth: number = 2): string {
242
+ const node = graph.findNode(target) ?? graph.getNode(target);
243
+ if (!node) return `Node not found: ${target}`;
244
+
245
+ const sub = getSubgraph(graph, node.id, depth);
246
+ if (!sub) return `Could not build subgraph for: ${target}`;
247
+
248
+ const lines: string[] = [];
249
+ lines.push(`${node.name} [${node.file}:${node.line}]`);
250
+
251
+ const outgoing = graph.getEdgesFrom(node.id);
252
+ const byKind = groupBy(outgoing, e => e.kind);
253
+
254
+ for (const [kind, edges] of byKind) {
255
+ for (const edge of edges) {
256
+ const targetNode = graph.getNode(edge.target);
257
+ const targetName = targetNode ? `${targetNode.name} [${targetNode.file}]` : edge.target;
258
+ const metaStr = formatEdgeMeta(edge);
259
+ lines.push(` ├─ ${kind} → ${targetName}${metaStr}`);
260
+
261
+ if (targetNode && depth > 1) {
262
+ const deeper = graph.getEdgesFrom(targetNode.id);
263
+ for (const d of deeper.slice(0, 5)) {
264
+ const dTarget = graph.getNode(d.target);
265
+ const dName = dTarget ? dTarget.name : d.target;
266
+ lines.push(` │ └─ ${d.kind} → ${dName}`);
267
+ }
268
+ if (deeper.length > 5) {
269
+ lines.push(` │ └─ ... (${deeper.length - 5} more)`);
270
+ }
271
+ }
272
+ }
273
+ }
274
+
275
+ const incoming = graph.getEdgesTo(node.id);
276
+ if (incoming.length > 0) {
277
+ lines.push(` used-by (${incoming.length}):`);
278
+ const grouped = groupBy(incoming, e => e.kind);
279
+ for (const [kind, edges] of grouped) {
280
+ for (const edge of edges.slice(0, 10)) {
281
+ const sourceNode = graph.getNode(edge.source);
282
+ const sourceName = sourceNode ? `${sourceNode.name} [${sourceNode.file}]` : edge.source;
283
+ lines.push(` │ ← ${kind} ← ${sourceName}`);
284
+ }
285
+ if (edges.length > 10) {
286
+ lines.push(` │ ... (${edges.length - 10} more)`);
287
+ }
288
+ }
289
+ }
290
+
291
+ return lines.join('\n');
292
+ }
293
+
294
+ /**
295
+ * Format everything about a specific file.
296
+ */
297
+ export function formatFileContext(graph: ReactGraph, filePath: string): string {
298
+ const nodes = graph.getNodesByFile(filePath);
299
+ if (nodes.length === 0) return `No nodes found for file: ${filePath}`;
300
+
301
+ const lines: string[] = [];
302
+ lines.push(`FILE: ${filePath}`);
303
+
304
+ // Exports
305
+ const exports = nodes.filter(n => n.exportType !== 'none');
306
+ if (exports.length > 0) {
307
+ lines.push(`EXPORTS: ${exports.map(n => `${n.name} (${n.exportType})`).join(', ')}`);
308
+ }
309
+
310
+ // Imports
311
+ const moduleNode = nodes.find(n => n.kind === 'Module');
312
+ if (moduleNode) {
313
+ const importEdges = graph.getEdgesFrom(moduleNode.id, ['imports']);
314
+ if (importEdges.length > 0) {
315
+ lines.push('IMPORTS:');
316
+ for (const edge of importEdges) {
317
+ const names = (edge.meta.names as string[]) ?? [];
318
+ lines.push(` ${names.join(', ')} ← ${edge.target}`);
319
+ }
320
+ }
321
+ }
322
+
323
+ // Components
324
+ for (const comp of nodes.filter(n => n.kind === 'Component')) {
325
+ lines.push('');
326
+ lines.push(`COMPONENT: ${comp.name} (line ${comp.line})`);
327
+
328
+ const renders = graph.getEdgesFrom(comp.id, ['renders']);
329
+ if (renders.length > 0) {
330
+ lines.push(` RENDERS: ${renders.map(e => resolveNodeName(graph, e.target)).join(', ')}`);
331
+ }
332
+
333
+ const hookEdges = graph.getEdgesFrom(comp.id, ['uses_hook']);
334
+ const customHooks = hookEdges.filter(e => !e.target.startsWith('builtin:'));
335
+ if (customHooks.length > 0) {
336
+ const hookDetails = customHooks.map(e => {
337
+ const name = (e.meta.hookName as string) ?? resolveNodeName(graph, e.target);
338
+ const destructured = e.meta.destructured as string[] | undefined;
339
+ return destructured ? `${name} → { ${destructured.join(', ')} }` : name;
340
+ });
341
+ lines.push(` HOOKS: ${hookDetails.join(', ')}`);
342
+ }
343
+
344
+ const provides = graph.getEdgesFrom(comp.id, ['provides']);
345
+ const consumes = graph.getEdgesFrom(comp.id, ['consumes']);
346
+ if (provides.length > 0 || consumes.length > 0) {
347
+ const parts: string[] = [];
348
+ for (const e of provides) parts.push(`${resolveNodeName(graph, e.target)}(provides)`);
349
+ for (const e of consumes) parts.push(`${resolveNodeName(graph, e.target)}(read)`);
350
+ lines.push(` CONTEXT: ${parts.join(', ')}`);
351
+ }
352
+
353
+ const storeReads = graph.getEdgesFrom(comp.id, ['reads_store']);
354
+ const storeWrites = graph.getEdgesFrom(comp.id, ['writes_store']);
355
+ if (storeReads.length > 0 || storeWrites.length > 0) {
356
+ const parts: string[] = [];
357
+ for (const e of storeReads) parts.push(`${resolveNodeName(graph, e.target)}(read)`);
358
+ for (const e of storeWrites) parts.push(`${resolveNodeName(graph, e.target)}(write)`);
359
+ lines.push(` STATE: ${parts.join(', ')}`);
360
+ }
361
+
362
+ if (comp.props && comp.props.length > 0) {
363
+ lines.push(` PROPS: ${comp.props.join(', ')}`);
364
+ }
365
+
366
+ const renderedBy = graph.getEdgesTo(comp.id, ['renders']);
367
+ if (renderedBy.length > 0) {
368
+ lines.push(` RENDERED BY: ${renderedBy.map(e => resolveNodeName(graph, e.source)).join(', ')}`);
369
+ }
370
+ }
371
+
372
+ // Hooks
373
+ for (const hook of nodes.filter(n => n.kind === 'Hook')) {
374
+ lines.push('');
375
+ lines.push(`HOOK: ${hook.name} (line ${hook.line})`);
376
+ if (hook.returns) lines.push(` RETURNS: ${hook.returns}`);
377
+
378
+ const usedBy = graph.getEdgesTo(hook.id, ['uses_hook']);
379
+ if (usedBy.length > 0) {
380
+ lines.push(` USED BY: ${usedBy.map(e => resolveNodeName(graph, e.source)).join(', ')}`);
381
+ }
382
+
383
+ const effects = hook.meta.effects as any[] | undefined;
384
+ if (effects?.length) {
385
+ for (const eff of effects) {
386
+ const depsStr = Array.isArray(eff.deps) ? `[${eff.deps.join(', ')}]` : eff.deps;
387
+ lines.push(` EFFECT: ${eff.type} deps=${depsStr}${eff.hasCleanup ? ' +cleanup' : ''}`);
388
+ }
389
+ }
390
+ }
391
+
392
+ // Stores
393
+ for (const store of nodes.filter(n => n.kind === 'Store')) {
394
+ lines.push('');
395
+ lines.push(`STORE: ${store.name} [${store.meta.library}]`);
396
+ const shape = store.meta.shape as string[] | undefined;
397
+ if (shape?.length) lines.push(` SHAPE: { ${shape.join(', ')} }`);
398
+ }
399
+
400
+ // Contexts
401
+ for (const ctx of nodes.filter(n => n.kind === 'Context')) {
402
+ lines.push('');
403
+ lines.push(`CONTEXT: ${ctx.name}`);
404
+ }
405
+
406
+ // Routes
407
+ for (const route of nodes.filter(n => n.kind === 'Route')) {
408
+ lines.push('');
409
+ lines.push(`ROUTE: ${route.name}`);
410
+ }
411
+
412
+ // API Endpoints
413
+ for (const ep of nodes.filter(n => n.kind === 'ApiEndpoint')) {
414
+ lines.push('');
415
+ lines.push(`ENDPOINT: ${ep.name}`);
416
+ }
417
+
418
+ return lines.join('\n');
419
+ }
420
+
421
+ /**
422
+ * Trace the data flow path from node A to node B.
423
+ */
424
+ export function formatTraceFlow(graph: ReactGraph, from: string, to: string): string {
425
+ const fromNode = graph.findNode(from) ?? graph.getNode(from);
426
+ const toNode = graph.findNode(to) ?? graph.getNode(to);
427
+
428
+ if (!fromNode) return `Source not found: ${from}`;
429
+ if (!toNode) return `Target not found: ${to}`;
430
+
431
+ // BFS to find shortest path
432
+ const path = findPath(graph, fromNode.id, toNode.id);
433
+ if (!path) return `No path found from ${fromNode.name} to ${toNode.name}`;
434
+
435
+ const lines: string[] = [];
436
+ lines.push(`${fromNode.name} → ${toNode.name}`);
437
+ lines.push('');
438
+
439
+ for (let i = 0; i < path.length; i++) {
440
+ const step = path[i]!;
441
+ const node = graph.getNode(step.nodeId);
442
+ const indent = ' '.repeat(i);
443
+ const arrow = i === 0 ? '' : `└─ ${step.edgeKind} → `;
444
+ const name = node ? `${node.name} [${node.file}]` : step.nodeId;
445
+ lines.push(`${indent}${arrow}${name}`);
446
+ }
447
+
448
+ return lines.join('\n');
449
+ }
450
+
451
+ /**
452
+ * Show impact analysis — what breaks if target changes.
453
+ */
454
+ export function formatImpact(graph: ReactGraph, target: string): string {
455
+ const node = graph.findNode(target) ?? graph.getNode(target);
456
+ if (!node) return `Node not found: ${target}`;
457
+
458
+ const lines: string[] = [];
459
+ lines.push(`IMPACT ANALYSIS: ${node.name} [${node.file}]`);
460
+ lines.push('');
461
+
462
+ // Direct dependents (things that import/use this node)
463
+ const directEdges = graph.getEdgesTo(node.id);
464
+ const directByKind = groupBy(directEdges, e => e.kind);
465
+
466
+ if (directEdges.length > 0) {
467
+ lines.push(`DIRECT (${directEdges.length} dependents):`);
468
+ for (const [kind, edges] of directByKind) {
469
+ for (const edge of edges) {
470
+ const source = graph.getNode(edge.source);
471
+ const name = source ? `${source.name} [${source.file}]` : edge.source;
472
+ lines.push(` ${kind} ← ${name}`);
473
+ }
474
+ }
475
+ lines.push('');
476
+ }
477
+
478
+ // Indirect: if this is a hook, find all components that use it,
479
+ // then find what renders those components
480
+ if (node.kind === 'Hook') {
481
+ const users = graph.getEdgesTo(node.id, ['uses_hook']);
482
+ const indirect = new Set<string>();
483
+
484
+ for (const edge of users) {
485
+ const userNode = graph.getNode(edge.source);
486
+ if (!userNode) continue;
487
+
488
+ // Who renders this component?
489
+ const renderers = graph.getEdgesTo(userNode.id, ['renders']);
490
+ for (const r of renderers) {
491
+ indirect.add(r.source);
492
+ }
493
+ }
494
+
495
+ if (indirect.size > 0) {
496
+ lines.push(`INDIRECT (via components using ${node.name}):`);
497
+ for (const id of indirect) {
498
+ const n = graph.getNode(id);
499
+ lines.push(` ${n ? `${n.name} [${n.file}]` : id}`);
500
+ }
501
+ lines.push('');
502
+ }
503
+ }
504
+
505
+ // If this is a store, find all readers and writers
506
+ if (node.kind === 'Store') {
507
+ const readers = graph.getEdgesTo(node.id, ['reads_store']);
508
+ const writers = graph.getEdgesTo(node.id, ['writes_store']);
509
+
510
+ if (readers.length > 0) {
511
+ lines.push('READERS (will see changed state shape):');
512
+ for (const e of readers) {
513
+ const n = graph.getNode(e.source);
514
+ lines.push(` ${n ? `${n.name} [${n.file}]` : e.source}`);
515
+ }
516
+ lines.push('');
517
+ }
518
+ if (writers.length > 0) {
519
+ lines.push('WRITERS (may need signature update):');
520
+ for (const e of writers) {
521
+ const n = graph.getNode(e.source);
522
+ lines.push(` ${n ? `${n.name} [${n.file}]` : e.source}`);
523
+ }
524
+ lines.push('');
525
+ }
526
+ }
527
+
528
+ // Safe to change analysis
529
+ if (node.kind === 'Component') {
530
+ const renders = graph.getEdgesFrom(node.id, ['renders']);
531
+ const renderedBy = graph.getEdgesTo(node.id, ['renders']);
532
+ lines.push('SAFE TO CHANGE:');
533
+ lines.push(' Internal implementation (same props + same renders = no breakage)');
534
+ if (node.props && node.props.length > 0) {
535
+ lines.push(`RISKY:`);
536
+ lines.push(` Props shape — ${renderedBy.length} parents pass: ${node.props.join(', ')}`);
537
+ }
538
+ }
539
+
540
+ return lines.join('\n');
541
+ }
542
+
543
+ /**
544
+ * Query the graph with filters.
545
+ */
546
+ export function formatFindNodes(graph: ReactGraph, filters: Record<string, unknown>): string {
547
+ // If query is provided, find a node by name and show it + all connections.
548
+ // This is the most common usage: "find_nodes({ query: 'useUIStore' })"
549
+ if (filters.query) {
550
+ return formatNodeQuery(graph, filters.query as string);
551
+ }
552
+
553
+ let results = graph.getAllNodes();
554
+ let hasFilter = false;
555
+
556
+ // Filter by kind
557
+ if (filters.kind) {
558
+ hasFilter = true;
559
+ const kind = filters.kind as string;
560
+ results = results.filter(n => n.kind === kind);
561
+ }
562
+
563
+ // Filter by name (substring match)
564
+ if (filters.name) {
565
+ hasFilter = true;
566
+ const name = (filters.name as string).toLowerCase();
567
+ results = results.filter(n => n.name.toLowerCase().includes(name));
568
+ }
569
+
570
+ // Filter by hook usage
571
+ if (filters.using_hook) {
572
+ hasFilter = true;
573
+ const hookName = filters.using_hook as string;
574
+ results = results.filter(n => {
575
+ const hookEdges = graph.getEdgesFrom(n.id, ['uses_hook']);
576
+ return hookEdges.some(e => {
577
+ const target = graph.getNode(e.target);
578
+ return target?.name === hookName || (e.meta.hookName as string) === hookName;
579
+ });
580
+ });
581
+ }
582
+
583
+ // Filter by fetches (components/hooks that make API calls)
584
+ if (filters.fetches) {
585
+ hasFilter = true;
586
+ results = results.filter(n => {
587
+ return graph.getEdgesFrom(n.id, ['fetches']).length > 0;
588
+ });
589
+ }
590
+
591
+ // Filter orphans (components rendered by nothing)
592
+ if (filters.orphan) {
593
+ hasFilter = true;
594
+ results = results.filter(n => {
595
+ if (n.kind !== 'Component') return false;
596
+ const renderedBy = graph.getEdgesTo(n.id, ['renders']);
597
+ return renderedBy.length === 0;
598
+ });
599
+ }
600
+
601
+ // Filter by store usage
602
+ if (filters.using_store) {
603
+ hasFilter = true;
604
+ const storeName = filters.using_store as string;
605
+ results = results.filter(n => {
606
+ const reads = graph.getEdgesFrom(n.id, ['reads_store']);
607
+ const writes = graph.getEdgesFrom(n.id, ['writes_store']);
608
+ return [...reads, ...writes].some(e => {
609
+ const target = graph.getNode(e.target);
610
+ return target?.name === storeName;
611
+ });
612
+ });
613
+ }
614
+
615
+ // Safety: if no recognized filter was applied, don't dump the entire graph
616
+ if (!hasFilter) {
617
+ return 'No filters provided. Use: query, name, kind, using_hook, using_store, fetches, or orphan.';
618
+ }
619
+
620
+ if (results.length === 0) return 'No matching nodes found.';
621
+
622
+ const lines: string[] = [];
623
+ lines.push(`Found ${results.length} nodes:`);
624
+ for (const node of results) {
625
+ lines.push(` ${node.name} [${node.kind}] [${node.file}:${node.line}]`);
626
+ }
627
+
628
+ return lines.join('\n');
629
+ }
630
+
631
+ /**
632
+ * Smart query: find a node by name and return it + all connected nodes,
633
+ * grouped by relationship type. This is what Claude actually needs when
634
+ * asking "who uses useUIStore?"
635
+ */
636
+ function formatNodeQuery(graph: ReactGraph, query: string): string {
637
+ const node = graph.findNode(query) ?? graph.getNode(query);
638
+ if (!node) {
639
+ // Try substring match
640
+ const all = graph.getAllNodes();
641
+ const matches = all.filter(n => n.name.toLowerCase().includes(query.toLowerCase()));
642
+ if (matches.length === 0) return `No nodes matching "${query}".`;
643
+ if (matches.length > 20) {
644
+ return `${matches.length} nodes match "${query}". Be more specific, or use kind filter.\n` +
645
+ matches.slice(0, 20).map(n => ` ${n.name} [${n.kind}] [${n.file}]`).join('\n');
646
+ }
647
+ const lines = [`Found ${matches.length} nodes matching "${query}":`];
648
+ for (const m of matches) lines.push(` ${m.name} [${m.kind}] [${m.file}:${m.line}]`);
649
+ return lines.join('\n');
650
+ }
651
+
652
+ const lines: string[] = [];
653
+ lines.push(`${node.name} [${node.kind}] [${node.file}:${node.line}]`);
654
+ lines.push('');
655
+
656
+ // Outgoing connections
657
+ const outgoing = graph.getEdgesFrom(node.id);
658
+ if (outgoing.length > 0) {
659
+ const byKind = groupBy(outgoing, e => e.kind);
660
+ for (const [kind, edges] of byKind) {
661
+ const targets = edges.map(e => {
662
+ const t = graph.getNode(e.target);
663
+ const meta = formatEdgeMeta(e);
664
+ return t ? `${t.name} [${t.file}]${meta}` : `${e.target}${meta}`;
665
+ });
666
+ lines.push(`${kind} → ${targets.join(', ')}`);
667
+ }
668
+ lines.push('');
669
+ }
670
+
671
+ // Incoming connections (who depends on this node)
672
+ const incoming = graph.getEdgesTo(node.id);
673
+ if (incoming.length > 0) {
674
+ const byKind = groupBy(incoming, e => e.kind);
675
+ for (const [kind, edges] of byKind) {
676
+ const sources = edges.map(e => {
677
+ const s = graph.getNode(e.source);
678
+ return s ? `${s.name} [${s.file}]` : e.source;
679
+ });
680
+ lines.push(`${kind} ← ${sources.join(', ')}`);
681
+ }
682
+ }
683
+
684
+ if (outgoing.length === 0 && incoming.length === 0) {
685
+ lines.push('No connections found (isolated node).');
686
+ }
687
+
688
+ return lines.join('\n');
689
+ }
690
+
691
+ // ---- Path finding ----
692
+
693
+ interface PathStep {
694
+ nodeId: string;
695
+ edgeKind: string;
696
+ }
697
+
698
+ function findPath(graph: ReactGraph, fromId: string, toId: string): PathStep[] | null {
699
+ const visited = new Set<string>();
700
+ const queue: { nodeId: string; path: PathStep[] }[] = [
701
+ { nodeId: fromId, path: [{ nodeId: fromId, edgeKind: '' }] },
702
+ ];
703
+
704
+ while (queue.length > 0) {
705
+ const { nodeId, path } = queue.shift()!;
706
+
707
+ if (nodeId === toId) return path;
708
+ if (visited.has(nodeId)) continue;
709
+ visited.add(nodeId);
710
+
711
+ // Follow outgoing edges
712
+ for (const edge of graph.getEdgesFrom(nodeId)) {
713
+ if (!visited.has(edge.target)) {
714
+ queue.push({
715
+ nodeId: edge.target,
716
+ path: [...path, { nodeId: edge.target, edgeKind: edge.kind }],
717
+ });
718
+ }
719
+ }
720
+ }
721
+
722
+ return null;
723
+ }
724
+
725
+ // ---- Helpers ----
726
+
727
+ function resolveNodeName(graph: ReactGraph, id: string): string {
728
+ const node = graph.getNode(id);
729
+ if (node) return node.name;
730
+ if (id.startsWith('builtin:')) return id.slice(8);
731
+ if (id.startsWith('external:')) return id.slice(9);
732
+ if (id.startsWith('unresolved:')) return id.slice(11);
733
+ if (id.startsWith('endpoint:')) return id.slice(9);
734
+ const colonIdx = id.lastIndexOf(':');
735
+ if (colonIdx > 0) return id.slice(colonIdx + 1);
736
+ return id;
737
+ }
738
+
739
+ function formatEdgeMeta(edge: GraphEdge): string {
740
+ const parts: string[] = [];
741
+ if (edge.meta.prop) parts.push(`prop=${edge.meta.prop}`);
742
+ if (edge.meta.hookName) parts.push(edge.meta.hookName as string);
743
+ if (edge.meta.method && edge.meta.url) parts.push(`${edge.meta.method} ${edge.meta.url}`);
744
+ if (edge.meta.library) parts.push(edge.meta.library as string);
745
+ if (parts.length === 0) return '';
746
+ return ` (${parts.join(', ')})`;
747
+ }
748
+
749
+ function groupBy<T>(items: T[], keyFn: (item: T) => string): Map<string, T[]> {
750
+ const map = new Map<string, T[]>();
751
+ for (const item of items) {
752
+ const key = keyFn(item);
753
+ if (!map.has(key)) map.set(key, []);
754
+ map.get(key)!.push(item);
755
+ }
756
+ return map;
757
+ }