From 2cb38440111db53ef10816d5828af2f8571850f3 Mon Sep 17 00:00:00 2001 From: murashit Date: Tue, 30 Jun 2026 08:52:30 +0900 Subject: [PATCH] Enforce chat signal read-model boundaries --- biome.jsonc | 22 ++++++++ .../no-chat-shell-read-model-imports.grit | 17 ++++++ .../no-chat-signal-type-references.grit | 8 +++ tests/scripts/grit-policy.test.mjs | 54 ++++++++++++++++--- 4 files changed, 93 insertions(+), 8 deletions(-) create mode 100644 scripts/grit/import-boundaries/no-chat-shell-read-model-imports.grit create mode 100644 scripts/grit/import-boundaries/no-chat-signal-type-references.grit diff --git a/biome.jsonc b/biome.jsonc index 9b7f0a1b..1bf3ff34 100644 --- a/biome.jsonc +++ b/biome.jsonc @@ -149,6 +149,28 @@ "!**/src/features/chat/panel/surface/**/*.tsx" ] }, + { + "path": "./scripts/grit/import-boundaries/no-chat-shell-read-model-imports.grit", + "includes": [ + "**/src/features/chat/**/*.ts", + "**/src/features/chat/**/*.tsx", + "!**/src/features/chat/panel/shell-read-model.ts", + "!**/src/features/chat/panel/shell.dom.tsx", + "!**/src/features/chat/panel/composer-controller.ts", + "!**/src/features/chat/panel/surface/**/*.ts", + "!**/src/features/chat/panel/surface/**/*.tsx" + ] + }, + { + "path": "./scripts/grit/import-boundaries/no-chat-signal-type-references.grit", + "includes": [ + "**/src/features/chat/**/*.ts", + "**/src/features/chat/**/*.tsx", + "!**/src/features/chat/panel/shell-read-model.ts", + "!**/src/features/chat/panel/surface/**/*.ts", + "!**/src/features/chat/panel/surface/**/*.tsx" + ] + }, { "path": "./scripts/grit/runtime/no-state-module-side-effects.grit", "includes": ["**/src/features/chat/application/state/**/*.ts"] diff --git a/scripts/grit/import-boundaries/no-chat-shell-read-model-imports.grit b/scripts/grit/import-boundaries/no-chat-shell-read-model-imports.grit new file mode 100644 index 00000000..ef45591f --- /dev/null +++ b/scripts/grit/import-boundaries/no-chat-shell-read-model-imports.grit @@ -0,0 +1,17 @@ +language js + +private pattern js_module_reference() { + or { + JsImport(), + JsImportCallExpression() + } +} + +private pattern chat_shell_read_model_source() { + r"^[\"'](?:(?:\.\./)+(?:panel/)?shell-read-model|(?:\./)?shell-read-model|src/features/chat/panel/shell-read-model)(?:\.ts)?[\"']$" +} + +js_module_reference() as $stmt where { + $stmt <: contains `$source` where { $source <: chat_shell_read_model_source() }, + register_diagnostic(span=$stmt, message="Import chat panel signal read models only from the shell and panel surface rendering adapters.", severity="error") +} diff --git a/scripts/grit/import-boundaries/no-chat-signal-type-references.grit b/scripts/grit/import-boundaries/no-chat-signal-type-references.grit new file mode 100644 index 00000000..588a8ec1 --- /dev/null +++ b/scripts/grit/import-boundaries/no-chat-signal-type-references.grit @@ -0,0 +1,8 @@ +language js + +or { + `ReadonlySignal<$type>` as $stmt, + `Signal<$type>` as $stmt +} where { + register_diagnostic(span=$stmt, message="Keep Preact signal types inside the chat panel read-model and surface rendering adapter layer.", severity="error") +} diff --git a/tests/scripts/grit-policy.test.mjs b/tests/scripts/grit-policy.test.mjs index 53b239ac..e025d02a 100644 --- a/tests/scripts/grit-policy.test.mjs +++ b/tests/scripts/grit-policy.test.mjs @@ -1,6 +1,6 @@ import { spawnSync } from "node:child_process"; import { readFileSync } from "node:fs"; -import { mkdir, mkdtemp, readdir, writeFile } from "node:fs/promises"; +import { copyFile, mkdir, mkdtemp, readdir, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import { describe, expect, it } from "vitest"; @@ -43,10 +43,13 @@ const CHAT_UI_OUTER_LAYER_MESSAGE = const DOM_BOUNDARY_MESSAGE = "Keep DOM reads, writes, measurements, hit-tests, focus, and event wiring in files named with a .dom, .obsidian, or .measure suffix."; const DOM_EVENTS_IMPORT_MESSAGE = "Import DOM event listener helpers only from explicit .dom, .obsidian, or .measure bridge files."; +const CHAT_SHELL_READ_MODEL_IMPORT_MESSAGE = + "Import chat panel signal read models only from the shell and panel surface rendering adapters."; +const CHAT_SIGNAL_TYPE_REFERENCE_MESSAGE = "Keep Preact signal types inside the chat panel read-model and surface rendering adapter layer."; let appServerBoundaryPolicyReportPromise; let renderingAndCssPolicyReportPromise; -describe.concurrent("GritQL source policy", () => { +describe("GritQL source policy", () => { it("keeps Biome wired to every checked-in Grit policy", async () => { const gritPolicyPaths = await listRelativeGritPolicyPaths(path.join(repoRoot, "scripts/grit")); const configuredGritPluginPaths = projectPluginEntries @@ -179,6 +182,8 @@ runner = new Runner(() => runner.stop()); const cwd = await tempBiomeWorkspace([ "no-implicit-dom-bridges.grit", "no-dom-events-imports.grit", + "no-chat-shell-read-model-imports.grit", + "no-chat-signal-type-references.grit", "no-preact-signal-imports.grit", "no-state-module-side-effects.grit", "no-ui-root-imports.grit", @@ -186,9 +191,21 @@ runner = new Runner(() => runner.stop()); await writeFile( path.join(cwd, "src/features/chat/panel/shell-read-model.ts"), ` -import { signal } from "@preact/signals"; +import { signal, type ReadonlySignal } from "@preact/signals"; export const status = signal("idle"); +export type SurfaceSignal = ReadonlySignal; +`.trimStart(), + ); + await writeFile( + path.join(cwd, "src/features/chat/presentation/signal-helper.ts"), + ` +import type { ChatPanelComposerReadModel } from "../panel/shell-read-model"; + +export type BadSignals = ReadonlySignal | Signal; +export function read(model: ChatPanelComposerReadModel): string | null { + return model.activeListedThreadName.value; +} `.trimStart(), ); await writeFile( @@ -342,8 +359,18 @@ export const render = renderUiRoot; path.join(cwd, "src/features/chat/panel/shell.dom.tsx"), ` import { renderUiRoot } from "../../../shared/ui/ui-root.dom"; +import { createChatPanelShellReadModelBinding } from "./shell-read-model"; export const render = renderUiRoot; +export const binding = createChatPanelShellReadModelBinding; +`.trimStart(), + ); + await writeFile( + path.join(cwd, "src/features/chat/panel/composer-controller.ts"), + ` +import type { ChatPanelComposerReadModel } from "./shell-read-model"; + +export type ComposerModel = ChatPanelComposerReadModel; `.trimStart(), ); await writeFile( @@ -384,6 +411,7 @@ export function timestamp(): number { [ "src/features/chat/panel/shell-read-model.ts", "src/features/chat/panel/surface/signal-surface.tsx", + "src/features/chat/presentation/signal-helper.ts", "src/shared/ui/components.tsx", "src/shared/ui/signal-escapes.tsx", "src/features/chat/ui/dom-bridge-escape.tsx", @@ -392,6 +420,7 @@ export function timestamp(): number { "src/app-server/services/runtime-overrides.ts", "src/features/chat/ui/root-import.tsx", "src/features/chat/panel/shell.dom.tsx", + "src/features/chat/panel/composer-controller.ts", "src/features/chat/ui/root-escapes.tsx", "src/features/chat/application/state/message-stream.ts", "src/features/chat/application/threads/resume-actions.ts", @@ -401,6 +430,9 @@ export function timestamp(): number { expect(pluginDiagnostics(report, "src/features/chat/panel/shell-read-model.ts")).toEqual([]); expect(pluginDiagnostics(report, "src/features/chat/panel/surface/signal-surface.tsx")).toEqual([]); + expect([...pluginMessages(report, "src/features/chat/presentation/signal-helper.ts")].sort()).toEqual( + [CHAT_SHELL_READ_MODEL_IMPORT_MESSAGE, CHAT_SIGNAL_TYPE_REFERENCE_MESSAGE, CHAT_SIGNAL_TYPE_REFERENCE_MESSAGE].sort(), + ); expect(pluginMessages(report, "src/shared/ui/components.tsx")).toEqual(["Do not import @preact/signals from this module."]); expect(pluginMessages(report, "src/shared/ui/signal-escapes.tsx")).toEqual([ "Do not import @preact/signals from this module.", @@ -417,6 +449,7 @@ export function timestamp(): number { "Import the Preact root adapter only from explicit root bridge files.", ]); expect(pluginDiagnostics(report, "src/features/chat/panel/shell.dom.tsx")).toEqual([]); + expect(pluginDiagnostics(report, "src/features/chat/panel/composer-controller.ts")).toEqual([]); expect(pluginMessages(report, "src/features/chat/ui/root-escapes.tsx")).toEqual([ "Import the Preact root adapter only from explicit root bridge files.", "Import the Preact root adapter only from explicit root bridge files.", @@ -1440,6 +1473,7 @@ export const load = listHookCatalog; async function tempBiomeWorkspace(plugins) { const cwd = await mkdtemp(path.join(tmpdir(), "codex-panel-grit-policy-")); + const tempPlugins = await Promise.all(plugins.map((plugin) => tempProjectPluginConfig(cwd, plugin))); await mkdir(cwd, { recursive: true }); await mkdir(path.join(cwd, "src/features/chat/domain/message-stream"), { recursive: true }); await mkdir(path.join(cwd, "src/features/chat/application/state"), { recursive: true }); @@ -1472,7 +1506,7 @@ async function tempBiomeWorkspace(plugins) { JSON.stringify({ $schema: "https://biomejs.dev/schemas/2.5.1/schema.json", vcs: { enabled: false }, - plugins: plugins.map((plugin) => projectPluginConfig(plugin)), + plugins: tempPlugins, css: { linter: { enabled: true } }, }), ); @@ -1496,16 +1530,20 @@ function biomeLint(files, cwd, options = {}) { return report; } -function projectPluginConfig(plugin) { +async function tempProjectPluginConfig(cwd, plugin) { const projectPlugin = projectPluginByName.get(plugin); if (!projectPlugin) { throw new Error(`Missing ${plugin} in biome.jsonc plugins`); } - const pluginPath = path.resolve(repoRoot, projectPluginPath(projectPlugin)); + const projectPluginRelativePath = normalizeProjectRelativePath(projectPluginPath(projectPlugin)); + const projectPluginPathAbsolute = path.resolve(repoRoot, projectPluginPath(projectPlugin)); + const tempPluginPath = path.join(cwd, projectPluginRelativePath); + await mkdir(path.dirname(tempPluginPath), { recursive: true }); + await copyFile(projectPluginPathAbsolute, tempPluginPath); if (typeof projectPlugin === "string") { - return pluginPath; + return tempPluginPath; } - return { ...projectPlugin, path: pluginPath }; + return { ...projectPlugin, path: tempPluginPath }; } function projectPluginPath(plugin) {