@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,59 @@
1
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
2
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
3
+ import {
4
+ CallToolRequestSchema,
5
+ ListToolsRequestSchema,
6
+ } from '@modelcontextprotocol/sdk/types.js';
7
+ import { loadGraph } from '../graph/serialize.js';
8
+ import { TOOL_DEFINITIONS, handleToolCall } from './tools.js';
9
+
10
+ export async function startServer(projectDir: string, liveGraph?: import('../graph/graph.js').ReactGraph): Promise<void> {
11
+ const graph = liveGraph ?? await loadGraph(projectDir);
12
+
13
+ if (!graph) {
14
+ console.error(
15
+ 'No graph found. Run "reactgraph index" first to build the project graph.'
16
+ );
17
+ process.exit(1);
18
+ }
19
+
20
+ const server = new Server(
21
+ {
22
+ name: 'reactgraph',
23
+ version: '0.1.0',
24
+ },
25
+ {
26
+ capabilities: {
27
+ tools: {},
28
+ },
29
+ }
30
+ );
31
+
32
+ // List available tools
33
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({
34
+ tools: TOOL_DEFINITIONS.map(t => ({
35
+ name: t.name,
36
+ description: t.description,
37
+ inputSchema: t.inputSchema,
38
+ })),
39
+ }));
40
+
41
+ // Handle tool calls
42
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
43
+ const { name, arguments: args } = request.params;
44
+ const result = handleToolCall(graph, name, (args ?? {}) as Record<string, unknown>);
45
+
46
+ return {
47
+ content: [
48
+ {
49
+ type: 'text' as const,
50
+ text: result,
51
+ },
52
+ ],
53
+ };
54
+ });
55
+
56
+ // Start with stdio transport
57
+ const transport = new StdioServerTransport();
58
+ await server.connect(transport);
59
+ }
@@ -0,0 +1,154 @@
1
+ import type { ReactGraph } from '../graph/graph.js';
2
+ import { formatMap, formatSubgraph, formatFileContext, formatTraceFlow, formatImpact, formatFindNodes } from './formatter.js';
3
+
4
+ export interface ToolDefinition {
5
+ name: string;
6
+ description: string;
7
+ inputSchema: Record<string, unknown>;
8
+ }
9
+
10
+ export const TOOL_DEFINITIONS: ToolDefinition[] = [
11
+ {
12
+ name: 'get_map',
13
+ description: 'Get a compact overview of the entire React project: components, hooks, stores, routes, contexts, and their relationships. Call this once at session start instead of exploring files manually.',
14
+ inputSchema: {
15
+ type: 'object',
16
+ properties: {
17
+ max_components: {
18
+ type: 'number',
19
+ description: 'Maximum number of components to show (default: 30, sorted by connectivity)',
20
+ },
21
+ },
22
+ },
23
+ },
24
+ {
25
+ name: 'get_subgraph',
26
+ description: 'Zoom into a specific component, hook, store, or file. Shows what it renders, what hooks it uses, who uses it, and related nodes up to the given depth.',
27
+ inputSchema: {
28
+ type: 'object',
29
+ properties: {
30
+ target: {
31
+ type: 'string',
32
+ description: 'Component name, hook name, store name, or file path to zoom into',
33
+ },
34
+ depth: {
35
+ type: 'number',
36
+ description: 'How many levels of connections to show (default: 2)',
37
+ },
38
+ },
39
+ required: ['target'],
40
+ },
41
+ },
42
+ {
43
+ name: 'get_file_context',
44
+ description: 'Get complete context about a specific file: exports, imports, components, hooks, stores, contexts, routes, effects, and all relationships.',
45
+ inputSchema: {
46
+ type: 'object',
47
+ properties: {
48
+ path: {
49
+ type: 'string',
50
+ description: 'Relative file path (e.g., "src/components/Dashboard.tsx")',
51
+ },
52
+ },
53
+ required: ['path'],
54
+ },
55
+ },
56
+ {
57
+ name: 'trace_flow',
58
+ description: 'Trace the data flow path between two nodes. Shows how data gets from component/hook A to component/hook B through renders, hooks, stores, and API calls.',
59
+ inputSchema: {
60
+ type: 'object',
61
+ properties: {
62
+ from: {
63
+ type: 'string',
64
+ description: 'Source node name (component, hook, or store)',
65
+ },
66
+ to: {
67
+ type: 'string',
68
+ description: 'Target node name (component, hook, store, or API endpoint)',
69
+ },
70
+ },
71
+ required: ['from', 'to'],
72
+ },
73
+ },
74
+ {
75
+ name: 'impact',
76
+ description: 'Analyze what would break if a component, hook, or store is changed. Shows direct dependents, indirect effects, and safe-to-change analysis.',
77
+ inputSchema: {
78
+ type: 'object',
79
+ properties: {
80
+ target: {
81
+ type: 'string',
82
+ description: 'Name or file path of the node to analyze',
83
+ },
84
+ },
85
+ required: ['target'],
86
+ },
87
+ },
88
+ {
89
+ name: 'find_nodes',
90
+ description: 'Query the graph. Use "query" to find a node by name and see all its connections (readers, writers, renderers, etc). Use other filters for bulk searches.',
91
+ inputSchema: {
92
+ type: 'object',
93
+ properties: {
94
+ query: {
95
+ type: 'string',
96
+ description: 'Find a node by name and show all its connections. Best for "who uses X?" questions. Example: "useUIStore" returns the store + all readers/writers.',
97
+ },
98
+ name: {
99
+ type: 'string',
100
+ description: 'Filter nodes by name substring match',
101
+ },
102
+ kind: {
103
+ type: 'string',
104
+ description: 'Node kind filter: Component, Hook, Store, Context, Route, ApiEndpoint',
105
+ },
106
+ using_hook: {
107
+ type: 'string',
108
+ description: 'Find nodes that use this hook (by name)',
109
+ },
110
+ using_store: {
111
+ type: 'string',
112
+ description: 'Find nodes that read/write this store (by name)',
113
+ },
114
+ fetches: {
115
+ type: 'boolean',
116
+ description: 'If true, find nodes that make API calls',
117
+ },
118
+ orphan: {
119
+ type: 'boolean',
120
+ description: 'If true, find components that are not rendered by anything',
121
+ },
122
+ },
123
+ },
124
+ },
125
+ ];
126
+
127
+ export function handleToolCall(
128
+ graph: ReactGraph,
129
+ name: string,
130
+ args: Record<string, unknown>,
131
+ ): string {
132
+ switch (name) {
133
+ case 'get_map':
134
+ return formatMap(graph, (args.max_components as number) ?? 30);
135
+
136
+ case 'get_subgraph':
137
+ return formatSubgraph(graph, args.target as string, (args.depth as number) ?? 2);
138
+
139
+ case 'get_file_context':
140
+ return formatFileContext(graph, args.path as string);
141
+
142
+ case 'trace_flow':
143
+ return formatTraceFlow(graph, args.from as string, args.to as string);
144
+
145
+ case 'impact':
146
+ return formatImpact(graph, args.target as string);
147
+
148
+ case 'find_nodes':
149
+ return formatFindNodes(graph, args);
150
+
151
+ default:
152
+ return `Unknown tool: ${name}`;
153
+ }
154
+ }
@@ -0,0 +1,29 @@
1
+ import { writeFile, mkdir } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { ReactGraph } from '../graph/graph.js';
4
+ import { formatMap } from '../mcp/formatter.js';
5
+
6
+ /**
7
+ * Generate .reactgraph/ai-context.md — the zero-setup flat file fallback.
8
+ * Claude can read this file directly without MCP configured.
9
+ */
10
+ export async function generateAIContext(graph: ReactGraph, projectDir: string): Promise<void> {
11
+ const map = formatMap(graph);
12
+ const stats = graph.stats();
13
+
14
+ const content = `# ReactGraph AI Context
15
+ # Auto-generated — do not edit. Re-generate with: reactgraph index
16
+ # Add to CLAUDE.md: "Read .reactgraph/ai-context.md at session start"
17
+
18
+ ${map}
19
+
20
+ ---
21
+ # To get more detail on any node, use the ReactGraph MCP server:
22
+ # reactgraph.get_subgraph("ComponentName")
23
+ # reactgraph.get_file_context("src/path/to/file.tsx")
24
+ `;
25
+
26
+ const dir = join(projectDir, '.reactgraph');
27
+ await mkdir(dir, { recursive: true });
28
+ await writeFile(join(dir, 'ai-context.md'), content);
29
+ }
@@ -0,0 +1,192 @@
1
+ import type Parser from 'tree-sitter';
2
+ import type { GraphNode, GraphEdge } from '../../graph/schema.js';
3
+ import { nodeId, findAll, findEnclosingFunction } from './types.js';
4
+ import type { ExtractionResult } from './types.js';
5
+
6
+ /**
7
+ * API call extractor — detects fetch, axios, and API wrapper calls.
8
+ *
9
+ * Patterns:
10
+ * fetch('/api/users')
11
+ * fetch('/api/users', { method: 'POST' })
12
+ * axios.get('/api/users')
13
+ * api.get('/api/users')
14
+ * api('/api/users', { method: 'POST' })
15
+ */
16
+ export function extractApiCalls(
17
+ tree: Parser.Tree,
18
+ filePath: string,
19
+ sourceCode: string,
20
+ existingNodes: GraphNode[],
21
+ ): ExtractionResult {
22
+ const nodes: GraphNode[] = [];
23
+ const edges: GraphEdge[] = [];
24
+ const root = tree.rootNode;
25
+
26
+ // Build function map for edge sources
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) continue;
39
+
40
+ const apiCall = detectApiCall(callee, call);
41
+ if (!apiCall) continue;
42
+
43
+ // Find enclosing function
44
+ const enclosingFn = findEnclosingFunction(call);
45
+ if (!enclosingFn) continue;
46
+ const enclosingName = getFunctionName(enclosingFn);
47
+ if (!enclosingName) continue;
48
+ const enclosingNode = functionNodes.get(enclosingName);
49
+ if (!enclosingNode) continue;
50
+
51
+ // Create or reference an ApiEndpoint node
52
+ const endpointId = `endpoint:${apiCall.method}:${apiCall.url}`;
53
+
54
+ edges.push({
55
+ source: enclosingNode.id,
56
+ target: endpointId,
57
+ kind: 'fetches',
58
+ meta: {
59
+ method: apiCall.method,
60
+ url: apiCall.url,
61
+ caller: apiCall.caller,
62
+ },
63
+ });
64
+ }
65
+
66
+ return { nodes, edges };
67
+ }
68
+
69
+ interface ApiCallInfo {
70
+ method: string;
71
+ url: string;
72
+ caller: string; // 'fetch' | 'axios' | 'api' | etc.
73
+ }
74
+
75
+ function detectApiCall(
76
+ callee: Parser.SyntaxNode,
77
+ call: Parser.SyntaxNode,
78
+ ): ApiCallInfo | null {
79
+ const args = call.childForFieldName('arguments');
80
+ if (!args) return null;
81
+
82
+ // Pattern 1: fetch('/api/users') or fetch('/api/users', { method: 'POST' })
83
+ if (callee.type === 'identifier' && callee.text === 'fetch') {
84
+ const url = extractStringArg(args, 0);
85
+ if (!url || !looksLikeApiUrl(url)) return null;
86
+ const method = extractMethodFromOptions(args) ?? 'GET';
87
+ return { method, url, caller: 'fetch' };
88
+ }
89
+
90
+ // Pattern 2: axios.get('/api/users'), axios.post(...)
91
+ if (callee.type === 'member_expression') {
92
+ const obj = callee.childForFieldName('object');
93
+ const prop = callee.childForFieldName('property');
94
+ if (!obj || !prop) return null;
95
+
96
+ const objName = obj.text;
97
+ const methodName = prop.text;
98
+
99
+ // axios.get, axios.post, etc.
100
+ const httpMethods = ['get', 'post', 'put', 'patch', 'delete', 'head', 'options'];
101
+ if (httpMethods.includes(methodName)) {
102
+ const url = extractStringArg(args, 0);
103
+ if (!url) return null;
104
+
105
+ // Accept axios, api, apiClient, http, client, etc.
106
+ const knownCallers = ['axios', 'api', 'apiClient', 'http', 'client', 'fetcher'];
107
+ if (knownCallers.includes(objName) || objName.toLowerCase().includes('api') || objName.toLowerCase().includes('client')) {
108
+ return { method: methodName.toUpperCase(), url, caller: objName };
109
+ }
110
+ }
111
+ }
112
+
113
+ // Pattern 3: api('/api/users') — direct function call with URL string
114
+ if (callee.type === 'identifier') {
115
+ const name = callee.text;
116
+ const knownWrappers = ['api', 'apiClient', 'fetcher', 'request', 'http'];
117
+ if (knownWrappers.includes(name)) {
118
+ const url = extractStringArg(args, 0);
119
+ if (!url || !looksLikeApiUrl(url)) return null;
120
+ const method = extractMethodFromOptions(args) ?? 'GET';
121
+ return { method, url, caller: name };
122
+ }
123
+ }
124
+
125
+ return null;
126
+ }
127
+
128
+ function extractStringArg(args: Parser.SyntaxNode, index: number): string | null {
129
+ // args children: ( arg0, arg1, ... )
130
+ // Skip punctuation nodes
131
+ let argIndex = 0;
132
+ for (let i = 0; i < args.childCount; i++) {
133
+ const child = args.child(i)!;
134
+ if (child.type === '(' || child.type === ')' || child.type === ',') continue;
135
+ if (argIndex === index) {
136
+ if (child.type === 'string') {
137
+ return child.text.replace(/^['"`]|['"`]$/g, '');
138
+ }
139
+ // Template literal: `${base}/api/users`
140
+ if (child.type === 'template_string') {
141
+ return child.text.replace(/^`|`$/g, '');
142
+ }
143
+ return null;
144
+ }
145
+ argIndex++;
146
+ }
147
+ return null;
148
+ }
149
+
150
+ function extractMethodFromOptions(args: Parser.SyntaxNode): string | null {
151
+ // Look for second argument that's an object with { method: 'POST' }
152
+ let argIndex = 0;
153
+ for (let i = 0; i < args.childCount; i++) {
154
+ const child = args.child(i)!;
155
+ if (child.type === '(' || child.type === ')' || child.type === ',') continue;
156
+ if (argIndex === 1 && child.type === 'object') {
157
+ // Find method property
158
+ for (let j = 0; j < child.childCount; j++) {
159
+ const prop = child.child(j)!;
160
+ if (prop.type === 'pair') {
161
+ const key = prop.childForFieldName('key');
162
+ const value = prop.childForFieldName('value');
163
+ if (key?.text === 'method' && value) {
164
+ return value.text.replace(/^['"`]|['"`]$/g, '').toUpperCase();
165
+ }
166
+ }
167
+ }
168
+ }
169
+ argIndex++;
170
+ }
171
+ return null;
172
+ }
173
+
174
+ function looksLikeApiUrl(url: string): boolean {
175
+ return url.startsWith('/api') ||
176
+ url.startsWith('http') ||
177
+ url.startsWith('/v1') ||
178
+ url.startsWith('/v2') ||
179
+ url.includes('/api/');
180
+ }
181
+
182
+ function getFunctionName(node: Parser.SyntaxNode): string | null {
183
+ const nameField = node.childForFieldName('name');
184
+ if (nameField) return nameField.text;
185
+ if (node.type === 'arrow_function' || node.type === 'function_expression') {
186
+ const parent = node.parent;
187
+ if (parent?.type === 'variable_declarator') {
188
+ return parent.childForFieldName('name')?.text ?? null;
189
+ }
190
+ }
191
+ return null;
192
+ }
@@ -0,0 +1,273 @@
1
+ import type Parser from 'tree-sitter';
2
+ import type { GraphNode, GraphEdge } from '../../graph/schema.js';
3
+ import { nodeId, isPascalCase, findAll, findFirst } from './types.js';
4
+ import type { ExtractionResult } from './types.js';
5
+
6
+ export function extractComponents(
7
+ tree: Parser.Tree,
8
+ filePath: string,
9
+ sourceCode: string,
10
+ _existingNodes: GraphNode[],
11
+ ): ExtractionResult {
12
+ const nodes: GraphNode[] = [];
13
+ const edges: GraphEdge[] = [];
14
+ const root = tree.rootNode;
15
+
16
+ for (let i = 0; i < root.childCount; i++) {
17
+ const child = root.child(i)!;
18
+ const component = detectComponent(child, filePath);
19
+ if (component) nodes.push(component);
20
+ }
21
+
22
+ return { nodes, edges };
23
+ }
24
+
25
+ function detectComponent(node: Parser.SyntaxNode, filePath: string): GraphNode | null {
26
+ // Pattern A: function_declaration
27
+ if (node.type === 'function_declaration') {
28
+ return tryFunctionComponent(node, filePath, 'none');
29
+ }
30
+
31
+ // Pattern: export_statement wrapping a declaration
32
+ if (node.type === 'export_statement') {
33
+ const isDefault = node.text.startsWith('export default');
34
+ const exportType = isDefault ? 'default' : 'named';
35
+
36
+ // export function Foo() { ... }
37
+ const funcDecl = findDirectChild(node, 'function_declaration');
38
+ if (funcDecl) return tryFunctionComponent(funcDecl, filePath, exportType);
39
+
40
+ // export const Foo = ...
41
+ const lexDecl = findDirectChild(node, 'lexical_declaration');
42
+ if (lexDecl) return tryLexicalComponent(lexDecl, filePath, exportType);
43
+
44
+ // export default function() { ... } (anonymous)
45
+ const funcExpr = findDirectChild(node, 'function');
46
+ if (funcExpr) return tryFunctionComponent(funcExpr, filePath, exportType);
47
+
48
+ // export class Foo extends Component { ... }
49
+ const classDecl = findDirectChild(node, 'class_declaration');
50
+ if (classDecl) return tryClassComponent(classDecl, filePath, exportType);
51
+
52
+ return null;
53
+ }
54
+
55
+ // Pattern B: const Foo = () => ...
56
+ if (node.type === 'lexical_declaration') {
57
+ return tryLexicalComponent(node, filePath, 'none');
58
+ }
59
+
60
+ // Pattern D: class Foo extends Component
61
+ if (node.type === 'class_declaration') {
62
+ return tryClassComponent(node, filePath, 'none');
63
+ }
64
+
65
+ return null;
66
+ }
67
+
68
+ function tryFunctionComponent(
69
+ node: Parser.SyntaxNode,
70
+ filePath: string,
71
+ exportType: 'default' | 'named' | 'none',
72
+ ): GraphNode | null {
73
+ const nameNode = node.childForFieldName('name');
74
+ const name = nameNode?.text;
75
+ if (!name || !isPascalCase(name)) return null;
76
+
77
+ const body = node.childForFieldName('body');
78
+ if (!body || !containsJSX(body)) return null;
79
+
80
+ return {
81
+ id: nodeId(filePath, name),
82
+ kind: 'Component',
83
+ name,
84
+ file: filePath,
85
+ line: node.startPosition.row + 1,
86
+ exportType,
87
+ props: extractProps(node),
88
+ meta: {},
89
+ };
90
+ }
91
+
92
+ function tryLexicalComponent(
93
+ node: Parser.SyntaxNode,
94
+ filePath: string,
95
+ exportType: 'default' | 'named' | 'none',
96
+ ): GraphNode | null {
97
+ const declarator = findDirectChild(node, 'variable_declarator');
98
+ if (!declarator) return null;
99
+
100
+ const nameNode = declarator.childForFieldName('name');
101
+ const name = nameNode?.text;
102
+ if (!name || !isPascalCase(name)) return null;
103
+
104
+ const value = declarator.childForFieldName('value');
105
+ if (!value) return null;
106
+
107
+ // Direct arrow function: const Foo = () => <div/>
108
+ if (value.type === 'arrow_function' || value.type === 'function_expression') {
109
+ const body = value.childForFieldName('body');
110
+ if (!body) return null;
111
+ if (!containsJSX(body) && !isJSX(body)) return null;
112
+
113
+ return {
114
+ id: nodeId(filePath, name),
115
+ kind: 'Component',
116
+ name,
117
+ file: filePath,
118
+ line: node.startPosition.row + 1,
119
+ exportType,
120
+ props: extractPropsFromArrow(value),
121
+ meta: {},
122
+ };
123
+ }
124
+
125
+ // Wrapped: const Foo = React.memo(() => ...) or memo(() => ...)
126
+ if (value.type === 'call_expression') {
127
+ const wrapper = getWrapperName(value);
128
+ if (wrapper) {
129
+ const innerFn = findInnerFunction(value);
130
+ if (innerFn) {
131
+ const body = innerFn.childForFieldName('body');
132
+ if (body && (containsJSX(body) || isJSX(body))) {
133
+ return {
134
+ id: nodeId(filePath, name),
135
+ kind: 'Component',
136
+ name,
137
+ file: filePath,
138
+ line: node.startPosition.row + 1,
139
+ exportType,
140
+ props: extractPropsFromArrow(innerFn),
141
+ meta: { wrapped: wrapper },
142
+ };
143
+ }
144
+ }
145
+ }
146
+ }
147
+
148
+ return null;
149
+ }
150
+
151
+ function tryClassComponent(
152
+ node: Parser.SyntaxNode,
153
+ filePath: string,
154
+ exportType: 'default' | 'named' | 'none',
155
+ ): GraphNode | null {
156
+ const nameNode = node.childForFieldName('name');
157
+ const name = nameNode?.text;
158
+ if (!name || !isPascalCase(name)) return null;
159
+
160
+ // Check extends React.Component or Component
161
+ const heritage = findDirectChild(node, 'class_heritage');
162
+ if (!heritage) return null;
163
+
164
+ const extendsText = heritage.text;
165
+ if (!extendsText.includes('Component') && !extendsText.includes('PureComponent')) return null;
166
+
167
+ // Check render() method has JSX
168
+ const body = node.childForFieldName('body');
169
+ if (!body) return null;
170
+
171
+ const renderMethod = findAll(body, 'method_definition').find(m => {
172
+ const n = m.childForFieldName('name');
173
+ return n?.text === 'render';
174
+ });
175
+
176
+ if (!renderMethod || !containsJSX(renderMethod)) return null;
177
+
178
+ return {
179
+ id: nodeId(filePath, name),
180
+ kind: 'Component',
181
+ name,
182
+ file: filePath,
183
+ line: node.startPosition.row + 1,
184
+ exportType,
185
+ props: [],
186
+ meta: { isClass: true },
187
+ };
188
+ }
189
+
190
+ function containsJSX(node: Parser.SyntaxNode): boolean {
191
+ if (isJSX(node)) return true;
192
+ for (let i = 0; i < node.childCount; i++) {
193
+ if (containsJSX(node.child(i)!)) return true;
194
+ }
195
+ return false;
196
+ }
197
+
198
+ function isJSX(node: Parser.SyntaxNode): boolean {
199
+ return (
200
+ node.type === 'jsx_element' ||
201
+ node.type === 'jsx_self_closing_element' ||
202
+ node.type === 'jsx_fragment'
203
+ );
204
+ }
205
+
206
+ function extractProps(funcNode: Parser.SyntaxNode): string[] {
207
+ const params = funcNode.childForFieldName('parameters');
208
+ if (!params || params.childCount === 0) return [];
209
+ return extractParamProps(params);
210
+ }
211
+
212
+ function extractPropsFromArrow(arrowNode: Parser.SyntaxNode): string[] {
213
+ const params = arrowNode.childForFieldName('parameters');
214
+ if (!params) return [];
215
+ return extractParamProps(params);
216
+ }
217
+
218
+ function extractParamProps(params: Parser.SyntaxNode): string[] {
219
+ // Look for destructured first param: ({ a, b, c })
220
+ for (let i = 0; i < params.childCount; i++) {
221
+ const param = params.child(i)!;
222
+ // required_parameter or just object_pattern
223
+ const objPattern =
224
+ param.type === 'object_pattern'
225
+ ? param
226
+ : findDirectChild(param, 'object_pattern');
227
+
228
+ if (objPattern) {
229
+ const props: string[] = [];
230
+ for (let j = 0; j < objPattern.childCount; j++) {
231
+ const child = objPattern.child(j)!;
232
+ if (child.type === 'shorthand_property_identifier_pattern' || child.type === 'shorthand_property_identifier') {
233
+ props.push(child.text);
234
+ } else if (child.type === 'pair_pattern') {
235
+ const key = child.childForFieldName('key');
236
+ if (key) props.push(key.text);
237
+ }
238
+ }
239
+ return props;
240
+ }
241
+ }
242
+
243
+ return [];
244
+ }
245
+
246
+ function getWrapperName(callNode: Parser.SyntaxNode): string | null {
247
+ const fn = callNode.childForFieldName('function');
248
+ if (!fn) return null;
249
+
250
+ const WRAPPERS = ['memo', 'React.memo', 'forwardRef', 'React.forwardRef'];
251
+ if (WRAPPERS.includes(fn.text)) return fn.text;
252
+ return null;
253
+ }
254
+
255
+ function findInnerFunction(callNode: Parser.SyntaxNode): Parser.SyntaxNode | null {
256
+ const args = callNode.childForFieldName('arguments');
257
+ if (!args) return null;
258
+
259
+ for (let i = 0; i < args.childCount; i++) {
260
+ const child = args.child(i)!;
261
+ if (child.type === 'arrow_function' || child.type === 'function_expression') {
262
+ return child;
263
+ }
264
+ }
265
+ return null;
266
+ }
267
+
268
+ function findDirectChild(node: Parser.SyntaxNode, type: string): Parser.SyntaxNode | null {
269
+ for (let i = 0; i < node.childCount; i++) {
270
+ if (node.child(i)!.type === type) return node.child(i)!;
271
+ }
272
+ return null;
273
+ }