diff --git a/biome.jsonc b/biome.jsonc index 18e58609..ddd7ffb5 100644 --- a/biome.jsonc +++ b/biome.jsonc @@ -22,10 +22,23 @@ // Shared source layering boundaries. { - "path": "./scripts/lint/no-lower-level-boundary-imports.grit", + "path": "./scripts/lint/no-domain-outer-layer-imports.grit", "includes": [ - "**/src/app-server/**/*.ts", - "**/src/app-server/**/*.tsx", + "**/src/domain/**/*.ts", + "**/src/domain/**/*.tsx", + "**/src/features/chat/domain/**/*.ts", + "**/src/features/chat/domain/**/*.tsx" + ] + }, + { + "path": "./scripts/lint/no-lower-level-feature-imports.grit", + "includes": ["**/src/app-server/**/*.ts", "**/src/app-server/**/*.tsx", "**/src/shared/**/*.ts", "**/src/shared/**/*.tsx"] + }, + { + "path": "./scripts/lint/no-app-server-connection-boundary-imports.grit", + "includes": [ + "**/src/app-server/protocol/**/*.ts", + "**/src/app-server/protocol/**/*.tsx", "**/src/domain/**/*.ts", "**/src/domain/**/*.tsx", "**/src/shared/**/*.ts", @@ -34,10 +47,6 @@ }, // Chat feature architecture. - { - "path": "./scripts/lint/no-chat-domain-outer-layer-imports.grit", - "includes": ["**/src/features/chat/domain/**/*.ts", "**/src/features/chat/domain/**/*.tsx"] - }, { "path": "./scripts/lint/no-chat-signal-imports.grit", "includes": ["**/src/**/*.ts", "**/src/**/*.tsx"] diff --git a/scripts/lint/no-app-server-connection-boundary-imports.grit b/scripts/lint/no-app-server-connection-boundary-imports.grit new file mode 100644 index 00000000..79b8f5a1 --- /dev/null +++ b/scripts/lint/no-app-server-connection-boundary-imports.grit @@ -0,0 +1,32 @@ +language js + +private pattern js_module_reference() { + or { + JsImport(), + JsExportNamedFromClause(), + JsExportFromClause(), + TsImportType(), + JsImportCallExpression() + } +} + +private pattern app_server_protocol_connection_source() { r"^[\"'](?:\.\./)+connection(?:/.*)?[\"']$" } + +private pattern root_app_server_connection_source() { + or { + r"^[\"'](?:\.\./)+app-server/connection(?:/.*)?[\"']$", + r"^[\"']src/app-server/connection(?:/.*)?[\"']$" + } +} + +private pattern app_server_connection_boundary_source() { + or { + app_server_protocol_connection_source(), + root_app_server_connection_source() + } +} + +js_module_reference() as $stmt where { + $stmt <: contains `$source` where { $source <: app_server_connection_boundary_source() }, + register_diagnostic(span=$stmt, message="App-server protocol, domain, and shared modules must not import app-server connection internals. Keep connection usage at app-server adapters.", severity="error") +} diff --git a/scripts/lint/no-chat-domain-outer-layer-imports.grit b/scripts/lint/no-chat-domain-outer-layer-imports.grit deleted file mode 100644 index 500d3cdb..00000000 --- a/scripts/lint/no-chat-domain-outer-layer-imports.grit +++ /dev/null @@ -1,18 +0,0 @@ -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"^[\"'](?:(?:\.\./)+|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") -} diff --git a/scripts/lint/no-domain-outer-layer-imports.grit b/scripts/lint/no-domain-outer-layer-imports.grit new file mode 100644 index 00000000..e09aca12 --- /dev/null +++ b/scripts/lint/no-domain-outer-layer-imports.grit @@ -0,0 +1,33 @@ +language js + +private pattern js_module_reference() { + or { + JsImport(), + JsExportNamedFromClause(), + JsExportFromClause(), + TsImportType(), + JsImportCallExpression() + } +} + +private pattern root_outer_layer_source() { + or { + r"^[\"'](?:\.\./)+(?:app-server|settings|workspace|shared/ui|features)(?:/.*)?[\"']$", + r"^[\"']src/(?:app-server|settings|workspace|shared/ui|features)(?:/.*)?[\"']$", + r"^[\"']obsidian[\"']$" + } +} + +private pattern chat_domain_outer_layer_source() { r"^[\"'](?:\.\./)+(?:application|host|panel|presentation|ui)(?:/.*)?[\"']$" } + +private pattern domain_outer_layer_source() { + or { + root_outer_layer_source(), + chat_domain_outer_layer_source() + } +} + +js_module_reference() as $stmt where { + $stmt <: contains `$source` where { $source <: domain_outer_layer_source() }, + register_diagnostic(span=$stmt, message="Domain modules must stay pure; outer layers may depend on domain, not the reverse.", severity="error") +} diff --git a/scripts/lint/no-lower-level-boundary-imports.grit b/scripts/lint/no-lower-level-boundary-imports.grit deleted file mode 100644 index 5a479e4f..00000000 --- a/scripts/lint/no-lower-level-boundary-imports.grit +++ /dev/null @@ -1,35 +0,0 @@ -language js - -private pattern js_module_reference() { - or { - 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 { - $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") - }, - js_module_reference() as $stmt where { - $filename <: r".*/src/(?:app-server/protocol|domain|shared)/.*", - $stmt <: contains `$source` where { - $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") - } -} diff --git a/scripts/lint/no-lower-level-feature-imports.grit b/scripts/lint/no-lower-level-feature-imports.grit new file mode 100644 index 00000000..3e35daaf --- /dev/null +++ b/scripts/lint/no-lower-level-feature-imports.grit @@ -0,0 +1,16 @@ +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"^[\"'](?:(?:\.\./)+features|src/features)(?:/.*)?[\"']$" }, + register_diagnostic(span=$stmt, message="Lower-level modules must not import feature modules. Move shared behavior to shared, domain, or app-server adapters.", severity="error") +} diff --git a/tests/scripts/grit-policy.test.mjs b/tests/scripts/grit-policy.test.mjs index 62c3e1df..0152d20f 100644 --- a/tests/scripts/grit-policy.test.mjs +++ b/tests/scripts/grit-policy.test.mjs @@ -134,7 +134,6 @@ runner = new Runner(() => runner.stop()); it("keeps chat architecture policies behind their intended boundaries", async () => { const cwd = await tempBiomeWorkspace([ - "no-chat-domain-outer-layer-imports.grit", "no-chat-signal-imports.grit", "no-implicit-dom-bridges.grit", "no-pure-chat-state-side-effects.grit", @@ -168,38 +167,6 @@ export async function loadSignals() { const signals = await import("@preact/signals"); export const loadedSignals = signals; -`.trimStart(), - ); - await writeFile( - path.join(cwd, "src/features/chat/domain/message-stream/selectors.ts"), - ` -import type { ChatStateStore } from "../../application/state/store"; -import type { Presenter } from 'src/features/chat/presentation/view'; - -export type Store = ChatStateStore; -export type View = Presenter; -`.trimStart(), - ); - await writeFile( - path.join(cwd, "src/features/chat/domain/message-stream/items.ts"), - ` -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 = await import("../../host/session"); -export const outerHost = host; `.trimStart(), ); await writeFile( @@ -282,9 +249,6 @@ export function timestamp(): number { "src/features/chat/panel/shell-state.tsx", "src/shared/ui/components.tsx", "src/shared/ui/signal-escapes.tsx", - "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", "src/features/chat/ui/dom-bridge-escape.tsx", "src/features/chat/ui/dom-bridge.dom.tsx", "src/app-server/services/abortable-operation.ts", @@ -307,17 +271,6 @@ export function timestamp(): number { "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.", ]); - expect(pluginMessages(report, "src/features/chat/domain/message-stream/selectors.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.", - ]); - 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.", - ]); expect(pluginMessages(report, "src/features/chat/ui/dom-bridge-escape.tsx")).toEqual([ "Keep imperative DOM writes and event wiring in files named with a .dom, .obsidian, or .measure suffix.", "Keep imperative DOM writes and event wiring in files named with a .dom, .obsidian, or .measure suffix.", @@ -532,40 +485,18 @@ export const response = [appServerUserInputResponse, runtimeMetrics] satisfies u ]); }); - it("keeps lower-level source independent from connection and feature layers", async () => { - const cwd = await tempBiomeWorkspace(["no-lower-level-boundary-imports.grit"]); - await writeFile( - path.join(cwd, "src/app-server/protocol/catalog.ts"), - ` -import type { AppServerClient } from "../connection/client"; - -export type Client = AppServerClient; -`.trimStart(), - ); - await writeFile( - path.join(cwd, "src/app-server/protocol/diagnostics.ts"), - ` -import type { ThreadPickerModal } from '../../features/thread-picker/modal'; - -export type Modal = ThreadPickerModal; -`.trimStart(), - ); - await writeFile( - path.join(cwd, "src/shared/date.ts"), - ` -import { formatDate } from "./format"; - -export const format = formatDate; -`.trimStart(), - ); + it("keeps domain modules independent from outer layers", async () => { + const cwd = await tempBiomeWorkspace(["no-domain-outer-layer-imports.grit"]); await writeFile( path.join(cwd, "src/domain/threads/model.ts"), ` import { listThreads } from "../../app-server/threads"; +import type { ThreadPickerModal } from "../../features/thread-picker/modal"; import { copyText } from "../../shared/ui/clipboard"; import type { App } from "obsidian"; export type Host = App; +export type Modal = ThreadPickerModal; export const list = listThreads; export const copy = copyText; `.trimStart(), @@ -576,33 +507,165 @@ export const copy = copyText; import { formatDate } from "../../shared/date"; export const format = formatDate; +`.trimStart(), + ); + await writeFile( + path.join(cwd, "src/features/chat/domain/message-stream/selectors.ts"), + ` +import type { ChatStateStore } from "../../application/state/store"; +import type { Presenter } from 'src/features/chat/presentation/view'; + +export type Store = ChatStateStore; +export type View = Presenter; +`.trimStart(), + ); + await writeFile( + path.join(cwd, "src/features/chat/domain/message-stream/items.ts"), + ` +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 = await import("../../host/session"); +export const outerHost = host; +`.trimStart(), + ); + + const report = biomeLint( + [ + "src/domain/threads/model.ts", + "src/domain/threads/format.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, + ); + + expect(pluginMessages(report, "src/domain/threads/model.ts")).toEqual([ + "Domain modules must stay pure; outer layers may depend on domain, not the reverse.", + "Domain modules must stay pure; outer layers may depend on domain, not the reverse.", + "Domain modules must stay pure; outer layers may depend on domain, not the reverse.", + "Domain modules must stay pure; outer layers may depend on domain, not the reverse.", + ]); + expect(pluginDiagnostics(report, "src/domain/threads/format.ts")).toEqual([]); + expect(pluginMessages(report, "src/features/chat/domain/message-stream/selectors.ts")).toEqual([ + "Domain modules must stay pure; outer layers may depend on domain, not the reverse.", + "Domain modules must stay pure; outer 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([ + "Domain modules must stay pure; outer layers may depend on domain, not the reverse.", + "Domain modules must stay pure; outer layers may depend on domain, not the reverse.", + "Domain modules must stay pure; outer layers may depend on domain, not the reverse.", + "Domain modules must stay pure; outer layers may depend on domain, not the reverse.", + ]); + }); + + it("keeps lower-level modules independent from feature modules", async () => { + const cwd = await tempBiomeWorkspace(["no-lower-level-feature-imports.grit"]); + await writeFile( + path.join(cwd, "src/app-server/protocol/diagnostics.ts"), + ` +import type { ThreadPickerModal } from '../../features/thread-picker/modal'; + +export type Modal = ThreadPickerModal; +`.trimStart(), + ); + await writeFile( + path.join(cwd, "src/shared/thread-picker.ts"), + ` +import type { ThreadPickerModal } from "../features/thread-picker/modal"; + +export type Modal = ThreadPickerModal; +`.trimStart(), + ); + await writeFile( + path.join(cwd, "src/shared/date.ts"), + ` +import { formatDate } from "./format"; + +export const format = formatDate; +`.trimStart(), + ); + + const report = biomeLint(["src/app-server/protocol/diagnostics.ts", "src/shared/thread-picker.ts", "src/shared/date.ts"], cwd); + + expect(pluginMessages(report, "src/app-server/protocol/diagnostics.ts")).toEqual([ + "Lower-level modules must not import feature modules. Move shared behavior to shared, domain, or app-server adapters.", + ]); + expect(pluginMessages(report, "src/shared/thread-picker.ts")).toEqual([ + "Lower-level modules must not import feature modules. Move shared behavior to shared, domain, or app-server adapters.", + ]); + expect(pluginDiagnostics(report, "src/shared/date.ts")).toEqual([]); + }); + + it("keeps app-server connection internals behind app-server adapters", async () => { + const cwd = await tempBiomeWorkspace(["no-app-server-connection-boundary-imports.grit"]); + await writeFile( + path.join(cwd, "src/app-server/protocol/catalog.ts"), + ` +import type { AppServerClient } from "../connection/client"; + +export type Client = AppServerClient; +`.trimStart(), + ); + await writeFile( + path.join(cwd, "src/app-server/services/catalog.ts"), + ` +import type { AppServerClient } from "../connection/client"; + +export type Client = AppServerClient; +`.trimStart(), + ); + await writeFile( + path.join(cwd, "src/domain/connection-client.ts"), + ` +import type { AppServerClient } from "../app-server/connection/client"; + +export type Client = AppServerClient; +`.trimStart(), + ); + await writeFile( + path.join(cwd, "src/shared/connection-client.ts"), + ` +import type { AppServerClient } from "src/app-server/connection/client"; + +export type Client = AppServerClient; `.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", + "src/app-server/services/catalog.ts", + "src/domain/connection-client.ts", + "src/shared/connection-client.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.", + "App-server protocol, domain, and shared modules must not import app-server connection internals. Keep connection usage at app-server adapters.", ]); - expect(pluginMessages(report, "src/app-server/protocol/diagnostics.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.", + expect(pluginDiagnostics(report, "src/app-server/services/catalog.ts")).toEqual([]); + expect(pluginMessages(report, "src/domain/connection-client.ts")).toEqual([ + "App-server protocol, domain, and shared modules must not import app-server connection internals. Keep connection usage at 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(pluginMessages(report, "src/shared/connection-client.ts")).toEqual([ + "App-server protocol, domain, and shared modules must not import app-server connection internals. Keep connection usage at app-server adapters.", ]); - expect(pluginDiagnostics(report, "src/domain/threads/format.ts")).toEqual([]); }); it("keeps chat application app-server projection RPCs behind facades", async () => {