@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.
- package/README.md +319 -0
- package/bun.lock +527 -0
- package/dist/cli/components/IndexProgress.d.ts +18 -0
- package/dist/cli/components/IndexProgress.d.ts.map +1 -0
- package/dist/cli/components/IndexProgress.js +26 -0
- package/dist/cli/components/IndexProgress.js.map +1 -0
- package/dist/cli/components/InitResult.d.ts +7 -0
- package/dist/cli/components/InitResult.d.ts.map +1 -0
- package/dist/cli/components/InitResult.js +6 -0
- package/dist/cli/components/InitResult.js.map +1 -0
- package/dist/cli/index-cmd.d.ts +7 -0
- package/dist/cli/index-cmd.d.ts.map +1 -0
- package/dist/cli/index-cmd.js +28 -0
- package/dist/cli/index-cmd.js.map +1 -0
- package/dist/cli/index.d.ts +3 -0
- package/dist/cli/index.d.ts.map +1 -0
- package/dist/cli/index.js +81 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/cli/init.d.ts +8 -0
- package/dist/cli/init.d.ts.map +1 -0
- package/dist/cli/init.js +77 -0
- package/dist/cli/init.js.map +1 -0
- package/dist/cli/serve.d.ts +2 -0
- package/dist/cli/serve.d.ts.map +1 -0
- package/dist/cli/serve.js +28 -0
- package/dist/cli/serve.js.map +1 -0
- package/dist/cli/unused.d.ts +2 -0
- package/dist/cli/unused.d.ts.map +1 -0
- package/dist/cli/unused.js +56 -0
- package/dist/cli/unused.js.map +1 -0
- package/dist/graph/graph.d.ts +30 -0
- package/dist/graph/graph.d.ts.map +1 -0
- package/dist/graph/graph.js +166 -0
- package/dist/graph/graph.js.map +1 -0
- package/dist/graph/index.d.ts +5 -0
- package/dist/graph/index.d.ts.map +1 -0
- package/dist/graph/index.js +5 -0
- package/dist/graph/index.js.map +1 -0
- package/dist/graph/schema.d.ts +33 -0
- package/dist/graph/schema.d.ts.map +1 -0
- package/dist/graph/schema.js +3 -0
- package/dist/graph/schema.js.map +1 -0
- package/dist/graph/serialize.d.ts +7 -0
- package/dist/graph/serialize.d.ts.map +1 -0
- package/dist/graph/serialize.js +39 -0
- package/dist/graph/serialize.js.map +1 -0
- package/dist/graph/traverse.d.ts +14 -0
- package/dist/graph/traverse.d.ts.map +1 -0
- package/dist/graph/traverse.js +50 -0
- package/dist/graph/traverse.js.map +1 -0
- package/dist/mcp/formatter.d.ts +26 -0
- package/dist/mcp/formatter.d.ts.map +1 -0
- package/dist/mcp/formatter.js +691 -0
- package/dist/mcp/formatter.js.map +1 -0
- package/dist/mcp/server.d.ts +2 -0
- package/dist/mcp/server.d.ts.map +1 -0
- package/dist/mcp/server.js +45 -0
- package/dist/mcp/server.js.map +1 -0
- package/dist/mcp/tools.d.ts +9 -0
- package/dist/mcp/tools.d.ts.map +1 -0
- package/dist/mcp/tools.js +136 -0
- package/dist/mcp/tools.js.map +1 -0
- package/dist/output/ai-context.d.ts +7 -0
- package/dist/output/ai-context.d.ts.map +1 -0
- package/dist/output/ai-context.js +26 -0
- package/dist/output/ai-context.js.map +1 -0
- package/dist/parser/extractors/api-calls.d.ts +15 -0
- package/dist/parser/extractors/api-calls.d.ts.map +1 -0
- package/dist/parser/extractors/api-calls.js +168 -0
- package/dist/parser/extractors/api-calls.js.map +1 -0
- package/dist/parser/extractors/components.d.ts +5 -0
- package/dist/parser/extractors/components.d.ts.map +1 -0
- package/dist/parser/extractors/components.js +236 -0
- package/dist/parser/extractors/components.js.map +1 -0
- package/dist/parser/extractors/context.d.ts +14 -0
- package/dist/parser/extractors/context.d.ts.map +1 -0
- package/dist/parser/extractors/context.js +196 -0
- package/dist/parser/extractors/context.js.map +1 -0
- package/dist/parser/extractors/effects.d.ts +14 -0
- package/dist/parser/extractors/effects.d.ts.map +1 -0
- package/dist/parser/extractors/effects.js +175 -0
- package/dist/parser/extractors/effects.js.map +1 -0
- package/dist/parser/extractors/hooks.d.ts +5 -0
- package/dist/parser/extractors/hooks.d.ts.map +1 -0
- package/dist/parser/extractors/hooks.js +242 -0
- package/dist/parser/extractors/hooks.js.map +1 -0
- package/dist/parser/extractors/imports.d.ts +6 -0
- package/dist/parser/extractors/imports.d.ts.map +1 -0
- package/dist/parser/extractors/imports.js +148 -0
- package/dist/parser/extractors/imports.js.map +1 -0
- package/dist/parser/extractors/index.d.ts +12 -0
- package/dist/parser/extractors/index.d.ts.map +1 -0
- package/dist/parser/extractors/index.js +11 -0
- package/dist/parser/extractors/index.js.map +1 -0
- package/dist/parser/extractors/jsx-tree.d.ts +5 -0
- package/dist/parser/extractors/jsx-tree.d.ts.map +1 -0
- package/dist/parser/extractors/jsx-tree.js +226 -0
- package/dist/parser/extractors/jsx-tree.js.map +1 -0
- package/dist/parser/extractors/routes.d.ts +13 -0
- package/dist/parser/extractors/routes.d.ts.map +1 -0
- package/dist/parser/extractors/routes.js +275 -0
- package/dist/parser/extractors/routes.js.map +1 -0
- package/dist/parser/extractors/state.d.ts +14 -0
- package/dist/parser/extractors/state.d.ts.map +1 -0
- package/dist/parser/extractors/state.js +368 -0
- package/dist/parser/extractors/state.js.map +1 -0
- package/dist/parser/extractors/types.d.ts +22 -0
- package/dist/parser/extractors/types.d.ts.map +1 -0
- package/dist/parser/extractors/types.js +51 -0
- package/dist/parser/extractors/types.js.map +1 -0
- package/dist/parser/indexer.d.ts +14 -0
- package/dist/parser/indexer.d.ts.map +1 -0
- package/dist/parser/indexer.js +167 -0
- package/dist/parser/indexer.js.map +1 -0
- package/dist/parser/pipeline.d.ts +16 -0
- package/dist/parser/pipeline.d.ts.map +1 -0
- package/dist/parser/pipeline.js +63 -0
- package/dist/parser/pipeline.js.map +1 -0
- package/dist/parser/setup.d.ts +4 -0
- package/dist/parser/setup.d.ts.map +1 -0
- package/dist/parser/setup.js +29 -0
- package/dist/parser/setup.js.map +1 -0
- package/dist/parser/walker.d.ts +6 -0
- package/dist/parser/walker.d.ts.map +1 -0
- package/dist/parser/walker.js +45 -0
- package/dist/parser/walker.js.map +1 -0
- package/dist/watcher.d.ts +12 -0
- package/dist/watcher.d.ts.map +1 -0
- package/dist/watcher.js +72 -0
- package/dist/watcher.js.map +1 -0
- package/package.json +51 -0
- package/src/cli/components/IndexProgress.tsx +79 -0
- package/src/cli/components/InitResult.tsx +28 -0
- package/src/cli/index-cmd.ts +41 -0
- package/src/cli/index.ts +92 -0
- package/src/cli/init.ts +97 -0
- package/src/cli/serve.ts +29 -0
- package/src/cli/unused.ts +88 -0
- package/src/graph/graph.ts +179 -0
- package/src/graph/index.ts +4 -0
- package/src/graph/schema.ts +68 -0
- package/src/graph/serialize.ts +40 -0
- package/src/graph/traverse.ts +66 -0
- package/src/mcp/formatter.ts +757 -0
- package/src/mcp/server.ts +59 -0
- package/src/mcp/tools.ts +154 -0
- package/src/output/ai-context.ts +29 -0
- package/src/parser/extractors/api-calls.ts +192 -0
- package/src/parser/extractors/components.ts +273 -0
- package/src/parser/extractors/context.ts +216 -0
- package/src/parser/extractors/effects.ts +205 -0
- package/src/parser/extractors/hooks.ts +268 -0
- package/src/parser/extractors/imports.ts +192 -0
- package/src/parser/extractors/index.ts +11 -0
- package/src/parser/extractors/jsx-tree.ts +271 -0
- package/src/parser/extractors/routes.ts +331 -0
- package/src/parser/extractors/state.ts +392 -0
- package/src/parser/extractors/types.ts +71 -0
- package/src/parser/indexer.ts +197 -0
- package/src/parser/pipeline.ts +89 -0
- package/src/parser/setup.ts +33 -0
- package/src/parser/walker.ts +61 -0
- package/src/watcher.ts +91 -0
- package/templates/CLAUDE.md +7 -0
- package/tests/extractors.test.ts +164 -0
- package/tests/fixtures/basic/src/App.tsx +12 -0
- package/tests/fixtures/basic/src/components/Dashboard.tsx +24 -0
- package/tests/fixtures/basic/src/components/MetricsCard.tsx +15 -0
- package/tests/fixtures/basic/src/components/Sidebar.tsx +20 -0
- package/tests/fixtures/basic/src/contexts/ThemeContext.tsx +16 -0
- package/tests/fixtures/basic/src/hooks/useAuth.ts +25 -0
- package/tests/fixtures/basic/src/stores/authStore.ts +15 -0
- package/tests/fixtures/basic/src/utils.ts +7 -0
- package/tests/graph.test.ts +91 -0
- package/tests/phase2.test.ts +309 -0
- package/tests/smoke.test.ts +77 -0
- package/tsconfig.json +20 -0
- 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
|
+
}
|