Skip to content

Commit 70978e2

Browse files
committed
BBC2-34 enforce layered import rule via bun test
Three tests, one per layer. Each walks `src/` and asserts every file in its layer respects the dependency rule: - `src/commands/**` may import from `src/backend/**` and `src/shared/**`. - `src/backend/<slice>/**` may import from `src/shared/**` and from the same slice. No cross-slice backend imports, no `src/commands/**`. - `src/shared/**` may import only from `src/shared/**`. Parses imports with a single regex that covers `import ... from`, side-effect `import "..."`, dynamic `import(...)`, and re-exports. Good enough for this codebase's consistent style; we'd reach for a proper AST parser only if imports grew beyond what the regex handles. Exemptions: `*.test.ts` files (tests may reach across layers) and `src/shared/bitbucket-http/generated.d.ts` (auto-generated). Non- relative imports (node modules, `msw`, `commander`, …) are always allowed.
1 parent 036b888 commit 70978e2

1 file changed

Lines changed: 116 additions & 0 deletions

File tree

src/test/architecture.test.ts

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
import { test, expect, describe } from "bun:test";
2+
import { dirname, relative, resolve } from "node:path";
3+
4+
/**
5+
* Layering rule, enforced as a unit test so CI catches violations:
6+
*
7+
* - `src/commands/**` may import from `src/backend/**` and `src/shared/**`.
8+
* - `src/backend/<slice>/**` may import from `src/shared/**` and from the
9+
* SAME slice. Cross-slice backend imports and imports from
10+
* `src/commands/**` are forbidden.
11+
* - `src/shared/<area>/**` may import only from `src/shared/**`. Imports
12+
* from `src/backend/**` or `src/commands/**` are forbidden.
13+
*
14+
* Non-relative imports (node modules, built-ins, `msw`, `commander`, …) are
15+
* always allowed. Test files (`*.test.ts`) are exempt so they can reach
16+
* across layers freely. The generated Bitbucket types file is excluded.
17+
*
18+
* Parsing: a single regex covers `import ... from "..."`, `import "..."`,
19+
* `import("...")`, and `export ... from "..."`. Good enough for this
20+
* codebase; we'd reach for a proper AST parser only if we grew imports the
21+
* regex can't handle (none so far).
22+
*/
23+
24+
type Layered = {
25+
kind: "commands" | "backend" | "shared";
26+
slice: string;
27+
};
28+
29+
type Classification = Layered | { kind: "other" };
30+
31+
const IMPORT_RE = /(?:from|import)\s*\(?\s*["']([^"']+)["']/gs;
32+
33+
function classify(path: string): Classification {
34+
const parts = path.split("/");
35+
if (parts[0] !== "src" || parts.length < 3) return { kind: "other" };
36+
const layer = parts[1];
37+
const slice = parts[2]!;
38+
if (layer === "commands" || layer === "backend" || layer === "shared") {
39+
return { kind: layer, slice };
40+
}
41+
return { kind: "other" };
42+
}
43+
44+
function isSkipped(file: string): boolean {
45+
if (file.endsWith(".test.ts")) return true;
46+
if (file === "src/shared/bitbucket-http/generated.d.ts") return true;
47+
return false;
48+
}
49+
50+
function ruleViolation(from: Layered, to: Classification): string | null {
51+
if (to.kind === "other") return null;
52+
53+
if (from.kind === "commands") return null;
54+
55+
if (from.kind === "backend") {
56+
if (to.kind === "shared") return null;
57+
if (to.kind === "backend" && to.slice === from.slice) return null;
58+
if (to.kind === "backend") {
59+
return `backend slice '${from.slice}' imports from backend slice '${to.slice}'`;
60+
}
61+
return `backend imports from commands`;
62+
}
63+
64+
// from.kind === "shared"
65+
if (to.kind === "shared") return null;
66+
return `shared imports from ${to.kind}`;
67+
}
68+
69+
async function violationsFor(
70+
layer: "commands" | "backend" | "shared",
71+
): Promise<string[]> {
72+
const glob = new Bun.Glob("**/*.ts");
73+
const violations: string[] = [];
74+
75+
for await (const relPath of glob.scan("src")) {
76+
const file = `src/${relPath}`;
77+
if (isSkipped(file)) continue;
78+
79+
const fromC = classify(file);
80+
if (fromC.kind !== layer) continue;
81+
82+
const content = await Bun.file(file).text();
83+
84+
for (const match of content.matchAll(IMPORT_RE)) {
85+
const spec = match[1]!;
86+
if (!spec.startsWith("./") && !spec.startsWith("../")) continue;
87+
88+
const absolute = resolve(dirname(file), spec);
89+
const normalized = relative(process.cwd(), absolute).replace(/\\/g, "/");
90+
const toC = classify(normalized);
91+
92+
const reason = ruleViolation(fromC, toC);
93+
if (reason) {
94+
violations.push(
95+
`${file} imports ${normalized}, which violates the commands→backend→shared rule (${reason})`,
96+
);
97+
}
98+
}
99+
}
100+
101+
return violations;
102+
}
103+
104+
describe("architecture layering", () => {
105+
test("commands layer follows the rule", async () => {
106+
expect(await violationsFor("commands")).toEqual([]);
107+
});
108+
109+
test("backend layer follows the rule", async () => {
110+
expect(await violationsFor("backend")).toEqual([]);
111+
});
112+
113+
test("shared layer follows the rule", async () => {
114+
expect(await violationsFor("shared")).toEqual([]);
115+
});
116+
});

0 commit comments

Comments
 (0)