From 73be1056c22cebf11189a48925c5e7a7d891111c Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Fri, 7 Aug 2026 12:47:57 +0200 Subject: [PATCH] ci: add domain architecture and state ratchet guards --- .../workflows/check-opencode-annotations.yml | 3 + package.json | 1 + script/architecture-allowlist.json | 34 +++ script/check-architecture.ts | 248 ++++++++++++++++++ 4 files changed, 286 insertions(+) create mode 100644 script/architecture-allowlist.json create mode 100644 script/check-architecture.ts diff --git a/.github/workflows/check-opencode-annotations.yml b/.github/workflows/check-opencode-annotations.yml index f494bbb4c8..51d40efb26 100644 --- a/.github/workflows/check-opencode-annotations.yml +++ b/.github/workflows/check-opencode-annotations.yml @@ -41,6 +41,9 @@ jobs: - name: Check Effect Promise facade allowlist run: bun run script/check-opencode-promise-facades.ts + - name: Check domain architecture boundaries and ratchets + run: bun run script/check-architecture.ts + - name: Check model tool network boundary run: bun run script/check-model-tool-network.ts diff --git a/package.json b/package.json index 518deb2f56..ee3d5953d9 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "dev:stats": "bun sst shell --stage=production -- bun run --cwd packages/stats/app dev", "dev:storybook": "bun --cwd packages/storybook storybook", "lint": "oxlint", + "check:architecture": "bun run script/check-architecture.ts", "typecheck": "bun turbo typecheck", "upgrade-opentui": "bun run script/upgrade-opentui.ts", "postinstall": "bun run --cwd packages/core fix-node-pty && bun run script/setup-git.ts", diff --git a/script/architecture-allowlist.json b/script/architecture-allowlist.json new file mode 100644 index 0000000000..6c79201e8d --- /dev/null +++ b/script/architecture-allowlist.json @@ -0,0 +1,34 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Kilo Architecture Ratchet Allowlist", + "description": "Baseline exception list for Kilo-owned domain architecture boundaries and state ratchets. Upstream-owned shared opencode files are exempt to prevent upstream merge conflicts. As Kilo legacy sites are refactored into clean Effect layers, decrement counts or remove entries to lock in progress.", + "rules": { + "kilo-instance-state-singletons": { + "description": "Legacy InstanceState.make usages in Kilo-owned code (packages/opencode/src/kilocode/**, packages/opencode/src/kilo-*/**). Target: encapsulate in scoped Effect Services in packages/core.", + "allowed": { + "packages/opencode/src/kilo-sessions/kilo-sessions.ts": { "count": 1, "owner": "session-runtime", "reason": "Kilo session coordination state" }, + "packages/opencode/src/kilocode/agent-manager/service.ts": { "count": 1, "owner": "agent-manager", "reason": "Agent Manager multi-project worktree state" }, + "packages/opencode/src/kilocode/background-process/index.ts": { "count": 1, "owner": "process-runtime", "reason": "Directory-keyed background process registry" }, + "packages/opencode/src/kilocode/interactive-terminal/index.ts": { "count": 1, "owner": "terminal-runtime", "reason": "Interactive terminal manager state" }, + "packages/opencode/src/kilocode/notebook/service.ts": { "count": 1, "owner": "notebook-runtime", "reason": "Notebook cell execution service state" }, + "packages/opencode/src/kilocode/project-id.ts": { "count": 1, "owner": "project-runtime", "reason": "Cached project identifier resolution" }, + "packages/opencode/src/kilocode/watcher.ts": { "count": 1, "owner": "watcher-runtime", "reason": "Eager location watcher subscription" } + } + }, + "kilo-database-constructors": { + "description": "Direct new Database() / new DatabaseSync() instantiations in Kilo-owned code. Target: all persistence routes through core Database.Service.", + "allowed": { + "packages/opencode/src/kilocode/session-export/sequence.ts": { "count": 1, "owner": "export-runtime", "reason": "Read-only offline export database reader" }, + "packages/opencode/src/kilocode/session-export/worker/storage.ts": { "count": 1, "owner": "export-runtime", "reason": "Worker-thread offline export database handle" } + } + }, + "kilo-tool-process-env": { + "description": "Direct process.env reads in Kilo-owned tools. Target: pass configuration explicitly via Tool.Context or Env.Service.", + "allowed": { + "packages/opencode/src/tool/mcp-websearch.ts": { "count": 2, "owner": "tool-runtime", "reason": "Top-level EXA_API_KEY snapshot" }, + "packages/opencode/src/tool/warpgrep.ts": { "count": 1, "owner": "tool-runtime", "reason": "MORPH_API_KEY fallback" }, + "packages/opencode/src/tool/websearch.ts": { "count": 4, "owner": "tool-runtime", "reason": "KILO_WEBSEARCH_PROVIDER, PARALLEL_API_KEY, EXA_API_KEY reads" } + } + } + } +} diff --git a/script/check-architecture.ts b/script/check-architecture.ts new file mode 100644 index 0000000000..84b0362eaa --- /dev/null +++ b/script/check-architecture.ts @@ -0,0 +1,248 @@ +#!/usr/bin/env bun +// kilocode_change - new file + +/** + * Enforces domain architecture boundaries and state ratchets for Kilo packages and Kilo-owned code. + * + * Upstream-owned shared opencode files are exempt to prevent upstream merge conflicts. + * + * Rules checked: + * 1. core-directionality: packages/core, packages/llm, and packages/schema must + * never import from packages/opencode (@/*), @kilocode/cli, or packages/kilo-vscode. + * 2. kilo-instance-state: No unclassified InstanceState.make singletons in Kilo-owned code + * (packages/opencode/src/kilocode, packages/opencode/src/kilo-sessions, packages/kilo-*). + * 3. kilo-database-constructors: Direct SQLite instantiation (new Database / new DatabaseSync) + * in Kilo-owned code is restricted to allowed exceptions. + * 4. kilo-tool-process-env: Direct process.env reads in Kilo tools must be classified. + * 5. kilo-httpapi-handlers: Handlers must not call raw OS operations (node:fs, spawn). + */ + +import path from "node:path" + +const ROOT = path.resolve(import.meta.dir, "..") +const ALLOWLIST_PATH = path.join(ROOT, "script", "architecture-allowlist.json") +const allowlist = await Bun.file(ALLOWLIST_PATH).json() + +type Violation = { file: string; rule: string; message: string } +const violations: Violation[] = [] + +function isKiloOwned(filePath: string): boolean { + const norm = filePath.replaceAll("\\", "/").toLowerCase() + return ( + norm.includes("/kilocode/") || + norm.includes("packages/kilocode") || + norm.includes("packages/kilo-") || + norm.startsWith("packages/kilo-") || + norm.includes("/kilo-sessions/") + ) +} + +// --------------------------------------------------------------------------- +// Rule 1: Core / LLM / Schema Directionality Guard +// --------------------------------------------------------------------------- + +const DOMAIN_SCOPES = ["packages/core/src", "packages/llm/src", "packages/schema/src"] +const FORBIDDEN_IMPORT_PATTERNS = [ + { pattern: /from\s+["']@\/.*["']/, reason: "internal opencode alias (@/*) in domain package" }, + { pattern: /from\s+["'].*packages\/opencode.*["']/, reason: "direct packages/opencode import in domain package" }, + { pattern: /from\s+["']@kilocode\/cli(?:[\/].*)?["']/, reason: "@kilocode/cli package import in domain package" }, + { pattern: /from\s+["'].*packages\/kilo-vscode.*["']/, reason: "packages/kilo-vscode import in domain package" }, +] + +for (const scope of DOMAIN_SCOPES) { + const scopeDir = path.join(ROOT, scope) + const glob = new Bun.Glob("**/*.{ts,tsx}") + for (const file of glob.scanSync({ cwd: scopeDir, onlyFiles: true })) { + const fullPath = path.join(scopeDir, file) + const text = await Bun.file(fullPath).text() + for (const rule of FORBIDDEN_IMPORT_PATTERNS) { + if (rule.pattern.test(text)) { + violations.push({ + file: `${scope}/${file}`, + rule: "core-directionality", + message: `Forbidden backward dependency: ${rule.reason}. Domain packages must not depend on application layers.`, + }) + } + } + } +} + +// --------------------------------------------------------------------------- +// Rule 2: Kilo InstanceState.make Ratchet (Kilo-owned code) +// --------------------------------------------------------------------------- + +const opencodeSrc = path.join(ROOT, "packages", "opencode", "src") +const opencodeGlob = new Bun.Glob("**/*.ts") +const kiloInstanceHits = new Map() + +for (const file of opencodeGlob.scanSync({ cwd: opencodeSrc, onlyFiles: true })) { + const relPath = `packages/opencode/src/${file.replaceAll("\\", "/")}` + if (!isKiloOwned(relPath)) continue + const text = await Bun.file(path.join(opencodeSrc, file)).text() + const matches = [...text.matchAll(/\bInstanceState\.make\b/g)] + if (matches.length > 0) { + kiloInstanceHits.set(relPath, matches.length) + } +} + +const allowedInstanceState: Record = + allowlist.rules["kilo-instance-state-singletons"]?.allowed ?? {} + +// Check for unclassified additions or count mismatches +for (const [file, count] of kiloInstanceHits) { + const expected = allowedInstanceState[file] + if (!expected) { + violations.push({ + file, + rule: "kilo-instance-state", + message: `Unclassified InstanceState.make found in Kilo-owned code (${count} site(s)). Encapsulate state in a scoped Effect Service in packages/core or add to architecture-allowlist.json.`, + }) + } else if (expected.count !== count) { + violations.push({ + file, + rule: "kilo-instance-state", + message: `Ratchet drift: expected ${expected.count} site(s), found ${count}. Update architecture-allowlist.json!`, + }) + } +} + +// Check for stale entries in allowlist +for (const file of Object.keys(allowedInstanceState)) { + if (!kiloInstanceHits.has(file)) { + violations.push({ + file, + rule: "kilo-instance-state", + message: `Stale allowlist entry: no InstanceState.make found in ${file}. Remove from architecture-allowlist.json to lock in progress!`, + }) + } +} + +// --------------------------------------------------------------------------- +// Rule 3: Kilo Database Direct Instantiation Guard (Kilo-owned code) +// --------------------------------------------------------------------------- + +const allowedDb: Record = + allowlist.rules["kilo-database-constructors"]?.allowed ?? {} + +const srcGlob = new Bun.Glob("packages/*/src/**/*.ts") +const kiloDbHits = new Map() + +for (const file of srcGlob.scanSync({ cwd: ROOT, onlyFiles: true })) { + const normPath = file.replaceAll("\\", "/") + if (!isKiloOwned(normPath)) continue + const text = await Bun.file(path.join(ROOT, file)).text() + const matches = [...text.matchAll(/\bnew\s+(?:Database|DatabaseSync)\s*\(/g)] + if (matches.length > 0) { + kiloDbHits.set(normPath, matches.length) + } +} + +for (const [file, count] of kiloDbHits) { + const expected = allowedDb[file] + if (!expected) { + violations.push({ + file, + rule: "kilo-database-constructors", + message: `Unclassified SQLite constructor (new Database / new DatabaseSync) in Kilo code (${count} site(s)). Route persistence through Database.Service in @opencode-ai/core.`, + }) + } else if (expected.count !== count) { + violations.push({ + file, + rule: "kilo-database-constructors", + message: `Ratchet drift for database constructor in ${file}: expected ${expected.count}, found ${count}. Update architecture-allowlist.json!`, + }) + } +} + +for (const file of Object.keys(allowedDb)) { + if (!kiloDbHits.has(file)) { + violations.push({ + file, + rule: "kilo-database-constructors", + message: `Stale database constructor allowlist entry: no direct instantiation found in ${file}. Remove from architecture-allowlist.json!`, + }) + } +} + +// --------------------------------------------------------------------------- +// Rule 4: Kilo Tool process.env Reads Guard +// --------------------------------------------------------------------------- + +const allowedToolEnv: Record = + allowlist.rules["kilo-tool-process-env"]?.allowed ?? {} + +const toolGlob = new Bun.Glob("packages/opencode/src/tool/**/*.ts") +const toolEnvHits = new Map() + +for (const file of toolGlob.scanSync({ cwd: ROOT, onlyFiles: true })) { + const normPath = file.replaceAll("\\", "/") + // Only check tools in the allowlist or new tools + const text = await Bun.file(path.join(ROOT, file)).text() + const matches = [...text.matchAll(/\bprocess\.env\b/g)] + if (matches.length > 0 && (allowedToolEnv[normPath] || normPath.includes("kilo") || normPath.includes("websearch") || normPath.includes("warpgrep"))) { + toolEnvHits.set(normPath, matches.length) + } +} + +for (const [file, count] of toolEnvHits) { + const expected = allowedToolEnv[file] + if (!expected) { + violations.push({ + file, + rule: "kilo-tool-process-env", + message: `Direct process.env read found in tool (${count} site(s)). Pass configuration via Tool.Context or Env.Service.`, + }) + } else if (expected.count !== count) { + violations.push({ + file, + rule: "kilo-tool-process-env", + message: `Ratchet drift for process.env in ${file}: expected ${expected.count}, found ${count}. Update architecture-allowlist.json!`, + }) + } +} + +for (const file of Object.keys(allowedToolEnv)) { + if (!toolEnvHits.has(file)) { + violations.push({ + file, + rule: "kilo-tool-process-env", + message: `Stale tool-process-env entry: no process.env read found in ${file}. Remove from architecture-allowlist.json!`, + }) + } +} + +// --------------------------------------------------------------------------- +// Rule 5: HttpApi Handler Boundaries (No raw OS operations in handlers) +// --------------------------------------------------------------------------- + +const handlerGlob = new Bun.Glob("packages/opencode/src/**/server/routes/instance/httpapi/handlers/**/*.ts") +for (const file of handlerGlob.scanSync({ cwd: ROOT, onlyFiles: true })) { + const text = await Bun.file(path.join(ROOT, file)).text() + if (/\b(?:child_process|Bun\.spawn|from ["']node:fs["']|from ["']fs\/promises["'])\b/.test(text)) { + violations.push({ + file, + rule: "kilo-httpapi-handlers", + message: `Direct OS/process operations forbidden in HttpApi route handlers. Delegate to domain Effect services.`, + }) + } +} + +// --------------------------------------------------------------------------- +// Output & Exit +// --------------------------------------------------------------------------- + +if (violations.length > 0) { + console.error(`\n❌ Found ${violations.length} architecture boundary violation(s):\n`) + for (const v of violations) { + console.error(` [${v.rule}] ${v.file}`) + console.error(` ↳ ${v.message}\n`) + } + console.error("Architecture rules protect domain decoupling and enable fast in-process testing.") + console.error("To refactor an existing site, update script/architecture-allowlist.json.") + process.exit(1) +} + +const totalTracked = + Object.keys(allowedInstanceState).length + Object.keys(allowedDb).length + Object.keys(allowedToolEnv).length + +console.log(`check-architecture: ok (${totalTracked} classified Kilo ratchet sites, 0 boundary violations).`)