Add explicit source boundary lint rules

This commit is contained in:
murashit 2026-06-27 15:42:25 +09:00
parent e5f65736d6
commit f9649cd870
8 changed files with 274 additions and 0 deletions

View file

@ -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.
{

View file

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

View file

@ -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")
}

View file

@ -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")
}

View file

@ -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")
}

View file

@ -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")
}

View file

@ -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")
}

View file

@ -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 });