@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,335 @@
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 {
6
+ makeGraph,
7
+ makeImportEdge,
8
+ makeFileNode,
9
+ makePackageNode,
10
+ } from "../graph/types.js";
11
+ import {
12
+ loadBoundariesConfig,
13
+ checkBoundaries,
14
+ } from "./index.js";
15
+ import type { BoundariesConfig, LayerConfig } from "./index.js";
16
+
17
+ function createTempDir(): string {
18
+ return fs.mkdtempSync(path.join(os.tmpdir(), "tsgraph-boundaries-"));
19
+ }
20
+
21
+ function makeConfig(layers: LayerConfig[]): BoundariesConfig {
22
+ return { layers };
23
+ }
24
+
25
+ const uiLayer: LayerConfig = {
26
+ name: "ui",
27
+ path: "src/components",
28
+ dependsOn: ["shared"],
29
+ };
30
+
31
+ const sharedLayer: LayerConfig = {
32
+ name: "shared",
33
+ path: "src/shared",
34
+ dependsOn: ["lib"],
35
+ };
36
+
37
+ const libLayer: LayerConfig = {
38
+ name: "lib",
39
+ path: "src/lib",
40
+ dependsOn: [],
41
+ };
42
+
43
+ const defaultConfig = makeConfig([uiLayer, sharedLayer, libLayer]);
44
+
45
+ describe("loadBoundariesConfig", () => {
46
+ it("reads and parses valid config", () => {
47
+ const dir = createTempDir();
48
+ const configDir = path.join(dir, ".tsgraph");
49
+ fs.mkdirSync(configDir, { recursive: true });
50
+ fs.writeFileSync(
51
+ path.join(configDir, "boundaries.json"),
52
+ JSON.stringify(defaultConfig),
53
+ "utf-8",
54
+ );
55
+ const loaded = loadBoundariesConfig(dir);
56
+ expect(loaded).not.toBeNull();
57
+ expect(loaded!.layers).toHaveLength(3);
58
+ expect(loaded!.layers[0].name).toBe("ui");
59
+ expect(loaded!.layers[0].dependsOn).toEqual(["shared"]);
60
+ fs.rmSync(dir, { recursive: true });
61
+ });
62
+
63
+ it("returns null when file does not exist", () => {
64
+ const dir = createTempDir();
65
+ const loaded = loadBoundariesConfig(dir);
66
+ expect(loaded).toBeNull();
67
+ fs.rmSync(dir, { recursive: true });
68
+ });
69
+
70
+ it("returns null for invalid JSON", () => {
71
+ const dir = createTempDir();
72
+ const configDir = path.join(dir, ".tsgraph");
73
+ fs.mkdirSync(configDir, { recursive: true });
74
+ fs.writeFileSync(
75
+ path.join(configDir, "boundaries.json"),
76
+ "not json",
77
+ "utf-8",
78
+ );
79
+ const loaded = loadBoundariesConfig(dir);
80
+ expect(loaded).toBeNull();
81
+ fs.rmSync(dir, { recursive: true });
82
+ });
83
+
84
+ it("returns null for invalid schema", () => {
85
+ const dir = createTempDir();
86
+ const configDir = path.join(dir, ".tsgraph");
87
+ fs.mkdirSync(configDir, { recursive: true });
88
+ fs.writeFileSync(
89
+ path.join(configDir, "boundaries.json"),
90
+ JSON.stringify({ layers: [{ name: "ui" }] }),
91
+ "utf-8",
92
+ );
93
+ const loaded = loadBoundariesConfig(dir);
94
+ expect(loaded).toBeNull();
95
+ fs.rmSync(dir, { recursive: true });
96
+ });
97
+ });
98
+
99
+ describe("checkBoundaries", () => {
100
+ it("allows legal import within same layer", () => {
101
+ const graph = makeGraph({
102
+ files: [
103
+ makeFileNode({ path: "src/components/Button.tsx", packageName: "app" }),
104
+ makeFileNode({ path: "src/components/Panel.tsx", packageName: "app" }),
105
+ ],
106
+ imports: [
107
+ makeImportEdge({
108
+ fromFile: "src/components/Button.tsx",
109
+ fromPackage: "app",
110
+ importPath: "./Panel",
111
+ }),
112
+ ],
113
+ });
114
+ const result = checkBoundaries(graph, defaultConfig);
115
+ expect(result.violations).toHaveLength(0);
116
+ expect(result.allowed).toBe(1);
117
+ });
118
+
119
+ it("allows legal import to a depended-on layer", () => {
120
+ const graph = makeGraph({
121
+ files: [
122
+ makeFileNode({ path: "src/components/Page.tsx", packageName: "app" }),
123
+ makeFileNode({ path: "src/shared/helper.ts", packageName: "shared" }),
124
+ ],
125
+ imports: [
126
+ makeImportEdge({
127
+ fromFile: "src/components/Page.tsx",
128
+ fromPackage: "app",
129
+ importPath: "../shared/helper",
130
+ }),
131
+ ],
132
+ });
133
+ const result = checkBoundaries(graph, defaultConfig);
134
+ expect(result.violations).toHaveLength(0);
135
+ expect(result.allowed).toBe(1);
136
+ });
137
+
138
+ it("flags violation for import to a non-depended layer", () => {
139
+ const graph = makeGraph({
140
+ files: [
141
+ makeFileNode({ path: "src/components/Page.tsx", packageName: "app" }),
142
+ makeFileNode({ path: "src/lib/util.ts", packageName: "lib" }),
143
+ ],
144
+ imports: [
145
+ makeImportEdge({
146
+ fromFile: "src/components/Page.tsx",
147
+ fromPackage: "app",
148
+ importPath: "../lib/util",
149
+ }),
150
+ ],
151
+ });
152
+ const result = checkBoundaries(graph, defaultConfig);
153
+ expect(result.violations).toHaveLength(1);
154
+ expect(result.allowed).toBe(0);
155
+ expect(result.violations[0].fromLayer).toBe("ui");
156
+ expect(result.violations[0].toLayer).toBe("lib");
157
+ expect(result.violations[0].fromFile).toBe("src/components/Page.tsx");
158
+ expect(result.violations[0].toFile).toBe("src/lib/util.ts");
159
+ expect(result.violations[0].toPackage).toBe("lib");
160
+ expect(result.violations[0].rule).toBe("ui → lib not allowed");
161
+ });
162
+
163
+ it("allows legal import through transitive dependency chain", () => {
164
+ const graph = makeGraph({
165
+ files: [
166
+ makeFileNode({ path: "src/shared/util.ts", packageName: "shared" }),
167
+ makeFileNode({ path: "src/lib/helper.ts", packageName: "lib" }),
168
+ ],
169
+ imports: [
170
+ makeImportEdge({
171
+ fromFile: "src/shared/util.ts",
172
+ fromPackage: "shared",
173
+ importPath: "../lib/helper",
174
+ }),
175
+ ],
176
+ });
177
+ const result = checkBoundaries(graph, defaultConfig);
178
+ expect(result.violations).toHaveLength(0);
179
+ expect(result.allowed).toBe(1);
180
+ });
181
+
182
+ it("treats bare module imports as allowed (unresolved)", () => {
183
+ const graph = makeGraph({
184
+ files: [
185
+ makeFileNode({ path: "src/components/Page.tsx", packageName: "app" }),
186
+ ],
187
+ imports: [
188
+ makeImportEdge({
189
+ fromFile: "src/components/Page.tsx",
190
+ fromPackage: "app",
191
+ importPath: "react",
192
+ }),
193
+ ],
194
+ });
195
+ const result = checkBoundaries(graph, defaultConfig);
196
+ expect(result.violations).toHaveLength(0);
197
+ expect(result.allowed).toBe(1);
198
+ });
199
+
200
+ it("treats imports to files outside known layers as allowed", () => {
201
+ const graph = makeGraph({
202
+ files: [
203
+ makeFileNode({ path: "src/components/Page.tsx", packageName: "app" }),
204
+ makeFileNode({ path: "src/generated/api.ts", packageName: "api" }),
205
+ ],
206
+ imports: [
207
+ makeImportEdge({
208
+ fromFile: "src/components/Page.tsx",
209
+ fromPackage: "app",
210
+ importPath: "../generated/api",
211
+ }),
212
+ ],
213
+ });
214
+ const result = checkBoundaries(graph, defaultConfig);
215
+ expect(result.violations).toHaveLength(0);
216
+ expect(result.allowed).toBe(1);
217
+ });
218
+
219
+ it("treats files outside any layer as allowed", () => {
220
+ const graph = makeGraph({
221
+ files: [
222
+ makeFileNode({ path: "src/generated/api.ts", packageName: "api" }),
223
+ makeFileNode({ path: "src/lib/util.ts", packageName: "lib" }),
224
+ ],
225
+ imports: [
226
+ makeImportEdge({
227
+ fromFile: "src/generated/api.ts",
228
+ fromPackage: "api",
229
+ importPath: "../lib/util",
230
+ }),
231
+ ],
232
+ });
233
+ const result = checkBoundaries(graph, defaultConfig);
234
+ expect(result.violations).toHaveLength(0);
235
+ expect(result.allowed).toBe(1);
236
+ });
237
+
238
+ it("handles mixed allowed and violating imports", () => {
239
+ const graph = makeGraph({
240
+ files: [
241
+ makeFileNode({ path: "src/components/Page.tsx", packageName: "app" }),
242
+ makeFileNode({ path: "src/shared/helper.ts", packageName: "shared" }),
243
+ makeFileNode({ path: "src/lib/util.ts", packageName: "lib" }),
244
+ ],
245
+ imports: [
246
+ makeImportEdge({
247
+ fromFile: "src/components/Page.tsx",
248
+ fromPackage: "app",
249
+ importPath: "../shared/helper",
250
+ }),
251
+ makeImportEdge({
252
+ fromFile: "src/components/Page.tsx",
253
+ fromPackage: "app",
254
+ importPath: "../lib/util",
255
+ }),
256
+ makeImportEdge({
257
+ fromFile: "src/components/Page.tsx",
258
+ fromPackage: "app",
259
+ importPath: "react",
260
+ }),
261
+ ],
262
+ });
263
+ const result = checkBoundaries(graph, defaultConfig);
264
+ expect(result.violations).toHaveLength(1);
265
+ expect(result.allowed).toBe(2);
266
+ expect(result.violations[0].toLayer).toBe("lib");
267
+ });
268
+
269
+ it("resolves imports with .tsx extension", () => {
270
+ const graph = makeGraph({
271
+ files: [
272
+ makeFileNode({ path: "src/components/Page.tsx", packageName: "app" }),
273
+ makeFileNode({ path: "src/shared/helper.tsx", packageName: "shared" }),
274
+ ],
275
+ imports: [
276
+ makeImportEdge({
277
+ fromFile: "src/components/Page.tsx",
278
+ fromPackage: "app",
279
+ importPath: "../shared/helper",
280
+ }),
281
+ ],
282
+ });
283
+ const result = checkBoundaries(graph, defaultConfig);
284
+ expect(result.violations).toHaveLength(0);
285
+ expect(result.allowed).toBe(1);
286
+ });
287
+
288
+ it("resolves imports with index.ts pattern", () => {
289
+ const graph = makeGraph({
290
+ files: [
291
+ makeFileNode({ path: "src/components/Page.tsx", packageName: "app" }),
292
+ makeFileNode({ path: "src/shared/index.ts", packageName: "shared" }),
293
+ ],
294
+ imports: [
295
+ makeImportEdge({
296
+ fromFile: "src/components/Page.tsx",
297
+ fromPackage: "app",
298
+ importPath: "../shared",
299
+ }),
300
+ ],
301
+ });
302
+ const result = checkBoundaries(graph, defaultConfig);
303
+ expect(result.violations).toHaveLength(0);
304
+ expect(result.allowed).toBe(1);
305
+ });
306
+
307
+ it("populates toPackage from graph file nodes", () => {
308
+ const graph = makeGraph({
309
+ files: [
310
+ makeFileNode({ path: "src/components/Page.tsx", packageName: "app" }),
311
+ makeFileNode({ path: "src/lib/util.ts", packageName: "lib-pkg" }),
312
+ ],
313
+ imports: [
314
+ makeImportEdge({
315
+ fromFile: "src/components/Page.tsx",
316
+ fromPackage: "app",
317
+ importPath: "../lib/util",
318
+ }),
319
+ ],
320
+ });
321
+ const result = checkBoundaries(graph, defaultConfig);
322
+ expect(result.violations).toHaveLength(1);
323
+ expect(result.violations[0].toPackage).toBe("lib-pkg");
324
+ });
325
+
326
+ it("returns config in result", () => {
327
+ const graph = makeGraph({
328
+ files: [],
329
+ imports: [],
330
+ });
331
+ const result = checkBoundaries(graph, defaultConfig);
332
+ expect(result.config).toBe(defaultConfig);
333
+ expect(result.config.layers).toHaveLength(3);
334
+ });
335
+ });
@@ -0,0 +1,137 @@
1
+ import type { Graph } from "../graph/types.js";
2
+ import path from "node:path";
3
+ import fs from "node:fs";
4
+ import { z } from "zod/v4";
5
+
6
+ export interface LayerConfig {
7
+ name: string;
8
+ path: string;
9
+ dependsOn: string[];
10
+ }
11
+
12
+ export interface BoundariesConfig {
13
+ layers: LayerConfig[];
14
+ }
15
+
16
+ export interface BoundaryViolation {
17
+ fromFile: string;
18
+ fromLayer: string;
19
+ toFile: string;
20
+ toLayer: string;
21
+ toPackage: string;
22
+ rule: string;
23
+ }
24
+
25
+ export interface BoundaryResult {
26
+ violations: BoundaryViolation[];
27
+ allowed: number;
28
+ config: BoundariesConfig;
29
+ }
30
+
31
+ const layerConfigSchema = z.object({
32
+ name: z.string(),
33
+ path: z.string(),
34
+ dependsOn: z.array(z.string()),
35
+ });
36
+
37
+ const boundariesConfigSchema = z.object({
38
+ layers: z.array(layerConfigSchema),
39
+ });
40
+
41
+ export function loadBoundariesConfig(rootDir: string): BoundariesConfig | null {
42
+ const configPath = path.join(rootDir, ".tsgraph", "boundaries.json");
43
+ try {
44
+ const raw = fs.readFileSync(configPath, "utf-8");
45
+ const parsed = JSON.parse(raw);
46
+ const result = boundariesConfigSchema.safeParse(parsed);
47
+ if (!result.success) return null;
48
+ return result.data;
49
+ } catch {
50
+ return null;
51
+ }
52
+ }
53
+
54
+ const EXTENSIONS = [".ts", ".tsx", ".js", ".jsx"];
55
+ const INDEX_PATTERNS = ["/index", ""];
56
+
57
+ function findLayerByPath(filePath: string, config: BoundariesConfig): LayerConfig | undefined {
58
+ return config.layers.find((layer) => filePath.startsWith(layer.path));
59
+ }
60
+
61
+ function resolveTargetPath(fromFile: string, importPath: string): string | undefined {
62
+ if (importPath.startsWith(".")) {
63
+ const dir = path.posix.dirname(fromFile);
64
+ return path.posix.normalize(path.posix.join(dir, importPath));
65
+ }
66
+ if (importPath.startsWith("/")) {
67
+ return path.posix.normalize(importPath);
68
+ }
69
+ return undefined;
70
+ }
71
+
72
+ function matchFile(resolved: string, files: { path: string }[]): string | undefined {
73
+ for (const file of files) {
74
+ if (file.path === resolved) return file.path;
75
+ for (const ext of EXTENSIONS) {
76
+ if (file.path === resolved + ext) return file.path;
77
+ }
78
+ for (const ext of EXTENSIONS) {
79
+ for (const idx of INDEX_PATTERNS) {
80
+ if (file.path === resolved + idx + ext) return file.path;
81
+ }
82
+ }
83
+ }
84
+ return undefined;
85
+ }
86
+
87
+ export function checkBoundaries(graph: Graph, config: BoundariesConfig): BoundaryResult {
88
+ const violations: BoundaryViolation[] = [];
89
+ let allowed = 0;
90
+
91
+ for (const imp of graph.imports) {
92
+ const fromLayer = findLayerByPath(imp.fromFile, config);
93
+ if (!fromLayer) {
94
+ allowed++;
95
+ continue;
96
+ }
97
+
98
+ const resolved = resolveTargetPath(imp.fromFile, imp.importPath);
99
+ if (!resolved) {
100
+ allowed++;
101
+ continue;
102
+ }
103
+
104
+ const matchedFile = matchFile(resolved, graph.files);
105
+ if (!matchedFile) {
106
+ allowed++;
107
+ continue;
108
+ }
109
+
110
+ const toLayer = findLayerByPath(matchedFile, config);
111
+ if (!toLayer) {
112
+ allowed++;
113
+ continue;
114
+ }
115
+
116
+ if (fromLayer.name === toLayer.name) {
117
+ allowed++;
118
+ continue;
119
+ }
120
+
121
+ if (!fromLayer.dependsOn.includes(toLayer.name)) {
122
+ const targetFile = graph.files.find((f) => f.path === matchedFile);
123
+ violations.push({
124
+ fromFile: imp.fromFile,
125
+ fromLayer: fromLayer.name,
126
+ toFile: matchedFile,
127
+ toLayer: toLayer.name,
128
+ toPackage: targetFile?.packageName ?? toLayer.name,
129
+ rule: `${fromLayer.name} → ${toLayer.name} not allowed`,
130
+ });
131
+ } else {
132
+ allowed++;
133
+ }
134
+ }
135
+
136
+ return { violations, allowed, config };
137
+ }
@@ -0,0 +1,114 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import {
3
+ makeGraph,
4
+ makeFileNode,
5
+ makeSymbolNode,
6
+ makeImportEdge,
7
+ } from "../graph/types.js";
8
+ import { getChanges, getStale } from "./index.js";
9
+ import path from "node:path";
10
+ import fs from "node:fs";
11
+ import os from "node:os";
12
+ import { execSync } from "node:child_process";
13
+
14
+ function createGitRepo(): string {
15
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "tsgraph-changes-"));
16
+ execSync("git init", { cwd: dir, stdio: "pipe" });
17
+ execSync('git config user.email "test@test.com"', { cwd: dir, stdio: "pipe" });
18
+ execSync('git config user.name "Test"', { cwd: dir, stdio: "pipe" });
19
+ return dir;
20
+ }
21
+
22
+ function writeFile(root: string, relPath: string, content: string) {
23
+ const fullPath = path.join(root, relPath);
24
+ fs.mkdirSync(path.dirname(fullPath), { recursive: true });
25
+ fs.writeFileSync(fullPath, content, "utf-8");
26
+ }
27
+
28
+ describe("getChanges", () => {
29
+ it("returns empty result if not a git repo", () => {
30
+ const graph = makeGraph({ files: [], symbols: [], imports: [] });
31
+ const result = getChanges(graph, "/tmp/nonexistent");
32
+ expect(result.totalFiles).toBe(0);
33
+ expect(result.totalSymbols).toBe(0);
34
+ });
35
+
36
+ it("finds changed files with symbols from a git diff", () => {
37
+ const dir = createGitRepo();
38
+ writeFile(dir, "src/index.ts", 'export const x = 1;\n');
39
+ execSync("git add . && git commit -m 'initial'", { cwd: dir, stdio: "pipe" });
40
+ writeFile(dir, "src/lib/util.ts", 'export const util = 2;\n');
41
+ execSync("git add . && git commit -m 'add util'", { cwd: dir, stdio: "pipe" });
42
+
43
+ const graph = makeGraph({
44
+ files: [
45
+ makeFileNode({ path: "src/index.ts", packageName: "app" }),
46
+ makeFileNode({ path: "src/lib/util.ts", packageName: "app" }),
47
+ ],
48
+ symbols: [
49
+ makeSymbolNode({
50
+ name: "x",
51
+ kind: "const",
52
+ file: "src/index.ts",
53
+ packageName: "app",
54
+ line: 1,
55
+ endLine: 1,
56
+ isExported: true,
57
+ }),
58
+ makeSymbolNode({
59
+ name: "util",
60
+ kind: "const",
61
+ file: "src/lib/util.ts",
62
+ packageName: "app",
63
+ line: 1,
64
+ endLine: 1,
65
+ isExported: true,
66
+ }),
67
+ ],
68
+ imports: [],
69
+ });
70
+
71
+ const result = getChanges(graph, dir, "HEAD~1");
72
+ expect(result.totalFiles).toBeGreaterThanOrEqual(1);
73
+ expect(result.totalSymbols).toBeGreaterThanOrEqual(1);
74
+ const utilFile = result.files.find((f) => f.path === "src/lib/util.ts");
75
+ expect(utilFile).toBeTruthy();
76
+ fs.rmSync(dir, { recursive: true });
77
+ });
78
+ });
79
+
80
+ describe("getStale", () => {
81
+ it("returns empty result if not a git repo", () => {
82
+ const graph = makeGraph({ files: [], symbols: [], imports: [] });
83
+ const result = getStale(graph, "/tmp/nonexistent");
84
+ expect(result.totalFiles).toBe(0);
85
+ });
86
+
87
+ it("returns files without recent commits", () => {
88
+ const dir = createGitRepo();
89
+ writeFile(dir, "src/old.ts", 'export const old = 1;\n');
90
+ execSync("git add . && git commit -m 'initial'", { cwd: dir, stdio: "pipe" });
91
+
92
+ const graph = makeGraph({
93
+ files: [
94
+ makeFileNode({ path: "src/old.ts", packageName: "app" }),
95
+ ],
96
+ symbols: [
97
+ makeSymbolNode({
98
+ name: "old",
99
+ kind: "const",
100
+ file: "src/old.ts",
101
+ packageName: "app",
102
+ line: 1,
103
+ endLine: 1,
104
+ isExported: true,
105
+ }),
106
+ ],
107
+ imports: [],
108
+ });
109
+
110
+ const result = getStale(graph, dir, 0);
111
+ expect(result.totalFiles).toBe(0);
112
+ fs.rmSync(dir, { recursive: true });
113
+ });
114
+ });
@@ -0,0 +1,95 @@
1
+ import type { Graph, SymbolNode, FileNode } from "../graph/types.js";
2
+ import {
3
+ getDiffFiles,
4
+ getStaleFiles,
5
+ isGitRepo,
6
+ } from "../git/index.js";
7
+ import type { ChangedFile } from "../git/index.js";
8
+
9
+ export interface ChangedSymbolInfo {
10
+ symbol: SymbolNode;
11
+ file: string;
12
+ status: ChangedFile["status"];
13
+ }
14
+
15
+ export interface ChangeResult {
16
+ files: {
17
+ path: string;
18
+ status: ChangedFile["status"];
19
+ symbolCount: number;
20
+ }[];
21
+ symbols: ChangedSymbolInfo[];
22
+ totalFiles: number;
23
+ totalSymbols: number;
24
+ }
25
+
26
+ export interface StaleResult {
27
+ files: {
28
+ path: string;
29
+ symbolCount: number;
30
+ symbolNames: string[];
31
+ }[];
32
+ totalFiles: number;
33
+ }
34
+
35
+ function symbolsInFile(graph: Graph, filePath: string): SymbolNode[] {
36
+ return graph.symbols.filter((s) => s.file === filePath);
37
+ }
38
+
39
+ export function getChanges(
40
+ graph: Graph,
41
+ rootDir: string,
42
+ base: string = "main",
43
+ ): ChangeResult {
44
+ if (!isGitRepo(rootDir)) {
45
+ return { files: [], symbols: [], totalFiles: 0, totalSymbols: 0 };
46
+ }
47
+
48
+ const changed = getDiffFiles(rootDir, base);
49
+ const files: ChangeResult["files"] = [];
50
+ const symbols: ChangedSymbolInfo[] = [];
51
+
52
+ for (const c of changed) {
53
+ const graphFile = graph.files.find((f) => f.path === c.path);
54
+ if (!graphFile) {
55
+ files.push({ path: c.path, status: c.status, symbolCount: 0 });
56
+ continue;
57
+ }
58
+ const fileSymbols = symbolsInFile(graph, c.path);
59
+ files.push({
60
+ path: c.path,
61
+ status: c.status,
62
+ symbolCount: fileSymbols.length,
63
+ });
64
+ for (const sym of fileSymbols) {
65
+ symbols.push({ symbol: sym, file: c.path, status: c.status });
66
+ }
67
+ }
68
+
69
+ return { files, symbols, totalFiles: files.length, totalSymbols: symbols.length };
70
+ }
71
+
72
+ export function getStale(
73
+ graph: Graph,
74
+ rootDir: string,
75
+ days: number = 90,
76
+ ): StaleResult {
77
+ if (!isGitRepo(rootDir)) {
78
+ return { files: [], totalFiles: 0 };
79
+ }
80
+
81
+ const stalePaths = getStaleFiles(rootDir, days);
82
+ const files: StaleResult["files"] = [];
83
+
84
+ for (const p of stalePaths) {
85
+ const syms = symbolsInFile(graph, p);
86
+ if (syms.length === 0) continue;
87
+ files.push({
88
+ path: p,
89
+ symbolCount: syms.length,
90
+ symbolNames: syms.map((s) => s.name),
91
+ });
92
+ }
93
+
94
+ return { files, totalFiles: files.length };
95
+ }