@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
package/src/cli/init.ts
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync, appendFileSync, mkdirSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import type { ReactGraphConfig } from '../graph/schema.js';
|
|
4
|
+
|
|
5
|
+
export interface InitResult {
|
|
6
|
+
framework: string;
|
|
7
|
+
srcDirs: string[];
|
|
8
|
+
configCreated: boolean;
|
|
9
|
+
gitignoreUpdated: boolean;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function runInit(projectDir: string): InitResult {
|
|
13
|
+
const framework = detectFramework(projectDir);
|
|
14
|
+
const srcDirs = detectSrcDirs(projectDir);
|
|
15
|
+
|
|
16
|
+
// Create .reactgraph/ directory
|
|
17
|
+
const rgDir = join(projectDir, '.reactgraph');
|
|
18
|
+
mkdirSync(rgDir, { recursive: true });
|
|
19
|
+
|
|
20
|
+
// Write config
|
|
21
|
+
const config: ReactGraphConfig = {
|
|
22
|
+
srcDirs,
|
|
23
|
+
framework,
|
|
24
|
+
exclude: ['**/*.test.*', '**/*.spec.*', '**/*.stories.*'],
|
|
25
|
+
};
|
|
26
|
+
writeFileSync(join(rgDir, 'config.json'), JSON.stringify(config, null, 2));
|
|
27
|
+
|
|
28
|
+
// Update .gitignore
|
|
29
|
+
const gitignoreUpdated = ensureGitignore(projectDir);
|
|
30
|
+
|
|
31
|
+
return {
|
|
32
|
+
framework,
|
|
33
|
+
srcDirs,
|
|
34
|
+
configCreated: true,
|
|
35
|
+
gitignoreUpdated,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function detectFramework(projectDir: string): ReactGraphConfig['framework'] {
|
|
40
|
+
// Next.js
|
|
41
|
+
if (
|
|
42
|
+
existsSync(join(projectDir, 'next.config.js')) ||
|
|
43
|
+
existsSync(join(projectDir, 'next.config.mjs')) ||
|
|
44
|
+
existsSync(join(projectDir, 'next.config.ts'))
|
|
45
|
+
) {
|
|
46
|
+
return 'next';
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Vite
|
|
50
|
+
if (
|
|
51
|
+
existsSync(join(projectDir, 'vite.config.ts')) ||
|
|
52
|
+
existsSync(join(projectDir, 'vite.config.js'))
|
|
53
|
+
) {
|
|
54
|
+
return 'vite';
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Remix
|
|
58
|
+
if (existsSync(join(projectDir, 'remix.config.js'))) {
|
|
59
|
+
return 'remix';
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// CRA (react-scripts in package.json)
|
|
63
|
+
try {
|
|
64
|
+
const pkg = JSON.parse(readFileSync(join(projectDir, 'package.json'), 'utf-8'));
|
|
65
|
+
if (pkg.dependencies?.['react-scripts'] || pkg.devDependencies?.['react-scripts']) {
|
|
66
|
+
return 'cra';
|
|
67
|
+
}
|
|
68
|
+
} catch {
|
|
69
|
+
// no package.json
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return 'unknown';
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function detectSrcDirs(projectDir: string): string[] {
|
|
76
|
+
const candidates = ['src', 'app', 'pages', 'lib'];
|
|
77
|
+
const found = candidates.filter(d => existsSync(join(projectDir, d)));
|
|
78
|
+
return found.length > 0 ? found : ['.'];
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function ensureGitignore(projectDir: string): boolean {
|
|
82
|
+
const gitignorePath = join(projectDir, '.gitignore');
|
|
83
|
+
const entry = '.reactgraph/';
|
|
84
|
+
|
|
85
|
+
try {
|
|
86
|
+
if (existsSync(gitignorePath)) {
|
|
87
|
+
const content = readFileSync(gitignorePath, 'utf-8');
|
|
88
|
+
if (content.includes(entry)) return false;
|
|
89
|
+
appendFileSync(gitignorePath, `\n# ReactGraph cache\n${entry}\n`);
|
|
90
|
+
} else {
|
|
91
|
+
writeFileSync(gitignorePath, `# ReactGraph cache\n${entry}\n`);
|
|
92
|
+
}
|
|
93
|
+
return true;
|
|
94
|
+
} catch {
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
}
|
package/src/cli/serve.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { loadGraph } from '../graph/serialize.js';
|
|
2
|
+
import { startServer } from '../mcp/server.js';
|
|
3
|
+
import { startWatcher } from '../watcher.js';
|
|
4
|
+
|
|
5
|
+
export async function runServe(projectDir: string, watchMode: boolean = false): Promise<void> {
|
|
6
|
+
if (watchMode) {
|
|
7
|
+
const graph = await loadGraph(projectDir);
|
|
8
|
+
if (!graph) {
|
|
9
|
+
console.error('No graph found. Run "reactgraph index" first.');
|
|
10
|
+
process.exit(1);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
startWatcher({
|
|
14
|
+
projectDir,
|
|
15
|
+
graph,
|
|
16
|
+
onUpdate: (file, stats) => {
|
|
17
|
+
process.stderr.write(`[reactgraph] Updated: ${file} (${stats.nodes} nodes, ${stats.edges} edges)\n`);
|
|
18
|
+
},
|
|
19
|
+
onError: (file, err) => {
|
|
20
|
+
process.stderr.write(`[reactgraph] Error parsing ${file}: ${err.message}\n`);
|
|
21
|
+
},
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
// Start MCP server with the live graph
|
|
25
|
+
await startServer(projectDir, graph);
|
|
26
|
+
} else {
|
|
27
|
+
await startServer(projectDir);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { render } from 'ink';
|
|
2
|
+
import React from 'react';
|
|
3
|
+
import { Box, Text } from 'ink';
|
|
4
|
+
import { loadGraph } from '../graph/serialize.js';
|
|
5
|
+
import type { ReactGraph } from '../graph/graph.js';
|
|
6
|
+
import type { GraphNode } from '../graph/schema.js';
|
|
7
|
+
|
|
8
|
+
export async function runUnused(projectDir: string): Promise<void> {
|
|
9
|
+
const graph = await loadGraph(projectDir);
|
|
10
|
+
if (!graph) {
|
|
11
|
+
console.error('No graph found. Run "reactgraph index" first.');
|
|
12
|
+
process.exit(1);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const orphanComponents = findOrphanComponents(graph);
|
|
16
|
+
const unusedHooks = findUnusedHooks(graph);
|
|
17
|
+
const unusedExports = findUnusedExports(graph);
|
|
18
|
+
|
|
19
|
+
const App = () => React.createElement(Box, { flexDirection: 'column', paddingTop: 1 },
|
|
20
|
+
React.createElement(Text, { bold: true }, 'ReactGraph — Unused Analysis'),
|
|
21
|
+
React.createElement(Text, null, ''),
|
|
22
|
+
|
|
23
|
+
// Orphan components
|
|
24
|
+
orphanComponents.length > 0
|
|
25
|
+
? React.createElement(Box, { flexDirection: 'column' },
|
|
26
|
+
React.createElement(Text, { color: 'yellow' }, `Orphan components (${orphanComponents.length}) — rendered by nothing:`),
|
|
27
|
+
...orphanComponents.map(c =>
|
|
28
|
+
React.createElement(Text, { key: c.id, dimColor: true }, ` ${c.name} [${c.file}:${c.line}]`)
|
|
29
|
+
)
|
|
30
|
+
)
|
|
31
|
+
: React.createElement(Text, { color: 'green' }, '✓ No orphan components'),
|
|
32
|
+
|
|
33
|
+
React.createElement(Text, null, ''),
|
|
34
|
+
|
|
35
|
+
// Unused hooks
|
|
36
|
+
unusedHooks.length > 0
|
|
37
|
+
? React.createElement(Box, { flexDirection: 'column' },
|
|
38
|
+
React.createElement(Text, { color: 'yellow' }, `Unused hooks (${unusedHooks.length}) — never called:`),
|
|
39
|
+
...unusedHooks.map(h =>
|
|
40
|
+
React.createElement(Text, { key: h.id, dimColor: true }, ` ${h.name} [${h.file}:${h.line}]`)
|
|
41
|
+
)
|
|
42
|
+
)
|
|
43
|
+
: React.createElement(Text, { color: 'green' }, '✓ No unused hooks'),
|
|
44
|
+
|
|
45
|
+
React.createElement(Text, null, ''),
|
|
46
|
+
|
|
47
|
+
// Unused exports
|
|
48
|
+
unusedExports.length > 0
|
|
49
|
+
? React.createElement(Box, { flexDirection: 'column' },
|
|
50
|
+
React.createElement(Text, { color: 'yellow' }, `Unused exports (${unusedExports.length}) — never imported:`),
|
|
51
|
+
...unusedExports.map(e =>
|
|
52
|
+
React.createElement(Text, { key: e.id, dimColor: true }, ` ${e.name} [${e.file}:${e.line}]`)
|
|
53
|
+
)
|
|
54
|
+
)
|
|
55
|
+
: React.createElement(Text, { color: 'green' }, '✓ No unused exports'),
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
const { unmount } = render(React.createElement(App));
|
|
59
|
+
setTimeout(() => unmount(), 100);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function findOrphanComponents(graph: ReactGraph): GraphNode[] {
|
|
63
|
+
return graph.getNodesByKind('Component').filter(c => {
|
|
64
|
+
// A component is orphan if nothing renders it and it's not a route target
|
|
65
|
+
const renderedBy = graph.getEdgesTo(c.id, ['renders']);
|
|
66
|
+
return renderedBy.length === 0;
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function findUnusedHooks(graph: ReactGraph): GraphNode[] {
|
|
71
|
+
return graph.getNodesByKind('Hook').filter(h => {
|
|
72
|
+
const usedBy = graph.getEdgesTo(h.id, ['uses_hook']);
|
|
73
|
+
return usedBy.length === 0;
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function findUnusedExports(graph: ReactGraph): GraphNode[] {
|
|
78
|
+
// Find exported nodes that are never imported by anything
|
|
79
|
+
const allNodes = graph.getAllNodes();
|
|
80
|
+
return allNodes.filter(n => {
|
|
81
|
+
if (n.exportType === 'none') return false;
|
|
82
|
+
if (n.kind === 'Module') return false; // Modules themselves aren't "used"
|
|
83
|
+
|
|
84
|
+
// Check if any edge points to this node
|
|
85
|
+
const incoming = graph.getEdgesTo(n.id);
|
|
86
|
+
return incoming.length === 0;
|
|
87
|
+
});
|
|
88
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import type { GraphNode, GraphEdge, NodeKind, EdgeKind } from './schema.js';
|
|
2
|
+
|
|
3
|
+
export class ReactGraph {
|
|
4
|
+
private nodes = new Map<string, GraphNode>();
|
|
5
|
+
private edges: GraphEdge[] = [];
|
|
6
|
+
|
|
7
|
+
// Secondary indexes
|
|
8
|
+
private byKind = new Map<NodeKind, Set<string>>();
|
|
9
|
+
private byFile = new Map<string, Set<string>>();
|
|
10
|
+
private byName = new Map<string, string[]>();
|
|
11
|
+
private edgesBySource = new Map<string, GraphEdge[]>();
|
|
12
|
+
private edgesByTarget = new Map<string, GraphEdge[]>();
|
|
13
|
+
private edgesByKind = new Map<EdgeKind, GraphEdge[]>();
|
|
14
|
+
|
|
15
|
+
addNode(node: GraphNode): void {
|
|
16
|
+
this.nodes.set(node.id, node);
|
|
17
|
+
|
|
18
|
+
// Index by kind
|
|
19
|
+
if (!this.byKind.has(node.kind)) this.byKind.set(node.kind, new Set());
|
|
20
|
+
this.byKind.get(node.kind)!.add(node.id);
|
|
21
|
+
|
|
22
|
+
// Index by file
|
|
23
|
+
if (!this.byFile.has(node.file)) this.byFile.set(node.file, new Set());
|
|
24
|
+
this.byFile.get(node.file)!.add(node.id);
|
|
25
|
+
|
|
26
|
+
// Index by name
|
|
27
|
+
if (!this.byName.has(node.name)) this.byName.set(node.name, []);
|
|
28
|
+
this.byName.get(node.name)!.push(node.id);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
addEdge(edge: GraphEdge): void {
|
|
32
|
+
this.edges.push(edge);
|
|
33
|
+
|
|
34
|
+
if (!this.edgesBySource.has(edge.source)) this.edgesBySource.set(edge.source, []);
|
|
35
|
+
this.edgesBySource.get(edge.source)!.push(edge);
|
|
36
|
+
|
|
37
|
+
if (!this.edgesByTarget.has(edge.target)) this.edgesByTarget.set(edge.target, []);
|
|
38
|
+
this.edgesByTarget.get(edge.target)!.push(edge);
|
|
39
|
+
|
|
40
|
+
if (!this.edgesByKind.has(edge.kind)) this.edgesByKind.set(edge.kind, []);
|
|
41
|
+
this.edgesByKind.get(edge.kind)!.push(edge);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
getNode(id: string): GraphNode | undefined {
|
|
45
|
+
return this.nodes.get(id);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
getAllNodes(): GraphNode[] {
|
|
49
|
+
return Array.from(this.nodes.values());
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
getAllEdges(): GraphEdge[] {
|
|
53
|
+
return this.edges;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
getNodesByKind(kind: NodeKind): GraphNode[] {
|
|
57
|
+
const ids = this.byKind.get(kind);
|
|
58
|
+
if (!ids) return [];
|
|
59
|
+
return Array.from(ids).map(id => this.nodes.get(id)!);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
getNodesByFile(file: string): GraphNode[] {
|
|
63
|
+
const ids = this.byFile.get(file);
|
|
64
|
+
if (!ids) return [];
|
|
65
|
+
return Array.from(ids).map(id => this.nodes.get(id)!);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
getNodesByName(name: string): GraphNode[] {
|
|
69
|
+
const ids = this.byName.get(name);
|
|
70
|
+
if (!ids) return [];
|
|
71
|
+
return ids.map(id => this.nodes.get(id)!);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
getEdgesFrom(nodeId: string, kinds?: EdgeKind[]): GraphEdge[] {
|
|
75
|
+
const edges = this.edgesBySource.get(nodeId) ?? [];
|
|
76
|
+
if (!kinds) return edges;
|
|
77
|
+
return edges.filter(e => kinds.includes(e.kind));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
getEdgesTo(nodeId: string, kinds?: EdgeKind[]): GraphEdge[] {
|
|
81
|
+
const edges = this.edgesByTarget.get(nodeId) ?? [];
|
|
82
|
+
if (!kinds) return edges;
|
|
83
|
+
return edges.filter(e => kinds.includes(e.kind));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
getEdgesByKind(kind: EdgeKind): GraphEdge[] {
|
|
87
|
+
return this.edgesByKind.get(kind) ?? [];
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
removeNodesByFile(file: string): void {
|
|
91
|
+
const ids = this.byFile.get(file);
|
|
92
|
+
if (!ids) return;
|
|
93
|
+
|
|
94
|
+
// Remove edges referencing these nodes
|
|
95
|
+
const idSet = new Set(ids);
|
|
96
|
+
this.edges = this.edges.filter(e => !idSet.has(e.source) && !idSet.has(e.target));
|
|
97
|
+
|
|
98
|
+
// Rebuild edge indexes
|
|
99
|
+
this.edgesBySource.clear();
|
|
100
|
+
this.edgesByTarget.clear();
|
|
101
|
+
this.edgesByKind.clear();
|
|
102
|
+
for (const edge of this.edges) {
|
|
103
|
+
if (!this.edgesBySource.has(edge.source)) this.edgesBySource.set(edge.source, []);
|
|
104
|
+
this.edgesBySource.get(edge.source)!.push(edge);
|
|
105
|
+
if (!this.edgesByTarget.has(edge.target)) this.edgesByTarget.set(edge.target, []);
|
|
106
|
+
this.edgesByTarget.get(edge.target)!.push(edge);
|
|
107
|
+
if (!this.edgesByKind.has(edge.kind)) this.edgesByKind.set(edge.kind, []);
|
|
108
|
+
this.edgesByKind.get(edge.kind)!.push(edge);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Remove nodes
|
|
112
|
+
for (const id of ids) {
|
|
113
|
+
const node = this.nodes.get(id)!;
|
|
114
|
+
this.nodes.delete(id);
|
|
115
|
+
|
|
116
|
+
// Clean kind index
|
|
117
|
+
this.byKind.get(node.kind)?.delete(id);
|
|
118
|
+
|
|
119
|
+
// Clean name index
|
|
120
|
+
const nameIds = this.byName.get(node.name);
|
|
121
|
+
if (nameIds) {
|
|
122
|
+
const idx = nameIds.indexOf(id);
|
|
123
|
+
if (idx >= 0) nameIds.splice(idx, 1);
|
|
124
|
+
if (nameIds.length === 0) this.byName.delete(node.name);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
this.byFile.delete(file);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
clear(): void {
|
|
132
|
+
this.nodes.clear();
|
|
133
|
+
this.edges = [];
|
|
134
|
+
this.byKind.clear();
|
|
135
|
+
this.byFile.clear();
|
|
136
|
+
this.byName.clear();
|
|
137
|
+
this.edgesBySource.clear();
|
|
138
|
+
this.edgesByTarget.clear();
|
|
139
|
+
this.edgesByKind.clear();
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Find a node by name with fuzzy matching: exact → case-insensitive → partial */
|
|
143
|
+
findNode(name: string): GraphNode | undefined {
|
|
144
|
+
// Exact match
|
|
145
|
+
const exact = this.byName.get(name);
|
|
146
|
+
if (exact?.length) return this.nodes.get(exact[0]);
|
|
147
|
+
|
|
148
|
+
// Case-insensitive
|
|
149
|
+
const lower = name.toLowerCase();
|
|
150
|
+
for (const [key, ids] of this.byName) {
|
|
151
|
+
if (key.toLowerCase() === lower && ids.length) return this.nodes.get(ids[0]);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Partial match
|
|
155
|
+
for (const [key, ids] of this.byName) {
|
|
156
|
+
if (key.toLowerCase().includes(lower) && ids.length) return this.nodes.get(ids[0]);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return undefined;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Count of in+out edges for a node */
|
|
163
|
+
connectivity(nodeId: string): number {
|
|
164
|
+
return (this.edgesBySource.get(nodeId)?.length ?? 0) +
|
|
165
|
+
(this.edgesByTarget.get(nodeId)?.length ?? 0);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
stats(): Record<string, number> {
|
|
169
|
+
const s: Record<string, number> = {
|
|
170
|
+
totalNodes: this.nodes.size,
|
|
171
|
+
totalEdges: this.edges.length,
|
|
172
|
+
files: this.byFile.size,
|
|
173
|
+
};
|
|
174
|
+
for (const [kind, ids] of this.byKind) {
|
|
175
|
+
s[kind] = ids.size;
|
|
176
|
+
}
|
|
177
|
+
return s;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// ---- Node Types ----
|
|
2
|
+
|
|
3
|
+
export type NodeKind =
|
|
4
|
+
| 'Component'
|
|
5
|
+
| 'Hook'
|
|
6
|
+
| 'BuiltinHook'
|
|
7
|
+
| 'Context'
|
|
8
|
+
| 'Route'
|
|
9
|
+
| 'Module'
|
|
10
|
+
| 'Store'
|
|
11
|
+
| 'ApiEndpoint'
|
|
12
|
+
| 'Type'
|
|
13
|
+
| 'Util'
|
|
14
|
+
| 'Constant';
|
|
15
|
+
|
|
16
|
+
export interface GraphNode {
|
|
17
|
+
id: string;
|
|
18
|
+
kind: NodeKind;
|
|
19
|
+
name: string;
|
|
20
|
+
file: string; // relative path
|
|
21
|
+
line: number;
|
|
22
|
+
exportType: 'default' | 'named' | 'none';
|
|
23
|
+
props?: string[];
|
|
24
|
+
returns?: string;
|
|
25
|
+
doc?: string;
|
|
26
|
+
meta: Record<string, unknown>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// ---- Edge Types ----
|
|
30
|
+
|
|
31
|
+
export type EdgeKind =
|
|
32
|
+
| 'imports'
|
|
33
|
+
| 'renders'
|
|
34
|
+
| 'passes_prop'
|
|
35
|
+
| 'uses_hook'
|
|
36
|
+
| 'provides'
|
|
37
|
+
| 'consumes'
|
|
38
|
+
| 'calls'
|
|
39
|
+
| 'fetches'
|
|
40
|
+
| 'reads_store'
|
|
41
|
+
| 'writes_store'
|
|
42
|
+
| 'routes_to'
|
|
43
|
+
| 'type_of';
|
|
44
|
+
|
|
45
|
+
export interface GraphEdge {
|
|
46
|
+
source: string; // node id
|
|
47
|
+
target: string; // node id
|
|
48
|
+
kind: EdgeKind;
|
|
49
|
+
meta: Record<string, unknown>;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ---- Serialization ----
|
|
53
|
+
|
|
54
|
+
export interface SerializedGraph {
|
|
55
|
+
version: number;
|
|
56
|
+
timestamp: string;
|
|
57
|
+
nodes: GraphNode[];
|
|
58
|
+
edges: GraphEdge[];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ---- Config ----
|
|
62
|
+
|
|
63
|
+
export interface ReactGraphConfig {
|
|
64
|
+
srcDirs: string[];
|
|
65
|
+
framework: 'next' | 'vite' | 'cra' | 'remix' | 'unknown';
|
|
66
|
+
exclude: string[];
|
|
67
|
+
aliases?: Record<string, string>;
|
|
68
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { readFile, writeFile, mkdir } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import type { SerializedGraph } from './schema.js';
|
|
4
|
+
import { ReactGraph } from './graph.js';
|
|
5
|
+
|
|
6
|
+
const GRAPH_VERSION = 1;
|
|
7
|
+
|
|
8
|
+
export function serializeGraph(graph: ReactGraph): SerializedGraph {
|
|
9
|
+
return {
|
|
10
|
+
version: GRAPH_VERSION,
|
|
11
|
+
timestamp: new Date().toISOString(),
|
|
12
|
+
nodes: graph.getAllNodes(),
|
|
13
|
+
edges: graph.getAllEdges(),
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function deserializeGraph(data: SerializedGraph): ReactGraph {
|
|
18
|
+
const graph = new ReactGraph();
|
|
19
|
+
for (const node of data.nodes) graph.addNode(node);
|
|
20
|
+
for (const edge of data.edges) graph.addEdge(edge);
|
|
21
|
+
return graph;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function saveGraph(graph: ReactGraph, projectDir: string): Promise<void> {
|
|
25
|
+
const dir = join(projectDir, '.reactgraph');
|
|
26
|
+
await mkdir(dir, { recursive: true });
|
|
27
|
+
const data = serializeGraph(graph);
|
|
28
|
+
await writeFile(join(dir, 'graph.json'), JSON.stringify(data, null, 2));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function loadGraph(projectDir: string): Promise<ReactGraph | null> {
|
|
32
|
+
try {
|
|
33
|
+
const raw = await readFile(join(projectDir, '.reactgraph', 'graph.json'), 'utf-8');
|
|
34
|
+
const data: SerializedGraph = JSON.parse(raw);
|
|
35
|
+
if (data.version !== GRAPH_VERSION) return null;
|
|
36
|
+
return deserializeGraph(data);
|
|
37
|
+
} catch {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type { GraphNode, GraphEdge, EdgeKind } from './schema.js';
|
|
2
|
+
import { ReactGraph } from './graph.js';
|
|
3
|
+
|
|
4
|
+
export interface SubgraphResult {
|
|
5
|
+
nodes: GraphNode[];
|
|
6
|
+
edges: GraphEdge[];
|
|
7
|
+
root: GraphNode;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** BFS from a node, collecting all connected nodes/edges up to depth */
|
|
11
|
+
export function getSubgraph(graph: ReactGraph, nodeId: string, depth: number = 2): SubgraphResult | null {
|
|
12
|
+
const root = graph.getNode(nodeId);
|
|
13
|
+
if (!root) return null;
|
|
14
|
+
|
|
15
|
+
const visitedNodes = new Set<string>([nodeId]);
|
|
16
|
+
const resultEdges: GraphEdge[] = [];
|
|
17
|
+
let frontier = [nodeId];
|
|
18
|
+
|
|
19
|
+
for (let d = 0; d < depth && frontier.length > 0; d++) {
|
|
20
|
+
const nextFrontier: string[] = [];
|
|
21
|
+
|
|
22
|
+
for (const id of frontier) {
|
|
23
|
+
// Outgoing edges
|
|
24
|
+
for (const edge of graph.getEdgesFrom(id)) {
|
|
25
|
+
resultEdges.push(edge);
|
|
26
|
+
if (!visitedNodes.has(edge.target)) {
|
|
27
|
+
visitedNodes.add(edge.target);
|
|
28
|
+
nextFrontier.push(edge.target);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Incoming edges
|
|
33
|
+
for (const edge of graph.getEdgesTo(id)) {
|
|
34
|
+
resultEdges.push(edge);
|
|
35
|
+
if (!visitedNodes.has(edge.source)) {
|
|
36
|
+
visitedNodes.add(edge.source);
|
|
37
|
+
nextFrontier.push(edge.source);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
frontier = nextFrontier;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const nodes = Array.from(visitedNodes)
|
|
46
|
+
.map(id => graph.getNode(id))
|
|
47
|
+
.filter((n): n is GraphNode => n !== undefined);
|
|
48
|
+
|
|
49
|
+
return { nodes, edges: resultEdges, root };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Get all nodes pointing to this node */
|
|
53
|
+
export function getIncoming(graph: ReactGraph, nodeId: string, edgeKinds?: EdgeKind[]): GraphNode[] {
|
|
54
|
+
const edges = graph.getEdgesTo(nodeId, edgeKinds);
|
|
55
|
+
return edges
|
|
56
|
+
.map(e => graph.getNode(e.source))
|
|
57
|
+
.filter((n): n is GraphNode => n !== undefined);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Get all nodes this node points to */
|
|
61
|
+
export function getOutgoing(graph: ReactGraph, nodeId: string, edgeKinds?: EdgeKind[]): GraphNode[] {
|
|
62
|
+
const edges = graph.getEdgesFrom(nodeId, edgeKinds);
|
|
63
|
+
return edges
|
|
64
|
+
.map(e => graph.getNode(e.target))
|
|
65
|
+
.filter((n): n is GraphNode => n !== undefined);
|
|
66
|
+
}
|