From f9649cd870109f44d1cc6b643ac8c5bf2c90bf6b Mon Sep 17 00:00:00 2001 From: murashit Date: Sat, 27 Jun 2026 15:42:25 +0900 Subject: [PATCH] Add explicit source boundary lint rules --- biome.jsonc | 20 +++ docs/development.md | 2 + ...o-chat-app-server-outer-layer-imports.grit | 18 ++ ...-chat-application-outer-layer-imports.grit | 18 ++ ...o-chat-panel-runtime-boundary-imports.grit | 18 ++ ...chat-presentation-outer-layer-imports.grit | 18 ++ .../lint/no-chat-ui-outer-layer-imports.grit | 18 ++ tests/scripts/grit-policy.test.mjs | 162 ++++++++++++++++++ 8 files changed, 274 insertions(+) create mode 100644 scripts/lint/no-chat-app-server-outer-layer-imports.grit create mode 100644 scripts/lint/no-chat-application-outer-layer-imports.grit create mode 100644 scripts/lint/no-chat-panel-runtime-boundary-imports.grit create mode 100644 scripts/lint/no-chat-presentation-outer-layer-imports.grit create mode 100644 scripts/lint/no-chat-ui-outer-layer-imports.grit diff --git a/biome.jsonc b/biome.jsonc index aef1455c..94bd0f79 100644 --- a/biome.jsonc +++ b/biome.jsonc @@ -43,6 +43,26 @@ "path": "./scripts/lint/no-state-module-side-effects.grit", "includes": ["**/src/features/chat/application/state/**/*.ts"] }, + { + "path": "./scripts/lint/no-chat-application-outer-layer-imports.grit", + "includes": ["**/src/features/chat/application/**/*.ts"] + }, + { + "path": "./scripts/lint/no-chat-app-server-outer-layer-imports.grit", + "includes": ["**/src/features/chat/app-server/**/*.ts"] + }, + { + "path": "./scripts/lint/no-chat-panel-runtime-boundary-imports.grit", + "includes": ["**/src/features/chat/panel/**/*.ts", "**/src/features/chat/panel/**/*.tsx"] + }, + { + "path": "./scripts/lint/no-chat-presentation-outer-layer-imports.grit", + "includes": ["**/src/features/chat/presentation/**/*.ts"] + }, + { + "path": "./scripts/lint/no-chat-ui-outer-layer-imports.grit", + "includes": ["**/src/features/chat/ui/**/*.ts", "**/src/features/chat/ui/**/*.tsx"] + }, // UI and DOM source boundaries. { diff --git a/docs/development.md b/docs/development.md index a0773d37..17a5f68a 100644 --- a/docs/development.md +++ b/docs/development.md @@ -63,6 +63,8 @@ Generated app-server types should stay behind app-server connection and protocol Chat panel-visible state belongs in `ChatStateStore` and should flow through named reducer actions and the shell-state adapter. Use Preact Signals only in `src/features/chat/panel/shell-state.tsx`; lint enforces this boundary. When a surface needs fewer dependencies, add or reuse a named shell-state projection instead of importing `@preact/signals` elsewhere. +Chat feature folders should keep dependencies flowing toward owned adapters and render surfaces: `application/` must not import host, panel, presentation, or UI layers; `app-server/` must not import host, panel, presentation, or UI layers; `panel/` must not import app-server adapters or host internals; `presentation/` must not import application, app-server, host, panel, or UI layers; and `ui/` must not import application, app-server, host, or panel layers. Biome enforces these folder-scoped import boundaries with per-folder plugin includes. + Use DOM reads, writes, measurements, hit-tests, focus/selection operations, and DOM event listener wiring only from explicit bridge modules, Obsidian-owned API boundaries, or rendering and measurement code that cannot be expressed cleanly as Preact components. Normal `.ts` and `.tsx` modules may keep refs and call named adapters, but they should not interpret DOM structure or layout directly. Name bridge files with a `.dom`, `.obsidian`, or `.measure` suffix so Biome can enforce the boundary without file-specific allowlists. Use `.tsx` only in rendering-owned source folders: chat panel and UI modules, Obsidian/settings surfaces, shared UI components, and explicit `.dom.tsx` rendering boundaries such as the selection rewrite popover and threads view shell. Non-rendering source should use `.ts`; Biome enforces this so lower-level app-server, domain, application, presentation, workspace, and generic shared modules do not grow JSX dependencies. diff --git a/scripts/lint/no-chat-app-server-outer-layer-imports.grit b/scripts/lint/no-chat-app-server-outer-layer-imports.grit new file mode 100644 index 00000000..133361d5 --- /dev/null +++ b/scripts/lint/no-chat-app-server-outer-layer-imports.grit @@ -0,0 +1,18 @@ +language js + +private pattern js_module_reference() { + or { + JsImport(), + JsExportNamedFromClause(), + JsExportFromClause(), + TsImportType(), + JsImportCallExpression() + } +} + +js_module_reference() as $stmt where { + $stmt <: contains `$source` where { + $source <: r"^[\"'](?:(?:\.\./)+(?:host|panel|presentation|ui)|src/features/chat/(?:host|panel|presentation|ui))(?:/.*)?[\"']$" + }, + register_diagnostic(span=$stmt, message="Chat app-server adapters must not import chat host, panel, presentation, or UI layers.", severity="error") +} diff --git a/scripts/lint/no-chat-application-outer-layer-imports.grit b/scripts/lint/no-chat-application-outer-layer-imports.grit new file mode 100644 index 00000000..a09818f9 --- /dev/null +++ b/scripts/lint/no-chat-application-outer-layer-imports.grit @@ -0,0 +1,18 @@ +language js + +private pattern js_module_reference() { + or { + JsImport(), + JsExportNamedFromClause(), + JsExportFromClause(), + TsImportType(), + JsImportCallExpression() + } +} + +js_module_reference() as $stmt where { + $stmt <: contains `$source` where { + $source <: r"^[\"'](?:(?:\.\./)+(?:host|panel|presentation|ui)|src/features/chat/(?:host|panel|presentation|ui))(?:/.*)?[\"']$" + }, + register_diagnostic(span=$stmt, message="Chat application modules must not import host, panel, presentation, or UI layers; expose state and workflow contracts instead.", severity="error") +} diff --git a/scripts/lint/no-chat-panel-runtime-boundary-imports.grit b/scripts/lint/no-chat-panel-runtime-boundary-imports.grit new file mode 100644 index 00000000..def9efba --- /dev/null +++ b/scripts/lint/no-chat-panel-runtime-boundary-imports.grit @@ -0,0 +1,18 @@ +language js + +private pattern js_module_reference() { + or { + JsImport(), + JsExportNamedFromClause(), + JsExportFromClause(), + TsImportType(), + JsImportCallExpression() + } +} + +js_module_reference() as $stmt where { + $stmt <: contains `$source` where { + $source <: r"^[\"'](?:(?:\.\./)+(?:app-server|host)|src/(?:app-server|features/chat/(?:app-server|host)))(?:/.*)?[\"']$" + }, + register_diagnostic(span=$stmt, message="Chat panel modules must not import app-server adapters or chat host internals.", severity="error") +} diff --git a/scripts/lint/no-chat-presentation-outer-layer-imports.grit b/scripts/lint/no-chat-presentation-outer-layer-imports.grit new file mode 100644 index 00000000..bc2c202d --- /dev/null +++ b/scripts/lint/no-chat-presentation-outer-layer-imports.grit @@ -0,0 +1,18 @@ +language js + +private pattern js_module_reference() { + or { + JsImport(), + JsExportNamedFromClause(), + JsExportFromClause(), + TsImportType(), + JsImportCallExpression() + } +} + +js_module_reference() as $stmt where { + $stmt <: contains `$source` where { + $source <: r"^[\"'](?:(?:\.\./)+(?:application|app-server|host|panel|ui)|src/(?:app-server|features/chat/(?:application|app-server|host|panel|ui)))(?:/.*)?[\"']$" + }, + register_diagnostic(span=$stmt, message="Chat presentation modules must stay pure view-model projection; keep application, app-server, host, panel, and UI dependencies outward.", severity="error") +} diff --git a/scripts/lint/no-chat-ui-outer-layer-imports.grit b/scripts/lint/no-chat-ui-outer-layer-imports.grit new file mode 100644 index 00000000..3d46cf1d --- /dev/null +++ b/scripts/lint/no-chat-ui-outer-layer-imports.grit @@ -0,0 +1,18 @@ +language js + +private pattern js_module_reference() { + or { + JsImport(), + JsExportNamedFromClause(), + JsExportFromClause(), + TsImportType(), + JsImportCallExpression() + } +} + +js_module_reference() as $stmt where { + $stmt <: contains `$source` where { + $source <: r"^[\"'](?:(?:\.\./)+(?:application|app-server|host|panel)|src/(?:app-server|features/chat/(?:application|app-server|host|panel)))(?:/.*)?[\"']$" + }, + register_diagnostic(span=$stmt, message="Chat UI modules must not import application, app-server, host, or panel layers; pass render-ready props and actions through UI contracts.", severity="error") +} diff --git a/tests/scripts/grit-policy.test.mjs b/tests/scripts/grit-policy.test.mjs index f5bbab5b..89697399 100644 --- a/tests/scripts/grit-policy.test.mjs +++ b/tests/scripts/grit-policy.test.mjs @@ -16,6 +16,14 @@ const projectPluginByName = new Map( ); const APP_SERVER_PROTOCOL_BOUNDARY_MESSAGE = "Source modules outside root src/app-server must use domain models and app-server services instead of app-server protocol modules. Chat turn-item conversion may consume turn protocol at its app-server boundary; feature state and UI must use Panel-owned models."; +const CHAT_APPLICATION_OUTER_LAYER_MESSAGE = + "Chat application modules must not import host, panel, presentation, or UI layers; expose state and workflow contracts instead."; +const CHAT_APP_SERVER_OUTER_LAYER_MESSAGE = "Chat app-server adapters must not import chat host, panel, presentation, or UI layers."; +const CHAT_PANEL_RUNTIME_BOUNDARY_MESSAGE = "Chat panel modules must not import app-server adapters or chat host internals."; +const CHAT_PRESENTATION_OUTER_LAYER_MESSAGE = + "Chat presentation modules must stay pure view-model projection; keep application, app-server, host, panel, and UI dependencies outward."; +const CHAT_UI_OUTER_LAYER_MESSAGE = + "Chat UI modules must not import application, app-server, host, or panel layers; pass render-ready props and actions through UI contracts."; 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."; @@ -381,6 +389,159 @@ export function timestamp(): number { expect(pluginDiagnostics(report, "src/features/chat/application/threads/resume-actions.ts")).toEqual([]); }); + it("keeps chat folder ownership boundaries explicit without filename-scoped Grit checks", async () => { + const cwd = await tempBiomeWorkspace([ + "no-chat-application-outer-layer-imports.grit", + "no-chat-app-server-outer-layer-imports.grit", + "no-chat-panel-runtime-boundary-imports.grit", + "no-chat-presentation-outer-layer-imports.grit", + "no-chat-ui-outer-layer-imports.grit", + ]); + await writeFile( + path.join(cwd, "src/features/chat/application/outer.ts"), + ` +import type { Host } from "../host/contracts"; +import type { PanelSnapshot } from "../panel/snapshot"; +import { statusText } from "../presentation/runtime/status"; +import { Toolbar } from "../ui/toolbar"; + +export type Escape = Host | PanelSnapshot; +export const values = [statusText, Toolbar] satisfies unknown[]; +`.trimStart(), + ); + await writeFile( + path.join(cwd, "src/features/chat/application/allowed.ts"), + ` +import type { MessageStreamItem } from "../domain/message-stream/items"; + +export type Item = MessageStreamItem; +`.trimStart(), + ); + await writeFile( + path.join(cwd, "src/features/chat/app-server/outer.ts"), + ` +import type { Host } from "../host/contracts"; +import type { PanelSnapshot } from "../panel/snapshot"; +import { statusText } from "../presentation/runtime/status"; +import { Toolbar } from "../ui/toolbar"; + +export type Escape = Host | PanelSnapshot; +export const values = [statusText, Toolbar] satisfies unknown[]; +`.trimStart(), + ); + await writeFile( + path.join(cwd, "src/features/chat/app-server/allowed.ts"), + ` +import type { AppServerClient } from "../../../app-server/connection/client"; +import type { ChatStateStore } from "../application/state/store"; +import type { MessageStreamItem } from "../domain/message-stream/items"; + +export type Allowed = AppServerClient | ChatStateStore | MessageStreamItem; +`.trimStart(), + ); + await writeFile( + path.join(cwd, "src/features/chat/panel/outer.tsx"), + ` +import type { AppServerClient } from "../../../app-server/connection/client"; +import type { ChatServerActionsHost } from "../app-server/actions/host"; +import type { Host } from "../host/contracts"; + +export type Escape = AppServerClient | ChatServerActionsHost | Host; +`.trimStart(), + ); + await writeFile( + path.join(cwd, "src/features/chat/panel/allowed.tsx"), + ` +import type { ChatStateStore } from "../application/state/store"; +import { Toolbar } from "../ui/toolbar"; + +export type Allowed = ChatStateStore; +export const toolbar = Toolbar; +`.trimStart(), + ); + await writeFile( + path.join(cwd, "src/features/chat/presentation/outer.ts"), + ` +import type { AppServerClient } from "../../../app-server/connection/client"; +import type { ChatStateStore } from "../application/state/store"; +import type { ChatServerActionsHost } from "../app-server/actions/host"; +import type { Host } from "../host/contracts"; +import type { PanelSnapshot } from "../panel/snapshot"; +import { Toolbar } from "../ui/toolbar"; + +export type Escape = AppServerClient | ChatStateStore | ChatServerActionsHost | Host | PanelSnapshot; +export const toolbar = Toolbar; +`.trimStart(), + ); + await writeFile( + path.join(cwd, "src/features/chat/presentation/allowed.ts"), + ` +import type { Thread } from "../../../domain/threads/model"; +import type { MessageStreamItem } from "../domain/message-stream/items"; + +export type Allowed = Thread | MessageStreamItem; +`.trimStart(), + ); + await writeFile( + path.join(cwd, "src/features/chat/ui/outer.tsx"), + ` +import type { AppServerClient } from "../../../app-server/connection/client"; +import type { ChatStateStore } from "../application/state/store"; +import type { ChatServerActionsHost } from "../app-server/actions/host"; +import type { Host } from "../host/contracts"; +import type { PanelSnapshot } from "../panel/snapshot"; + +export type Escape = AppServerClient | ChatStateStore | ChatServerActionsHost | Host | PanelSnapshot; +`.trimStart(), + ); + await writeFile( + path.join(cwd, "src/features/chat/ui/allowed.tsx"), + ` +import type { Thread } from "../../../domain/threads/model"; +import type { MessageStreamItem } from "../domain/message-stream/items"; +import { statusText } from "../presentation/runtime/status"; + +export type Allowed = Thread | MessageStreamItem; +export const value = statusText; +`.trimStart(), + ); + + const report = biomeLint( + [ + "src/features/chat/application/outer.ts", + "src/features/chat/application/allowed.ts", + "src/features/chat/app-server/outer.ts", + "src/features/chat/app-server/allowed.ts", + "src/features/chat/panel/outer.tsx", + "src/features/chat/panel/allowed.tsx", + "src/features/chat/presentation/outer.ts", + "src/features/chat/presentation/allowed.ts", + "src/features/chat/ui/outer.tsx", + "src/features/chat/ui/allowed.tsx", + ], + cwd, + ); + + expect(pluginMessages(report, "src/features/chat/application/outer.ts")).toEqual( + Array.from({ length: 4 }, () => CHAT_APPLICATION_OUTER_LAYER_MESSAGE), + ); + expect(pluginDiagnostics(report, "src/features/chat/application/allowed.ts")).toEqual([]); + expect(pluginMessages(report, "src/features/chat/app-server/outer.ts")).toEqual( + Array.from({ length: 4 }, () => CHAT_APP_SERVER_OUTER_LAYER_MESSAGE), + ); + expect(pluginDiagnostics(report, "src/features/chat/app-server/allowed.ts")).toEqual([]); + expect(pluginMessages(report, "src/features/chat/panel/outer.tsx")).toEqual( + Array.from({ length: 3 }, () => CHAT_PANEL_RUNTIME_BOUNDARY_MESSAGE), + ); + expect(pluginDiagnostics(report, "src/features/chat/panel/allowed.tsx")).toEqual([]); + expect(pluginMessages(report, "src/features/chat/presentation/outer.ts")).toEqual( + Array.from({ length: 6 }, () => CHAT_PRESENTATION_OUTER_LAYER_MESSAGE), + ); + expect(pluginDiagnostics(report, "src/features/chat/presentation/allowed.ts")).toEqual([]); + expect(pluginMessages(report, "src/features/chat/ui/outer.tsx")).toEqual(Array.from({ length: 5 }, () => CHAT_UI_OUTER_LAYER_MESSAGE)); + expect(pluginDiagnostics(report, "src/features/chat/ui/allowed.tsx")).toEqual([]); + }); + it("keeps generated app-server imports behind explicit app-server boundaries", async () => { const report = await appServerBoundaryPolicyReport(); @@ -993,6 +1154,7 @@ async function tempBiomeWorkspace(plugins) { await mkdir(path.join(cwd, "src/features/chat/host"), { recursive: true }); await mkdir(path.join(cwd, "src/features/chat/panel"), { recursive: true }); await mkdir(path.join(cwd, "src/features/chat/panel/surface"), { recursive: true }); + await mkdir(path.join(cwd, "src/features/chat/presentation"), { recursive: true }); await mkdir(path.join(cwd, "src/features/chat/ui"), { recursive: true }); await mkdir(path.join(cwd, "src/features/selection-rewrite"), { recursive: true }); await mkdir(path.join(cwd, "src/features/threads-view"), { recursive: true });