Tighten Biome GritQL lint policies

This commit is contained in:
murashit 2026-06-25 17:47:21 +09:00
parent d05b083c12
commit 2888c0cff0
16 changed files with 346 additions and 175 deletions

View file

@ -16,14 +16,12 @@
// Chat feature architecture.
"./scripts/lint/no-chat-domain-outer-layer-imports.grit",
"./scripts/lint/no-chat-signal-imports.grit",
"./scripts/lint/no-chat-state-escape-hatches.grit",
"./scripts/lint/no-implicit-dom-bridges.grit",
"./scripts/lint/no-pure-chat-state-side-effects.grit",
"./scripts/lint/no-ui-root-imports.grit",
// Project-wide source shape.
"./scripts/lint/no-handwritten-reexports.grit",
"./scripts/lint/no-non-reexport-index.grit",
"./scripts/lint/no-self-referential-initializer-callback.grit",
"./scripts/lint/no-unsafe-iterator-value.grit",
"./scripts/lint/no-uncontrolled-preact-form-state.grit",

View file

@ -2,11 +2,12 @@ language js
or {
or {
`client.resumeThread($...)` as $stmt,
`client.threadTurnsList($...)` as $stmt,
`client.forkThread($...)` as $stmt,
`client.rollbackThread($...)` as $stmt
`$client.resumeThread($...)` as $stmt,
`$client.threadTurnsList($...)` as $stmt,
`$client.forkThread($...)` as $stmt,
`$client.rollbackThread($...)` as $stmt
} where {
$client <: r"^(?:client|[A-Za-z_$][A-Za-z0-9_$]*Client)$",
$filename <: r".*/src/features/chat/application/.*",
register_diagnostic(span=$stmt, message="Keep app-server projection RPCs behind app-server facades; chat application code should consume Panel-owned snapshots or view models.", severity="error")
},

View file

@ -1,13 +1,17 @@
language js
or {
private pattern js_module_reference() {
or {
JsImport() as $stmt,
JsExportNamedFromClause() as $stmt,
JsExportFromClause() as $stmt,
TsImportType() as $stmt,
JsImportCallExpression() as $stmt
} where {
JsImport(),
JsExportNamedFromClause(),
JsExportFromClause(),
TsImportType(),
JsImportCallExpression()
}
}
or {
js_module_reference() as $stmt where {
$stmt <: contains `$source` where {
$source <: r"^[\"'](?:(?:\.\./)+app-server/protocol|src/app-server/protocol)/[^/\"']+(?:/.*)?[\"']$"
},
@ -21,18 +25,12 @@ or {
},
{
$filename <: r".*/src/features/chat/app-server/(?:inbound/notification-plan|mappers/message-stream/turn-items)\.ts$",
$stmt <: contains `$source` where {
not { $source <: r"^[\"'](?:(?:\.\./)+app-server/protocol/turn|src/app-server/protocol/turn)(?:/.*)?[\"']$" }
},
not { $source <: r"^[\"'](?:(?:\.\./)+app-server/protocol/turn|src/app-server/protocol/turn)(?:/.*)?[\"']$" },
register_diagnostic(span=$stmt, message="Chat app-server ingestion and message-stream conversion may consume the app-server turn protocol only. Convert other protocol payloads to local or domain models at the boundary.", severity="error")
},
{
$filename <: r".*/src/features/chat/app-server/inbound/(?:handler|routing)\.ts$",
$stmt <: contains `$source` where {
not {
$source <: r"^[\"'](?:(?:\.\./)+app-server/protocol/server-requests|src/app-server/protocol/server-requests)(?:/.*)?[\"']$"
}
},
not { $source <: r"^[\"'](?:(?:\.\./)+app-server/protocol/server-requests|src/app-server/protocol/server-requests)(?:/.*)?[\"']$" },
register_diagnostic(span=$stmt, message="Chat app-server request handling may consume server request protocol projections only. Convert app-server payloads to chat pending request domain models at this boundary.", severity="error")
}
}
@ -44,5 +42,17 @@ or {
not { $filename <: r".*/src/features/chat/app-server/(?:inbound/notification-plan|mappers/message-stream/turn-items)\.ts$" },
not { $filename <: r".*/src/features/chat/app-server/inbound/(?:handler|routing)\.ts$" },
register_diagnostic(span=$stmt, message="Source modules outside app-server must use domain models and app-server services instead of app-server protocol modules. Chat ingestion and message-stream conversion may consume app-server turn protocol at the boundary; feature state and UI must use Panel-owned models.", severity="error")
},
`require($source)` as $stmt where {
$source <: r"^[\"'](?:(?:\.\./)+app-server/protocol|src/app-server/protocol)/[^/\"']+(?:/.*)?[\"']$",
$filename <: r".*/src/features/chat/app-server/(?:inbound/notification-plan|mappers/message-stream/turn-items)\.ts$",
not { $source <: r"^[\"'](?:(?:\.\./)+app-server/protocol/turn|src/app-server/protocol/turn)(?:/.*)?[\"']$" },
register_diagnostic(span=$stmt, message="Chat app-server ingestion and message-stream conversion may consume the app-server turn protocol only. Convert other protocol payloads to local or domain models at the boundary.", severity="error")
},
`require($source)` as $stmt where {
$source <: r"^[\"'](?:(?:\.\./)+app-server/protocol|src/app-server/protocol)/[^/\"']+(?:/.*)?[\"']$",
$filename <: r".*/src/features/chat/app-server/inbound/(?:handler|routing)\.ts$",
not { $source <: r"^[\"'](?:(?:\.\./)+app-server/protocol/server-requests|src/app-server/protocol/server-requests)(?:/.*)?[\"']$" },
register_diagnostic(span=$stmt, message="Chat app-server request handling may consume server request protocol projections only. Convert app-server payloads to chat pending request domain models at this boundary.", severity="error")
}
}

View file

@ -1,12 +1,26 @@
language js
JsImport() as $stmt where {
$filename <: r".*/src/features/chat/domain/.*",
$stmt <: contains `$source` where {
or {
$source <: r"^[\"'](?:\.\./)+(?:app-server|application|host|panel|presentation|ui)(?:/.*)?[\"']$",
$source <: r"^[\"']src/features/chat/(?:app-server|application|host|panel|presentation|ui)(?:/.*)?[\"']$"
}
},
register_diagnostic(span=$stmt, message="Keep chat/domain as Panel-owned meaning models and pure derivations; app-server, application, host, panel, presentation, and UI layers may depend on domain, not the reverse.", severity="error")
private pattern js_module_reference() {
or {
JsImport(),
JsExportNamedFromClause(),
JsExportFromClause(),
TsImportType(),
JsImportCallExpression()
}
}
or {
js_module_reference() as $stmt where {
$filename <: r".*/src/features/chat/domain/.*",
$stmt <: contains `$source` where {
$source <: r"^[\"'](?:(?:\.\./)+|src/features/chat/)(?:app-server|application|host|panel|presentation|ui)(?:/.*)?[\"']$"
},
register_diagnostic(span=$stmt, message="Keep chat/domain as Panel-owned meaning models and pure derivations; app-server, application, host, panel, presentation, and UI layers may depend on domain, not the reverse.", severity="error")
},
`require($source)` as $stmt where {
$filename <: r".*/src/features/chat/domain/.*",
$source <: r"^[\"'](?:(?:\.\./)+|src/features/chat/)(?:app-server|application|host|panel|presentation|ui)(?:/.*)?[\"']$",
register_diagnostic(span=$stmt, message="Keep chat/domain as Panel-owned meaning models and pure derivations; app-server, application, host, panel, presentation, and UI layers may depend on domain, not the reverse.", severity="error")
}
}

View file

@ -1,7 +1,24 @@
language js
JsImport() as $stmt where {
$stmt <: contains `$source` where { $source <: r"^[\"']@preact/signals[\"']$" },
not { $filename <: r".*/src/features/chat/panel/shell-state\.tsx$" },
register_diagnostic(span=$stmt, message="Use @preact/signals only in src/features/chat/panel/shell-state.tsx as the reducer-to-Preact derived projection adapter.", severity="error")
private pattern js_module_reference() {
or {
JsImport(),
JsExportNamedFromClause(),
JsExportFromClause(),
TsImportType(),
JsImportCallExpression()
}
}
or {
js_module_reference() as $stmt where {
$stmt <: contains `$source` where { $source <: r"^[\"']@preact/signals[\"']$" },
not { $filename <: r".*/src/features/chat/panel/shell-state\.tsx$" },
register_diagnostic(span=$stmt, message="Use @preact/signals only in src/features/chat/panel/shell-state.tsx as the reducer-to-Preact derived projection adapter.", severity="error")
},
`require($source)` as $stmt where {
$source <: r"^[\"']@preact/signals[\"']$",
not { $filename <: r".*/src/features/chat/panel/shell-state\.tsx$" },
register_diagnostic(span=$stmt, message="Use @preact/signals only in src/features/chat/panel/shell-state.tsx as the reducer-to-Preact derived projection adapter.", severity="error")
}
}

View file

@ -1,5 +0,0 @@
language js
`"state/patched"` as $stmt where {
register_diagnostic(span=$stmt, message="Use a named ChatAction instead of reintroducing the generic state patch escape hatch.", severity="error")
}

View file

@ -1,26 +1,26 @@
language js
or {
private pattern js_module_reference() {
or {
JsImport() as $stmt,
JsExportNamedFromClause() as $stmt,
JsExportFromClause() as $stmt,
TsImportType() as $stmt,
JsImportCallExpression() as $stmt
} where {
JsImport(),
JsExportNamedFromClause(),
JsExportFromClause(),
TsImportType(),
JsImportCallExpression()
}
}
or {
js_module_reference() as $stmt where {
$stmt <: contains `$source` where { $source <: r"^[\"'].*(?:src/)?generated/app-server(?:/.*)?[\"']$" },
not { $filename <: r".*/src/generated/.*" },
not { $filename <: r".*/src/app-server/connection/.*" },
not { $filename <: r".*/(?:src/generated|src/app-server/connection|tests/app-server)/.*" },
not { $filename <: r".*/src/app-server/protocol/(?:turn|server-requests)\.ts$" },
not { $filename <: r".*/tests/app-server/.*" },
register_diagnostic(span=$stmt, message="Keep generated app-server types behind src/app-server and tests/app-server; expose Panel-owned models outside raw app-server adapters.", severity="error")
},
`require($source)` as $stmt where {
$source <: r"^[\"'].*(?:src/)?generated/app-server(?:/.*)?[\"']$",
not { $filename <: r".*/src/generated/.*" },
not { $filename <: r".*/src/app-server/connection/.*" },
not { $filename <: r".*/(?:src/generated|src/app-server/connection|tests/app-server)/.*" },
not { $filename <: r".*/src/app-server/protocol/(?:turn|server-requests)\.ts$" },
not { $filename <: r".*/tests/app-server/.*" },
register_diagnostic(span=$stmt, message="Keep generated app-server types behind src/app-server and tests/app-server; expose Panel-owned models outside raw app-server adapters.", severity="error")
}
}

View file

@ -1,16 +1,43 @@
language js
private pattern js_non_import_module_reference() {
or {
JsExportNamedFromClause(),
JsExportFromClause(),
TsImportType(),
JsImportCallExpression()
}
}
private pattern js_static_module_reference() {
or {
JsImport(),
JsExportNamedFromClause(),
JsExportFromClause()
}
}
private pattern js_module_reference() {
or {
JsImport(),
JsExportNamedFromClause(),
JsExportFromClause(),
TsImportType(),
JsImportCallExpression()
}
}
or {
JsImport() as $stmt where {
$stmt <: contains `$source` where { $source <: r"^[\"'].*generated/app-server/v2/Thread[\"']$" },
$stmt <: contains `Thread`,
not { $stmt <: r".*\bThread\s+as\s+AppServerThread\b.*" },
register_diagnostic(span=$stmt, message="Import generated app-server Thread as AppServerThread, or use the Panel-owned domain Thread model.", severity="error")
},
or {
JsImport() as $stmt,
JsExportNamedFromClause() as $stmt,
JsExportFromClause() as $stmt
} where {
js_non_import_module_reference() as $stmt where {
$stmt <: contains `$source` where { $source <: r"^[\"'].*generated/app-server/v2/Thread[\"']$" },
register_diagnostic(span=$stmt, message="Import generated app-server Thread as AppServerThread, or use the Panel-owned domain Thread model.", severity="error")
},
js_static_module_reference() as $stmt where {
$filename <: r".*/src/app-server/protocol/turn\.ts$",
$stmt <: contains `$source` where {
$source <: r"^[\"'].*generated/app-server/.*[\"']$",
@ -18,16 +45,18 @@ or {
},
register_diagnostic(span=$stmt, message="Only generated ThreadItem is allowed in the turn protocol exception. Model other turn payload shapes locally.", severity="error")
},
or {
JsImport() as $stmt,
JsExportNamedFromClause() as $stmt,
JsExportFromClause() as $stmt
} where {
js_module_reference() as $stmt where {
$filename <: r".*/src/app-server/protocol/server-requests\.ts$",
$stmt <: contains `$source` where {
$source <: r"^[\"'].*generated/app-server/.*[\"']$",
not { $source <: r"^[\"'].*generated/app-server/(?:RequestId|ServerRequest)[\"']$" }
},
register_diagnostic(span=$stmt, message="Only generated RequestId and ServerRequest are allowed in the server request protocol exception.", severity="error")
},
`require($source)` as $stmt where {
$filename <: r".*/src/app-server/protocol/server-requests\.ts$",
$source <: r"^[\"'].*generated/app-server/.*[\"']$",
not { $source <: r"^[\"'].*generated/app-server/(?:RequestId|ServerRequest)[\"']$" },
register_diagnostic(span=$stmt, message="Only generated RequestId and ServerRequest are allowed in the server request protocol exception.", severity="error")
}
}

View file

@ -14,9 +14,7 @@ or {
`$target.removeEventListener($...)` as $stmt
} where {
$filename <: r".*/src/.*\.tsx?$",
not { $filename <: r".*\.dom\.tsx?$" },
not { $filename <: r".*\.obsidian\.tsx?$" },
not { $filename <: r".*\.measure\.tsx?$" },
not { $filename <: r".*\.(?:dom|obsidian|measure)\.tsx?$" },
not { $target <: `signal` },
register_diagnostic(span=$stmt, message="Keep imperative DOM writes and event wiring in files named with a .dom, .obsidian, or .measure suffix.", severity="error")
}

View file

@ -1,38 +1,54 @@
language js
or {
private pattern js_module_reference() {
or {
JsImport() as $stmt,
JsExportNamedFromClause() as $stmt,
JsExportFromClause() as $stmt,
TsImportType() as $stmt,
JsImportCallExpression() as $stmt
} where {
JsImport(),
JsExportNamedFromClause(),
JsExportFromClause(),
TsImportType(),
JsImportCallExpression()
}
}
or {
js_module_reference() as $stmt where {
$filename <: r".*/src/domain/.*",
$stmt <: contains `$source` where {
or {
$source <: r"^[\"'](?:(?:\.\./)+(?:app-server|settings|workspace|shared/ui)|src/(?:app-server|settings|workspace|shared/ui))(?:/.*)?[\"']$",
$source <: r"^[\"']obsidian[\"']$"
}
},
register_diagnostic(span=$stmt, message="Domain modules must stay pure and generated-independent; keep app-server, settings, workspace, UI, and Obsidian dependencies at boundary callers.", severity="error")
},
js_module_reference() as $stmt where {
$filename <: r".*/src/(?:app-server|domain|shared)/.*",
$stmt <: contains `$source` where { or { $source <: r"^[\"'](?:(?:\.\./)+features|src/features)(?:/.*)?[\"']$" } },
$stmt <: contains `$source` where { $source <: r"^[\"'](?:(?:\.\./)+features|src/features)(?:/.*)?[\"']$" },
register_diagnostic(span=$stmt, message="Lower-level modules must not import feature modules or app-server connection internals. Move shared behavior to shared, domain, or app-server adapters.", severity="error")
},
or {
JsImport() as $stmt,
JsExportNamedFromClause() as $stmt,
JsExportFromClause() as $stmt,
TsImportType() as $stmt,
JsImportCallExpression() as $stmt
} where {
or { $filename <: r".*/src/app-server/protocol/.*", $filename <: r".*/src/(?:domain|shared)/.*" },
js_module_reference() as $stmt where {
$filename <: r".*/src/(?:app-server/protocol|domain|shared)/.*",
$stmt <: contains `$source` where {
or { $source <: r"^[\"'](?:(?:\.\./)+(?:app-server/)?connection|src/app-server/connection)(?:/.*)?[\"']$" }
$source <: r"^[\"'](?:(?:\.\./)+(?:app-server/)?connection|src/app-server/connection)(?:/.*)?[\"']$"
},
register_diagnostic(span=$stmt, message="Lower-level modules must not import feature modules or app-server connection internals. Move shared behavior to shared, domain, or app-server adapters.", severity="error")
},
`require($source)` as $stmt where {
$filename <: r".*/src/domain/.*",
or {
$source <: r"^[\"'](?:(?:\.\./)+(?:app-server|settings|workspace|shared/ui)|src/(?:app-server|settings|workspace|shared/ui))(?:/.*)?[\"']$",
$source <: r"^[\"']obsidian[\"']$"
},
register_diagnostic(span=$stmt, message="Domain modules must stay pure and generated-independent; keep app-server, settings, workspace, UI, and Obsidian dependencies at boundary callers.", severity="error")
},
`require($source)` as $stmt where {
$filename <: r".*/src/(?:app-server|domain|shared)/.*",
or { $source <: r"^[\"'](?:(?:\.\./)+features|src/features)(?:/.*)?[\"']$" },
$source <: r"^[\"'](?:(?:\.\./)+features|src/features)(?:/.*)?[\"']$",
register_diagnostic(span=$stmt, message="Lower-level modules must not import feature modules or app-server connection internals. Move shared behavior to shared, domain, or app-server adapters.", severity="error")
},
`require($source)` as $stmt where {
or { $filename <: r".*/src/app-server/protocol/.*", $filename <: r".*/src/(?:domain|shared)/.*" },
or { $source <: r"^[\"'](?:(?:\.\./)+(?:app-server/)?connection|src/app-server/connection)(?:/.*)?[\"']$" },
$filename <: r".*/src/(?:app-server/protocol|domain|shared)/.*",
$source <: r"^[\"'](?:(?:\.\./)+(?:app-server/)?connection|src/app-server/connection)(?:/.*)?[\"']$",
register_diagnostic(span=$stmt, message="Lower-level modules must not import feature modules or app-server connection internals. Move shared behavior to shared, domain, or app-server adapters.", severity="error")
}
}

View file

@ -1,16 +0,0 @@
language js
file($name, $body) where {
$name <: r".*/src/.*/index\.tsx?$",
$body <: contains bubble or {
JsImport() as $stmt,
JsExport() as $stmt where {
not {
$stmt <: contains or {
JsExportNamedFromClause(),
JsExportFromClause()
}
}
}
} where { register_diagnostic(span=$stmt, message="Keep src index files as re-export-only boundaries.", severity="error") }
}

View file

@ -1,14 +1,7 @@
language js
or {
`const $name = new $constructor($callback)` as $stmt where {
$callback <: `() => $body`,
$body <: contains `$name`,
register_diagnostic(span=$stmt, message="Avoid referencing a variable from a callback inside its own initializer; declare it first with an explicit type.", severity="error")
},
`const $name = new $constructor($callback)` as $stmt where {
$callback <: `function() { $body }`,
$body <: contains `$name`,
register_diagnostic(span=$stmt, message="Avoid referencing a variable from a callback inside its own initializer; declare it first with an explicit type.", severity="error")
}
`const $name = new $constructor($callback)` as $stmt where {
or { $callback <: `() => $body`, $callback <: `function() { $body }` },
$body <: contains `$name`,
register_diagnostic(span=$stmt, message="Avoid referencing a variable from a callback inside its own initializer; declare it first with an explicit type.", severity="error")
}

View file

@ -1,10 +1,26 @@
language js
JsImport() as $stmt where {
$filename <: r".*/src/.*",
$stmt <: contains `$source` where { $source <: r"^[\"'].*shared/ui/ui-root\.dom[\"']$" },
not { $filename <: r".*\.dom\.tsx?$" },
not { $filename <: r".*\.obsidian\.tsx?$" },
not { $filename <: r".*\.measure\.tsx?$" },
register_diagnostic(span=$stmt, message="Import the Preact root adapter only from explicit root bridge files.", severity="error")
private pattern js_module_reference() {
or {
JsImport(),
JsExportNamedFromClause(),
JsExportFromClause(),
TsImportType(),
JsImportCallExpression()
}
}
or {
js_module_reference() as $stmt where {
$filename <: r".*/src/.*",
$stmt <: contains `$source` where { $source <: r"^[\"'].*shared/ui/ui-root\.dom[\"']$" },
not { $filename <: r".*\.(?:dom|obsidian|measure)\.tsx?$" },
register_diagnostic(span=$stmt, message="Import the Preact root adapter only from explicit root bridge files.", severity="error")
},
`require($source)` as $stmt where {
$filename <: r".*/src/.*",
$source <: r"^[\"'].*shared/ui/ui-root\.dom[\"']$",
not { $filename <: r".*\.(?:dom|obsidian|measure)\.tsx?$" },
register_diagnostic(span=$stmt, message="Import the Preact root adapter only from explicit root bridge files.", severity="error")
}
}

View file

@ -17,7 +17,7 @@ import type { RequestId } from "../../src/generated/app-server/RequestId";
import type { ServerNotification } from "../../src/generated/app-server/ServerNotification";
import type { ServerRequest } from "../../src/generated/app-server/ServerRequest";
import type { ModelListResponse } from "../../src/generated/app-server/v2/ModelListResponse";
import type { Thread as ThreadRecord } from "../../src/generated/app-server/v2/Thread";
import type { Thread as AppServerThread } from "../../src/generated/app-server/v2/Thread";
import type { ThreadStartResponse } from "../../src/generated/app-server/v2/ThreadStartResponse";
import type { TurnStartResponse } from "../../src/generated/app-server/v2/TurnStartResponse";
@ -331,7 +331,7 @@ function threadStartResponse(threadId: string): ThreadStartResponse {
};
}
function thread(id: string): ThreadRecord {
function thread(id: string): AppServerThread {
return {
id,
sessionId: "session",

View file

@ -1,7 +1,7 @@
import { describe, expect, it } from "vitest";
import { threadActivationSnapshotFromAppServerResponse } from "../../src/app-server/threads";
import type { Thread as ThreadRecord } from "../../src/generated/app-server/v2/Thread";
import type { Thread as AppServerThread } from "../../src/generated/app-server/v2/Thread";
import type { ThreadResumeResponse } from "../../src/generated/app-server/v2/ThreadResumeResponse";
describe("app-server thread activation", () => {
@ -21,7 +21,7 @@ describe("app-server thread activation", () => {
});
});
function responseFixture(thread: ThreadRecord): ThreadResumeResponse {
function responseFixture(thread: AppServerThread): ThreadResumeResponse {
return {
thread,
model: "gpt-5.5",
@ -40,7 +40,7 @@ function responseFixture(thread: ThreadRecord): ThreadResumeResponse {
};
}
function threadFixture(id: string, name: string): ThreadRecord {
function threadFixture(id: string, name: string): AppServerThread {
return {
id,
sessionId: "session",

View file

@ -48,28 +48,35 @@ import { signal } from '@preact/signals';
export const status = signal("idle");
`.trimStart(),
);
await writeFile(
path.join(cwd, "src/shared/ui/signal-escapes.tsx"),
`
export { signal } from "@preact/signals";
export type SignalValue = import("@preact/signals").Signal<string>;
const report = biomeLint(["src/features/chat/panel/shell-state.tsx", "src/shared/ui/components.tsx"], cwd);
export async function loadSignals() {
return import("@preact/signals");
}
const signals = require("@preact/signals");
export const loadedSignals = signals;
`.trimStart(),
);
const report = biomeLint(
["src/features/chat/panel/shell-state.tsx", "src/shared/ui/components.tsx", "src/shared/ui/signal-escapes.tsx"],
cwd,
);
expect(pluginDiagnostics(report, "src/features/chat/panel/shell-state.tsx")).toEqual([]);
expect(pluginMessages(report, "src/shared/ui/components.tsx")).toEqual([
"Use @preact/signals only in src/features/chat/panel/shell-state.tsx as the reducer-to-Preact derived projection adapter.",
]);
});
it("keeps removed chat state escape hatches out of source", async () => {
const cwd = await tempBiomeWorkspace(["no-chat-state-escape-hatches.grit"]);
await writeFile(
path.join(cwd, "src/features/chat/application/state/root-reducer.ts"),
`
export const actionType = "state/patched";
`.trimStart(),
);
const report = biomeLint(["src/features/chat/application/state/root-reducer.ts"], cwd);
expect(pluginMessages(report, "src/features/chat/application/state/root-reducer.ts")).toEqual([
"Use a named ChatAction instead of reintroducing the generic state patch escape hatch.",
expect(pluginMessages(report, "src/shared/ui/signal-escapes.tsx")).toEqual([
"Use @preact/signals only in src/features/chat/panel/shell-state.tsx as the reducer-to-Preact derived projection adapter.",
"Use @preact/signals only in src/features/chat/panel/shell-state.tsx as the reducer-to-Preact derived projection adapter.",
"Use @preact/signals only in src/features/chat/panel/shell-state.tsx as the reducer-to-Preact derived projection adapter.",
"Use @preact/signals only in src/features/chat/panel/shell-state.tsx as the reducer-to-Preact derived projection adapter.",
]);
});
@ -91,11 +98,29 @@ export type View = Presenter;
import type { MessageStreamItem } from "./item";
export type Item = MessageStreamItem;
`.trimStart(),
);
await writeFile(
path.join(cwd, "src/features/chat/domain/message-stream/outer-shapes.ts"),
`
export { createChatStateStore } from "../../application/state/store";
export type OuterStore = import("../../application/state/store").ChatStateStore;
export async function loadComposer() {
return import("src/features/chat/ui/composer");
}
const host = require("../../host/session");
export const outerHost = host;
`.trimStart(),
);
const report = biomeLint(
["src/features/chat/domain/message-stream/selectors.ts", "src/features/chat/domain/message-stream/items.ts"],
[
"src/features/chat/domain/message-stream/selectors.ts",
"src/features/chat/domain/message-stream/items.ts",
"src/features/chat/domain/message-stream/outer-shapes.ts",
],
cwd,
);
@ -104,6 +129,12 @@ export type Item = MessageStreamItem;
"Keep chat/domain as Panel-owned meaning models and pure derivations; app-server, application, host, panel, presentation, and UI layers may depend on domain, not the reverse.",
]);
expect(pluginDiagnostics(report, "src/features/chat/domain/message-stream/items.ts")).toEqual([]);
expect(pluginMessages(report, "src/features/chat/domain/message-stream/outer-shapes.ts")).toEqual([
"Keep chat/domain as Panel-owned meaning models and pure derivations; app-server, application, host, panel, presentation, and UI layers may depend on domain, not the reverse.",
"Keep chat/domain as Panel-owned meaning models and pure derivations; app-server, application, host, panel, presentation, and UI layers may depend on domain, not the reverse.",
"Keep chat/domain as Panel-owned meaning models and pure derivations; app-server, application, host, panel, presentation, and UI layers may depend on domain, not the reverse.",
"Keep chat/domain as Panel-owned meaning models and pure derivations; app-server, application, host, panel, presentation, and UI layers may depend on domain, not the reverse.",
]);
});
it("blocks generated app-server imports outside app-server boundaries", async () => {
@ -178,7 +209,9 @@ export type Item = TurnItem;
`
import { toolInventoryAppsFromAppInfos } from "../../../../app-server/protocol/tool-inventory";
export const convert = toolInventoryAppsFromAppInfos;
const toolInventory = require("../../../../app-server/protocol/tool-inventory");
export const convert = [toolInventoryAppsFromAppInfos, toolInventory];
`.trimStart(),
);
await writeFile(
@ -208,6 +241,7 @@ export type Item = TurnItem;
]);
expect(pluginMessages(report, "src/features/chat/app-server/inbound/notification-plan.ts")).toEqual([
"Chat app-server ingestion and message-stream conversion may consume the app-server turn protocol only. Convert other protocol payloads to local or domain models at the boundary.",
"Chat app-server ingestion and message-stream conversion may consume the app-server turn protocol only. Convert other protocol payloads to local or domain models at the boundary.",
]);
expect(pluginDiagnostics(report, "src/features/chat/app-server/mappers/message-stream/turn-items.ts")).toEqual([]);
});
@ -228,6 +262,14 @@ export const response = appServerUserInputResponse;
import { appServerUserInputResponse } from "../../../../app-server/protocol/server-requests";
export const response = appServerUserInputResponse;
`.trimStart(),
);
await writeFile(
path.join(cwd, "src/features/chat/app-server/inbound/handler.ts"),
`
const runtimeMetrics = require("../../../../app-server/protocol/runtime-metrics");
export const response = runtimeMetrics;
`.trimStart(),
);
await writeFile(
@ -243,6 +285,7 @@ export const response = appServerUserInputResponse;
[
"src/features/chat/panel/surface/message-stream-presenter.ts",
"src/features/chat/app-server/inbound/app-server-logs.ts",
"src/features/chat/app-server/inbound/handler.ts",
"src/features/chat/app-server/inbound/routing.ts",
],
cwd,
@ -254,6 +297,9 @@ export const response = appServerUserInputResponse;
expect(pluginMessages(report, "src/features/chat/app-server/inbound/app-server-logs.ts")).toEqual([
"Source modules outside app-server must use domain models and app-server services instead of app-server protocol modules. Chat ingestion and message-stream conversion may consume app-server turn protocol at the boundary; feature state and UI must use Panel-owned models.",
]);
expect(pluginMessages(report, "src/features/chat/app-server/inbound/handler.ts")).toEqual([
"Chat app-server request handling may consume server request protocol projections only. Convert app-server payloads to chat pending request domain models at this boundary.",
]);
expect(pluginDiagnostics(report, "src/features/chat/app-server/inbound/routing.ts")).toEqual([]);
});
@ -326,8 +372,37 @@ import { formatDate } from "./format";
export const format = formatDate;
`.trimStart(),
);
await writeFile(
path.join(cwd, "src/domain/threads/model.ts"),
`
import { listThreads } from "../../app-server/threads";
import { copyText } from "../../shared/ui/clipboard";
import type { App } from "obsidian";
const report = biomeLint(["src/app-server/protocol/catalog.ts", "src/app-server/protocol/diagnostics.ts", "src/shared/date.ts"], cwd);
export type Host = App;
export const list = listThreads;
export const copy = copyText;
`.trimStart(),
);
await writeFile(
path.join(cwd, "src/domain/threads/format.ts"),
`
import { formatDate } from "../../shared/date";
export const format = formatDate;
`.trimStart(),
);
const report = biomeLint(
[
"src/app-server/protocol/catalog.ts",
"src/app-server/protocol/diagnostics.ts",
"src/shared/date.ts",
"src/domain/threads/model.ts",
"src/domain/threads/format.ts",
],
cwd,
);
expect(pluginMessages(report, "src/app-server/protocol/catalog.ts")).toEqual([
"Lower-level modules must not import feature modules or app-server connection internals. Move shared behavior to shared, domain, or app-server adapters.",
@ -336,6 +411,12 @@ export const format = formatDate;
"Lower-level modules must not import feature modules or app-server connection internals. Move shared behavior to shared, domain, or app-server adapters.",
]);
expect(pluginDiagnostics(report, "src/shared/date.ts")).toEqual([]);
expect(pluginMessages(report, "src/domain/threads/model.ts")).toEqual([
"Domain modules must stay pure and generated-independent; keep app-server, settings, workspace, UI, and Obsidian dependencies at boundary callers.",
"Domain modules must stay pure and generated-independent; keep app-server, settings, workspace, UI, and Obsidian dependencies at boundary callers.",
"Domain modules must stay pure and generated-independent; keep app-server, settings, workspace, UI, and Obsidian dependencies at boundary callers.",
]);
expect(pluginDiagnostics(report, "src/domain/threads/format.ts")).toEqual([]);
});
it("keeps generated app-server import shapes behind narrow aliases and protocol exceptions", async () => {
@ -354,6 +435,14 @@ export type ConnectionThread = Thread;
import type { Thread as AppServerThread } from "../../generated/app-server/v2/Thread";
export type ConnectionThread = AppServerThread;
`.trimStart(),
);
await writeFile(
path.join(cwd, "src/app-server/connection/record-thread.ts"),
`
import type { Thread as ThreadRecord } from "../../generated/app-server/v2/Thread";
export type ConnectionThread = ThreadRecord;
`.trimStart(),
);
await writeFile(
@ -374,6 +463,14 @@ import type { ToolRequestUserInputParams } from "../../generated/app-server/v2/T
export type Request = ServerRequest;
export type Params = ToolRequestUserInputParams;
export type InputParams = import("../../generated/app-server/v2/ToolRequestUserInputParams").ToolRequestUserInputParams;
export async function loadParams() {
return import("../../generated/app-server/v2/ToolRequestUserInputParams");
}
const params = require("../../generated/app-server/v2/ToolRequestUserInputParams");
export const loadedParams = params;
`.trimStart(),
);
@ -381,6 +478,7 @@ export type Params = ToolRequestUserInputParams;
[
"src/app-server/connection/thread.ts",
"src/app-server/connection/aliased-thread.ts",
"src/app-server/connection/record-thread.ts",
"src/app-server/protocol/turn.ts",
"src/app-server/protocol/server-requests.ts",
],
@ -391,41 +489,20 @@ export type Params = ToolRequestUserInputParams;
"Import generated app-server Thread as AppServerThread, or use the Panel-owned domain Thread model.",
]);
expect(pluginDiagnostics(report, "src/app-server/connection/aliased-thread.ts")).toEqual([]);
expect(pluginMessages(report, "src/app-server/connection/record-thread.ts")).toEqual([
"Import generated app-server Thread as AppServerThread, or use the Panel-owned domain Thread model.",
]);
expect(pluginMessages(report, "src/app-server/protocol/turn.ts")).toEqual([
"Only generated ThreadItem is allowed in the turn protocol exception. Model other turn payload shapes locally.",
]);
expect(pluginMessages(report, "src/app-server/protocol/server-requests.ts")).toEqual([
"Only generated RequestId and ServerRequest are allowed in the server request protocol exception.",
"Only generated RequestId and ServerRequest are allowed in the server request protocol exception.",
"Only generated RequestId and ServerRequest are allowed in the server request protocol exception.",
"Only generated RequestId and ServerRequest are allowed in the server request protocol exception.",
]);
});
it("keeps src index files as re-export-only boundaries", async () => {
const cwd = await tempBiomeWorkspace(["no-non-reexport-index.grit"]);
await writeFile(
path.join(cwd, "src/features/chat/index.ts"),
`
import { value } from "./value";
export const local = value;
`.trimStart(),
);
await writeFile(
path.join(cwd, "src/features/thread-picker/index.ts"),
`
export { openThreadPicker } from "./modal";
export type { ThreadPickerOptions } from "./types";
`.trimStart(),
);
const report = biomeLint(["src/features/chat/index.ts", "src/features/thread-picker/index.ts"], cwd);
expect(pluginMessages(report, "src/features/chat/index.ts")).toEqual([
"Keep src index files as re-export-only boundaries.",
"Keep src index files as re-export-only boundaries.",
]);
expect(pluginDiagnostics(report, "src/features/thread-picker/index.ts")).toEqual([]);
});
it("keeps chat application app-server projection RPCs behind facades", async () => {
const cwd = await tempBiomeWorkspace(["no-app-server-projection-rpcs.grit"]);
await writeFile(
@ -433,8 +510,8 @@ export type { ThreadPickerOptions } from "./types";
`
import type { AppServerClient } from "../../../../app-server/connection/client";
export async function read(client: AppServerClient): Promise<void> {
await client.threadTurnsList("thread", null, 20);
export async function read(appServerClient: AppServerClient): Promise<void> {
await appServerClient.threadTurnsList("thread", null, 20);
}
`.trimStart(),
);
@ -531,13 +608,36 @@ import { renderUiRoot } from "../../../shared/ui/ui-root.dom";
export const render = renderUiRoot;
`.trimStart(),
);
await writeFile(
path.join(cwd, "src/features/chat/ui/root-escapes.tsx"),
`
export { renderUiRoot } from "../../../shared/ui/ui-root.dom";
export type RootRenderer = import("../../../shared/ui/ui-root.dom").RootRenderer;
const report = biomeLint(["src/features/chat/ui/composer.tsx", "src/features/chat/panel/shell.dom.tsx"], cwd);
export async function loadRoot() {
return import("../../../shared/ui/ui-root.dom");
}
const root = require("../../../shared/ui/ui-root.dom");
export const loadedRoot = root;
`.trimStart(),
);
const report = biomeLint(
["src/features/chat/ui/composer.tsx", "src/features/chat/panel/shell.dom.tsx", "src/features/chat/ui/root-escapes.tsx"],
cwd,
);
expect(pluginMessages(report, "src/features/chat/ui/composer.tsx")).toEqual([
"Import the Preact root adapter only from explicit root bridge files.",
]);
expect(pluginDiagnostics(report, "src/features/chat/panel/shell.dom.tsx")).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.",
"Import the Preact root adapter only from explicit root bridge files.",
"Import the Preact root adapter only from explicit root bridge files.",
]);
});
it("keeps chat state transforms pure and catches global scheduling calls", async () => {
@ -753,7 +853,7 @@ async function tempBiomeWorkspace(plugins) {
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/ui"), { recursive: true });
await mkdir(path.join(cwd, "src/features/thread-picker"), { recursive: true });
await mkdir(path.join(cwd, "src/domain/threads"), { recursive: true });
await mkdir(path.join(cwd, "src/app-server/connection"), { recursive: true });
await mkdir(path.join(cwd, "src/app-server/protocol"), { recursive: true });
await mkdir(path.join(cwd, "src/app-server/services"), { recursive: true });