|
| 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