@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,102 @@
1
+ import path from "node:path";
2
+ import type { AppRouterNode, Graph } from "../graph/types.js";
3
+ import type { ScannedFile } from "../scanner/index.js";
4
+
5
+ const APP_ROUTER_FILES = new Set([
6
+ "page", "layout", "loading", "error", "not-found",
7
+ "route", "template", "default",
8
+ ]);
9
+
10
+ type FileMap = Record<string, string>;
11
+
12
+ function classifyFile(fileName: string): string | undefined {
13
+ const base = path.basename(fileName, path.extname(fileName));
14
+ if (APP_ROUTER_FILES.has(base)) return base;
15
+ return undefined;
16
+ }
17
+
18
+ function buildRouterTree(
19
+ appDir: string,
20
+ files: ScannedFile[],
21
+ ): AppRouterNode | undefined {
22
+ const dirMap = new Map<string, FileMap>();
23
+
24
+ for (const sf of files) {
25
+ const rel = sf.relativePath;
26
+ if (!rel.startsWith("app/") && !rel.startsWith("app\\")) continue;
27
+ const afterApp = rel.replace(/^app[/\\]/, "");
28
+ const dir = path.dirname(afterApp);
29
+ const fileName = path.basename(afterApp);
30
+ const kind = classifyFile(fileName);
31
+ if (!kind) continue;
32
+
33
+ let entry = dirMap.get(dir);
34
+ if (!entry) {
35
+ entry = {};
36
+ dirMap.set(dir, entry);
37
+ }
38
+ entry[kind] = rel;
39
+ }
40
+
41
+ if (dirMap.size === 0) return undefined;
42
+
43
+ const segments = [...dirMap.keys()].filter((d) => d !== ".");
44
+
45
+ function nodeForDir(dirParts: string[]): AppRouterNode {
46
+ const dirPath = dirParts.join("/");
47
+ const files = dirMap.get(dirPath) ?? {};
48
+ const childDirs = segments.filter((s) => {
49
+ const parts = s.split("/");
50
+ if (parts.length !== dirParts.length + 1) return false;
51
+ return parts.slice(0, -1).join("/") === dirPath;
52
+ });
53
+
54
+ return {
55
+ path: "/" + dirParts.map((p) => p.replace(/^\(|\)$/g, "")).join("/"),
56
+ dir: dirPath === "." ? "app" : path.join("app", dirPath),
57
+ files: {
58
+ page: files.page,
59
+ layout: files.layout,
60
+ loading: files.loading,
61
+ error: files.error,
62
+ notFound: files["not-found"],
63
+ route: files.route,
64
+ template: files.template,
65
+ default: files.default,
66
+ },
67
+ children: childDirs.map((s) => {
68
+ const parts = s.split("/");
69
+ return nodeForDir(parts);
70
+ }),
71
+ };
72
+ }
73
+
74
+ const rootChildren = segments
75
+ .filter((s) => !s.includes("/"))
76
+ .map((s) => nodeForDir(s === "." ? [] : [s]));
77
+
78
+ const rootFiles = dirMap.get(".") ?? {};
79
+ return {
80
+ path: "/",
81
+ dir: "app",
82
+ files: {
83
+ page: rootFiles.page,
84
+ layout: rootFiles.layout,
85
+ loading: rootFiles.loading,
86
+ error: rootFiles.error,
87
+ notFound: rootFiles["not-found"],
88
+ route: rootFiles.route,
89
+ template: rootFiles.template,
90
+ default: rootFiles.default,
91
+ },
92
+ children: rootChildren,
93
+ };
94
+ }
95
+
96
+ export function extractAppRouter(
97
+ graph: Graph,
98
+ scanned: ScannedFile[],
99
+ ): Graph {
100
+ const tree = buildRouterTree(graph.root, scanned);
101
+ return { ...graph, appRouter: tree ?? undefined };
102
+ }
@@ -0,0 +1,69 @@
1
+ import path from "node:path";
2
+ import { Project } from "ts-morph";
3
+ import type { Graph, HTTPRoute } from "../graph/types.js";
4
+ import type { ScannedFile } from "../scanner/index.js";
5
+
6
+ const HTTP_METHODS = new Set(["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"]);
7
+
8
+ function appRouteToHttpPath(relativePath: string): string {
9
+ let p = relativePath
10
+ .replace(/^app[/\\]/, "")
11
+ .replace(/\/route\.[jt]sx?$/, "")
12
+ .replace(/\[\.\.\.(.+?)\]/g, "*$1")
13
+ .replace(/\[(.+?)\]/g, ":$1")
14
+ .replace(/^\(.+?\)\//, "")
15
+ .replace(/\/(\(.+?\))/g, "");
16
+
17
+ if (!p.startsWith("/")) p = "/" + p;
18
+ return p === "/" ? "/" : p.replace(/\/$/, "");
19
+ }
20
+
21
+ export function extractAPIRoutes(
22
+ graph: Graph,
23
+ scanned: ScannedFile[],
24
+ rootDir: string,
25
+ ): Graph {
26
+ const routeFiles = scanned.filter((sf) => {
27
+ const base = path.basename(sf.path, path.extname(sf.path));
28
+ return base === "route" && sf.relativePath.startsWith("app/");
29
+ });
30
+
31
+ if (routeFiles.length === 0) return graph;
32
+
33
+ const project = new Project({ compilerOptions: { noEmit: true } });
34
+ const newRoutes: HTTPRoute[] = [...graph.routes];
35
+
36
+ for (const rf of routeFiles) {
37
+ try {
38
+ const sourceFile = project.addSourceFileAtPath(rf.path);
39
+ } catch {
40
+ continue;
41
+ }
42
+ }
43
+
44
+ for (const rf of routeFiles) {
45
+ try {
46
+ const sourceFile = project.getSourceFile(rf.path);
47
+ if (!sourceFile) continue;
48
+
49
+ const httpPath = appRouteToHttpPath(rf.relativePath);
50
+
51
+ for (const func of sourceFile.getFunctions()) {
52
+ const name = func.getName();
53
+ if (!name || !HTTP_METHODS.has(name.toUpperCase())) continue;
54
+
55
+ newRoutes.push({
56
+ method: name.toUpperCase(),
57
+ path: httpPath,
58
+ handler: rf.relativePath,
59
+ file: rf.relativePath,
60
+ line: func.getStartLineNumber(),
61
+ });
62
+ }
63
+ } catch {
64
+ continue;
65
+ }
66
+ }
67
+
68
+ return { ...graph, routes: newRoutes };
69
+ }
@@ -0,0 +1,90 @@
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 { addOpencodePlugin } from "./index.js";
6
+
7
+ let tmpDir: string;
8
+
9
+ function setup() {
10
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "tsgraph-opencode-"));
11
+ }
12
+
13
+ function teardown() {
14
+ fs.rmSync(tmpDir, { recursive: true });
15
+ }
16
+
17
+ function readJson(filePath: string): Record<string, unknown> {
18
+ return JSON.parse(fs.readFileSync(filePath, "utf-8"));
19
+ }
20
+
21
+ describe("addOpencodePlugin", () => {
22
+ it("creates opencode.json when it does not exist", () => {
23
+ setup();
24
+ const result = addOpencodePlugin(tmpDir);
25
+ expect(result.opencodeJsonUpdated).toBe(true);
26
+ expect(result.errors).toHaveLength(0);
27
+
28
+ const configPath = path.join(tmpDir, "opencode.json");
29
+ expect(fs.existsSync(configPath)).toBe(true);
30
+
31
+ const config = readJson(configPath);
32
+ expect(config["mcpServers"]).toEqual({
33
+ tsgraph: { command: "npx", args: ["tsgraph", "mcp"] },
34
+ });
35
+ teardown();
36
+ });
37
+
38
+ it("updates existing opencode.json preserving other fields", () => {
39
+ setup();
40
+ const configPath = path.join(tmpDir, "opencode.json");
41
+ fs.writeFileSync(
42
+ configPath,
43
+ JSON.stringify({ existingKey: "keep-me", mcpServers: { other: { command: "other" } } }, null, 2) + "\n",
44
+ );
45
+
46
+ const result = addOpencodePlugin(tmpDir);
47
+ expect(result.opencodeJsonUpdated).toBe(true);
48
+ expect(result.errors).toHaveLength(0);
49
+
50
+ const config = readJson(configPath);
51
+ expect(config["existingKey"]).toBe("keep-me");
52
+ expect((config["mcpServers"] as Record<string, unknown>)["other"]).toEqual({ command: "other" });
53
+ expect((config["mcpServers"] as Record<string, unknown>)["tsgraph"]).toEqual({
54
+ command: "npx",
55
+ args: ["tsgraph", "mcp"],
56
+ });
57
+ teardown();
58
+ });
59
+
60
+ it("creates .opencode/agents/tsgraph.json", () => {
61
+ setup();
62
+ const result = addOpencodePlugin(tmpDir);
63
+ expect(result.agentCreated).toBe(true);
64
+ expect(result.errors).toHaveLength(0);
65
+
66
+ const agentPath = path.join(tmpDir, ".opencode", "agents", "tsgraph.json");
67
+ expect(fs.existsSync(agentPath)).toBe(true);
68
+
69
+ const agent = readJson(agentPath);
70
+ expect(agent["name"]).toBe("tsgraph");
71
+ expect(agent["description"]).toBe(
72
+ "Query the tsgraph codebase index for symbols, imports, dependencies, and analysis",
73
+ );
74
+ expect(agent["tools"]).toContain("tsgraph_query");
75
+ expect(agent["tools"]).toContain("tsgraph_plan");
76
+ teardown();
77
+ });
78
+
79
+ it("handles invalid existing opencode.json gracefully", () => {
80
+ setup();
81
+ const configPath = path.join(tmpDir, "opencode.json");
82
+ fs.writeFileSync(configPath, "not valid json");
83
+
84
+ const result = addOpencodePlugin(tmpDir);
85
+ expect(result.opencodeJsonUpdated).toBe(false);
86
+ expect(result.errors.length).toBeGreaterThan(0);
87
+ expect(result.errors[0]).toContain("Failed to update opencode.json");
88
+ teardown();
89
+ });
90
+ });
@@ -0,0 +1,83 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+
4
+ const MCP_SERVER_CONFIG = {
5
+ command: "npx",
6
+ args: ["@shvmgyl15/tsgraph", "mcp"],
7
+ };
8
+
9
+ const AGENT_CONFIG = {
10
+ name: "tsgraph",
11
+ description: "Query the tsgraph codebase index for symbols, imports, dependencies, and analysis",
12
+ tools: [
13
+ "tsgraph_callers",
14
+ "tsgraph_callees",
15
+ "tsgraph_node",
16
+ "tsgraph_source",
17
+ "tsgraph_query",
18
+ "tsgraph_imports",
19
+ "tsgraph_public",
20
+ "tsgraph_focus",
21
+ "tsgraph_context",
22
+ "tsgraph_complexity",
23
+ "tsgraph_hotspot",
24
+ "tsgraph_coupling",
25
+ "tsgraph_deps",
26
+ "tsgraph_impact",
27
+ "tsgraph_path",
28
+ "tsgraph_orphans",
29
+ "tsgraph_trace",
30
+ "tsgraph_boundaries",
31
+ "tsgraph_changes",
32
+ "tsgraph_stale",
33
+ "tsgraph_plan",
34
+ "tsgraph_review",
35
+ ],
36
+ };
37
+
38
+ export interface AddPluginResult {
39
+ opencodeJsonUpdated: boolean;
40
+ agentCreated: boolean;
41
+ errors: string[];
42
+ }
43
+
44
+ export function addOpencodePlugin(rootDir: string): AddPluginResult {
45
+ const errors: string[] = [];
46
+ let opencodeJsonUpdated = false;
47
+ let agentCreated = false;
48
+
49
+ const opencodeJsonPath = path.join(rootDir, "opencode.json");
50
+
51
+ try {
52
+ let config: Record<string, unknown>;
53
+ if (fs.existsSync(opencodeJsonPath)) {
54
+ const raw = fs.readFileSync(opencodeJsonPath, "utf-8");
55
+ config = JSON.parse(raw);
56
+ } else {
57
+ config = {};
58
+ }
59
+
60
+ config["mcpServers"] = {
61
+ ...((config["mcpServers"] as Record<string, unknown>) || {}),
62
+ tsgraph: MCP_SERVER_CONFIG,
63
+ };
64
+
65
+ fs.writeFileSync(opencodeJsonPath, JSON.stringify(config, null, 2) + "\n");
66
+ opencodeJsonUpdated = true;
67
+ } catch (e) {
68
+ errors.push(`Failed to update opencode.json: ${String(e)}`);
69
+ }
70
+
71
+ try {
72
+ const agentsDir = path.join(rootDir, ".opencode", "agents");
73
+ fs.mkdirSync(agentsDir, { recursive: true });
74
+
75
+ const agentPath = path.join(agentsDir, "tsgraph.json");
76
+ fs.writeFileSync(agentPath, JSON.stringify(AGENT_CONFIG, null, 2) + "\n");
77
+ agentCreated = true;
78
+ } catch (e) {
79
+ errors.push(`Failed to create tsgraph agent: ${String(e)}`);
80
+ }
81
+
82
+ return { opencodeJsonUpdated, agentCreated, errors };
83
+ }
@@ -0,0 +1,339 @@
1
+ import path from "node:path";
2
+ import fs from "node:fs";
3
+ import {
4
+ Project,
5
+ Node,
6
+ SyntaxKind,
7
+ type SourceFile,
8
+ } from "ts-morph";
9
+ import type {
10
+ Graph,
11
+ SymbolNode,
12
+ CallEdge,
13
+ ImportEdge,
14
+ Dependency,
15
+ FileNode,
16
+ PackageNode,
17
+ } from "../graph/types.js";
18
+ import { GRAPH_VERSION } from "../graph/types.js";
19
+ import type { ScannedFile } from "../scanner/index.js";
20
+ import { extractNextJs } from "../nextjs/index.js";
21
+
22
+ function symbolId(file: string, name: string): string {
23
+ return `${file}::${name}`;
24
+ }
25
+
26
+ function countLines(filePath: string): number {
27
+ try {
28
+ const content = fs.readFileSync(filePath, "utf-8");
29
+ return content.split("\n").length;
30
+ } catch {
31
+ return 0;
32
+ }
33
+ }
34
+
35
+ function extractFileNodes(
36
+ rootDir: string,
37
+ scanned: ScannedFile[],
38
+ pkg: string,
39
+ ): FileNode[] {
40
+ return scanned.map((sf) => ({
41
+ id: sf.relativePath,
42
+ path: sf.relativePath,
43
+ packageName: pkg,
44
+ lines: countLines(sf.path),
45
+ generated: sf.isGenerated,
46
+ }));
47
+ }
48
+
49
+ function extractSymbols(
50
+ sourceFile: SourceFile,
51
+ filePath: string,
52
+ pkgName: string,
53
+ ): SymbolNode[] {
54
+ const symbols: SymbolNode[] = [];
55
+
56
+ for (const func of sourceFile.getFunctions()) {
57
+ const name = func.getName();
58
+ if (!name) continue;
59
+ symbols.push({
60
+ id: symbolId(filePath, name),
61
+ kind: "function",
62
+ name,
63
+ packageName: pkgName,
64
+ file: filePath,
65
+ line: func.getStartLineNumber(),
66
+ endLine: func.getEndLineNumber(),
67
+ isExported: func.isExported(),
68
+ arity: func.getParameters().length,
69
+ });
70
+ }
71
+
72
+ for (const cls of sourceFile.getClasses()) {
73
+ const name = cls.getName();
74
+ if (!name) continue;
75
+ symbols.push({
76
+ id: symbolId(filePath, name),
77
+ kind: "class",
78
+ name,
79
+ packageName: pkgName,
80
+ file: filePath,
81
+ line: cls.getStartLineNumber(),
82
+ endLine: cls.getEndLineNumber(),
83
+ isExported: cls.isExported(),
84
+ });
85
+
86
+ for (const method of cls.getMethods()) {
87
+ const mName = method.getName();
88
+ if (!mName) continue;
89
+ symbols.push({
90
+ id: symbolId(filePath, `${name}.${mName}`),
91
+ kind: "method",
92
+ name: mName,
93
+ receiver: name,
94
+ packageName: pkgName,
95
+ file: filePath,
96
+ line: method.getStartLineNumber(),
97
+ endLine: method.getEndLineNumber(),
98
+ isExported: true,
99
+ arity: method.getParameters().length,
100
+ });
101
+ }
102
+ }
103
+
104
+ for (const iface of sourceFile.getInterfaces()) {
105
+ const name = iface.getName();
106
+ symbols.push({
107
+ id: symbolId(filePath, name),
108
+ kind: "interface",
109
+ name,
110
+ packageName: pkgName,
111
+ file: filePath,
112
+ line: iface.getStartLineNumber(),
113
+ endLine: iface.getEndLineNumber(),
114
+ isExported: iface.isExported(),
115
+ });
116
+ }
117
+
118
+ for (const alias of sourceFile.getTypeAliases()) {
119
+ const name = alias.getName();
120
+ symbols.push({
121
+ id: symbolId(filePath, name),
122
+ kind: "type_alias",
123
+ name,
124
+ packageName: pkgName,
125
+ file: filePath,
126
+ line: alias.getStartLineNumber(),
127
+ endLine: alias.getEndLineNumber(),
128
+ isExported: alias.isExported(),
129
+ });
130
+ }
131
+
132
+ for (const enm of sourceFile.getEnums()) {
133
+ const name = enm.getName();
134
+ symbols.push({
135
+ id: symbolId(filePath, name),
136
+ kind: "enum",
137
+ name,
138
+ packageName: pkgName,
139
+ file: filePath,
140
+ line: enm.getStartLineNumber(),
141
+ endLine: enm.getEndLineNumber(),
142
+ isExported: enm.isExported(),
143
+ });
144
+ }
145
+
146
+ for (const vs of sourceFile.getVariableStatements()) {
147
+ const isExported = vs.isExported();
148
+ const isConst =
149
+ vs
150
+ .getDeclarationList()
151
+ .getFirstChildByKind(SyntaxKind.ConstKeyword) !== undefined;
152
+ const varKind = isConst ? "const" : "var";
153
+ for (const decl of vs.getDeclarations()) {
154
+ const name = decl.getName();
155
+ symbols.push({
156
+ id: symbolId(filePath, name),
157
+ kind: varKind,
158
+ name,
159
+ packageName: pkgName,
160
+ file: filePath,
161
+ line: decl.getStartLineNumber(),
162
+ endLine: decl.getEndLineNumber(),
163
+ isExported,
164
+ });
165
+ }
166
+ }
167
+
168
+ return symbols;
169
+ }
170
+
171
+ function extractCalls(
172
+ sourceFile: SourceFile,
173
+ filePath: string,
174
+ ): CallEdge[] {
175
+ const calls: CallEdge[] = [];
176
+
177
+ for (const func of sourceFile.getFunctions()) {
178
+ const name = func.getName();
179
+ if (!name) continue;
180
+ const funcId = symbolId(filePath, name);
181
+ func.forEachDescendant((node) => {
182
+ if (Node.isCallExpression(node)) {
183
+ const calleeRaw = node.getExpression().getText();
184
+ calls.push({
185
+ callerSymbolId: funcId,
186
+ callerName: name,
187
+ calleeRaw,
188
+ file: filePath,
189
+ line: node.getStartLineNumber(),
190
+ });
191
+ }
192
+ return false;
193
+ });
194
+ }
195
+
196
+ for (const cls of sourceFile.getClasses()) {
197
+ const clsName = cls.getName();
198
+ if (!clsName) continue;
199
+ for (const method of cls.getMethods()) {
200
+ const mName = method.getName();
201
+ if (!mName) continue;
202
+ const methodId = symbolId(filePath, `${clsName}.${mName}`);
203
+ method.forEachDescendant((node) => {
204
+ if (Node.isCallExpression(node)) {
205
+ const calleeRaw = node.getExpression().getText();
206
+ calls.push({
207
+ callerSymbolId: methodId,
208
+ callerName: mName,
209
+ calleeRaw,
210
+ file: filePath,
211
+ line: node.getStartLineNumber(),
212
+ });
213
+ }
214
+ return false;
215
+ });
216
+ }
217
+ }
218
+
219
+ return calls;
220
+ }
221
+
222
+ function extractImports(
223
+ sourceFile: SourceFile,
224
+ filePath: string,
225
+ pkgName: string,
226
+ ): ImportEdge[] {
227
+ const imports: ImportEdge[] = [];
228
+
229
+ for (const imp of sourceFile.getImportDeclarations()) {
230
+ const importPath = imp.getModuleSpecifierValue();
231
+
232
+ const defaultImport = imp.getDefaultImport();
233
+ if (defaultImport) {
234
+ imports.push({
235
+ fromFile: filePath,
236
+ fromPackage: pkgName,
237
+ importPath,
238
+ alias: defaultImport.getText(),
239
+ isDefault: true,
240
+ });
241
+ }
242
+
243
+ for (const named of imp.getNamedImports()) {
244
+ imports.push({
245
+ fromFile: filePath,
246
+ fromPackage: pkgName,
247
+ importPath,
248
+ alias: named.getName(),
249
+ isDefault: false,
250
+ });
251
+ }
252
+ }
253
+
254
+ return imports;
255
+ }
256
+
257
+ function readDependencies(rootDir: string): Dependency[] {
258
+ const pkgPath = path.join(rootDir, "package.json");
259
+ try {
260
+ const raw = fs.readFileSync(pkgPath, "utf-8");
261
+ const pkg = JSON.parse(raw);
262
+ const deps: Dependency[] = [];
263
+ const allDeps: Record<string, string> = {
264
+ ...pkg.dependencies,
265
+ ...pkg.devDependencies,
266
+ };
267
+ for (const [module, version] of Object.entries(allDeps)) {
268
+ deps.push({ module, version: String(version) });
269
+ }
270
+ return deps;
271
+ } catch {
272
+ return [];
273
+ }
274
+ }
275
+
276
+ function computePackageName(rootDir: string): string {
277
+ try {
278
+ const pkgPath = path.join(rootDir, "package.json");
279
+ const raw = fs.readFileSync(pkgPath, "utf-8");
280
+ const pkg = JSON.parse(raw);
281
+ return pkg.name ?? path.basename(rootDir);
282
+ } catch {
283
+ return path.basename(rootDir);
284
+ }
285
+ }
286
+
287
+ export function parseProject(rootDir: string, scanned: ScannedFile[]): Graph {
288
+ const pkgName = computePackageName(rootDir);
289
+ const fileNodes = extractFileNodes(rootDir, scanned, pkgName);
290
+
291
+ const rootPackage: PackageNode = {
292
+ id: pkgName,
293
+ name: pkgName,
294
+ importPathBestEffort: pkgName,
295
+ dir: rootDir,
296
+ files: scanned.map((sf) => sf.relativePath),
297
+ };
298
+
299
+ const project = new Project({
300
+ compilerOptions: {
301
+ allowJs: true,
302
+ noEmit: true,
303
+ },
304
+ });
305
+
306
+ const parsable = scanned.filter(
307
+ (sf) =>
308
+ sf.kind === "ts" || sf.kind === "tsx" || sf.kind === "js" || sf.kind === "jsx",
309
+ );
310
+
311
+ for (const sf of parsable) {
312
+ try {
313
+ project.addSourceFileAtPath(sf.path);
314
+ } catch {
315
+ // skip files that fail to parse
316
+ }
317
+ }
318
+
319
+ const allSymbols: SymbolNode[] = [];
320
+ const allCalls: CallEdge[] = [];
321
+ const allImports: ImportEdge[] = [];
322
+
323
+ for (const sourceFile of project.getSourceFiles()) {
324
+ const relPath = path.relative(rootDir, sourceFile.getFilePath());
325
+ const symbols = extractSymbols(sourceFile, relPath, pkgName);
326
+ const calls = extractCalls(sourceFile, relPath);
327
+ const imports = extractImports(sourceFile, relPath, pkgName);
328
+
329
+ allSymbols.push(...symbols);
330
+ allCalls.push(...calls);
331
+ allImports.push(...imports);
332
+ }
333
+
334
+ const dependencies = readDependencies(rootDir);
335
+
336
+ const baseGraph = { version: GRAPH_VERSION, generatedAt: new Date().toISOString(), root: rootDir, packages: [rootPackage], files: fileNodes, symbols: allSymbols, imports: allImports, calls: allCalls, envReads: [], dependencies, routes: [], concurrency: [], testEdges: [], implements: [], mutations: [], errors: [] };
337
+
338
+ return extractNextJs(baseGraph, rootDir, scanned);
339
+ }