@shvmgyl15/tsgraph 0.1.0

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 (187) hide show
  1. package/AGENTS.md +64 -0
  2. package/README.md +128 -0
  3. package/TODOS.md +61 -0
  4. package/dist/analysis/analysis.test.d.ts +2 -0
  5. package/dist/analysis/analysis.test.d.ts.map +1 -0
  6. package/dist/analysis/analysis.test.js +359 -0
  7. package/dist/analysis/analysis.test.js.map +1 -0
  8. package/dist/analysis/complexity.d.ts +8 -0
  9. package/dist/analysis/complexity.d.ts.map +1 -0
  10. package/dist/analysis/complexity.js +88 -0
  11. package/dist/analysis/complexity.js.map +1 -0
  12. package/dist/analysis/coupling.d.ts +17 -0
  13. package/dist/analysis/coupling.d.ts.map +1 -0
  14. package/dist/analysis/coupling.js +71 -0
  15. package/dist/analysis/coupling.js.map +1 -0
  16. package/dist/analysis/hotspot.d.ts +10 -0
  17. package/dist/analysis/hotspot.d.ts.map +1 -0
  18. package/dist/analysis/hotspot.js +33 -0
  19. package/dist/analysis/hotspot.js.map +1 -0
  20. package/dist/analysis/index.d.ts +9 -0
  21. package/dist/analysis/index.d.ts.map +1 -0
  22. package/dist/analysis/index.js +5 -0
  23. package/dist/analysis/index.js.map +1 -0
  24. package/dist/boundaries/index.d.ts +25 -0
  25. package/dist/boundaries/index.d.ts.map +1 -0
  26. package/dist/boundaries/index.js +103 -0
  27. package/dist/boundaries/index.js.map +1 -0
  28. package/dist/boundaries/index.test.d.ts +2 -0
  29. package/dist/boundaries/index.test.d.ts.map +1 -0
  30. package/dist/boundaries/index.test.js +293 -0
  31. package/dist/boundaries/index.test.js.map +1 -0
  32. package/dist/changes/index.d.ts +28 -0
  33. package/dist/changes/index.d.ts.map +1 -0
  34. package/dist/changes/index.js +48 -0
  35. package/dist/changes/index.js.map +1 -0
  36. package/dist/changes/index.test.d.ts +2 -0
  37. package/dist/changes/index.test.d.ts.map +1 -0
  38. package/dist/changes/index.test.js +104 -0
  39. package/dist/changes/index.test.js.map +1 -0
  40. package/dist/cli/index.d.ts +3 -0
  41. package/dist/cli/index.d.ts.map +1 -0
  42. package/dist/cli/index.js +659 -0
  43. package/dist/cli/index.js.map +1 -0
  44. package/dist/git/index.d.ts +16 -0
  45. package/dist/git/index.d.ts.map +1 -0
  46. package/dist/git/index.js +73 -0
  47. package/dist/git/index.js.map +1 -0
  48. package/dist/git/index.test.d.ts +2 -0
  49. package/dist/git/index.test.d.ts.map +1 -0
  50. package/dist/git/index.test.js +78 -0
  51. package/dist/git/index.test.js.map +1 -0
  52. package/dist/graph/types.d.ts +156 -0
  53. package/dist/graph/types.d.ts.map +1 -0
  54. package/dist/graph/types.js +166 -0
  55. package/dist/graph/types.js.map +1 -0
  56. package/dist/graph/types.test.d.ts +2 -0
  57. package/dist/graph/types.test.d.ts.map +1 -0
  58. package/dist/graph/types.test.js +326 -0
  59. package/dist/graph/types.test.js.map +1 -0
  60. package/dist/mcp/mcp.test.d.ts +2 -0
  61. package/dist/mcp/mcp.test.d.ts.map +1 -0
  62. package/dist/mcp/mcp.test.js +151 -0
  63. package/dist/mcp/mcp.test.js.map +1 -0
  64. package/dist/mcp/server.d.ts +2 -0
  65. package/dist/mcp/server.d.ts.map +1 -0
  66. package/dist/mcp/server.js +209 -0
  67. package/dist/mcp/server.js.map +1 -0
  68. package/dist/nextjs/index.d.ts +8 -0
  69. package/dist/nextjs/index.d.ts.map +1 -0
  70. package/dist/nextjs/index.js +16 -0
  71. package/dist/nextjs/index.js.map +1 -0
  72. package/dist/nextjs/nextjs.test.d.ts +2 -0
  73. package/dist/nextjs/nextjs.test.d.ts.map +1 -0
  74. package/dist/nextjs/nextjs.test.js +190 -0
  75. package/dist/nextjs/nextjs.test.js.map +1 -0
  76. package/dist/nextjs/pages.d.ts +4 -0
  77. package/dist/nextjs/pages.d.ts.map +1 -0
  78. package/dist/nextjs/pages.js +36 -0
  79. package/dist/nextjs/pages.js.map +1 -0
  80. package/dist/nextjs/react.d.ts +3 -0
  81. package/dist/nextjs/react.d.ts.map +1 -0
  82. package/dist/nextjs/react.js +86 -0
  83. package/dist/nextjs/react.js.map +1 -0
  84. package/dist/nextjs/router.d.ts +4 -0
  85. package/dist/nextjs/router.d.ts.map +1 -0
  86. package/dist/nextjs/router.js +86 -0
  87. package/dist/nextjs/router.js.map +1 -0
  88. package/dist/nextjs/routes.d.ts +4 -0
  89. package/dist/nextjs/routes.d.ts.map +1 -0
  90. package/dist/nextjs/routes.js +58 -0
  91. package/dist/nextjs/routes.js.map +1 -0
  92. package/dist/opencode/index.d.ts +7 -0
  93. package/dist/opencode/index.d.ts.map +1 -0
  94. package/dist/opencode/index.js +71 -0
  95. package/dist/opencode/index.js.map +1 -0
  96. package/dist/opencode/index.test.d.ts +2 -0
  97. package/dist/opencode/index.test.d.ts.map +1 -0
  98. package/dist/opencode/index.test.js +71 -0
  99. package/dist/opencode/index.test.js.map +1 -0
  100. package/dist/parser/index.d.ts +4 -0
  101. package/dist/parser/index.d.ts.map +1 -0
  102. package/dist/parser/index.js +282 -0
  103. package/dist/parser/index.js.map +1 -0
  104. package/dist/parser/parser.test.d.ts +2 -0
  105. package/dist/parser/parser.test.d.ts.map +1 -0
  106. package/dist/parser/parser.test.js +225 -0
  107. package/dist/parser/parser.test.js.map +1 -0
  108. package/dist/plan/index.d.ts +32 -0
  109. package/dist/plan/index.d.ts.map +1 -0
  110. package/dist/plan/index.js +107 -0
  111. package/dist/plan/index.js.map +1 -0
  112. package/dist/plan/index.test.d.ts +2 -0
  113. package/dist/plan/index.test.d.ts.map +1 -0
  114. package/dist/plan/index.test.js +143 -0
  115. package/dist/plan/index.test.js.map +1 -0
  116. package/dist/report/index.d.ts +9 -0
  117. package/dist/report/index.d.ts.map +1 -0
  118. package/dist/report/index.js +108 -0
  119. package/dist/report/index.js.map +1 -0
  120. package/dist/scanner/index.d.ts +13 -0
  121. package/dist/scanner/index.d.ts.map +1 -0
  122. package/dist/scanner/index.js +78 -0
  123. package/dist/scanner/index.js.map +1 -0
  124. package/dist/scanner/scanner.test.d.ts +2 -0
  125. package/dist/scanner/scanner.test.d.ts.map +1 -0
  126. package/dist/scanner/scanner.test.js +113 -0
  127. package/dist/scanner/scanner.test.js.map +1 -0
  128. package/dist/search/index.d.ts +32 -0
  129. package/dist/search/index.d.ts.map +1 -0
  130. package/dist/search/index.js +97 -0
  131. package/dist/search/index.js.map +1 -0
  132. package/dist/search/search.test.d.ts +2 -0
  133. package/dist/search/search.test.d.ts.map +1 -0
  134. package/dist/search/search.test.js +446 -0
  135. package/dist/search/search.test.js.map +1 -0
  136. package/dist/traversal/index.d.ts +5 -0
  137. package/dist/traversal/index.d.ts.map +1 -0
  138. package/dist/traversal/index.js +3 -0
  139. package/dist/traversal/index.js.map +1 -0
  140. package/dist/traversal/traversal.d.ts +31 -0
  141. package/dist/traversal/traversal.d.ts.map +1 -0
  142. package/dist/traversal/traversal.js +130 -0
  143. package/dist/traversal/traversal.js.map +1 -0
  144. package/dist/traversal/traversal.test.d.ts +2 -0
  145. package/dist/traversal/traversal.test.d.ts.map +1 -0
  146. package/dist/traversal/traversal.test.js +224 -0
  147. package/dist/traversal/traversal.test.js.map +1 -0
  148. package/opencode.json +24 -0
  149. package/package.json +29 -0
  150. package/src/analysis/analysis.test.ts +405 -0
  151. package/src/analysis/complexity.ts +107 -0
  152. package/src/analysis/coupling.ts +106 -0
  153. package/src/analysis/hotspot.ts +52 -0
  154. package/src/analysis/index.ts +17 -0
  155. package/src/boundaries/index.test.ts +335 -0
  156. package/src/boundaries/index.ts +137 -0
  157. package/src/changes/index.test.ts +114 -0
  158. package/src/changes/index.ts +95 -0
  159. package/src/cli/index.ts +736 -0
  160. package/src/git/index.test.ts +92 -0
  161. package/src/git/index.ts +86 -0
  162. package/src/graph/types.test.ts +383 -0
  163. package/src/graph/types.ts +353 -0
  164. package/src/mcp/mcp.test.ts +176 -0
  165. package/src/mcp/server.ts +217 -0
  166. package/src/nextjs/index.ts +23 -0
  167. package/src/nextjs/nextjs.test.ts +233 -0
  168. package/src/nextjs/pages.ts +43 -0
  169. package/src/nextjs/react.ts +100 -0
  170. package/src/nextjs/router.ts +102 -0
  171. package/src/nextjs/routes.ts +69 -0
  172. package/src/opencode/index.test.ts +90 -0
  173. package/src/opencode/index.ts +83 -0
  174. package/src/parser/index.ts +339 -0
  175. package/src/parser/parser.test.ts +282 -0
  176. package/src/plan/index.test.ts +162 -0
  177. package/src/plan/index.ts +161 -0
  178. package/src/report/index.ts +128 -0
  179. package/src/scanner/index.ts +97 -0
  180. package/src/scanner/scanner.test.ts +135 -0
  181. package/src/search/index.ts +163 -0
  182. package/src/search/search.test.ts +512 -0
  183. package/src/traversal/index.ts +5 -0
  184. package/src/traversal/traversal.test.ts +266 -0
  185. package/src/traversal/traversal.ts +185 -0
  186. package/tsconfig.json +20 -0
  187. package/vitest.config.ts +7 -0
@@ -0,0 +1,217 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
4
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
5
+ import { z } from "zod/v4";
6
+ import type { Graph } from "../graph/types.js";
7
+ import { deserialize } from "../graph/types.js";
8
+ import { findCallers, findCallees, findNode, querySymbols, findImports, findPublic, context } from "../search/index.js";
9
+ import { impact, findPath, findOrphans, trace } from "../traversal/index.js";
10
+ import { analyzeComplexity, findHotspots, analyzeCoupling } from "../analysis/index.js";
11
+
12
+ function loadGraph(rootDir: string): Graph {
13
+ const graphPath = path.join(rootDir, ".tsgraph", "graph.json");
14
+ const raw = fs.readFileSync(graphPath, "utf-8");
15
+ return deserialize(raw);
16
+ }
17
+
18
+ function text(text: string) {
19
+ return { content: [{ type: "text" as const, text }] };
20
+ }
21
+
22
+ function error(msg: string) {
23
+ return { content: [{ type: "text" as const, text: msg }], isError: true as const };
24
+ }
25
+
26
+ function withGraph<T>(rootDir: string, fn: (graph: Graph) => T): T {
27
+ try {
28
+ const graph = loadGraph(rootDir);
29
+ return fn(graph);
30
+ } catch (err) {
31
+ throw new Error(`Failed to load graph: ${(err as Error).message}`);
32
+ }
33
+ }
34
+
35
+ export async function startMcpServer(rootDir: string): Promise<void> {
36
+ const server = new McpServer({
37
+ name: "tsgraph",
38
+ version: "0.1.0",
39
+ });
40
+
41
+ server.resource(
42
+ "graph.json",
43
+ `file://${path.join(rootDir, ".tsgraph", "graph.json")}`,
44
+ { mimeType: "application/json" },
45
+ async (uri) => ({
46
+ contents: [{
47
+ uri: uri.href,
48
+ mimeType: "application/json",
49
+ text: fs.readFileSync(new URL(uri.href), "utf-8"),
50
+ }],
51
+ }),
52
+ );
53
+
54
+ server.registerTool("callers", {
55
+ description: "Find which functions call a given symbol",
56
+ inputSchema: z.object({ symbol: z.string() }),
57
+ }, async ({ symbol }) => {
58
+ try {
59
+ return text(JSON.stringify(withGraph(rootDir, (g) => findCallers(g, symbol)), null, 2));
60
+ } catch (err) {
61
+ return error((err as Error).message);
62
+ }
63
+ });
64
+
65
+ server.registerTool("callees", {
66
+ description: "Find which functions a given symbol calls",
67
+ inputSchema: z.object({ symbol: z.string() }),
68
+ }, async ({ symbol }) => {
69
+ try {
70
+ return text(JSON.stringify(withGraph(rootDir, (g) => findCallees(g, symbol)), null, 2));
71
+ } catch (err) {
72
+ return error((err as Error).message);
73
+ }
74
+ });
75
+
76
+ server.registerTool("node", {
77
+ description: "Get detailed information about a symbol",
78
+ inputSchema: z.object({ symbol: z.string() }),
79
+ }, async ({ symbol }) => {
80
+ try {
81
+ return text(JSON.stringify(withGraph(rootDir, (g) => findNode(g, symbol)), null, 2));
82
+ } catch (err) {
83
+ return error((err as Error).message);
84
+ }
85
+ });
86
+
87
+ server.registerTool("query", {
88
+ description: "Search for symbols matching a pattern",
89
+ inputSchema: z.object({ pattern: z.string() }),
90
+ }, async ({ pattern }) => {
91
+ try {
92
+ return text(JSON.stringify(withGraph(rootDir, (g) => querySymbols(g, pattern)), null, 2));
93
+ } catch (err) {
94
+ return error((err as Error).message);
95
+ }
96
+ });
97
+
98
+ server.registerTool("context", {
99
+ description: "Bundle node, source, callers, and callees for a symbol",
100
+ inputSchema: z.object({ symbol: z.string() }),
101
+ }, async ({ symbol }) => {
102
+ try {
103
+ return text(JSON.stringify(withGraph(rootDir, (g) => context(g, symbol)), null, 2));
104
+ } catch (err) {
105
+ return error((err as Error).message);
106
+ }
107
+ });
108
+
109
+ server.registerTool("imports", {
110
+ description: "Find all files importing a specific package path",
111
+ inputSchema: z.object({ path: z.string() }),
112
+ }, async ({ path: importPath }) => {
113
+ try {
114
+ return text(JSON.stringify(withGraph(rootDir, (g) => findImports(g, importPath)), null, 2));
115
+ } catch (err) {
116
+ return error((err as Error).message);
117
+ }
118
+ });
119
+
120
+ server.registerTool("public", {
121
+ description: "List exported symbols, optionally scoped to a package",
122
+ inputSchema: z.object({ package: z.string().optional() }),
123
+ }, async ({ package: pkg }) => {
124
+ try {
125
+ return text(JSON.stringify(withGraph(rootDir, (g) => findPublic(g, pkg)), null, 2));
126
+ } catch (err) {
127
+ return error((err as Error).message);
128
+ }
129
+ });
130
+
131
+ server.registerTool("impact", {
132
+ description: "Show downstream blast radius (callers recursively)",
133
+ inputSchema: z.object({ symbol: z.string(), depth: z.number().optional() }),
134
+ }, async ({ symbol, depth }) => {
135
+ try {
136
+ return text(JSON.stringify(withGraph(rootDir, (g) => impact(g, symbol, depth)), null, 2));
137
+ } catch (err) {
138
+ return error((err as Error).message);
139
+ }
140
+ });
141
+
142
+ server.registerTool("path", {
143
+ description: "Find shortest call path between two symbols",
144
+ inputSchema: z.object({ from: z.string(), to: z.string(), depth: z.number().optional() }),
145
+ }, async ({ from, to, depth }) => {
146
+ try {
147
+ return text(JSON.stringify(withGraph(rootDir, (g) => findPath(g, from, to, depth)), null, 2));
148
+ } catch (err) {
149
+ return error((err as Error).message);
150
+ }
151
+ });
152
+
153
+ server.registerTool("orphans", {
154
+ description: "Find dead code — symbols with no callers or tests",
155
+ inputSchema: z.object({}),
156
+ }, async () => {
157
+ try {
158
+ return text(JSON.stringify(withGraph(rootDir, (g) => findOrphans(g)), null, 2));
159
+ } catch (err) {
160
+ return error((err as Error).message);
161
+ }
162
+ });
163
+
164
+ server.registerTool("trace", {
165
+ description: "Find a string literal across symbols and trace callers upstream",
166
+ inputSchema: z.object({ string: z.string(), depth: z.number().optional() }),
167
+ }, async ({ string: searchStr, depth }) => {
168
+ try {
169
+ return text(JSON.stringify(withGraph(rootDir, (g) => trace(g, searchStr, depth)), null, 2));
170
+ } catch (err) {
171
+ return error((err as Error).message);
172
+ }
173
+ });
174
+
175
+ server.registerTool("complexity", {
176
+ description: "Show cyclomatic complexity for functions and methods",
177
+ inputSchema: z.object({ file: z.string().optional(), sort: z.boolean().optional(), min: z.number().optional() }),
178
+ }, async ({ file, sort, min }) => {
179
+ try {
180
+ const graph = loadGraph(rootDir);
181
+ let results = analyzeComplexity(graph, file);
182
+ if (min) results = results.filter((r) => r.complexity >= min);
183
+ if (sort) results.sort((a, b) => b.complexity - a.complexity);
184
+ return text(JSON.stringify(results, null, 2));
185
+ } catch (err) {
186
+ return error((err as Error).message);
187
+ }
188
+ });
189
+
190
+ server.registerTool("hotspot", {
191
+ description: "Rank files by complexity × size (hotness score)",
192
+ inputSchema: z.object({ top: z.number().optional() }),
193
+ }, async ({ top }) => {
194
+ try {
195
+ return text(JSON.stringify(withGraph(rootDir, (g) => findHotspots(g, top)), null, 2));
196
+ } catch (err) {
197
+ return error((err as Error).message);
198
+ }
199
+ });
200
+
201
+ server.registerTool("coupling", {
202
+ description: "Show package coupling based on import edges",
203
+ inputSchema: z.object({ package: z.string().optional() }),
204
+ }, async ({ package: pkg }) => {
205
+ try {
206
+ const graph = loadGraph(rootDir);
207
+ let results = analyzeCoupling(graph);
208
+ if (pkg) results = results.filter((r) => r.packageName === pkg);
209
+ return text(JSON.stringify(results, null, 2));
210
+ } catch (err) {
211
+ return error((err as Error).message);
212
+ }
213
+ });
214
+
215
+ const transport = new StdioServerTransport();
216
+ await server.connect(transport);
217
+ }
@@ -0,0 +1,23 @@
1
+ import type { Graph } from "../graph/types.js";
2
+ import type { ScannedFile } from "../scanner/index.js";
3
+ import { extractAppRouter } from "./router.js";
4
+ import { extractPagesRouter } from "./pages.js";
5
+ import { classifyReactComponents } from "./react.js";
6
+ import { extractAPIRoutes } from "./routes.js";
7
+
8
+ export { extractAppRouter } from "./router.js";
9
+ export { extractPagesRouter } from "./pages.js";
10
+ export { classifyReactComponents } from "./react.js";
11
+ export { extractAPIRoutes } from "./routes.js";
12
+
13
+ export function extractNextJs(
14
+ graph: Graph,
15
+ rootDir: string,
16
+ scanned: ScannedFile[],
17
+ ): Graph {
18
+ let g = extractAppRouter(graph, scanned);
19
+ g = extractPagesRouter(g, scanned);
20
+ g = classifyReactComponents(g, rootDir);
21
+ g = extractAPIRoutes(g, scanned, rootDir);
22
+ return g;
23
+ }
@@ -0,0 +1,233 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import path from "node:path";
3
+ import fs from "node:fs";
4
+ import os from "node:os";
5
+ import { scanFiles } from "../scanner/index.js";
6
+ import { parseProject } from "../parser/index.js";
7
+ import { extractAppRouter } from "./router.js";
8
+ import { extractPagesRouter } from "./pages.js";
9
+ import { classifyReactComponents } from "./react.js";
10
+ import { extractAPIRoutes } from "./routes.js";
11
+ import { makeGraph, makeFileNode } from "../graph/types.js";
12
+
13
+ function createTempDir(): string {
14
+ return fs.mkdtempSync(path.join(os.tmpdir(), "tsgraph-test-"));
15
+ }
16
+
17
+ function writeFile(dir: string, relativePath: string, content: string) {
18
+ const fullPath = path.join(dir, relativePath);
19
+ fs.mkdirSync(path.dirname(fullPath), { recursive: true });
20
+ fs.writeFileSync(fullPath, content, "utf-8");
21
+ }
22
+
23
+ describe("extractAppRouter", () => {
24
+ it("builds tree with page, layout, loading", () => {
25
+ const dir = createTempDir();
26
+ writeFile(dir, "app/page.tsx", "export default function Home() {}");
27
+ writeFile(dir, "app/layout.tsx", "export default function RootLayout() {}");
28
+ writeFile(dir, "app/loading.tsx", "export default function Loading() {}");
29
+
30
+ const { files } = scanFiles(dir);
31
+ const graph = makeGraph({ root: dir, files: files.map((f) => makeFileNode({ path: f.relativePath })) });
32
+ const result = extractAppRouter(graph, files);
33
+
34
+ expect(result.appRouter).toBeTruthy();
35
+ expect(result.appRouter!.path).toBe("/");
36
+ expect(result.appRouter!.files.page).toMatch(/app\/page\.tsx$/);
37
+ expect(result.appRouter!.files.layout).toMatch(/app\/layout\.tsx$/);
38
+ expect(result.appRouter!.files.loading).toMatch(/app\/loading\.tsx$/);
39
+ expect(result.appRouter!.files.error).toBeUndefined();
40
+
41
+ fs.rmSync(dir, { recursive: true });
42
+ });
43
+
44
+ it("detects nested route segments", () => {
45
+ const dir = createTempDir();
46
+ writeFile(dir, "app/layout.tsx", "export default function Root() {}");
47
+ writeFile(dir, "app/dashboard/page.tsx", "export default function Dashboard() {}");
48
+ writeFile(dir, "app/dashboard/layout.tsx", "export default function DashLayout() {}");
49
+ writeFile(dir, "app/dashboard/settings/page.tsx", "export default function Settings() {}");
50
+
51
+ const { files } = scanFiles(dir);
52
+ const graph = makeGraph({ root: dir, files: files.map((f) => makeFileNode({ path: f.relativePath })) });
53
+ const result = extractAppRouter(graph, files);
54
+
55
+ expect(result.appRouter).toBeTruthy();
56
+ expect(result.appRouter!.children).toHaveLength(1);
57
+ expect(result.appRouter!.children[0].path).toBe("/dashboard");
58
+ expect(result.appRouter!.children[0].files.page).toMatch(/dashboard\/page\.tsx$/);
59
+ expect(result.appRouter!.children[0].children).toHaveLength(1);
60
+ expect(result.appRouter!.children[0].children[0].path).toBe("/dashboard/settings");
61
+
62
+ fs.rmSync(dir, { recursive: true });
63
+ });
64
+
65
+ it("returns undefined when no app directory", () => {
66
+ const dir = createTempDir();
67
+ writeFile(dir, "src/index.ts", "const x = 1;");
68
+ const { files } = scanFiles(dir);
69
+ const graph = makeGraph({ root: dir });
70
+ const result = extractAppRouter(graph, files);
71
+ expect(result.appRouter).toBeUndefined();
72
+
73
+ fs.rmSync(dir, { recursive: true });
74
+ });
75
+ });
76
+
77
+ describe("extractPagesRouter", () => {
78
+ it("detects index and nested pages", () => {
79
+ const dir = createTempDir();
80
+ writeFile(dir, "pages/index.tsx", "export default function Home() {}");
81
+ writeFile(dir, "pages/about.tsx", "export default function About() {}");
82
+ writeFile(dir, "pages/blog/[slug].tsx", "export default function Post() {}");
83
+
84
+ const { files } = scanFiles(dir);
85
+ const graph = makeGraph({ root: dir });
86
+ const result = extractPagesRouter(graph, files);
87
+
88
+ expect(result.routes).toHaveLength(3);
89
+ const paths = result.routes.map((r) => r.path).sort();
90
+ expect(paths).toEqual(["/", "/about", "/blog/:slug"]);
91
+ expect(result.routes[0].method).toBe("GET");
92
+
93
+ fs.rmSync(dir, { recursive: true });
94
+ });
95
+
96
+ it("ignores non-page files", () => {
97
+ const dir = createTempDir();
98
+ writeFile(dir, "pages/api/users.ts", "export default function handler() {}");
99
+ writeFile(dir, "pages/_app.tsx", "export default function App() {}");
100
+ writeFile(dir, "pages/_document.tsx", "");
101
+
102
+ const { files } = scanFiles(dir);
103
+ const graph = makeGraph({ root: dir });
104
+ const result = extractPagesRouter(graph, files);
105
+
106
+ const paths = result.routes.map((r) => r.path);
107
+ expect(paths).toContain("/api/users");
108
+
109
+ fs.rmSync(dir, { recursive: true });
110
+ });
111
+ });
112
+
113
+ describe("classifyReactComponents", () => {
114
+ it("sets isClientComponent from 'use client' directive", () => {
115
+ const dir = createTempDir();
116
+ writeFile(dir, "component.tsx", `"use client";
117
+ export function Button() { return null; }`);
118
+
119
+ const { files } = scanFiles(dir);
120
+ const graph = parseProject(dir, files);
121
+ const button = graph.symbols.find((s) => s.name === "Button");
122
+ expect(button).toBeTruthy();
123
+ expect(button!.isClientComponent).toBe(true);
124
+
125
+ fs.rmSync(dir, { recursive: true });
126
+ });
127
+
128
+ it("sets isServerComponent from 'use server' directive", () => {
129
+ const dir = createTempDir();
130
+ writeFile(dir, "action.ts", `"use server";
131
+ export async function submit() { return null; }`);
132
+
133
+ const { files } = scanFiles(dir);
134
+ const graph = parseProject(dir, files);
135
+ const submit = graph.symbols.find((s) => s.name === "submit");
136
+ expect(submit).toBeTruthy();
137
+ expect(submit!.isServerComponent).toBe(true);
138
+
139
+ fs.rmSync(dir, { recursive: true });
140
+ });
141
+
142
+ it("detects hooks in tsx functions", () => {
143
+ const dir = createTempDir();
144
+ writeFile(dir, "counter.tsx", `
145
+ import { useState } from "react";
146
+ export function Counter() {
147
+ const [count, setCount] = useState(0);
148
+ return null;
149
+ }`);
150
+
151
+ const { files } = scanFiles(dir);
152
+ const graph = parseProject(dir, files);
153
+ const counter = graph.symbols.find((s) => s.name === "Counter");
154
+ expect(counter).toBeTruthy();
155
+ expect(counter!.isClientComponent).toBe(true);
156
+
157
+ fs.rmSync(dir, { recursive: true });
158
+ });
159
+ });
160
+
161
+ describe("extractAPIRoutes", () => {
162
+ it("extracts HTTP methods from route.ts", () => {
163
+ const dir = createTempDir();
164
+ writeFile(dir, "app/api/users/route.ts", `
165
+ export async function GET() { return Response.json({}); }
166
+ export async function POST(req: Request) { return Response.json({}); }
167
+ `);
168
+
169
+ const { files } = scanFiles(dir);
170
+ const graph = makeGraph({ root: dir });
171
+ const result = extractAPIRoutes(graph, files, dir);
172
+
173
+ const routes = result.routes.sort((a, b) => a.method.localeCompare(b.method));
174
+ expect(routes).toHaveLength(2);
175
+ expect(routes[0].method).toBe("GET");
176
+ expect(routes[0].path).toBe("/api/users");
177
+ expect(routes[1].method).toBe("POST");
178
+ expect(routes[1].path).toBe("/api/users");
179
+
180
+ fs.rmSync(dir, { recursive: true });
181
+ });
182
+
183
+ it("handles dynamic route params", () => {
184
+ const dir = createTempDir();
185
+ writeFile(dir, "app/api/items/[id]/route.ts", `
186
+ export async function GET(req: Request, { params }: { params: { id: string } }) {
187
+ return Response.json({});
188
+ }
189
+ `);
190
+
191
+ const { files } = scanFiles(dir);
192
+ const graph = makeGraph({ root: dir });
193
+ const result = extractAPIRoutes(graph, files, dir);
194
+
195
+ expect(result.routes).toHaveLength(1);
196
+ expect(result.routes[0].path).toBe("/api/items/:id");
197
+
198
+ fs.rmSync(dir, { recursive: true });
199
+ });
200
+ });
201
+
202
+ describe("end-to-end: parseProject with Next.js", () => {
203
+ it("produces appRouter and routes from a Next.js project", () => {
204
+ const dir = createTempDir();
205
+ writeFile(dir, "package.json", JSON.stringify({ name: "my-app", dependencies: { "next": "^14" } }));
206
+ writeFile(dir, "app/layout.tsx", "export default function Root({ children }: { children: React.ReactNode }) { return null; }");
207
+ writeFile(dir, "app/page.tsx", `"use client";
208
+ import { useState } from "react";
209
+ export default function Home() {
210
+ const [count, setCount] = useState(0);
211
+ return null;
212
+ }`);
213
+ writeFile(dir, "app/api/hello/route.ts", "export async function GET() { return Response.json({}); }");
214
+ writeFile(dir, "pages/about.tsx", "export default function About() { return null; }");
215
+
216
+ const { files } = scanFiles(dir);
217
+ const graph = parseProject(dir, files);
218
+
219
+ expect(graph.appRouter).toBeTruthy();
220
+ expect(graph.appRouter!.files.layout).toBeTruthy();
221
+ expect(graph.appRouter!.files.page).toBeTruthy();
222
+
223
+ const home = graph.symbols.find((s) => s.name === "Home");
224
+ expect(home).toBeTruthy();
225
+ expect(home!.isClientComponent).toBe(true);
226
+
227
+ const routePaths = graph.routes.map((r) => r.path);
228
+ expect(routePaths).toContain("/api/hello");
229
+ expect(routePaths).toContain("/about");
230
+
231
+ fs.rmSync(dir, { recursive: true });
232
+ });
233
+ });
@@ -0,0 +1,43 @@
1
+ import path from "node:path";
2
+ import type { Graph, HTTPRoute } from "../graph/types.js";
3
+ import type { ScannedFile } from "../scanner/index.js";
4
+
5
+ function pagePathToRoute(relativePath: string): string | undefined {
6
+ let p = relativePath
7
+ .replace(/^pages[/\\]/, "")
8
+ .replace(/\.[jt]sx?$/, "")
9
+ .replace(/index$/, "")
10
+ .replace(/\[\.\.\.(.+?)\]/, "*$1")
11
+ .replace(/\[(.+?)\]/g, ":$1");
12
+
13
+ if (!p.startsWith("/")) p = "/" + p;
14
+ return p === "/" ? "/" : p.replace(/\/$/, "");
15
+ }
16
+
17
+ export function extractPagesRouter(
18
+ graph: Graph,
19
+ scanned: ScannedFile[],
20
+ ): Graph {
21
+ const newRoutes: HTTPRoute[] = [...graph.routes];
22
+
23
+ for (const sf of scanned) {
24
+ const rel = sf.relativePath;
25
+ if (!rel.startsWith("pages/") && !rel.startsWith("pages\\")) continue;
26
+ const ext = path.extname(rel);
27
+ if (ext !== ".ts" && ext !== ".tsx" && ext !== ".js" && ext !== ".jsx") continue;
28
+
29
+ const route = pagePathToRoute(rel);
30
+ if (!route) continue;
31
+
32
+ const isApi = route.startsWith("/api");
33
+ newRoutes.push({
34
+ method: "GET",
35
+ path: route,
36
+ handler: isApi ? `pages${route}` : rel,
37
+ file: rel,
38
+ line: 1,
39
+ });
40
+ }
41
+
42
+ return { ...graph, routes: newRoutes };
43
+ }
@@ -0,0 +1,100 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import type { Graph, SymbolNode } from "../graph/types.js";
4
+
5
+ const REACT_HOOKS = new Set([
6
+ "useState", "useEffect", "useContext", "useReducer", "useCallback",
7
+ "useMemo", "useRef", "useImperativeHandle", "useLayoutEffect",
8
+ "useDebugValue", "useTransition", "useDeferredValue", "useId",
9
+ "useSyncExternalStore", "useInsertionEffect", "useActionState",
10
+ "useOptimistic",
11
+ ]);
12
+
13
+ function getDirectives(filePath: string): {
14
+ isClient: boolean;
15
+ isServer: boolean;
16
+ } {
17
+ try {
18
+ const fd = fs.openSync(filePath, "r");
19
+ const buffer = Buffer.alloc(512);
20
+ const bytesRead = fs.readSync(fd, buffer, 0, 512, 0);
21
+ fs.closeSync(fd);
22
+ const head = buffer.toString("utf-8", 0, bytesRead);
23
+ const lines = head.split("\n").slice(0, 5);
24
+ const trimmed = lines.map((l) => l.trim().replace(/;$/, ""));
25
+ return {
26
+ isClient: trimmed.some((l) => l === `"use client"` || l === `'use client'`),
27
+ isServer: trimmed.some((l) => l === `"use server"` || l === `'use server'`),
28
+ };
29
+ } catch {
30
+ return { isClient: false, isServer: false };
31
+ }
32
+ }
33
+
34
+ function checkHookUsage(
35
+ filePath: string,
36
+ symbols: SymbolNode[],
37
+ ): SymbolNode[] {
38
+ const updated: SymbolNode[] = [];
39
+ try {
40
+ const content = fs.readFileSync(filePath, "utf-8");
41
+ for (const sym of symbols) {
42
+ const lines = content.split("\n");
43
+ const snippet = lines.slice(sym.line - 1, sym.endLine).join("\n");
44
+ const usesHooks = [...REACT_HOOKS].some((hook) => {
45
+ const idx = snippet.indexOf(hook);
46
+ if (idx === -1) return false;
47
+ const before = snippet[idx - 1];
48
+ if (before && (before === "." || /[a-zA-Z0-9]/.test(before))) return false;
49
+ return true;
50
+ });
51
+ if (usesHooks) {
52
+ updated.push({ ...sym, isClientComponent: true });
53
+ } else {
54
+ updated.push(sym);
55
+ }
56
+ }
57
+ } catch {
58
+ return symbols;
59
+ }
60
+ return updated;
61
+ }
62
+
63
+ export function classifyReactComponents(
64
+ graph: Graph,
65
+ rootDir: string,
66
+ ): Graph {
67
+ const symByFile = new Map<string, SymbolNode[]>();
68
+ for (const sym of graph.symbols) {
69
+ const list = symByFile.get(sym.file) ?? [];
70
+ list.push(sym);
71
+ symByFile.set(sym.file, list);
72
+ }
73
+
74
+ const updatedSymbols: SymbolNode[] = [];
75
+
76
+ for (const [fileRel, symbols] of symByFile) {
77
+ const filePath = path.join(rootDir, fileRel);
78
+ const { isClient, isServer } = getDirectives(filePath);
79
+
80
+ if (isClient || isServer) {
81
+ updatedSymbols.push(
82
+ ...symbols.map((s) => ({
83
+ ...s,
84
+ isClientComponent: isClient || s.isClientComponent,
85
+ isServerComponent: isServer || s.isServerComponent,
86
+ })),
87
+ );
88
+ continue;
89
+ }
90
+
91
+ if (fileRel.endsWith(".tsx")) {
92
+ const checked = checkHookUsage(filePath, symbols);
93
+ updatedSymbols.push(...checked);
94
+ } else {
95
+ updatedSymbols.push(...symbols);
96
+ }
97
+ }
98
+
99
+ return { ...graph, symbols: updatedSymbols };
100
+ }