@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,216 @@
1
+ import type Parser from 'tree-sitter';
2
+ import type { GraphNode, GraphEdge } from '../../graph/schema.js';
3
+ import { nodeId, findAll, findEnclosingFunction, walkTree } from './types.js';
4
+ import type { ExtractionResult } from './types.js';
5
+
6
+ /**
7
+ * Context extractor — detects React Context creation, Provider usage, and consumers.
8
+ *
9
+ * Patterns:
10
+ * const ThemeCtx = createContext(default) → Context node
11
+ * <ThemeCtx.Provider value={...}> → provides edge
12
+ * useContext(ThemeCtx) → consumes edge
13
+ * <ThemeCtx.Consumer>{fn}</ThemeCtx.Consumer> → consumes edge
14
+ */
15
+ export function extractContext(
16
+ tree: Parser.Tree,
17
+ filePath: string,
18
+ sourceCode: string,
19
+ existingNodes: GraphNode[],
20
+ ): ExtractionResult {
21
+ const nodes: GraphNode[] = [];
22
+ const edges: GraphEdge[] = [];
23
+ const root = tree.rootNode;
24
+
25
+ // Build map of component/hook names in this file for edge sources
26
+ const functionNodes = new Map<string, GraphNode>();
27
+ for (const n of existingNodes) {
28
+ if (n.file === filePath && (n.kind === 'Component' || n.kind === 'Hook')) {
29
+ functionNodes.set(n.name, n);
30
+ }
31
+ }
32
+
33
+ // Pass 1: Find context creation — const Ctx = createContext(...)
34
+ const contextNames = new Map<string, string>(); // variable name → node id
35
+ for (let i = 0; i < root.childCount; i++) {
36
+ const child = root.child(i)!;
37
+ const ctx = detectContextCreation(child, filePath);
38
+ if (ctx) {
39
+ nodes.push(ctx);
40
+ contextNames.set(ctx.name, ctx.id);
41
+ }
42
+
43
+ // Also check inside export statements
44
+ if (child.type === 'export_statement') {
45
+ for (let j = 0; j < child.childCount; j++) {
46
+ const inner = child.child(j)!;
47
+ const ctx2 = detectContextCreation(inner, filePath);
48
+ if (ctx2) {
49
+ ctx2.exportType = child.text.startsWith('export default') ? 'default' : 'named';
50
+ nodes.push(ctx2);
51
+ contextNames.set(ctx2.name, ctx2.id);
52
+ }
53
+ }
54
+ }
55
+ }
56
+
57
+ // Also track context names imported from other files
58
+ // We look for any identifier used in useContext() or .Provider that we can resolve
59
+ const importedContexts = buildImportedContextMap(root, existingNodes);
60
+
61
+ // Pass 2: Find Provider usage in JSX — <Ctx.Provider value={...}>
62
+ const jsxOpenings = findAll(root, 'jsx_opening_element');
63
+ const jsxSelfClosing = findAll(root, 'jsx_self_closing_element');
64
+
65
+ for (const jsx of [...jsxOpenings, ...jsxSelfClosing]) {
66
+ const nameNode = jsx.childForFieldName('name') ?? jsx.child(1);
67
+ if (!nameNode || nameNode.type !== 'member_expression') continue;
68
+
69
+ const prop = nameNode.childForFieldName('property');
70
+ if (prop?.text !== 'Provider') continue;
71
+
72
+ const obj = nameNode.childForFieldName('object');
73
+ if (!obj) continue;
74
+
75
+ const ctxName = obj.text;
76
+ const ctxId = contextNames.get(ctxName) ?? importedContexts.get(ctxName) ?? `unresolved:${ctxName}`;
77
+
78
+ // Find enclosing component
79
+ const enclosingFn = findEnclosingFunction(jsx);
80
+ if (!enclosingFn) continue;
81
+ const enclosingName = getFunctionName(enclosingFn);
82
+ if (!enclosingName) continue;
83
+ const enclosingNode = functionNodes.get(enclosingName);
84
+ if (!enclosingNode) continue;
85
+
86
+ edges.push({
87
+ source: enclosingNode.id,
88
+ target: ctxId,
89
+ kind: 'provides',
90
+ meta: {},
91
+ });
92
+ }
93
+
94
+ // Pass 3: Find useContext() consumers
95
+ const callExprs = findAll(root, 'call_expression');
96
+ for (const call of callExprs) {
97
+ const callee = call.childForFieldName('function');
98
+ if (!callee || callee.text !== 'useContext') continue;
99
+
100
+ const args = call.childForFieldName('arguments');
101
+ if (!args || args.childCount < 2) continue;
102
+
103
+ const ctxArg = args.child(1); // skip "("
104
+ if (!ctxArg) continue;
105
+
106
+ const ctxName = ctxArg.text;
107
+ const ctxId = contextNames.get(ctxName) ?? importedContexts.get(ctxName) ?? `unresolved:${ctxName}`;
108
+
109
+ const enclosingFn = findEnclosingFunction(call);
110
+ if (!enclosingFn) continue;
111
+ const enclosingName = getFunctionName(enclosingFn);
112
+ if (!enclosingName) continue;
113
+ const enclosingNode = functionNodes.get(enclosingName);
114
+ if (!enclosingNode) continue;
115
+
116
+ edges.push({
117
+ source: enclosingNode.id,
118
+ target: ctxId,
119
+ kind: 'consumes',
120
+ meta: {},
121
+ });
122
+ }
123
+
124
+ // Pass 4: Find <Ctx.Consumer> pattern (legacy)
125
+ for (const jsx of [...jsxOpenings, ...jsxSelfClosing]) {
126
+ const nameNode = jsx.childForFieldName('name') ?? jsx.child(1);
127
+ if (!nameNode || nameNode.type !== 'member_expression') continue;
128
+
129
+ const prop = nameNode.childForFieldName('property');
130
+ if (prop?.text !== 'Consumer') continue;
131
+
132
+ const obj = nameNode.childForFieldName('object');
133
+ if (!obj) continue;
134
+
135
+ const ctxName = obj.text;
136
+ const ctxId = contextNames.get(ctxName) ?? importedContexts.get(ctxName) ?? `unresolved:${ctxName}`;
137
+
138
+ const enclosingFn = findEnclosingFunction(jsx);
139
+ if (!enclosingFn) continue;
140
+ const enclosingName = getFunctionName(enclosingFn);
141
+ if (!enclosingName) continue;
142
+ const enclosingNode = functionNodes.get(enclosingName);
143
+ if (!enclosingNode) continue;
144
+
145
+ edges.push({
146
+ source: enclosingNode.id,
147
+ target: ctxId,
148
+ kind: 'consumes',
149
+ meta: { legacy: true },
150
+ });
151
+ }
152
+
153
+ return { nodes, edges };
154
+ }
155
+
156
+ function detectContextCreation(node: Parser.SyntaxNode, filePath: string): GraphNode | null {
157
+ if (node.type !== 'lexical_declaration') return null;
158
+
159
+ const declarator = findDirectChild(node, 'variable_declarator');
160
+ if (!declarator) return null;
161
+
162
+ const nameNode = declarator.childForFieldName('name');
163
+ const value = declarator.childForFieldName('value');
164
+ if (!nameNode || !value) return null;
165
+
166
+ if (value.type !== 'call_expression') return null;
167
+
168
+ const callee = value.childForFieldName('function');
169
+ if (!callee) return null;
170
+
171
+ // Match createContext or React.createContext
172
+ if (callee.text !== 'createContext' && callee.text !== 'React.createContext') return null;
173
+
174
+ return {
175
+ id: nodeId(filePath, nameNode.text),
176
+ kind: 'Context',
177
+ name: nameNode.text,
178
+ file: filePath,
179
+ line: node.startPosition.row + 1,
180
+ exportType: 'none',
181
+ meta: {},
182
+ };
183
+ }
184
+
185
+ function buildImportedContextMap(
186
+ root: Parser.SyntaxNode,
187
+ existingNodes: GraphNode[],
188
+ ): Map<string, string> {
189
+ const map = new Map<string, string>();
190
+ for (const n of existingNodes) {
191
+ if (n.kind === 'Context') {
192
+ map.set(n.name, n.id);
193
+ }
194
+ }
195
+ return map;
196
+ }
197
+
198
+ function getFunctionName(node: Parser.SyntaxNode): string | null {
199
+ const nameField = node.childForFieldName('name');
200
+ if (nameField) return nameField.text;
201
+
202
+ if (node.type === 'arrow_function' || node.type === 'function_expression') {
203
+ const parent = node.parent;
204
+ if (parent?.type === 'variable_declarator') {
205
+ return parent.childForFieldName('name')?.text ?? null;
206
+ }
207
+ }
208
+ return null;
209
+ }
210
+
211
+ function findDirectChild(node: Parser.SyntaxNode, type: string): Parser.SyntaxNode | null {
212
+ for (let i = 0; i < node.childCount; i++) {
213
+ if (node.child(i)!.type === type) return node.child(i)!;
214
+ }
215
+ return null;
216
+ }
@@ -0,0 +1,205 @@
1
+ import type Parser from 'tree-sitter';
2
+ import type { GraphNode, GraphEdge } from '../../graph/schema.js';
3
+ import { findAll, findEnclosingFunction } from './types.js';
4
+ import type { ExtractionResult } from './types.js';
5
+
6
+ const EFFECT_HOOKS = new Set(['useEffect', 'useLayoutEffect', 'useInsertionEffect']);
7
+
8
+ /**
9
+ * Effects extractor — annotates existing component/hook nodes with useEffect metadata.
10
+ * Does not create new nodes — enriches existing ones.
11
+ *
12
+ * Captures:
13
+ * - dependency array contents (or 'mount-only' / 'every-render')
14
+ * - whether cleanup function exists
15
+ * - function calls inside the effect body
16
+ */
17
+ export function extractEffects(
18
+ tree: Parser.Tree,
19
+ filePath: string,
20
+ sourceCode: string,
21
+ existingNodes: GraphNode[],
22
+ ): ExtractionResult {
23
+ const edges: GraphEdge[] = [];
24
+ const root = tree.rootNode;
25
+
26
+ // Build function map
27
+ const functionNodes = new Map<string, GraphNode>();
28
+ for (const n of existingNodes) {
29
+ if (n.file === filePath && (n.kind === 'Component' || n.kind === 'Hook')) {
30
+ functionNodes.set(n.name, n);
31
+ }
32
+ }
33
+
34
+ const callExprs = findAll(root, 'call_expression');
35
+
36
+ for (const call of callExprs) {
37
+ const callee = call.childForFieldName('function');
38
+ if (!callee || !EFFECT_HOOKS.has(callee.text)) continue;
39
+
40
+ const enclosingFn = findEnclosingFunction(call);
41
+ if (!enclosingFn) continue;
42
+ const enclosingName = getFunctionName(enclosingFn);
43
+ if (!enclosingName) continue;
44
+ const enclosingNode = functionNodes.get(enclosingName);
45
+ if (!enclosingNode) continue;
46
+
47
+ const args = call.childForFieldName('arguments');
48
+ if (!args) continue;
49
+
50
+ // Extract callback (first arg) and deps array (second arg)
51
+ const { callback, depsNode } = extractEffectArgs(args);
52
+
53
+ // Parse dependency array
54
+ let deps: string | string[];
55
+ if (!depsNode) {
56
+ deps = 'every-render';
57
+ } else if (depsNode.type === 'array' && depsNode.childCount <= 2) {
58
+ // [ ] — just brackets, empty array
59
+ deps = 'mount-only';
60
+ } else if (depsNode.type === 'array') {
61
+ deps = extractArrayIdentifiers(depsNode);
62
+ } else {
63
+ deps = 'unknown';
64
+ }
65
+
66
+ // Check for cleanup (return statement in callback returning a function)
67
+ const hasCleanup = callback ? detectCleanup(callback) : false;
68
+
69
+ // Extract function calls inside the effect body
70
+ const triggers = callback ? extractTriggers(callback) : [];
71
+
72
+ // Store as metadata on the enclosing node
73
+ if (!enclosingNode.meta.effects) {
74
+ enclosingNode.meta.effects = [];
75
+ }
76
+ (enclosingNode.meta.effects as EffectMeta[]).push({
77
+ type: callee.text,
78
+ deps,
79
+ hasCleanup,
80
+ triggers,
81
+ });
82
+ }
83
+
84
+ return { nodes: [], edges };
85
+ }
86
+
87
+ interface EffectMeta {
88
+ type: string;
89
+ deps: string | string[];
90
+ hasCleanup: boolean;
91
+ triggers: string[];
92
+ }
93
+
94
+ function extractEffectArgs(args: Parser.SyntaxNode): {
95
+ callback: Parser.SyntaxNode | null;
96
+ depsNode: Parser.SyntaxNode | null;
97
+ } {
98
+ let callback: Parser.SyntaxNode | null = null;
99
+ let depsNode: Parser.SyntaxNode | null = null;
100
+ let argIndex = 0;
101
+
102
+ for (let i = 0; i < args.childCount; i++) {
103
+ const child = args.child(i)!;
104
+ if (child.type === '(' || child.type === ')' || child.type === ',') continue;
105
+
106
+ if (argIndex === 0) {
107
+ callback = child;
108
+ } else if (argIndex === 1) {
109
+ depsNode = child;
110
+ }
111
+ argIndex++;
112
+ }
113
+
114
+ return { callback, depsNode };
115
+ }
116
+
117
+ function extractArrayIdentifiers(arrayNode: Parser.SyntaxNode): string[] {
118
+ const ids: string[] = [];
119
+ for (let i = 0; i < arrayNode.childCount; i++) {
120
+ const child = arrayNode.child(i)!;
121
+ if (child.type === 'identifier') {
122
+ ids.push(child.text);
123
+ } else if (child.type === 'member_expression') {
124
+ ids.push(child.text);
125
+ }
126
+ }
127
+ return ids;
128
+ }
129
+
130
+ function detectCleanup(callback: Parser.SyntaxNode): boolean {
131
+ // Look for a return statement that returns a function
132
+ const returns = findAll(callback, 'return_statement');
133
+ for (const ret of returns) {
134
+ // Make sure this return is directly in the callback, not in a nested function
135
+ const enclosing = findEnclosingFunction(ret);
136
+ if (enclosing && enclosing !== callback) {
137
+ // Check if enclosing is the callback itself
138
+ // For arrow functions, the callback IS the arrow_function
139
+ if (enclosing.parent !== callback && enclosing !== callback) continue;
140
+ }
141
+
142
+ const val = ret.child(1);
143
+ if (!val) continue;
144
+
145
+ if (
146
+ val.type === 'arrow_function' ||
147
+ val.type === 'function_expression' ||
148
+ val.type === 'identifier' // return cleanup;
149
+ ) {
150
+ return true;
151
+ }
152
+ }
153
+ return false;
154
+ }
155
+
156
+ function extractTriggers(callback: Parser.SyntaxNode): string[] {
157
+ const triggers: string[] = [];
158
+ const calls = findAll(callback, 'call_expression');
159
+
160
+ for (const call of calls) {
161
+ const callee = call.childForFieldName('function');
162
+ if (!callee) continue;
163
+
164
+ // Skip nested function definitions
165
+ const enclosing = findEnclosingFunction(call);
166
+ if (enclosing && enclosing !== callback && !isDirectChild(enclosing, callback)) continue;
167
+
168
+ if (callee.type === 'identifier') {
169
+ triggers.push(callee.text);
170
+ } else if (callee.type === 'member_expression') {
171
+ triggers.push(callee.text);
172
+ }
173
+ }
174
+
175
+ return triggers;
176
+ }
177
+
178
+ function isDirectChild(inner: Parser.SyntaxNode, outer: Parser.SyntaxNode): boolean {
179
+ let current = inner.parent;
180
+ while (current) {
181
+ if (current === outer) return true;
182
+ if (
183
+ current.type === 'arrow_function' ||
184
+ current.type === 'function_expression' ||
185
+ current.type === 'function_declaration'
186
+ ) {
187
+ // Hit another function boundary — not direct
188
+ return current === outer;
189
+ }
190
+ current = current.parent;
191
+ }
192
+ return false;
193
+ }
194
+
195
+ function getFunctionName(node: Parser.SyntaxNode): string | null {
196
+ const nameField = node.childForFieldName('name');
197
+ if (nameField) return nameField.text;
198
+ if (node.type === 'arrow_function' || node.type === 'function_expression') {
199
+ const parent = node.parent;
200
+ if (parent?.type === 'variable_declarator') {
201
+ return parent.childForFieldName('name')?.text ?? null;
202
+ }
203
+ }
204
+ return null;
205
+ }
@@ -0,0 +1,268 @@
1
+ import type Parser from 'tree-sitter';
2
+ import type { GraphNode, GraphEdge } from '../../graph/schema.js';
3
+ import { nodeId, isHookName, findAll, findEnclosingFunction, isPascalCase } from './types.js';
4
+ import type { ExtractionResult } from './types.js';
5
+
6
+ const BUILTIN_HOOKS = new Set([
7
+ 'useState', 'useEffect', 'useContext', 'useReducer', 'useCallback',
8
+ 'useMemo', 'useRef', 'useImperativeHandle', 'useLayoutEffect',
9
+ 'useDebugValue', 'useDeferredValue', 'useTransition', 'useId',
10
+ 'useSyncExternalStore', 'useInsertionEffect', 'useActionState',
11
+ 'useFormStatus', 'useOptimistic', 'use',
12
+ ]);
13
+
14
+ export function extractHooks(
15
+ tree: Parser.Tree,
16
+ filePath: string,
17
+ sourceCode: string,
18
+ existingNodes: GraphNode[],
19
+ ): ExtractionResult {
20
+ const nodes: GraphNode[] = [];
21
+ const edges: GraphEdge[] = [];
22
+ const root = tree.rootNode;
23
+
24
+ // Build a map of component/hook names defined in this file from existing nodes
25
+ const definedFunctions = new Map<string, GraphNode>();
26
+ for (const n of existingNodes) {
27
+ if (n.file === filePath && (n.kind === 'Component' || n.kind === 'Hook')) {
28
+ definedFunctions.set(n.name, n);
29
+ }
30
+ }
31
+
32
+ // Pass 1: Find custom hook definitions
33
+ for (let i = 0; i < root.childCount; i++) {
34
+ const child = root.child(i)!;
35
+ const hook = detectHookDefinition(child, filePath);
36
+ if (hook) {
37
+ nodes.push(hook);
38
+ definedFunctions.set(hook.name, hook);
39
+ }
40
+ }
41
+
42
+ // Pass 2: Find all hook call sites
43
+ const callExprs = findAll(root, 'call_expression');
44
+ for (const call of callExprs) {
45
+ const callee = call.childForFieldName('function');
46
+ if (!callee) continue;
47
+
48
+ const calleeName = callee.text;
49
+ if (!isHookName(calleeName) && calleeName !== 'use') continue;
50
+
51
+ // Find enclosing component or hook
52
+ const enclosingFn = findEnclosingFunction(call);
53
+ if (!enclosingFn) continue;
54
+
55
+ const enclosingName = getFunctionName(enclosingFn);
56
+ if (!enclosingName) continue;
57
+
58
+ const enclosingNode = definedFunctions.get(enclosingName);
59
+ if (!enclosingNode) continue;
60
+
61
+ // Determine target
62
+ const targetId = BUILTIN_HOOKS.has(calleeName)
63
+ ? `builtin:${calleeName}`
64
+ : resolveHookTarget(calleeName, filePath, existingNodes);
65
+
66
+ // Get destructured names
67
+ const destructured = getDestructuredNames(call);
68
+
69
+ edges.push({
70
+ source: enclosingNode.id,
71
+ target: targetId,
72
+ kind: 'uses_hook',
73
+ meta: {
74
+ hookName: calleeName,
75
+ ...(destructured.length > 0 ? { destructured } : {}),
76
+ },
77
+ });
78
+ }
79
+
80
+ return { nodes, edges };
81
+ }
82
+
83
+ function detectHookDefinition(node: Parser.SyntaxNode, filePath: string): GraphNode | null {
84
+ // function useXxx() { ... }
85
+ if (node.type === 'function_declaration') {
86
+ const nameNode = node.childForFieldName('name');
87
+ const name = nameNode?.text;
88
+ if (!name || !isHookName(name)) return null;
89
+ return makeHookNode(name, filePath, node);
90
+ }
91
+
92
+ // export function useXxx() { ... }
93
+ if (node.type === 'export_statement') {
94
+ const isDefault = node.text.startsWith('export default');
95
+ const funcDecl = findDirectChild(node, 'function_declaration');
96
+ if (funcDecl) {
97
+ const nameNode = funcDecl.childForFieldName('name');
98
+ const name = nameNode?.text;
99
+ if (!name || !isHookName(name)) return null;
100
+ const hook = makeHookNode(name, filePath, funcDecl);
101
+ hook.exportType = isDefault ? 'default' : 'named';
102
+ return hook;
103
+ }
104
+
105
+ const lexDecl = findDirectChild(node, 'lexical_declaration');
106
+ if (lexDecl) {
107
+ return tryLexicalHook(lexDecl, filePath, isDefault ? 'default' : 'named');
108
+ }
109
+
110
+ return null;
111
+ }
112
+
113
+ // const useXxx = () => { ... }
114
+ if (node.type === 'lexical_declaration') {
115
+ return tryLexicalHook(node, filePath, 'none');
116
+ }
117
+
118
+ return null;
119
+ }
120
+
121
+ function tryLexicalHook(
122
+ node: Parser.SyntaxNode,
123
+ filePath: string,
124
+ exportType: 'default' | 'named' | 'none',
125
+ ): GraphNode | null {
126
+ const declarator = findDirectChild(node, 'variable_declarator');
127
+ if (!declarator) return null;
128
+
129
+ const nameNode = declarator.childForFieldName('name');
130
+ const name = nameNode?.text;
131
+ if (!name || !isHookName(name)) return null;
132
+
133
+ const value = declarator.childForFieldName('value');
134
+ if (!value) return null;
135
+
136
+ if (value.type === 'arrow_function' || value.type === 'function_expression') {
137
+ const hook = makeHookNode(name, filePath, node);
138
+ hook.exportType = exportType;
139
+ return hook;
140
+ }
141
+
142
+ return null;
143
+ }
144
+
145
+ function makeHookNode(name: string, filePath: string, node: Parser.SyntaxNode): GraphNode {
146
+ const returns = extractReturnShape(node);
147
+ return {
148
+ id: nodeId(filePath, name),
149
+ kind: 'Hook',
150
+ name,
151
+ file: filePath,
152
+ line: node.startPosition.row + 1,
153
+ exportType: 'none',
154
+ returns: returns ?? undefined,
155
+ meta: {},
156
+ };
157
+ }
158
+
159
+ function extractReturnShape(node: Parser.SyntaxNode): string | null {
160
+ const returns = findAll(node, 'return_statement');
161
+ for (const ret of returns) {
162
+ // Skip returns inside nested functions
163
+ const enclosing = findEnclosingFunction(ret);
164
+ if (enclosing && enclosing !== node) {
165
+ // Check if enclosing is directly the function body
166
+ const ourBody = node.childForFieldName('body');
167
+ if (ourBody && !isDescendantOf(ret, ourBody)) continue;
168
+ }
169
+
170
+ const val = ret.child(1); // skip 'return' keyword
171
+ if (!val) continue;
172
+
173
+ if (val.type === 'object') {
174
+ // { user, login, logout }
175
+ const keys: string[] = [];
176
+ for (let i = 0; i < val.childCount; i++) {
177
+ const child = val.child(i)!;
178
+ if (child.type === 'shorthand_property' || child.type === 'pair') {
179
+ const key = child.childForFieldName('key') ?? child.child(0);
180
+ if (key) keys.push(key.text);
181
+ }
182
+ }
183
+ if (keys.length > 0) return `{ ${keys.join(', ')} }`;
184
+ }
185
+
186
+ if (val.type === 'array') {
187
+ const items: string[] = [];
188
+ for (let i = 0; i < val.childCount; i++) {
189
+ const child = val.child(i)!;
190
+ if (child.type === 'identifier') items.push(child.text);
191
+ }
192
+ if (items.length > 0) return `[${items.join(', ')}]`;
193
+ }
194
+ }
195
+
196
+ return null;
197
+ }
198
+
199
+ function isDescendantOf(child: Parser.SyntaxNode, ancestor: Parser.SyntaxNode): boolean {
200
+ let current = child.parent;
201
+ while (current) {
202
+ if (current === ancestor) return true;
203
+ current = current.parent;
204
+ }
205
+ return false;
206
+ }
207
+
208
+ function getFunctionName(node: Parser.SyntaxNode): string | null {
209
+ // function_declaration: has name field
210
+ const nameField = node.childForFieldName('name');
211
+ if (nameField) return nameField.text;
212
+
213
+ // arrow_function: look at parent variable_declarator
214
+ if (node.type === 'arrow_function' || node.type === 'function_expression') {
215
+ const parent = node.parent;
216
+ if (parent?.type === 'variable_declarator') {
217
+ const n = parent.childForFieldName('name');
218
+ return n?.text ?? null;
219
+ }
220
+ }
221
+
222
+ return null;
223
+ }
224
+
225
+ function getDestructuredNames(callNode: Parser.SyntaxNode): string[] {
226
+ const parent = callNode.parent;
227
+ if (parent?.type !== 'variable_declarator') return [];
228
+
229
+ const nameNode = parent.childForFieldName('name');
230
+ if (!nameNode) return [];
231
+
232
+ const names: string[] = [];
233
+
234
+ if (nameNode.type === 'object_pattern') {
235
+ for (let i = 0; i < nameNode.childCount; i++) {
236
+ const child = nameNode.child(i)!;
237
+ if (child.type === 'shorthand_property_identifier_pattern') {
238
+ names.push(child.text);
239
+ } else if (child.type === 'pair_pattern') {
240
+ const key = child.childForFieldName('key');
241
+ if (key) names.push(key.text);
242
+ }
243
+ }
244
+ } else if (nameNode.type === 'array_pattern') {
245
+ for (let i = 0; i < nameNode.childCount; i++) {
246
+ const child = nameNode.child(i)!;
247
+ if (child.type === 'identifier') names.push(child.text);
248
+ }
249
+ }
250
+
251
+ return names;
252
+ }
253
+
254
+ function resolveHookTarget(hookName: string, filePath: string, existingNodes: GraphNode[]): string {
255
+ // Look for a Hook node with this name in existing nodes
256
+ for (const n of existingNodes) {
257
+ if (n.kind === 'Hook' && n.name === hookName) return n.id;
258
+ }
259
+ // Unresolved — may be from an import we haven't linked yet
260
+ return `unresolved:${hookName}`;
261
+ }
262
+
263
+ function findDirectChild(node: Parser.SyntaxNode, type: string): Parser.SyntaxNode | null {
264
+ for (let i = 0; i < node.childCount; i++) {
265
+ if (node.child(i)!.type === type) return node.child(i)!;
266
+ }
267
+ return null;
268
+ }