Skip to content

Commit 2e4e011

Browse files
committed
chore(architecture): enforce dependency boundaries
1 parent 9f6d17b commit 2e4e011

4 files changed

Lines changed: 348 additions & 0 deletions

File tree

.github/workflows/ci.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,9 @@ jobs:
8484
- name: Lint
8585
run: bun run lint
8686

87+
- name: Verify architecture boundaries
88+
run: bun run architecture:check
89+
8790
- name: Typecheck
8891
run: bun run typecheck
8992

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@
4444
"test:personality:smoke": "bun run --cwd apps/web test:personality:smoke",
4545
"brand:check": "node scripts/check-brand-identity.ts",
4646
"migrations:check": "node scripts/check-migration-lineage.ts",
47+
"architecture:check": "node scripts/check-architecture-boundaries.ts",
4748
"test:desktop-smoke": "turbo run smoke-test --filter=@forkara/desktop",
4849
"test:device": "bun scripts/device-helper-smoke.ts",
4950
"test:device:probe": "bun scripts/device-helper-smoke.ts --probe-only",
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
2+
import { tmpdir } from "node:os";
3+
import { dirname, join } from "node:path";
4+
import { afterEach, describe, expect, it } from "vitest";
5+
6+
import {
7+
checkArchitectureBoundaries,
8+
formatArchitectureBoundaryViolations,
9+
} from "./check-architecture-boundaries.ts";
10+
11+
const temporaryRoots: string[] = [];
12+
13+
function fixture(files: Readonly<Record<string, string>>) {
14+
const root = mkdtempSync(join(tmpdir(), "forkara-architecture-boundaries-"));
15+
temporaryRoots.push(root);
16+
for (const [path, contents] of Object.entries(files)) {
17+
const target = join(root, path);
18+
mkdirSync(dirname(target), { recursive: true });
19+
writeFileSync(target, contents);
20+
}
21+
return root;
22+
}
23+
24+
afterEach(() => {
25+
for (const root of temporaryRoots.splice(0)) rmSync(root, { force: true, recursive: true });
26+
});
27+
28+
describe("architecture dependency boundaries", () => {
29+
it("allows contracts and web to use their intended shared interfaces", () => {
30+
const root = fixture({
31+
"apps/web/src/client.ts": 'import { ThreadId } from "@forkara/contracts";',
32+
"packages/contracts/src/schema.ts": 'import { Schema } from "effect";',
33+
});
34+
35+
expect(checkArchitectureBoundaries(root)).toEqual([]);
36+
});
37+
38+
it("reports actionable failures for every prohibited dependency direction", () => {
39+
const root = fixture({
40+
"apps/web/src/client.ts": 'import "../../server/src/wsRpc";',
41+
"apps/desktop/src/main.ts": 'import "../../server/src/main";',
42+
"packages/contracts/src/schema.ts":
43+
'import "../../../apps/server/src/persistence/Layers/Sqlite";',
44+
"apps/server/src/orchestration/runner.ts": 'import "../provider/Layers/CodexAdapter";',
45+
"apps/server/src/provider/Layers/CodexAdapter.ts": 'import "./ClaudeAdapter";',
46+
"apps/server/src/agentGateway/httpRoute.ts": 'import "../persistence/Layers/Sqlite";',
47+
});
48+
49+
const violations = checkArchitectureBoundaries(root);
50+
expect(violations.map((violation) => violation.rule)).toEqual([
51+
"web-must-not-import-server",
52+
"desktop-must-not-import-server-implementation",
53+
"interface-adapters-must-not-reach-raw-persistence",
54+
"orchestration-must-not-import-concrete-provider",
55+
"provider-implementations-must-not-cross-import",
56+
"contracts-schema-only",
57+
]);
58+
expect(formatArchitectureBoundaryViolations(violations)).toContain(
59+
"remediation: Use @forkara/contracts, @forkara/shared, or a typed server interface",
60+
);
61+
});
62+
});
Lines changed: 282 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,282 @@
1+
import { existsSync, readdirSync, readFileSync } from "node:fs";
2+
import { dirname, relative, resolve, sep } from "node:path";
3+
4+
export interface ArchitectureBoundaryViolation {
5+
readonly importer: string;
6+
readonly imported: string;
7+
readonly remedy: string;
8+
readonly rule: string;
9+
}
10+
11+
const SOURCE_FILE = /\.(?:[cm]?ts|tsx)$/u;
12+
const STATIC_IMPORT_SPECIFIER =
13+
/\b(?:import|export)\s+(?:type\s+)?(?:[\s\S]*?\s+from\s*)?["']([^"']+)["']/gu;
14+
const DYNAMIC_IMPORT_SPECIFIER = /\bimport\s*\(\s*["']([^"']+)["']/gu;
15+
16+
// These are named, test-only bridges rather than path globs. E2E fixtures have
17+
// to compose the real browser, desktop, and server harnesses; no production web
18+
// module may use those imports. The Claude SDK bridge is a #174 compatibility
19+
// seam until external-history loading is exposed through ProviderAdapter.
20+
const TEMPORARY_COMPATIBILITY_BRIDGES = new Map<string, ReadonlySet<string>>([
21+
[
22+
"apps/web/e2e/fixtures/mcpBrowserHarness.ts",
23+
new Set([
24+
"../../../server/src/agentGateway/browserTools",
25+
"../../../server/src/agentGateway/inFlightRequestRegistry",
26+
"../../../server/src/agentGateway/mcpTransport",
27+
"../../../server/src/agentGateway/Layers/AgentGatewaySessionRegistry",
28+
"../../../server/src/browserAutomation/Layers/BrowserAutomationHost",
29+
"../../../server/src/agentGateway/Services/AgentGatewayCredentials",
30+
]),
31+
],
32+
[
33+
"apps/web/e2e/fixtures/visibleBrowserMain.ts",
34+
new Set([
35+
"../../../desktop/src/browserManager",
36+
"../../../desktop/src/browserUsePipeServer",
37+
"../../../desktop/src/ipcChannels",
38+
"../../../desktop/src/browserAnnotations/webviewSecurity",
39+
]),
40+
],
41+
[
42+
"apps/server/src/orchestration/importThreadRoute.ts",
43+
new Set(["../provider/claudeAgentSdk.ts"]),
44+
],
45+
]);
46+
47+
const PROVIDER_NEUTRAL_SERVICE_MODULES = new Set([
48+
"ProviderAdapter",
49+
"ProviderAdapterRegistry",
50+
"ProviderService",
51+
"ProviderSessionDirectory",
52+
]);
53+
54+
const PROVIDER_FAMILY_BY_MODULE = new Map<string, string>([
55+
["AntigravityAdapter", "antigravity"],
56+
["ClaudeAdapter", "claude"],
57+
["CodexAdapter", "codex"],
58+
["CursorAdapter", "acp"],
59+
["DroidAdapter", "acp"],
60+
["GrokAdapter", "acp"],
61+
["KiloAdapter", "opencode"],
62+
["OpenCodeAdapter", "opencode"],
63+
["PiAdapter", "pi"],
64+
]);
65+
66+
function toPosix(value: string) {
67+
return value.split(sep).join("/");
68+
}
69+
70+
function repositoryPath(repoRoot: string, path: string) {
71+
return toPosix(relative(repoRoot, path));
72+
}
73+
74+
function isWithin(path: string, directory: string) {
75+
const rel = relative(directory, path);
76+
return rel.length === 0 || (!rel.startsWith("..") && !rel.includes(`..${sep}`));
77+
}
78+
79+
function sourceFiles(directory: string): ReadonlyArray<string> {
80+
const files: string[] = [];
81+
const visit = (current: string) => {
82+
for (const entry of readdirSync(current, { withFileTypes: true })) {
83+
if (["dist", "dist-electron", "node_modules", ".turbo"].includes(entry.name)) continue;
84+
const candidate = resolve(current, entry.name);
85+
if (entry.isDirectory()) visit(candidate);
86+
else if (entry.isFile() && SOURCE_FILE.test(entry.name)) files.push(candidate);
87+
}
88+
};
89+
if (existsSync(directory)) visit(directory);
90+
return files;
91+
}
92+
93+
function importSpecifiers(source: string) {
94+
return [STATIC_IMPORT_SPECIFIER, DYNAMIC_IMPORT_SPECIFIER].flatMap((pattern) =>
95+
[...source.matchAll(pattern)].flatMap((match) => (match[1] ? [match[1]] : [])),
96+
);
97+
}
98+
99+
function resolvedLocalPath(importer: string, specifier: string) {
100+
return specifier.startsWith(".") ? resolve(dirname(importer), specifier) : null;
101+
}
102+
103+
function isTemporaryCompatibilityBridge(importer: string, imported: string) {
104+
return TEMPORARY_COMPATIBILITY_BRIDGES.get(importer)?.has(imported) ?? false;
105+
}
106+
107+
function providerFamily(path: string) {
108+
if (path.includes("/provider/acp/")) return "acp";
109+
for (const [module, family] of PROVIDER_FAMILY_BY_MODULE) {
110+
if (path.endsWith(`/${module}`) || path.endsWith(`/${module}.ts`)) return family;
111+
}
112+
return null;
113+
}
114+
115+
function checkImport(input: {
116+
readonly importer: string;
117+
readonly imported: string;
118+
readonly repoRoot: string;
119+
}): ReadonlyArray<ArchitectureBoundaryViolation> {
120+
const importer = repositoryPath(input.repoRoot, input.importer);
121+
const localTarget = resolvedLocalPath(input.importer, input.imported);
122+
const contractsRoot = resolve(input.repoRoot, "packages/contracts");
123+
const webRoot = resolve(input.repoRoot, "apps/web");
124+
const desktopRoot = resolve(input.repoRoot, "apps/desktop");
125+
const orchestrationRoot = resolve(input.repoRoot, "apps/server/src/orchestration");
126+
const providerRoot = resolve(input.repoRoot, "apps/server/src/provider");
127+
const violations: ArchitectureBoundaryViolation[] = [];
128+
const violation = (rule: string, remedy: string) => {
129+
violations.push({ importer, imported: input.imported, rule, remedy });
130+
};
131+
132+
if (isWithin(input.importer, contractsRoot)) {
133+
const isContractsTest = importer.endsWith(".test.ts");
134+
if (
135+
(!isContractsTest && input.imported.startsWith("node:")) ||
136+
input.imported === "electron" ||
137+
input.imported === "react" ||
138+
(input.imported.startsWith("@forkara/") && input.imported !== "@forkara/contracts") ||
139+
(localTarget !== null && !isWithin(localTarget, contractsRoot))
140+
) {
141+
violation(
142+
"contracts-schema-only",
143+
"Keep packages/contracts to schemas and protocol data; move runtime behavior behind a contracts type or an owning application package.",
144+
);
145+
}
146+
}
147+
148+
if (!isTemporaryCompatibilityBridge(importer, input.imported)) {
149+
const targetsServer =
150+
(localTarget !== null && isWithin(localTarget, resolve(input.repoRoot, "apps/server"))) ||
151+
input.imported === "@forkara/server" ||
152+
input.imported.startsWith("@forkara/server/");
153+
const targetsDesktop =
154+
localTarget !== null && isWithin(localTarget, resolve(input.repoRoot, "apps/desktop"));
155+
if (isWithin(input.importer, webRoot) && targetsServer) {
156+
violation(
157+
"web-must-not-import-server",
158+
"Use @forkara/contracts, @forkara/shared, or a typed server interface instead of server implementation.",
159+
);
160+
}
161+
if (isWithin(input.importer, webRoot) && targetsDesktop) {
162+
violation(
163+
"web-must-not-import-desktop-backend",
164+
"Keep Electron/native host code behind a typed browser or desktop bridge.",
165+
);
166+
}
167+
}
168+
169+
const desktopTargetsServer =
170+
(localTarget !== null && isWithin(localTarget, resolve(input.repoRoot, "apps/server"))) ||
171+
input.imported === "@forkara/server" ||
172+
input.imported.startsWith("@forkara/server/");
173+
if (isWithin(input.importer, desktopRoot) && desktopTargetsServer) {
174+
violation(
175+
"desktop-must-not-import-server-implementation",
176+
"Use the server process/RPC boundary; desktop owns native lifecycle, not server implementation.",
177+
);
178+
}
179+
180+
if (
181+
isWithin(input.importer, orchestrationRoot) &&
182+
localTarget !== null &&
183+
isWithin(localTarget, providerRoot)
184+
) {
185+
const providerTarget = repositoryPath(input.repoRoot, localTarget);
186+
const serviceMatch = providerTarget.match(/\/provider\/Services\/([^/]+?)(?:\.ts)?$/u);
187+
const serviceModule = serviceMatch?.[1];
188+
const isNeutralService =
189+
serviceModule !== undefined && PROVIDER_NEUTRAL_SERVICE_MODULES.has(serviceModule);
190+
const isGenericProviderHelper =
191+
providerTarget.endsWith("/Errors") ||
192+
providerTarget.endsWith("/Errors.ts") ||
193+
/\/provider\/(?:bullyMode|debugMode|goalMode|makeNoMistake|providerAttachmentPaths|responseInstructions|skillPromptInjection|terminalTurnApplicability|threadMentionContext|unmappedProviderEvents)(?:\.ts)?$/u.test(
194+
providerTarget,
195+
);
196+
if (
197+
!isNeutralService &&
198+
!isGenericProviderHelper &&
199+
!isTemporaryCompatibilityBridge(importer, input.imported)
200+
) {
201+
violation(
202+
"orchestration-must-not-import-concrete-provider",
203+
"Depend on ProviderService, ProviderAdapterRegistry, ProviderAdapter, or a provider-neutral contract; keep concrete provider code inside apps/server/src/provider.",
204+
);
205+
}
206+
}
207+
208+
if (
209+
isWithin(input.importer, providerRoot) &&
210+
localTarget !== null &&
211+
isWithin(localTarget, providerRoot)
212+
) {
213+
const targetPath = repositoryPath(input.repoRoot, localTarget);
214+
const importerFamily = providerFamily(repositoryPath(input.repoRoot, input.importer));
215+
const targetFamily = providerFamily(targetPath);
216+
if (
217+
importerFamily &&
218+
targetFamily &&
219+
importerFamily !== targetFamily &&
220+
!targetPath.includes("/provider/acp/")
221+
) {
222+
violation(
223+
"provider-implementations-must-not-cross-import",
224+
"Use ProviderAdapter/ProviderService or a declared protocol-family module (for example provider/acp), not another concrete provider implementation.",
225+
);
226+
}
227+
}
228+
229+
const isInterfaceAdapter =
230+
importer === "apps/server/src/wsRpc.ts" ||
231+
importer === "apps/server/src/agentGateway/httpRoute.ts" ||
232+
importer === "apps/server/src/externalMcp/httpRoute.ts";
233+
if (
234+
isInterfaceAdapter &&
235+
localTarget !== null &&
236+
/\/persistence\/(?:Layers|Migrations)\//u.test(repositoryPath(input.repoRoot, localTarget))
237+
) {
238+
violation(
239+
"interface-adapters-must-not-reach-raw-persistence",
240+
"Call the owning application service; interface adapters must not access SQLite layers, migrations, or projection implementation directly.",
241+
);
242+
}
243+
244+
return violations;
245+
}
246+
247+
export function checkArchitectureBoundaries(
248+
repoRoot: string,
249+
): ReadonlyArray<ArchitectureBoundaryViolation> {
250+
return ["apps/web", "apps/desktop", "apps/server/src", "packages/contracts"].flatMap(
251+
(directory) =>
252+
sourceFiles(resolve(repoRoot, directory)).flatMap((importer) =>
253+
importSpecifiers(readFileSync(importer, "utf8")).flatMap((imported) =>
254+
checkImport({ importer, imported, repoRoot }),
255+
),
256+
),
257+
);
258+
}
259+
260+
export function formatArchitectureBoundaryViolations(
261+
violations: ReadonlyArray<ArchitectureBoundaryViolation>,
262+
) {
263+
return violations
264+
.map(
265+
({ importer, imported, remedy, rule }) =>
266+
`${importer} imports ${imported}\n rule: ${rule}\n remediation: ${remedy}`,
267+
)
268+
.join("\n\n");
269+
}
270+
271+
if (import.meta.main) {
272+
const repoRoot = resolve(import.meta.dirname, "..");
273+
const violations = checkArchitectureBoundaries(repoRoot);
274+
if (violations.length > 0) {
275+
console.error(
276+
`Architecture boundary check failed (${violations.length} violation(s)):\n\n${formatArchitectureBoundaryViolations(violations)}`,
277+
);
278+
process.exitCode = 1;
279+
} else {
280+
console.info("Architecture boundary check passed.");
281+
}
282+
}

0 commit comments

Comments
 (0)