@shvmgyl15/tsgraph 0.1.0 → 0.2.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.
- package/dist/changes/index.test.js +2 -6
- package/dist/changes/index.test.js.map +1 -1
- package/dist/cli/index.js +184 -4
- package/dist/cli/index.js.map +1 -1
- package/dist/git/index.test.js +4 -6
- package/dist/git/index.test.js.map +1 -1
- package/dist/opencode/index.js +1 -1
- package/dist/opencode/index.js.map +1 -1
- package/dist/opencode/index.test.js +2 -2
- package/dist/opencode/index.test.js.map +1 -1
- package/dist/search/index.d.ts.map +1 -1
- package/dist/search/index.js +12 -4
- package/dist/search/index.js.map +1 -1
- package/package.json +16 -1
- package/AGENTS.md +0 -64
- package/TODOS.md +0 -61
- package/opencode.json +0 -24
- package/src/analysis/analysis.test.ts +0 -405
- package/src/analysis/complexity.ts +0 -107
- package/src/analysis/coupling.ts +0 -106
- package/src/analysis/hotspot.ts +0 -52
- package/src/analysis/index.ts +0 -17
- package/src/boundaries/index.test.ts +0 -335
- package/src/boundaries/index.ts +0 -137
- package/src/changes/index.test.ts +0 -114
- package/src/changes/index.ts +0 -95
- package/src/cli/index.ts +0 -736
- package/src/git/index.test.ts +0 -92
- package/src/git/index.ts +0 -86
- package/src/graph/types.test.ts +0 -383
- package/src/graph/types.ts +0 -353
- package/src/mcp/mcp.test.ts +0 -176
- package/src/mcp/server.ts +0 -217
- package/src/nextjs/index.ts +0 -23
- package/src/nextjs/nextjs.test.ts +0 -233
- package/src/nextjs/pages.ts +0 -43
- package/src/nextjs/react.ts +0 -100
- package/src/nextjs/router.ts +0 -102
- package/src/nextjs/routes.ts +0 -69
- package/src/opencode/index.test.ts +0 -90
- package/src/opencode/index.ts +0 -83
- package/src/parser/index.ts +0 -339
- package/src/parser/parser.test.ts +0 -282
- package/src/plan/index.test.ts +0 -162
- package/src/plan/index.ts +0 -161
- package/src/report/index.ts +0 -128
- package/src/scanner/index.ts +0 -97
- package/src/scanner/scanner.test.ts +0 -135
- package/src/search/index.ts +0 -163
- package/src/search/search.test.ts +0 -512
- package/src/traversal/index.ts +0 -5
- package/src/traversal/traversal.test.ts +0 -266
- package/src/traversal/traversal.ts +0 -185
- package/tsconfig.json +0 -20
- package/vitest.config.ts +0 -7
package/src/analysis/coupling.ts
DELETED
|
@@ -1,106 +0,0 @@
|
|
|
1
|
-
import type { Graph, ImportEdge, SymbolNode } from "../graph/types.js";
|
|
2
|
-
|
|
3
|
-
export interface CouplingResult {
|
|
4
|
-
packageName: string;
|
|
5
|
-
coupledTo: string;
|
|
6
|
-
importCount: number;
|
|
7
|
-
fileCount: number;
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
export interface DepsNode {
|
|
11
|
-
name: string;
|
|
12
|
-
kind: string;
|
|
13
|
-
file: string;
|
|
14
|
-
line: number;
|
|
15
|
-
children: DepsNode[];
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
export function analyzeCoupling(graph: Graph): CouplingResult[] {
|
|
19
|
-
const pkgFiles = new Map<string, Set<string>>();
|
|
20
|
-
for (const f of graph.files) {
|
|
21
|
-
const files = pkgFiles.get(f.packageName) ?? new Set();
|
|
22
|
-
files.add(f.path);
|
|
23
|
-
pkgFiles.set(f.packageName, files);
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
const pkgImportTargets = new Map<string, Map<string, { files: Set<string>; count: number }>>();
|
|
27
|
-
for (const imp of graph.imports) {
|
|
28
|
-
const target = imp.importPath.split("/")[0];
|
|
29
|
-
const byPkg = pkgImportTargets.get(imp.fromPackage) ?? new Map();
|
|
30
|
-
const entry = byPkg.get(target) ?? { files: new Set<string>(), count: 0 };
|
|
31
|
-
entry.files.add(imp.fromFile);
|
|
32
|
-
entry.count++;
|
|
33
|
-
byPkg.set(target, entry);
|
|
34
|
-
pkgImportTargets.set(imp.fromPackage, byPkg);
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
const results: CouplingResult[] = [];
|
|
38
|
-
for (const [pkg, targets] of pkgImportTargets) {
|
|
39
|
-
for (const [target, entry] of targets) {
|
|
40
|
-
if (pkg === target) continue;
|
|
41
|
-
results.push({
|
|
42
|
-
packageName: pkg,
|
|
43
|
-
coupledTo: target,
|
|
44
|
-
importCount: entry.count,
|
|
45
|
-
fileCount: entry.files.size,
|
|
46
|
-
});
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
results.sort((a, b) => b.importCount - a.importCount);
|
|
51
|
-
return results;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
export function dependencyTree(
|
|
55
|
-
graph: Graph,
|
|
56
|
-
symbolName: string,
|
|
57
|
-
maxDepth: number = 3,
|
|
58
|
-
): DepsNode | undefined {
|
|
59
|
-
const sym = graph.symbols.find((s) => s.name === symbolName);
|
|
60
|
-
if (!sym) return undefined;
|
|
61
|
-
|
|
62
|
-
function buildTree(
|
|
63
|
-
current: SymbolNode,
|
|
64
|
-
depth: number,
|
|
65
|
-
visited: Set<string>,
|
|
66
|
-
): DepsNode {
|
|
67
|
-
const node: DepsNode = {
|
|
68
|
-
name: current.name,
|
|
69
|
-
kind: current.kind,
|
|
70
|
-
file: current.file,
|
|
71
|
-
line: current.line,
|
|
72
|
-
children: [],
|
|
73
|
-
};
|
|
74
|
-
|
|
75
|
-
if (depth >= maxDepth) return node;
|
|
76
|
-
|
|
77
|
-
const calleeEdges = graph.calls.filter(
|
|
78
|
-
(c) => c.callerSymbolId === current.id,
|
|
79
|
-
);
|
|
80
|
-
|
|
81
|
-
for (const edge of calleeEdges) {
|
|
82
|
-
if (visited.has(edge.calleeRaw)) continue;
|
|
83
|
-
visited.add(edge.calleeRaw);
|
|
84
|
-
|
|
85
|
-
const calleeSym = graph.symbols.find(
|
|
86
|
-
(s) => s.name === edge.calleeRaw && s.file === edge.file,
|
|
87
|
-
);
|
|
88
|
-
|
|
89
|
-
if (calleeSym) {
|
|
90
|
-
node.children.push(buildTree(calleeSym, depth + 1, visited));
|
|
91
|
-
} else {
|
|
92
|
-
node.children.push({
|
|
93
|
-
name: edge.calleeRaw,
|
|
94
|
-
kind: "unknown",
|
|
95
|
-
file: edge.file,
|
|
96
|
-
line: edge.line,
|
|
97
|
-
children: [],
|
|
98
|
-
});
|
|
99
|
-
}
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
return node;
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
return buildTree(sym, 0, new Set([symbolName]));
|
|
106
|
-
}
|
package/src/analysis/hotspot.ts
DELETED
|
@@ -1,52 +0,0 @@
|
|
|
1
|
-
import type { Graph } from "../graph/types.js";
|
|
2
|
-
import { analyzeComplexity } from "./complexity.js";
|
|
3
|
-
|
|
4
|
-
export interface HotspotResult {
|
|
5
|
-
file: string;
|
|
6
|
-
score: number;
|
|
7
|
-
symbolCount: number;
|
|
8
|
-
totalComplexity: number;
|
|
9
|
-
lines: number;
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
export function findHotspots(
|
|
13
|
-
graph: Graph,
|
|
14
|
-
topN: number = 10,
|
|
15
|
-
): HotspotResult[] {
|
|
16
|
-
const fileNodes = new Map(graph.files.map((f) => [f.path, f]));
|
|
17
|
-
const complexityResults = analyzeComplexity(graph);
|
|
18
|
-
const fileScores = new Map<
|
|
19
|
-
string,
|
|
20
|
-
{ totalComplexity: number; symbolCount: number; lines: number }
|
|
21
|
-
>();
|
|
22
|
-
|
|
23
|
-
for (const r of complexityResults) {
|
|
24
|
-
const entry = fileScores.get(r.symbol.file) ?? {
|
|
25
|
-
totalComplexity: 0,
|
|
26
|
-
symbolCount: 0,
|
|
27
|
-
lines: 1,
|
|
28
|
-
};
|
|
29
|
-
entry.totalComplexity += r.complexity;
|
|
30
|
-
entry.symbolCount++;
|
|
31
|
-
fileScores.set(r.symbol.file, entry);
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
for (const [filePath, entry] of fileScores) {
|
|
35
|
-
const fn = fileNodes.get(filePath);
|
|
36
|
-
entry.lines = fn?.lines ?? 1;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
const results: HotspotResult[] = [];
|
|
40
|
-
for (const [file, data] of fileScores) {
|
|
41
|
-
results.push({
|
|
42
|
-
file,
|
|
43
|
-
score: data.totalComplexity * data.lines,
|
|
44
|
-
symbolCount: data.symbolCount,
|
|
45
|
-
totalComplexity: data.totalComplexity,
|
|
46
|
-
lines: data.lines,
|
|
47
|
-
});
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
results.sort((a, b) => b.score - a.score);
|
|
51
|
-
return results.slice(0, topN);
|
|
52
|
-
}
|
package/src/analysis/index.ts
DELETED
|
@@ -1,17 +0,0 @@
|
|
|
1
|
-
import type { Graph } from "../graph/types.js";
|
|
2
|
-
import { analyzeComplexity, cyclomaticComplexity } from "./complexity.js";
|
|
3
|
-
import type { ComplexityResult } from "./complexity.js";
|
|
4
|
-
import { findHotspots } from "./hotspot.js";
|
|
5
|
-
import type { HotspotResult } from "./hotspot.js";
|
|
6
|
-
import { analyzeCoupling, dependencyTree } from "./coupling.js";
|
|
7
|
-
import type { CouplingResult, DepsNode } from "./coupling.js";
|
|
8
|
-
|
|
9
|
-
export {
|
|
10
|
-
analyzeComplexity,
|
|
11
|
-
cyclomaticComplexity,
|
|
12
|
-
findHotspots,
|
|
13
|
-
analyzeCoupling,
|
|
14
|
-
dependencyTree,
|
|
15
|
-
};
|
|
16
|
-
|
|
17
|
-
export type { ComplexityResult, HotspotResult, CouplingResult, DepsNode };
|
|
@@ -1,335 +0,0 @@
|
|
|
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
|
-
});
|
package/src/boundaries/index.ts
DELETED
|
@@ -1,137 +0,0 @@
|
|
|
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
|
-
}
|