@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,89 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { resolve } from 'node:path';
3
+ import type { GraphNode, GraphEdge } from '../graph/schema.js';
4
+ import { parseFile } from './setup.js';
5
+ import { extractImports } from './extractors/imports.js';
6
+ import { extractComponents } from './extractors/components.js';
7
+ import { extractHooks } from './extractors/hooks.js';
8
+ import { extractJSXTree } from './extractors/jsx-tree.js';
9
+ import { extractContext } from './extractors/context.js';
10
+ import { extractState } from './extractors/state.js';
11
+ import { extractApiCalls } from './extractors/api-calls.js';
12
+ import { extractRoutes } from './extractors/routes.js';
13
+ import { extractEffects } from './extractors/effects.js';
14
+ import type { ExtractionResult } from './extractors/types.js';
15
+
16
+ export interface PipelineResult {
17
+ nodes: GraphNode[];
18
+ edges: GraphEdge[];
19
+ filePath: string;
20
+ }
21
+
22
+ /**
23
+ * Run all extractors on a single file in order.
24
+ *
25
+ * @param globalNodes - Nodes from ALL previously-processed files.
26
+ * Cross-file extractors (state, context, hooks, jsx-tree) use this
27
+ * to resolve references to stores, contexts, and components defined
28
+ * in other files.
29
+ */
30
+ export async function processFile(
31
+ relativePath: string,
32
+ projectDir: string,
33
+ globalNodes: GraphNode[] = [],
34
+ ): Promise<PipelineResult> {
35
+ const absolutePath = resolve(projectDir, relativePath);
36
+ const sourceCode = await readFile(absolutePath, 'utf-8');
37
+ const tree = parseFile(relativePath, sourceCode);
38
+
39
+ if (!tree) {
40
+ return { nodes: [], edges: [], filePath: relativePath };
41
+ }
42
+
43
+ // allNodes = this file's nodes as they accumulate through extractors
44
+ const allNodes: GraphNode[] = [];
45
+ const allEdges: GraphEdge[] = [];
46
+
47
+ // visibleNodes = this file's nodes + global nodes from other files
48
+ // Extractors see both when resolving cross-file references
49
+ function visibleNodes(): GraphNode[] {
50
+ return [...globalNodes, ...allNodes];
51
+ }
52
+
53
+ function run(extractor: (tree: any, fp: string, src: string, nodes: GraphNode[]) => ExtractionResult) {
54
+ const result = extractor(tree, relativePath, sourceCode, visibleNodes());
55
+ allNodes.push(...result.nodes);
56
+ allEdges.push(...result.edges);
57
+ }
58
+
59
+ // 1. Imports (creates Module nodes + import edges)
60
+ const importResult = extractImports(tree, relativePath, sourceCode, visibleNodes(), projectDir);
61
+ allNodes.push(...importResult.nodes);
62
+ allEdges.push(...importResult.edges);
63
+
64
+ // 2. Components
65
+ run(extractComponents);
66
+
67
+ // 3. Hooks
68
+ run(extractHooks);
69
+
70
+ // 4. JSX render tree
71
+ run(extractJSXTree);
72
+
73
+ // 5. Context (needs components/hooks for edge sources)
74
+ run(extractContext);
75
+
76
+ // 6. State management (needs components/hooks + cross-file stores)
77
+ run(extractState);
78
+
79
+ // 7. API calls (needs components/hooks for edge sources)
80
+ run(extractApiCalls);
81
+
82
+ // 8. Routes (needs components for linking)
83
+ run(extractRoutes);
84
+
85
+ // 9. Effects (annotates existing nodes — must run last)
86
+ run(extractEffects);
87
+
88
+ return { nodes: allNodes, edges: allEdges, filePath: relativePath };
89
+ }
@@ -0,0 +1,33 @@
1
+ import Parser from 'tree-sitter';
2
+ // @ts-expect-error no declaration file for tree-sitter grammars
3
+ import TypeScript from 'tree-sitter-typescript/typescript';
4
+ // @ts-expect-error no declaration file for tree-sitter grammars
5
+ import TSX from 'tree-sitter-typescript/tsx';
6
+ import JavaScript from 'tree-sitter-javascript';
7
+
8
+ const tsParser = new Parser();
9
+ tsParser.setLanguage(TypeScript);
10
+
11
+ const tsxParser = new Parser();
12
+ tsxParser.setLanguage(TSX);
13
+
14
+ const jsParser = new Parser();
15
+ jsParser.setLanguage(JavaScript as unknown as Parser.Language);
16
+
17
+ const EXT_MAP: Record<string, Parser> = {
18
+ '.ts': tsParser,
19
+ '.tsx': tsxParser,
20
+ '.js': jsParser,
21
+ '.jsx': tsxParser, // JSX uses TSX grammar
22
+ };
23
+
24
+ export function getParser(filePath: string): Parser | null {
25
+ const ext = filePath.slice(filePath.lastIndexOf('.'));
26
+ return EXT_MAP[ext] ?? null;
27
+ }
28
+
29
+ export function parseFile(filePath: string, sourceCode: string): Parser.Tree | null {
30
+ const parser = getParser(filePath);
31
+ if (!parser) return null;
32
+ return parser.parse(sourceCode);
33
+ }
@@ -0,0 +1,61 @@
1
+ import { readdir, readFile, stat } from 'node:fs/promises';
2
+ import { join, relative } from 'node:path';
3
+ import ignore, { type Ignore } from 'ignore';
4
+
5
+ const ALWAYS_SKIP = new Set(['node_modules', '.next', 'dist', 'build', '.reactgraph', '.git']);
6
+ const EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.jsx']);
7
+
8
+ export interface WalkedFile {
9
+ absolutePath: string;
10
+ relativePath: string;
11
+ }
12
+
13
+ export async function walkProject(
14
+ projectDir: string,
15
+ extraExclude: string[] = [],
16
+ ): Promise<WalkedFile[]> {
17
+ const ig = await loadGitignore(projectDir);
18
+ for (const pat of extraExclude) ig.add(pat);
19
+
20
+ const files: WalkedFile[] = [];
21
+ await walk(projectDir, projectDir, ig, files);
22
+ return files;
23
+ }
24
+
25
+ async function loadGitignore(projectDir: string): Promise<Ignore> {
26
+ const ig = ignore();
27
+ try {
28
+ const content = await readFile(join(projectDir, '.gitignore'), 'utf-8');
29
+ ig.add(content);
30
+ } catch {
31
+ // no .gitignore, that's fine
32
+ }
33
+ return ig;
34
+ }
35
+
36
+ async function walk(
37
+ dir: string,
38
+ projectDir: string,
39
+ ig: Ignore,
40
+ files: WalkedFile[],
41
+ ): Promise<void> {
42
+ const entries = await readdir(dir, { withFileTypes: true });
43
+
44
+ for (const entry of entries) {
45
+ if (ALWAYS_SKIP.has(entry.name)) continue;
46
+
47
+ const abs = join(dir, entry.name);
48
+ const rel = relative(projectDir, abs);
49
+
50
+ if (ig.ignores(rel)) continue;
51
+
52
+ if (entry.isDirectory()) {
53
+ await walk(abs, projectDir, ig, files);
54
+ } else if (entry.isFile()) {
55
+ const ext = entry.name.slice(entry.name.lastIndexOf('.'));
56
+ if (EXTENSIONS.has(ext)) {
57
+ files.push({ absolutePath: abs, relativePath: rel });
58
+ }
59
+ }
60
+ }
61
+ }
package/src/watcher.ts ADDED
@@ -0,0 +1,91 @@
1
+ import { watch } from 'chokidar';
2
+ import { readFile } from 'node:fs/promises';
3
+ import { createHash } from 'node:crypto';
4
+ import { relative } from 'node:path';
5
+ import { ReactGraph } from './graph/graph.js';
6
+ import { saveGraph } from './graph/serialize.js';
7
+ import { processFile } from './parser/pipeline.js';
8
+ import { generateAIContext } from './output/ai-context.js';
9
+
10
+ const EXTENSIONS = /\.(tsx?|jsx?)$/;
11
+ const IGNORE = ['**/node_modules/**', '**/.next/**', '**/dist/**', '**/build/**', '**/.reactgraph/**'];
12
+
13
+ export interface WatcherOptions {
14
+ projectDir: string;
15
+ graph: ReactGraph;
16
+ onUpdate?: (file: string, stats: { nodes: number; edges: number }) => void;
17
+ onError?: (file: string, error: Error) => void;
18
+ }
19
+
20
+ export function startWatcher(opts: WatcherOptions): () => void {
21
+ const { projectDir, graph, onUpdate, onError } = opts;
22
+ const fileHashes = new Map<string, string>();
23
+
24
+ // Initialize hashes for current graph files
25
+ // (We'll compute them on first change detection)
26
+
27
+ const watcher = watch(projectDir, {
28
+ ignored: IGNORE,
29
+ ignoreInitial: true,
30
+ persistent: true,
31
+ });
32
+
33
+ const handleChange = async (absPath: string) => {
34
+ if (!EXTENSIONS.test(absPath)) return;
35
+
36
+ const relPath = relative(projectDir, absPath).replace(/\\/g, '/');
37
+
38
+ try {
39
+ const content = await readFile(absPath, 'utf-8');
40
+ const hash = createHash('sha256').update(content).digest('hex').slice(0, 16);
41
+
42
+ // Skip if unchanged
43
+ if (fileHashes.get(relPath) === hash) return;
44
+ fileHashes.set(relPath, hash);
45
+
46
+ // Remove old nodes/edges for this file
47
+ graph.removeNodesByFile(relPath);
48
+
49
+ // Re-process file
50
+ const result = await processFile(relPath, projectDir);
51
+ for (const node of result.nodes) graph.addNode(node);
52
+ for (const edge of result.edges) graph.addEdge(edge);
53
+
54
+ // Persist
55
+ await saveGraph(graph, projectDir);
56
+ await generateAIContext(graph, projectDir);
57
+
58
+ onUpdate?.(relPath, {
59
+ nodes: graph.stats().totalNodes,
60
+ edges: graph.stats().totalEdges,
61
+ });
62
+ } catch (err) {
63
+ onError?.(relPath, err as Error);
64
+ }
65
+ };
66
+
67
+ const handleDelete = async (absPath: string) => {
68
+ if (!EXTENSIONS.test(absPath)) return;
69
+
70
+ const relPath = relative(projectDir, absPath).replace(/\\/g, '/');
71
+ graph.removeNodesByFile(relPath);
72
+ fileHashes.delete(relPath);
73
+
74
+ await saveGraph(graph, projectDir);
75
+ await generateAIContext(graph, projectDir);
76
+
77
+ onUpdate?.(relPath, {
78
+ nodes: graph.stats().totalNodes,
79
+ edges: graph.stats().totalEdges,
80
+ });
81
+ };
82
+
83
+ watcher.on('change', handleChange);
84
+ watcher.on('add', handleChange);
85
+ watcher.on('unlink', handleDelete);
86
+
87
+ // Return cleanup function
88
+ return () => {
89
+ watcher.close();
90
+ };
91
+ }
@@ -0,0 +1,7 @@
1
+ ## Navigation Rules (ReactGraph)
2
+
3
+ - ALWAYS call reactgraph.get_map() at session start instead of exploring files
4
+ - Use reactgraph.get_subgraph(name) before modifying any component or hook
5
+ - Use reactgraph.get_file_context(path) for detailed file info before editing
6
+ - Do NOT use grep/find/glob to discover project structure — the graph has it
7
+ - If ReactGraph MCP is not available, read .reactgraph/ai-context.md instead
@@ -0,0 +1,164 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { parseFile } from '../src/parser/setup.js';
3
+ import { extractComponents } from '../src/parser/extractors/components.js';
4
+ import { extractHooks } from '../src/parser/extractors/hooks.js';
5
+ import { extractJSXTree } from '../src/parser/extractors/jsx-tree.js';
6
+
7
+ describe('Component extractor', () => {
8
+ it('detects function declaration component', () => {
9
+ const code = `
10
+ function Dashboard() {
11
+ return <div>Hello</div>;
12
+ }
13
+ `;
14
+ const tree = parseFile('test.tsx', code)!;
15
+ const { nodes } = extractComponents(tree, 'test.tsx', code, []);
16
+ expect(nodes).toHaveLength(1);
17
+ expect(nodes[0]!.name).toBe('Dashboard');
18
+ expect(nodes[0]!.kind).toBe('Component');
19
+ });
20
+
21
+ it('detects arrow function component', () => {
22
+ const code = `
23
+ const Card = ({ title, value }: Props) => {
24
+ return <div>{title}: {value}</div>;
25
+ };
26
+ `;
27
+ const tree = parseFile('test.tsx', code)!;
28
+ const { nodes } = extractComponents(tree, 'test.tsx', code, []);
29
+ expect(nodes).toHaveLength(1);
30
+ expect(nodes[0]!.name).toBe('Card');
31
+ expect(nodes[0]!.props).toContain('title');
32
+ expect(nodes[0]!.props).toContain('value');
33
+ });
34
+
35
+ it('detects memo-wrapped component', () => {
36
+ const code = `
37
+ const Card = React.memo(({ title }: Props) => {
38
+ return <div>{title}</div>;
39
+ });
40
+ `;
41
+ const tree = parseFile('test.tsx', code)!;
42
+ const { nodes } = extractComponents(tree, 'test.tsx', code, []);
43
+ expect(nodes).toHaveLength(1);
44
+ expect(nodes[0]!.name).toBe('Card');
45
+ expect(nodes[0]!.meta.wrapped).toBe('React.memo');
46
+ });
47
+
48
+ it('detects exported component', () => {
49
+ const code = `
50
+ export function Header() {
51
+ return <header>Logo</header>;
52
+ }
53
+ `;
54
+ const tree = parseFile('test.tsx', code)!;
55
+ const { nodes } = extractComponents(tree, 'test.tsx', code, []);
56
+ expect(nodes).toHaveLength(1);
57
+ expect(nodes[0]!.exportType).toBe('named');
58
+ });
59
+
60
+ it('ignores non-component functions', () => {
61
+ const code = `
62
+ function formatDate(date: Date) {
63
+ return date.toISOString();
64
+ }
65
+ const helper = () => 42;
66
+ `;
67
+ const tree = parseFile('test.tsx', code)!;
68
+ const { nodes } = extractComponents(tree, 'test.tsx', code, []);
69
+ expect(nodes).toHaveLength(0);
70
+ });
71
+ });
72
+
73
+ describe('Hook extractor', () => {
74
+ it('detects custom hook definition', () => {
75
+ const code = `
76
+ function useCounter() {
77
+ const [count, setCount] = useState(0);
78
+ return { count, increment: () => setCount(c => c + 1) };
79
+ }
80
+ `;
81
+ const tree = parseFile('test.ts', code)!;
82
+ const { nodes } = extractHooks(tree, 'test.ts', code, []);
83
+ expect(nodes).toHaveLength(1);
84
+ expect(nodes[0]!.name).toBe('useCounter');
85
+ expect(nodes[0]!.kind).toBe('Hook');
86
+ });
87
+
88
+ it('detects hook usage in component', () => {
89
+ const code = `
90
+ function Dashboard() {
91
+ const { data } = useQuery();
92
+ return <div>{data}</div>;
93
+ }
94
+ `;
95
+ const tree = parseFile('test.tsx', code)!;
96
+ // Provide the Dashboard component node so the hook extractor can find it
97
+ const existingNodes = [{
98
+ id: 'test.tsx:Dashboard',
99
+ kind: 'Component' as const,
100
+ name: 'Dashboard',
101
+ file: 'test.tsx',
102
+ line: 2,
103
+ exportType: 'none' as const,
104
+ meta: {},
105
+ }];
106
+ const { edges } = extractHooks(tree, 'test.tsx', code, existingNodes);
107
+ const hookEdge = edges.find(e => e.kind === 'uses_hook');
108
+ expect(hookEdge).toBeDefined();
109
+ expect(hookEdge!.source).toBe('test.tsx:Dashboard');
110
+ });
111
+ });
112
+
113
+ describe('JSX tree extractor', () => {
114
+ it('detects rendered components', () => {
115
+ const code = `
116
+ function App() {
117
+ return (
118
+ <div>
119
+ <Header />
120
+ <Sidebar isOpen={true} />
121
+ </div>
122
+ );
123
+ }
124
+ `;
125
+ const tree = parseFile('test.tsx', code)!;
126
+ const existingNodes = [{
127
+ id: 'test.tsx:App',
128
+ kind: 'Component' as const,
129
+ name: 'App',
130
+ file: 'test.tsx',
131
+ line: 2,
132
+ exportType: 'none' as const,
133
+ meta: {},
134
+ }];
135
+ const { edges } = extractJSXTree(tree, 'test.tsx', code, existingNodes);
136
+
137
+ const rendersEdges = edges.filter(e => e.kind === 'renders');
138
+ expect(rendersEdges).toHaveLength(2);
139
+
140
+ const propEdges = edges.filter(e => e.kind === 'passes_prop');
141
+ expect(propEdges.length).toBeGreaterThanOrEqual(1);
142
+ expect(propEdges.some(e => (e.meta.prop as string) === 'isOpen')).toBe(true);
143
+ });
144
+
145
+ it('ignores HTML elements', () => {
146
+ const code = `
147
+ function App() {
148
+ return <div><span>text</span></div>;
149
+ }
150
+ `;
151
+ const tree = parseFile('test.tsx', code)!;
152
+ const existingNodes = [{
153
+ id: 'test.tsx:App',
154
+ kind: 'Component' as const,
155
+ name: 'App',
156
+ file: 'test.tsx',
157
+ line: 2,
158
+ exportType: 'none' as const,
159
+ meta: {},
160
+ }];
161
+ const { edges } = extractJSXTree(tree, 'test.tsx', code, existingNodes);
162
+ expect(edges.filter(e => e.kind === 'renders')).toHaveLength(0);
163
+ });
164
+ });
@@ -0,0 +1,12 @@
1
+ import React from 'react';
2
+ import Dashboard from './components/Dashboard';
3
+ import Sidebar from './components/Sidebar';
4
+
5
+ export default function App() {
6
+ return (
7
+ <div>
8
+ <Sidebar isOpen={true} onClose={() => {}} />
9
+ <Dashboard />
10
+ </div>
11
+ );
12
+ }
@@ -0,0 +1,24 @@
1
+ import React from 'react';
2
+ import { useAuth } from '../hooks/useAuth';
3
+ import { useAuthStore } from '../stores/authStore';
4
+ import MetricsCard from './MetricsCard';
5
+
6
+ export default function Dashboard() {
7
+ const { user, isLoading } = useAuth();
8
+ const token = useAuthStore((s) => s.token);
9
+ const items = [
10
+ { id: 1, title: 'Revenue', value: 1000 },
11
+ { id: 2, title: 'Users', value: 500 },
12
+ ];
13
+
14
+ if (isLoading) return <div>Loading...</div>;
15
+
16
+ return (
17
+ <div>
18
+ <h1>Welcome, {user?.name}</h1>
19
+ {items.map(item => (
20
+ <MetricsCard key={item.id} title={item.title} value={item.value} />
21
+ ))}
22
+ </div>
23
+ );
24
+ }
@@ -0,0 +1,15 @@
1
+ import React from 'react';
2
+
3
+ interface MetricsCardProps {
4
+ title: string;
5
+ value: number;
6
+ }
7
+
8
+ export default function MetricsCard({ title, value }: MetricsCardProps) {
9
+ return (
10
+ <div>
11
+ <h3>{title}</h3>
12
+ <span>{value}</span>
13
+ </div>
14
+ );
15
+ }
@@ -0,0 +1,20 @@
1
+ import React from 'react';
2
+ import { useAuth } from '../hooks/useAuth';
3
+
4
+ interface SidebarProps {
5
+ isOpen: boolean;
6
+ onClose: () => void;
7
+ }
8
+
9
+ export default function Sidebar({ isOpen, onClose }: SidebarProps) {
10
+ const { user } = useAuth();
11
+
12
+ if (!isOpen) return null;
13
+
14
+ return (
15
+ <nav>
16
+ <div>{user?.name}</div>
17
+ <button onClick={onClose}>Close</button>
18
+ </nav>
19
+ );
20
+ }
@@ -0,0 +1,16 @@
1
+ import React, { createContext, useContext, useState } from 'react';
2
+
3
+ export const ThemeContext = createContext('light');
4
+
5
+ export function ThemeProvider({ children }: { children: React.ReactNode }) {
6
+ const [theme, setTheme] = useState('light');
7
+ return (
8
+ <ThemeContext.Provider value={theme}>
9
+ {children}
10
+ </ThemeContext.Provider>
11
+ );
12
+ }
13
+
14
+ export function useTheme() {
15
+ return useContext(ThemeContext);
16
+ }
@@ -0,0 +1,25 @@
1
+ import { useState, useEffect } from 'react';
2
+
3
+ export function useAuth() {
4
+ const [user, setUser] = useState(null);
5
+ const [isLoading, setIsLoading] = useState(true);
6
+
7
+ useEffect(() => {
8
+ fetch('/api/auth/me')
9
+ .then(res => res.json())
10
+ .then(data => {
11
+ setUser(data);
12
+ setIsLoading(false);
13
+ });
14
+ }, []);
15
+
16
+ const login = (email: string, password: string) => {
17
+ // login logic
18
+ };
19
+
20
+ const logout = () => {
21
+ setUser(null);
22
+ };
23
+
24
+ return { user, isLoading, login, logout };
25
+ }
@@ -0,0 +1,15 @@
1
+ import { create } from 'zustand';
2
+
3
+ interface AuthState {
4
+ user: { name: string } | null;
5
+ token: string | null;
6
+ login: (user: { name: string }, token: string) => void;
7
+ logout: () => void;
8
+ }
9
+
10
+ export const useAuthStore = create<AuthState>((set) => ({
11
+ user: null,
12
+ token: null,
13
+ login: (user, token) => set({ user, token }),
14
+ logout: () => set({ user: null, token: null }),
15
+ }));
@@ -0,0 +1,7 @@
1
+ export function formatDate(date: Date): string {
2
+ return date.toLocaleDateString();
3
+ }
4
+
5
+ export function cn(...classes: string[]): string {
6
+ return classes.filter(Boolean).join(' ');
7
+ }
@@ -0,0 +1,91 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { ReactGraph } from '../src/graph/graph.js';
3
+ import type { GraphNode, GraphEdge } from '../src/graph/schema.js';
4
+
5
+ function makeNode(overrides: Partial<GraphNode> = {}): GraphNode {
6
+ return {
7
+ id: 'test:Node',
8
+ kind: 'Component',
9
+ name: 'Node',
10
+ file: 'src/Node.tsx',
11
+ line: 1,
12
+ exportType: 'default',
13
+ meta: {},
14
+ ...overrides,
15
+ };
16
+ }
17
+
18
+ describe('ReactGraph', () => {
19
+ it('adds and retrieves nodes', () => {
20
+ const g = new ReactGraph();
21
+ const node = makeNode();
22
+ g.addNode(node);
23
+
24
+ expect(g.getNode('test:Node')).toBe(node);
25
+ expect(g.getNodesByKind('Component')).toHaveLength(1);
26
+ expect(g.getNodesByFile('src/Node.tsx')).toHaveLength(1);
27
+ expect(g.getNodesByName('Node')).toHaveLength(1);
28
+ });
29
+
30
+ it('adds and retrieves edges', () => {
31
+ const g = new ReactGraph();
32
+ g.addNode(makeNode({ id: 'a', name: 'A' }));
33
+ g.addNode(makeNode({ id: 'b', name: 'B' }));
34
+ g.addEdge({ source: 'a', target: 'b', kind: 'renders', meta: {} });
35
+
36
+ expect(g.getEdgesFrom('a')).toHaveLength(1);
37
+ expect(g.getEdgesTo('b')).toHaveLength(1);
38
+ expect(g.getEdgesByKind('renders')).toHaveLength(1);
39
+ });
40
+
41
+ it('removes nodes by file', () => {
42
+ const g = new ReactGraph();
43
+ g.addNode(makeNode({ id: 'a', name: 'A', file: 'f1.tsx' }));
44
+ g.addNode(makeNode({ id: 'b', name: 'B', file: 'f2.tsx' }));
45
+ g.addEdge({ source: 'a', target: 'b', kind: 'renders', meta: {} });
46
+
47
+ g.removeNodesByFile('f1.tsx');
48
+
49
+ expect(g.getNode('a')).toBeUndefined();
50
+ expect(g.getNode('b')).toBeDefined();
51
+ expect(g.getEdgesFrom('a')).toHaveLength(0);
52
+ expect(g.getEdgesTo('b')).toHaveLength(0);
53
+ });
54
+
55
+ it('finds nodes with fuzzy matching', () => {
56
+ const g = new ReactGraph();
57
+ g.addNode(makeNode({ id: 'a', name: 'Dashboard' }));
58
+
59
+ expect(g.findNode('Dashboard')?.id).toBe('a');
60
+ expect(g.findNode('dashboard')?.id).toBe('a');
61
+ expect(g.findNode('dash')?.id).toBe('a');
62
+ expect(g.findNode('zzz')).toBeUndefined();
63
+ });
64
+
65
+ it('reports stats', () => {
66
+ const g = new ReactGraph();
67
+ g.addNode(makeNode({ id: 'a', name: 'A' }));
68
+ g.addNode(makeNode({ id: 'b', name: 'B', kind: 'Hook' }));
69
+ g.addEdge({ source: 'a', target: 'b', kind: 'uses_hook', meta: {} });
70
+
71
+ const s = g.stats();
72
+ expect(s.totalNodes).toBe(2);
73
+ expect(s.totalEdges).toBe(1);
74
+ expect(s.Component).toBe(1);
75
+ expect(s.Hook).toBe(1);
76
+ });
77
+
78
+ it('computes connectivity', () => {
79
+ const g = new ReactGraph();
80
+ g.addNode(makeNode({ id: 'a', name: 'A' }));
81
+ g.addNode(makeNode({ id: 'b', name: 'B' }));
82
+ g.addNode(makeNode({ id: 'c', name: 'C' }));
83
+ g.addEdge({ source: 'a', target: 'b', kind: 'renders', meta: {} });
84
+ g.addEdge({ source: 'a', target: 'c', kind: 'renders', meta: {} });
85
+ g.addEdge({ source: 'c', target: 'a', kind: 'uses_hook', meta: {} });
86
+
87
+ expect(g.connectivity('a')).toBe(3); // 2 out + 1 in
88
+ expect(g.connectivity('b')).toBe(1); // 1 in
89
+ expect(g.connectivity('c')).toBe(2); // 1 in + 1 out
90
+ });
91
+ });