From 80364ecf63ec7f7dbe25df43567f4a74e601aa4d Mon Sep 17 00:00:00 2001 From: shm11C3 Date: Sat, 3 Oct 2026 12:53:04 +0900 Subject: [PATCH 1/4] chore(frontend): enforce layer boundaries with Biome and record ADR 0027 The frontend had no enforced dependency direction and no single owner for Jotai state. Generic layers import features, features import each other, and atoms are declared in hook files as well as store modules. ADR 0027 (proposed) defines downward-only layers and explicit state ownership. This change adds the guardrails the current code already satisfies: - per-layer noRestrictedImports overrides in .config/biome/frontend-boundaries.jsonc, extended from biome.jsonc - noImportCycles, noBarrelFile and noReExportAll - a GritQL plugin that rejects module-scope `let` outside store modules Constraints that still have violations are recorded as pending exceptions tied to the migration slices in #2325, deferred to 1.12.0. No application code changes. Every enforced and pending constraint was proven with per-scope canary imports (107 reject/allow checks), because a mistyped Biome scope silently disables a rule instead of failing. --- .agents/rules/frontend.md | 6 + .config/biome/frontend-boundaries.jsonc | 883 ++++++++++++++++++ .config/biome/no-module-scope-let.grit | 17 + biome.jsonc | 9 + docs/README.md | 2 + ...nd-layer-boundaries-and-state-ownership.md | 80 ++ docs/adr/README.md | 1 + docs/agents/lessons/README.md | 1 + ...ove-biome-boundary-config-with-canaries.md | 43 + docs/design/frontend-architecture.md | 138 +++ src/AGENTS.md | 8 +- src/README.md | 25 +- 12 files changed, 1210 insertions(+), 3 deletions(-) create mode 100644 .config/biome/frontend-boundaries.jsonc create mode 100644 .config/biome/no-module-scope-let.grit create mode 100644 docs/adr/0027-frontend-layer-boundaries-and-state-ownership.md create mode 100644 docs/agents/lessons/prove-biome-boundary-config-with-canaries.md create mode 100644 docs/design/frontend-architecture.md diff --git a/.agents/rules/frontend.md b/.agents/rules/frontend.md index 8f90f47a6..d9ca47b36 100644 --- a/.agents/rules/frontend.md +++ b/.agents/rules/frontend.md @@ -15,5 +15,11 @@ Follow `src/AGENTS.md` and `docs/design-principles.md`. - Keep high-frequency updates from rerendering unrelated subtrees; add a focused regression test when changing fan-out. - Add user-visible text to the language files and use existing i18n patterns. +- Keep imports downward-only per + [ADR 0027](../../docs/adr/0027-frontend-layer-boundaries-and-state-ownership.md): + no cross-feature imports, no `../`, and atoms only in `store/` modules. + Pending exceptions in `.config/biome/frontend-boundaries.jsonc` only + shrink. Prove any edit to that file with canaries, because a mistyped scope + silently disables the rule. - Verify visual and interaction changes in rendered desktop and compact views. Inspect E2E screenshots/artifacts before weakening selectors. diff --git a/.config/biome/frontend-boundaries.jsonc b/.config/biome/frontend-boundaries.jsonc new file mode 100644 index 000000000..c24e3c1aa --- /dev/null +++ b/.config/biome/frontend-boundaries.jsonc @@ -0,0 +1,883 @@ +{ + "$schema": "../../node_modules/@biomejs/biome/configuration_schema.json", + // Frontend layer boundaries and state ownership (ADR 0027, + // docs/design/frontend-architecture.md). + // + // A later override that configures a rule REPLACES that rule's options from + // earlier overrides for the files it matches; options are not merged. Keep + // the boundary scopes disjoint, keep every override's constraint set + // complete, and keep the App override last so it reclassifies the files S4 + // moves into src/app/. + // + // "pending Sn (#2325)" marks a target constraint that still has violations. + // The slice Sn of #2325 removes the violations and restores the constraint. + // Never add a new pending exception to make new code pass. + // + // Plugin paths in this file resolve from the repository root (the config + // that extends this file), not from this directory. + "overrides": [ + // Module-scope `let` outside store modules. + { + "includes": [ + "src/**", + "!src/**/store/**", + "!src/**/*.test.ts", + "!src/**/*.test.tsx", + // Process-wide handle to the Tauri Store plugin, not application state. + "!src/lib/tauriStore.ts", + "!src/features/settings/hooks/useSettingsAtom.ts", // pending S3 (#2325) + "!src/features/settings/hooks/useDatabaseConversion.ts" // pending S5 (#2325) + ], + "plugins": ["./.config/biome/no-module-scope-let.grit"] + }, + // Foundation + { + "includes": [ + "src/lib/**", + "src/types/**", + "src/consts/**", + "!src/**/*.test.ts", + "!src/**/*.test.tsx" + ], + "linter": { + "rules": { + "style": { + "noRestrictedImports": { + "level": "error", + "options": { + "patterns": [ + { + "message": "This import crosses a frontend layer boundary. See docs/design/frontend-architecture.md.", + "group": [ + "@/features/**", + "@/hooks/**", + "@/components/**", + "@/store/**", + "@/app/**", + "@/App", + "@/lazyScreens", + "../**", + "react", + "react-dom", + "jotai", + "jotai/**" + ] + } + ] + } + } + } + } + } + }, + // Shared store + { + "includes": ["src/store/**", "!src/**/*.test.ts", "!src/**/*.test.tsx"], + "linter": { + "rules": { + "style": { + "noRestrictedImports": { + "level": "error", + "options": { + "patterns": [ + { + "message": "This import crosses a frontend layer boundary. See docs/design/frontend-architecture.md.", + "group": [ + "@/features/**", + "@/hooks/**", + "@/components/**", + "@/app/**", + "@/App", + "@/lazyScreens", + "../**", + "react", + "react-dom", + "jotai/react", + "jotai/react/**" + ] + } + ], + "paths": { + "jotai": { + "message": "This Jotai API is not allowed in this layer. See docs/design/frontend-architecture.md.", + "importNames": [ + "createStore", + "getDefaultStore", + "Provider", + "useAtom", + "useAtomValue", + "useSetAtom", + "useStore" + ] + } + } + } + } + } + } + } + }, + // Shared hooks + { + "includes": ["src/hooks/**", "!src/**/*.test.ts", "!src/**/*.test.tsx"], + "linter": { + "rules": { + "style": { + "noRestrictedImports": { + "level": "error", + "options": { + "patterns": [ + { + "message": "This import crosses a frontend layer boundary. See docs/design/frontend-architecture.md.", + "group": [ + "@/features/**", + "@/components/**", + "@/app/**", + "@/App", + "@/lazyScreens", + "../**", + "jotai/**", + "!@/features/settings/**" // pending S3 (#2325): delete this exemption + ] + } + ], + "paths": { + "jotai": { + "message": "This Jotai API is not allowed in this layer. See docs/design/frontend-architecture.md.", + "importNames": [ + "createStore", + "getDefaultStore", + "Provider" + // "atom", -- pending S5 (#2325) + ] + } + } + } + } + } + } + } + }, + // UI primitives + { + "includes": [ + "src/components/ui/**", + "src/components/icons/**", + "!src/**/*.test.ts", + "!src/**/*.test.tsx" + ], + "linter": { + "rules": { + "style": { + "noRestrictedImports": { + "level": "error", + "options": { + "patterns": [ + { + "message": "This import crosses a frontend layer boundary. See docs/design/frontend-architecture.md.", + "group": [ + "@/features/**", + "@/hooks/**", + "@/store/**", + "@/components/charts/**", + "@/components/shared/**", + "@/app/**", + "@/App", + "@/lazyScreens", + "../**", + "jotai", + "jotai/**" + ] + } + ], + "paths": { + "@/rspc/bindings": { + "message": "UI primitives do not call IPC. See docs/design/frontend-architecture.md.", + "importNames": ["commands", "events"] + } + } + } + } + } + } + } + }, + // Shared components: charts + { + "includes": [ + "src/components/charts/**", + "!src/**/*.test.ts", + "!src/**/*.test.tsx" + ], + "linter": { + "rules": { + "style": { + "noRestrictedImports": { + "level": "error", + "options": { + "patterns": [ + { + "message": "This import crosses a frontend layer boundary. See docs/design/frontend-architecture.md.", + "group": [ + "@/features/**", + "@/components/shared/**", + "@/app/**", + "@/App", + "@/lazyScreens", + "../**", + "jotai/**", + "!@/features/hardware/**", // pending S2 (#2325): delete this exemption + "!@/features/settings/**" // pending S3 (#2325): delete this exemption + ] + } + ], + "paths": { + "jotai": { + "message": "This Jotai API is not allowed in this layer. See docs/design/frontend-architecture.md.", + "importNames": [ + "atom", + // "useAtom", -- pending S5 (#2325) + "useSetAtom", + "useStore", + "createStore", + "getDefaultStore", + "Provider" + ] + } + } + } + } + } + } + } + }, + // Shared components + { + "includes": [ + "src/components/shared/**", + "src/components/*.tsx", + "!src/**/*.test.ts", + "!src/**/*.test.tsx" + ], + "linter": { + "rules": { + "style": { + "noRestrictedImports": { + "level": "error", + "options": { + "patterns": [ + { + "message": "This import crosses a frontend layer boundary. See docs/design/frontend-architecture.md.", + "group": [ + "@/features/**", + "@/app/**", + "@/App", + "@/lazyScreens", + // "../**", -- pending S1 (#2325) + "jotai/**", + "!@/features/settings/**" // pending S3 (#2325): delete this exemption + ] + } + ], + "paths": { + "jotai": { + "message": "This Jotai API is not allowed in this layer. See docs/design/frontend-architecture.md.", + "importNames": [ + "atom", + "useAtom", + // "useSetAtom", -- pending S5 (#2325) + "useStore", + "createStore", + "getDefaultStore", + "Provider" + ] + } + } + } + } + } + } + } + }, + // Feature catch-all + { + "includes": [ + "src/features/**", + "!src/**/*.test.ts", + "!src/**/*.test.tsx" + ], + "linter": { + "rules": { + "style": { + "noRestrictedImports": { + "level": "error", + "options": { + "patterns": [ + { + "message": "This import crosses a frontend layer boundary. See docs/design/frontend-architecture.md.", + "group": [ + "@/features/**", + "@/app/**", + "@/App", + "@/lazyScreens", + "../**", + "jotai/**" + ] + } + ], + "paths": { + "jotai": { + "message": "This Jotai API is not allowed in this layer. See docs/design/frontend-architecture.md.", + "importNames": [ + "atom", + "useAtom", + "useSetAtom", + "useStore", + "createStore", + "getDefaultStore", + "Provider" + ] + } + } + } + } + } + } + } + }, + // Feature hardware: UI + { + "includes": [ + "src/features/hardware/**", + "!src/features/hardware/store/**", + "!src/features/hardware/hooks/**", + "!src/features/hardware/**/hooks/**", + "!src/**/*.test.ts", + "!src/**/*.test.tsx" + ], + "linter": { + "rules": { + "style": { + "noRestrictedImports": { + "level": "error", + "options": { + "patterns": [ + { + "message": "This import crosses a frontend layer boundary. See docs/design/frontend-architecture.md.", + "group": [ + "@/features/**", + "@/app/**", + "@/App", + "@/lazyScreens", + // "../**", -- pending S1 (#2325) + "jotai/**", + "!@/features/hardware/**", + "!@/features/settings/**" // pending S3 (#2325): delete this exemption + ] + } + ], + "paths": { + "jotai": { + "message": "This Jotai API is not allowed in this layer. See docs/design/frontend-architecture.md.", + "importNames": [ + "atom", + // "useAtom", -- pending S5 (#2325) + "useSetAtom", + "useStore", + "createStore", + "getDefaultStore", + "Provider" + ] + } + } + } + } + } + } + } + }, + // Feature hardware: hooks + { + "includes": [ + "src/features/hardware/hooks/**", + "src/features/hardware/**/hooks/**", + "!src/**/*.test.ts", + "!src/**/*.test.tsx" + ], + "linter": { + "rules": { + "style": { + "noRestrictedImports": { + "level": "error", + "options": { + "patterns": [ + { + "message": "This import crosses a frontend layer boundary. See docs/design/frontend-architecture.md.", + "group": [ + "@/features/**", + "@/components/**", + "@/app/**", + "@/App", + "@/lazyScreens", + // "../**", -- pending S1 (#2325) + "jotai/**", + "!@/features/hardware/**", + "!@/features/settings/**" // pending S3 (#2325): delete this exemption + ] + } + ], + "paths": { + "jotai": { + "message": "This Jotai API is not allowed in this layer. See docs/design/frontend-architecture.md.", + "importNames": [ + "createStore", + "getDefaultStore", + "Provider" + // "atom", -- pending S5 (#2325) + ] + } + } + } + } + } + } + } + }, + // Feature hardware: store + { + "includes": [ + "src/features/hardware/store/**", + "!src/**/*.test.ts", + "!src/**/*.test.tsx" + ], + "linter": { + "rules": { + "style": { + "noRestrictedImports": { + "level": "error", + "options": { + "patterns": [ + { + "message": "This import crosses a frontend layer boundary. See docs/design/frontend-architecture.md.", + "group": [ + "@/features/**", + "@/hooks/**", + "@/components/**", + "@/app/**", + "@/App", + "@/lazyScreens", + "../**", + "react", + "react-dom", + "jotai/react", + "jotai/react/**", + "!@/features/hardware/**" + ] + } + ], + "paths": { + "jotai": { + "message": "This Jotai API is not allowed in this layer. See docs/design/frontend-architecture.md.", + "importNames": [ + "createStore", + "getDefaultStore", + "Provider", + "useAtom", + "useAtomValue", + "useSetAtom", + "useStore" + ] + } + } + } + } + } + } + } + }, + // Feature settings: UI + { + "includes": [ + "src/features/settings/**", + "!src/features/settings/store/**", + "!src/features/settings/hooks/**", + "!src/features/settings/**/hooks/**", + "!src/**/*.test.ts", + "!src/**/*.test.tsx" + ], + "linter": { + "rules": { + "style": { + "noRestrictedImports": { + "level": "error", + "options": { + "patterns": [ + { + "message": "This import crosses a frontend layer boundary. See docs/design/frontend-architecture.md.", + "group": [ + "@/features/**", + "@/app/**", + "@/App", + "@/lazyScreens", + // "../**", -- pending S1 (#2325) + "jotai/**", + "!@/features/settings/**", + "!@/features/hardware/**", // pending S2 (#2325): delete this exemption + "!@/features/menu/**" // pending S5 (#2325): delete this exemption + ] + } + ], + "paths": { + "jotai": { + "message": "This Jotai API is not allowed in this layer. See docs/design/frontend-architecture.md.", + "importNames": [ + "atom", + // "useAtom", -- pending S5 (#2325) + // "useSetAtom", -- pending S5 (#2325) + "useStore", + "createStore", + "getDefaultStore", + "Provider" + ] + } + } + } + } + } + } + } + }, + // Feature settings: hooks + { + "includes": [ + "src/features/settings/hooks/**", + "src/features/settings/**/hooks/**", + "!src/**/*.test.ts", + "!src/**/*.test.tsx" + ], + "linter": { + "rules": { + "style": { + "noRestrictedImports": { + "level": "error", + "options": { + "patterns": [ + { + "message": "This import crosses a frontend layer boundary. See docs/design/frontend-architecture.md.", + "group": [ + "@/features/**", + "@/components/**", + "@/app/**", + "@/App", + "@/lazyScreens", + "../**", + "jotai/**", + "!@/features/settings/**", + "!@/features/hardware/**" // pending S2 (#2325): delete this exemption + ] + } + ], + "paths": { + "jotai": { + "message": "This Jotai API is not allowed in this layer. See docs/design/frontend-architecture.md.", + "importNames": [ + "createStore", + "getDefaultStore", + "Provider" + // "atom", -- pending S5 (#2325) + ] + } + } + } + } + } + } + } + }, + // Feature menu: UI + { + "includes": [ + "src/features/menu/**", + "!src/features/menu/store/**", + "!src/features/menu/hooks/**", + "!src/features/menu/**/hooks/**", + "!src/**/*.test.ts", + "!src/**/*.test.tsx" + ], + "linter": { + "rules": { + "style": { + "noRestrictedImports": { + "level": "error", + "options": { + "patterns": [ + { + "message": "This import crosses a frontend layer boundary. See docs/design/frontend-architecture.md.", + "group": [ + "@/features/**", + "@/app/**", + "@/App", + // "@/lazyScreens", -- pending S4 (#2325) + "../**", + "jotai/**", + "!@/features/menu/**" + ] + } + ], + "paths": { + "jotai": { + "message": "This Jotai API is not allowed in this layer. See docs/design/frontend-architecture.md.", + "importNames": [ + "atom", + "useAtom", + "useSetAtom", + "useStore", + "createStore", + "getDefaultStore", + "Provider" + ] + } + } + } + } + } + } + } + }, + // Feature menu: hooks + { + "includes": [ + "src/features/menu/hooks/**", + "src/features/menu/**/hooks/**", + "!src/**/*.test.ts", + "!src/**/*.test.tsx" + ], + "linter": { + "rules": { + "style": { + "noRestrictedImports": { + "level": "error", + "options": { + "patterns": [ + { + "message": "This import crosses a frontend layer boundary. See docs/design/frontend-architecture.md.", + "group": [ + "@/features/**", + "@/components/**", + "@/app/**", + "@/App", + "@/lazyScreens", + "../**", + "jotai/**", + "!@/features/menu/**" + ] + } + ], + "paths": { + "jotai": { + "message": "This Jotai API is not allowed in this layer. See docs/design/frontend-architecture.md.", + "importNames": [ + "createStore", + "getDefaultStore", + "Provider" + // "atom", -- pending S5 (#2325) + ] + } + } + } + } + } + } + } + }, + // Feature tray + { + "includes": [ + "src/features/tray/**", + "!src/**/*.test.ts", + "!src/**/*.test.tsx" + ], + "linter": { + "rules": { + "style": { + "noRestrictedImports": { + "level": "error", + "options": { + "patterns": [ + { + "message": "This import crosses a frontend layer boundary. See docs/design/frontend-architecture.md.", + "group": [ + "@/features/**", + "@/app/**", + "@/App", + "@/lazyScreens", + "../**", + "jotai/**", + "!@/features/tray/**" + ] + } + ], + "paths": { + "jotai": { + "message": "This Jotai API is not allowed in this layer. See docs/design/frontend-architecture.md.", + "importNames": [ + "atom", + "useAtom", + "useSetAtom", + "useStore", + "createStore", + "getDefaultStore", + "Provider" + ] + } + } + } + } + } + } + } + }, + // Feature updater: UI + { + "includes": [ + "src/features/updater/**", + "!src/features/updater/store/**", + "!src/features/updater/hooks/**", + "!src/features/updater/**/hooks/**", + "!src/**/*.test.ts", + "!src/**/*.test.tsx" + ], + "linter": { + "rules": { + "style": { + "noRestrictedImports": { + "level": "error", + "options": { + "patterns": [ + { + "message": "This import crosses a frontend layer boundary. See docs/design/frontend-architecture.md.", + "group": [ + "@/features/**", + "@/app/**", + "@/App", + "@/lazyScreens", + "../**", + "jotai/**", + "!@/features/updater/**" + ] + } + ], + "paths": { + "jotai": { + "message": "This Jotai API is not allowed in this layer. See docs/design/frontend-architecture.md.", + "importNames": [ + "atom", + "useAtom", + "useSetAtom", + "useStore", + "createStore", + "getDefaultStore", + "Provider" + ] + } + } + } + } + } + } + } + }, + // Feature updater: hooks + { + "includes": [ + "src/features/updater/hooks/**", + "src/features/updater/**/hooks/**", + "!src/**/*.test.ts", + "!src/**/*.test.tsx" + ], + "linter": { + "rules": { + "style": { + "noRestrictedImports": { + "level": "error", + "options": { + "patterns": [ + { + "message": "This import crosses a frontend layer boundary. See docs/design/frontend-architecture.md.", + "group": [ + "@/features/**", + "@/components/**", + "@/app/**", + "@/App", + "@/lazyScreens", + "../**", + "jotai/**", + "!@/features/updater/**" + ] + } + ], + "paths": { + "jotai": { + "message": "This Jotai API is not allowed in this layer. See docs/design/frontend-architecture.md.", + "importNames": [ + "createStore", + "getDefaultStore", + "Provider", + "atom" + ] + } + } + } + } + } + } + } + }, + // App + { + "includes": [ + "src/App.tsx", + "src/lazyScreens.tsx", + "src/components/shared/CloseToTrayFirstRunDialog.tsx", + "src/components/shared/DatabaseConversionPromptDialog.tsx", + "src/components/shared/ElevationUnavailableNotice.tsx", + "src/components/shared/ExternalComponentGuidanceDialog.tsx", + "src/components/shared/NavigationRestructureNotice.tsx", + "src/components/shared/NsisMigrationNoticeDialog.tsx", + "src/components/shared/ScreenTemplate.tsx", + "src/components/ui/FullScreenExit.tsx" + ], + "linter": { + "rules": { + "style": { + "noRestrictedImports": { + "level": "error", + "options": { + "patterns": [ + { + "message": "This import crosses a frontend layer boundary. See docs/design/frontend-architecture.md.", + "group": ["jotai/**"] + } + ], + "paths": { + "jotai": { + "message": "This Jotai API is not allowed in this layer. See docs/design/frontend-architecture.md.", + "importNames": [ + "atom", + // "useAtom", -- pending S5 (#2325) + // "useSetAtom", -- pending S5 (#2325) + "useStore", + "createStore", + "getDefaultStore", + "Provider" + ] + } + } + } + } + } + } + } + } + ] +} diff --git a/.config/biome/no-module-scope-let.grit b/.config/biome/no-module-scope-let.grit new file mode 100644 index 000000000..ec4a78c41 --- /dev/null +++ b/.config/biome/no-module-scope-let.grit @@ -0,0 +1,17 @@ +// Module-scope `let` is state the Jotai store cannot see, scope per Provider, +// or reset between tests. Shared state belongs in a `store/` module. +// See docs/design/frontend-architecture.md. +// +// Matched by node type and source text: the snippet form `let $x = $y` does not +// match in Biome 2.5 even though `const $x = $y` does. +JsVariableDeclaration() as $declaration where { + $declaration <: r"^let\s[\s\S]*", + not $declaration <: within or { + JsFunctionBody(), + JsArrowFunctionExpression(), + JsFunctionExpression(), + JsClassDeclaration(), + JsClassExpression() + } , + register_diagnostic(span=$declaration, message="Module-scope `let` is state outside the Jotai store. Declare it in a `store/` module (an atom, or store-keyed state) so it is scoped per Provider and resets between tests.") +} diff --git a/biome.jsonc b/biome.jsonc index 1161bca03..7d6b72e22 100644 --- a/biome.jsonc +++ b/biome.jsonc @@ -1,5 +1,7 @@ { "$schema": "./node_modules/@biomejs/biome/configuration_schema.json", + // Frontend layer boundaries live in their own file; see its header. + "extends": ["./.config/biome/frontend-boundaries.jsonc"], "assist": { "actions": { "source": { "organizeImports": "on" } } }, "vcs": { "enabled": true, @@ -14,6 +16,13 @@ // TypeScript's noPropertyAccessFromIndexSignature requires bracket access for index signatures. "useLiteralKeys": "off" }, + "performance": { + "noBarrelFile": "error", + "noReExportAll": "error" + }, + "suspicious": { + "noImportCycles": "error" + }, "nursery": { "useSortedClasses": { "level": "error", diff --git a/docs/README.md b/docs/README.md index 83ed6271d..029957103 100644 --- a/docs/README.md +++ b/docs/README.md @@ -28,6 +28,8 @@ documentation. - [Relicense to GPL-3.0-or-later decision](adr/0020-relicense-to-gpl-3.0-or-later.md) - [Sensor hardware specs (clean-room)](specs/sensors/) - [Frontend architecture](../src/README.md) +- [Frontend layer boundaries decision](adr/0027-frontend-layer-boundaries-and-state-ownership.md) +- [Frontend architecture Design Doc](design/frontend-architecture.md) - [Core crate guide](../core/README.md) - [Tauri app crate guide](../src-tauri/README.md) - [Add a new language](development/add-language.md) diff --git a/docs/adr/0027-frontend-layer-boundaries-and-state-ownership.md b/docs/adr/0027-frontend-layer-boundaries-and-state-ownership.md new file mode 100644 index 000000000..fd56418a8 --- /dev/null +++ b/docs/adr/0027-frontend-layer-boundaries-and-state-ownership.md @@ -0,0 +1,80 @@ +# Frontend Layer Boundaries and State Ownership + +Status: proposed + +Tracking issue: [#2325](https://github.com/shm11c3/HardwareVisualizer/issues/2325). + +The lint guardrails for constraints the code already satisfies are in place. +The structural migration is deferred to 1.12.0 development. Implementation +detail and the rule-to-lint mapping live in +[`docs/design/frontend-architecture.md`](../design/frontend-architecture.md). + +## Context + +The frontend grew without an enforced dependency direction. Generic layers +depend on features: shared components, charts, and shared hooks import +`features/settings`. Features also depend on one another: +`features/hardware` and `features/settings` import each other. + +Jotai state has no single home. Atoms are declared in hook files as well as in +store modules. Components write atoms directly, while other state hides behind +facade hooks. Some shared state sits in module-scope variables that a +Provider-scoped store cannot isolate between tests. + +Two placements cause most of the cross-layer imports: + +- Application Preference state lives inside `features/settings`, although every + screen reads it. +- Chart vocabulary lives inside `features/hardware`, although charts and + settings share it. + +## Decision + +1. **The frontend has ordered layers, and imports point only downward:** + foundation (`rspc`, `types`, `consts`, `lib`) → store → shared hooks → + UI primitives → shared components → features → app. + - A feature never imports another feature. + - Code that composes features belongs in the app layer. +2. **Cross-directory imports use the `@/` alias.** Parent-relative imports are + not allowed, because they bypass path-based boundary rules. +3. **State ownership is explicit:** + - Atoms are declared only in `store/` modules: `src/store` for cross-feature + state and `src/features//store` for feature state. + - Components read atoms. Writes go through named hook functions. + - Shared mutable state does not live in module-scope variables outside + store modules. + - `getDefaultStore` is not used. +4. **Application Preference state is cross-feature state.** It lives in the + shared store and hook layers, not inside the settings feature. +5. **Biome enforces these rules.** + - A constraint is enforced as an error as soon as the code satisfies it. + - A constraint that still has violations is recorded as a pending exception + with the migration slice that removes it. It is never weakened silently. + +## Alternatives Considered + +- **Document the layering without lint.** Rejected. The current state is what + convention-only layering produced. A rule nobody checks drifts the same way + again. +- **Use dependency-cruiser or eslint-plugin-boundaries.** Rejected for now. + Either one adds a second lint toolchain beside Biome. Biome's path rules + plus the alias-only import rule cover the same layer and feature + boundaries, and keep one lint command. +- **Keep atoms next to the hooks that use them.** Rejected. Co-location hides + shared state inside hooks that other features then import for the atom + alone. It also leaves no rule that tells a reader where state lives. +- **Migrate everything in one change.** Rejected. The migration touches most + frontend files and would conflict with every in-flight change. It lands as + ordered slices instead, each of which restores specific pending constraints. + +## Consequences + +- New code cannot add an import that crosses an enforced boundary, a barrel + file, an import cycle, or module-scope `let` outside store modules. +- Constraints with pending exceptions remain unenforced until their slice + lands. Until then, review has to catch new violations of those constraints. +- Biome replaces, rather than merges, a rule's options when a later override + matches the same file. The boundary configuration therefore needs disjoint + scopes and a complete constraint set in each override. +- The Live Metrics Buffer (#1638) has to live in a store module to satisfy the + module-state rule. Its module-level store fits that placement. diff --git a/docs/adr/README.md b/docs/adr/README.md index 709d3e778..f6eaa7967 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -47,3 +47,4 @@ ADR status describes decision maturity, not implementation or release status. - [0024 External Component Setup](0024-external-component-setup.md) - [0025 Retire the SQLite Conversion Path in v2.0.0](0025-retire-sqlite-conversion-path-in-v2.md) - [0026 Refresh Outdated External Component Files](0026-refresh-outdated-external-component-files.md) +- [0027 Frontend Layer Boundaries and State Ownership](0027-frontend-layer-boundaries-and-state-ownership.md) diff --git a/docs/agents/lessons/README.md b/docs/agents/lessons/README.md index 982d553d1..212975d54 100644 --- a/docs/agents/lessons/README.md +++ b/docs/agents/lessons/README.md @@ -124,3 +124,4 @@ shared enforcement surface. - [Place scripts with their owner](place-scripts-with-their-owner.md) - [Keep tray flyout geometry transient](keep-tray-flyout-geometry-transient.md) - [Claim Windows Open before database startup](claim-windows-open-before-database.md) +- [Prove Biome boundary config with canaries](prove-biome-boundary-config-with-canaries.md) diff --git a/docs/agents/lessons/prove-biome-boundary-config-with-canaries.md b/docs/agents/lessons/prove-biome-boundary-config-with-canaries.md new file mode 100644 index 000000000..e2fa0be47 --- /dev/null +++ b/docs/agents/lessons/prove-biome-boundary-config-with-canaries.md @@ -0,0 +1,43 @@ +--- +id: LRN-20261003-prove-biome-boundary-config-with-canaries +status: promoted +cause_status: confirmed +scope: biome.jsonc, .config/biome/** +trigger: when adding or editing Biome overrides, noRestrictedImports scopes, or GritQL plugins +failure_signature: a boundary rule reports zero diagnostics because it never ran; overlapping overrides silently drop earlier constraints +root_cause: "a later Biome override replaces, rather than merges, the options of a rule configured earlier for the same file; a mistyped scope, a plugin path that does not resolve the way the author expected, or a GritQL snippet that never matches produces no error, only no diagnostics" +guardrail: the boundary file keeps disjoint scopes with complete constraint sets and documents the replacement semantics; every edit is proven with per-scope canary imports that must be rejected or allowed +canonical_refs: .config/biome/frontend-boundaries.jsonc, docs/design/frontend-architecture.md, .agents/rules/frontend.md +verification: two overrides matching the same file with different noRestrictedImports options report only the later one's diagnostics; the 2026-10-03 canary run passed 107 expected-reject and expected-allow checks across every scope +evidence: "issue #2325; ADR 0027; experiments recorded in docs/design/frontend-architecture.md (Biome behaviors section)" +revalidate_when: Biome changes override merging, noRestrictedImports pattern matching, plugin path resolution in extended configs, or GritQL snippet matching for `let` +--- + +# Prove Biome boundary config with canaries + +## Observation + +Designing the frontend layer rules, several configurations reported zero +diagnostics for reasons unrelated to the code being clean: + +- An override listed earlier stopped producing diagnostics once a broader + override also configured `noRestrictedImports` for the same files. +- A GritQL snippet `let $x = $y` matched nothing, while `const $x = $y` + matched. +- A plugin path written relative to the extended config failed to load. The + same path written relative to the repository root loaded. +- Counting violations from `biome lint` output undercounted, because only 20 + diagnostics print by default. + +## Confirmed cause + +Biome applies the last matching override's options for a rule. It does not +combine them with earlier overrides. A configuration mistake never fails +loudly as a rule error. It shows up as an absence of diagnostics, which looks +exactly like compliant code. + +## Promotion + +The Design Doc records the verified behaviors and the canary procedure. The +boundary file's header states the replacement rule and the path resolution. +The frontend rule requires canaries for any edit to that file. diff --git a/docs/design/frontend-architecture.md b/docs/design/frontend-architecture.md new file mode 100644 index 000000000..e3c0423dc --- /dev/null +++ b/docs/design/frontend-architecture.md @@ -0,0 +1,138 @@ +# Frontend Architecture Design + +Decision: [ADR 0027](../adr/0027-frontend-layer-boundaries-and-state-ownership.md) +(proposed). Migration tracking: [#2325](https://github.com/shm11c3/HardwareVisualizer/issues/2325). + +## Problem + +The frontend has no enforced dependency direction and no single owner for +Jotai state. Generic layers import features, features import each other, and +atoms are declared wherever they were first needed. Those are the conditions +under which a boundary erodes one convenient import at a time. + +## Layers + +A layer may import from the layers listed above it, never from the layers +listed below it. + +| Layer | Paths | May import | +| --- | --- | --- | +| Foundation | `src/rspc`, `src/types`, `src/consts`, `src/lib` | Foundation only. No React, no Jotai. | +| Store | `src/store`, `src/features//store` | Foundation, plus the feature's own non-UI modules. No React or Jotai React bindings. | +| Shared hooks | `src/hooks` | Foundation, store | +| UI primitives | `src/components/ui`, `src/components/icons` | Foundation. Never IPC `commands` or `events`. No Jotai. | +| Shared components | `src/components/charts`, `src/components/shared` | All of the above. Charts never import shared components. | +| Feature | `src/features/` | All of the above and its own feature. Never another feature. | +| App | `src/app` (planned), `src/App.tsx`, `src/lazyScreens.tsx`, `src/main*.tsx` | Anything | + +Within a feature, `hooks/` may not import components, and `store/` may not +import hooks, components, or React. + +Two rules close the obvious bypasses: + +- **No parent-relative imports.** Every import that leaves its own directory + uses `@/`, because the path rules match the specifier as written. +- **No barrels and no `export *`.** Every dependency stays visible at the + import site. + +## State Ownership + +| Where | Declare atoms | Write atoms | Read atoms | +| --- | --- | --- | --- | +| `store/` modules | yes | in derived atoms | yes | +| Hooks | no | yes | yes | +| Components and the app layer | no | no | `useAtomValue` only | + +- `src/store` holds cross-feature state, including Application Preferences once + #2325 moves them. `src/features//store` holds state owned by one feature. +- `getDefaultStore` and `createStore` stay out of production code. State reaches + React through the Provider-scoped store, which is what lets tests isolate + each case with their own store. +- Module-scope `let` is allowed only in store modules, because only there is it + visible as owned state. Anything shared between hook instances becomes an + atom or store-keyed state, for example the `WeakMap` keyed by the Jotai store + that `useProcessInfo` uses. +- The persistence boundary does not change. Application Preferences still + persist through typed Rust settings commands, and Tauri Store remains only + for UI-local state. + +## Enforcement + +Root `biome.jsonc` enables `noImportCycles`, `noBarrelFile`, and +`noReExportAll`. It extends `.config/biome/frontend-boundaries.jsonc`, which +holds one `noRestrictedImports` override per layer scope, plus the GritQL +plugin `.config/biome/no-module-scope-let.grit`. + +| Rule | Mechanism | +| --- | --- | +| Downward-only imports, no cross-feature imports | `noRestrictedImports` pattern groups per scope | +| Alias-only cross-directory imports | `../**` in every scope's group | +| Jotai API placement | `paths.jotai.importNames` per scope, plus `jotai/**` subpaths banned outside store modules | +| UI primitives never call IPC | `paths["@/rspc/bindings"].importNames` = `commands`, `events` | +| No module-scope `let` outside store modules | GritQL plugin through an override | +| No cycles, barrels, or `export *` | `noImportCycles`, `noBarrelFile`, `noReExportAll` | + +Test files are outside the boundary scopes. They legitimately seed a +`createStore` and import across layers to build fixtures. + +`src/main*.tsx` and `src/e2e/**` are composition roots and test +infrastructure, so they are also unscoped. + +A feature directory with no override of its own falls into a catch-all that +bans every `@/features/**` import, including its own. A new feature therefore +fails lint until it gets its own override. + +### Pending exceptions + +A target constraint that the current code still violates is commented out in +the boundary file as `pending Sn (#2325)`. Negated patterns +(`!@/features/settings/**`) exempt one dependency, and commented-out entries +disable one API or pattern. + +These rules keep the pending list honest: + +- The list shrinks only. Each migration slice deletes the exceptions it + resolves. +- New code that needs a new exception is a design discussion, not a config + edit. +- Files that the app-layer slice will move are listed in the last override + and get app-layer rules now, so their current directory's rules stay strict. + +### Biome behaviors this relies on + +These behaviors were verified on Biome 2.5. + +- **A later override replaces the options of a rule configured earlier for the + same file. It does not merge them.** Scopes must stay disjoint, every + override must carry its complete set, and the app override must stay last. +- `noRestrictedImports` group patterns are gitignore-style and support + negation (`!`). +- Plugin paths in an extended config resolve from the repository root. +- The snippet `let $x = $y` does not match in GritQL, while `const $x = $y` + does. The plugin therefore matches `JsVariableDeclaration()` by source text. +- Plugin diagnostics are suppressed with `// biome-ignore lint/plugin: `. +- `biome lint` prints only 20 diagnostics by default. Use `--max-diagnostics` + when counting. + +Changes to the boundary file have to be proven against canaries, because a +mistyped scope does not fail. It silently disables the rule. To prove a +change, place one file per affected scope with one import that each +constraint must reject and one that it must allow, then confirm the +diagnostics line by line. + +## Migration + +The slices, their order, and the baseline measurements live in #2325. They are +a starting plan. Re-measure and re-slice when 1.12.0 work begins. + +A slice is done when its pending exceptions are removed and `npm run lint:ci`, +`npm run build`, and `npm test` pass. A slice that changes 1 Hz atoms also +needs the fan-out regression test that `src/AGENTS.md` requires. + +## Open Questions + +- What the shared preference store and hook are called (`preferences` / + `usePreferences` or similar). This is decided in the preference slice. +- Whether presentation components may call IPC `commands` directly, or only + hooks may. Today only UI primitives are restricted. #2311 changes how + failed reads are reported and may settle this. diff --git a/src/AGENTS.md b/src/AGENTS.md index 0ecbfe860..42213c6a5 100644 --- a/src/AGENTS.md +++ b/src/AGENTS.md @@ -7,7 +7,13 @@ These instructions add to the repository root `AGENTS.md` for work under - Call the backend through generated commands from `@/rspc/bindings` and handle the generated Result shape. Never edit `src/rspc/bindings.ts` manually. -- Use Jotai for shared frontend state and keep state close to its feature. +- Use Jotai for shared frontend state. Declare new atoms in the owning + `store/` module and follow the layer boundaries in + [ADR 0027](../docs/adr/0027-frontend-layer-boundaries-and-state-ownership.md), + which `npm run lint:ci` enforces. +- Never add a pending exception to + `.config/biome/frontend-boundaries.jsonc` to make new code pass; change the + design instead. - Application Preferences go through typed Rust settings commands and `settings.json`. Use Tauri Store only for resettable UI-local/transient state. - `showGpuUsageSource` is a known legacy exception that still uses Tauri Store. diff --git a/src/README.md b/src/README.md index 0fa6eb368..f38f410ae 100644 --- a/src/README.md +++ b/src/README.md @@ -32,6 +32,27 @@ Keep feature-specific code under `features//` when the code is not meaningfully reused elsewhere. Put reusable primitives in `components/`, cross-feature hooks in `hooks/`, and pure helpers in `lib/`. +## Layer Boundaries + +Imports point only downward: + +```text +foundation (rspc, types, consts, lib) -> store -> hooks -> components/ui + -> components/{charts,shared} -> features/ -> app +``` + +- A feature never imports another feature. +- Cross-directory imports use `@/`. +- Atoms are declared only in `store/` modules. +- Components read atoms with `useAtomValue` and write through hooks. + +`npm run lint:ci` enforces these rules through +`.config/biome/frontend-boundaries.jsonc`. See +[ADR 0027](../docs/adr/0027-frontend-layer-boundaries-and-state-ownership.md) +and the [Design Doc](../docs/design/frontend-architecture.md). Existing code +that predates the rules is listed there as pending exceptions, which only +shrink. + ## Backend IPC - Use generated commands from `src/rspc/bindings.ts`. @@ -56,8 +77,8 @@ Use the existing boundary when choosing where state lives: `src/lib/tauriStore.ts` and `src/hooks/useTauriStore.ts`. Examples include ephemeral selections, cached UI choices, or view state that can be reset without losing an explicit user configuration. -- Cross-feature frontend state should use Jotai atoms under the owning feature - or `src/store/` when it is genuinely shared. +- Declare Jotai atoms in a `store/` module: `src/features//store/` + for feature state, and `src/store/` when the state is genuinely shared. Do not write user-facing preferences directly from the frontend with Tauri Store. From 993392f6f938b2f776bcbc9e48a3c775c32cc36d Mon Sep 17 00:00:00 2001 From: shm11C3 Date: Thu, 8 Oct 2026 00:05:35 +0900 Subject: [PATCH 2/4] chore(frontend): close boundary exemptions found in review - Feature store and hooks overrides admitted their whole feature through `!@/features//**`, so a store or hook could import the feature's own components. They now admit only a closed allow-list (store, types, consts, utils, funcs, plus hooks for hooks, plus hardware/gpuIdentity). - `import * as X from "jotai"` (or "@/rspc/bindings") bypassed every importNames restriction. `*` is now restricted wherever names are. - Foundation and store layers now also ban `react/**` and `react-dom/**`. - settings, menu, tray and updater get store and hooks overrides, so a feature store module that declares atoms passes instead of falling into the catch-all. Proven with 147 reject/allow canary checks. --- .config/biome/frontend-boundaries.jsonc | 405 +++++++++++++++++++++++- docs/design/frontend-architecture.md | 20 +- 2 files changed, 407 insertions(+), 18 deletions(-) diff --git a/.config/biome/frontend-boundaries.jsonc b/.config/biome/frontend-boundaries.jsonc index c24e3c1aa..ce67df18f 100644 --- a/.config/biome/frontend-boundaries.jsonc +++ b/.config/biome/frontend-boundaries.jsonc @@ -58,7 +58,9 @@ "@/lazyScreens", "../**", "react", + "react/**", "react-dom", + "react-dom/**", "jotai", "jotai/**" ] @@ -91,7 +93,9 @@ "@/lazyScreens", "../**", "react", + "react/**", "react-dom", + "react-dom/**", "jotai/react", "jotai/react/**" ] @@ -101,6 +105,7 @@ "jotai": { "message": "This Jotai API is not allowed in this layer. See docs/design/frontend-architecture.md.", "importNames": [ + "*", "createStore", "getDefaultStore", "Provider", @@ -145,6 +150,7 @@ "jotai": { "message": "This Jotai API is not allowed in this layer. See docs/design/frontend-architecture.md.", "importNames": [ + "*", "createStore", "getDefaultStore", "Provider" @@ -193,7 +199,7 @@ "paths": { "@/rspc/bindings": { "message": "UI primitives do not call IPC. See docs/design/frontend-architecture.md.", - "importNames": ["commands", "events"] + "importNames": ["*", "commands", "events"] } } } @@ -235,6 +241,7 @@ "jotai": { "message": "This Jotai API is not allowed in this layer. See docs/design/frontend-architecture.md.", "importNames": [ + "*", "atom", // "useAtom", -- pending S5 (#2325) "useSetAtom", @@ -283,6 +290,7 @@ "jotai": { "message": "This Jotai API is not allowed in this layer. See docs/design/frontend-architecture.md.", "importNames": [ + "*", "atom", "useAtom", // "useSetAtom", -- pending S5 (#2325) @@ -299,7 +307,7 @@ } } }, - // Feature catch-all + // Feature catch-all: a feature without its own overrides below fails here { "includes": [ "src/features/**", @@ -329,6 +337,7 @@ "jotai": { "message": "This Jotai API is not allowed in this layer. See docs/design/frontend-architecture.md.", "importNames": [ + "*", "atom", "useAtom", "useSetAtom", @@ -345,7 +354,7 @@ } } }, - // Feature hardware: UI + // Feature hardware: ui { "includes": [ "src/features/hardware/**", @@ -380,6 +389,7 @@ "jotai": { "message": "This Jotai API is not allowed in this layer. See docs/design/frontend-architecture.md.", "importNames": [ + "*", "atom", // "useAtom", -- pending S5 (#2325) "useSetAtom", @@ -421,7 +431,17 @@ "@/lazyScreens", // "../**", -- pending S1 (#2325) "jotai/**", - "!@/features/hardware/**", + "!@/features/hardware/store/**", + "!@/features/hardware/**/types", + "!@/features/hardware/**/types/**", + "!@/features/hardware/**/consts", + "!@/features/hardware/**/consts/**", + "!@/features/hardware/**/utils", + "!@/features/hardware/**/utils/**", + "!@/features/hardware/**/funcs", + "!@/features/hardware/**/funcs/**", + "!@/features/hardware/**/hooks/**", + "!@/features/hardware/gpuIdentity", "!@/features/settings/**" // pending S3 (#2325): delete this exemption ] } @@ -430,6 +450,7 @@ "jotai": { "message": "This Jotai API is not allowed in this layer. See docs/design/frontend-architecture.md.", "importNames": [ + "*", "createStore", "getDefaultStore", "Provider" @@ -468,10 +489,21 @@ "@/lazyScreens", "../**", "react", + "react/**", "react-dom", + "react-dom/**", "jotai/react", "jotai/react/**", - "!@/features/hardware/**" + "!@/features/hardware/store/**", + "!@/features/hardware/**/types", + "!@/features/hardware/**/types/**", + "!@/features/hardware/**/consts", + "!@/features/hardware/**/consts/**", + "!@/features/hardware/**/utils", + "!@/features/hardware/**/utils/**", + "!@/features/hardware/**/funcs", + "!@/features/hardware/**/funcs/**", + "!@/features/hardware/gpuIdentity" ] } ], @@ -479,6 +511,7 @@ "jotai": { "message": "This Jotai API is not allowed in this layer. See docs/design/frontend-architecture.md.", "importNames": [ + "*", "createStore", "getDefaultStore", "Provider", @@ -495,7 +528,7 @@ } } }, - // Feature settings: UI + // Feature settings: ui { "includes": [ "src/features/settings/**", @@ -531,6 +564,7 @@ "jotai": { "message": "This Jotai API is not allowed in this layer. See docs/design/frontend-architecture.md.", "importNames": [ + "*", "atom", // "useAtom", -- pending S5 (#2325) // "useSetAtom", -- pending S5 (#2325) @@ -572,7 +606,16 @@ "@/lazyScreens", "../**", "jotai/**", - "!@/features/settings/**", + "!@/features/settings/store/**", + "!@/features/settings/**/types", + "!@/features/settings/**/types/**", + "!@/features/settings/**/consts", + "!@/features/settings/**/consts/**", + "!@/features/settings/**/utils", + "!@/features/settings/**/utils/**", + "!@/features/settings/**/funcs", + "!@/features/settings/**/funcs/**", + "!@/features/settings/**/hooks/**", "!@/features/hardware/**" // pending S2 (#2325): delete this exemption ] } @@ -581,6 +624,7 @@ "jotai": { "message": "This Jotai API is not allowed in this layer. See docs/design/frontend-architecture.md.", "importNames": [ + "*", "createStore", "getDefaultStore", "Provider" @@ -594,7 +638,70 @@ } } }, - // Feature menu: UI + // Feature settings: store + { + "includes": [ + "src/features/settings/store/**", + "!src/**/*.test.ts", + "!src/**/*.test.tsx" + ], + "linter": { + "rules": { + "style": { + "noRestrictedImports": { + "level": "error", + "options": { + "patterns": [ + { + "message": "This import crosses a frontend layer boundary. See docs/design/frontend-architecture.md.", + "group": [ + "@/features/**", + "@/hooks/**", + "@/components/**", + "@/app/**", + "@/App", + "@/lazyScreens", + "../**", + "react", + "react/**", + "react-dom", + "react-dom/**", + "jotai/react", + "jotai/react/**", + "!@/features/settings/store/**", + "!@/features/settings/**/types", + "!@/features/settings/**/types/**", + "!@/features/settings/**/consts", + "!@/features/settings/**/consts/**", + "!@/features/settings/**/utils", + "!@/features/settings/**/utils/**", + "!@/features/settings/**/funcs", + "!@/features/settings/**/funcs/**" + ] + } + ], + "paths": { + "jotai": { + "message": "This Jotai API is not allowed in this layer. See docs/design/frontend-architecture.md.", + "importNames": [ + "*", + "createStore", + "getDefaultStore", + "Provider", + "useAtom", + "useAtomValue", + "useSetAtom", + "useStore" + ] + } + } + } + } + } + } + } + }, + // Feature menu: ui { "includes": [ "src/features/menu/**", @@ -628,6 +735,7 @@ "jotai": { "message": "This Jotai API is not allowed in this layer. See docs/design/frontend-architecture.md.", "importNames": [ + "*", "atom", "useAtom", "useSetAtom", @@ -669,7 +777,16 @@ "@/lazyScreens", "../**", "jotai/**", - "!@/features/menu/**" + "!@/features/menu/store/**", + "!@/features/menu/**/types", + "!@/features/menu/**/types/**", + "!@/features/menu/**/consts", + "!@/features/menu/**/consts/**", + "!@/features/menu/**/utils", + "!@/features/menu/**/utils/**", + "!@/features/menu/**/funcs", + "!@/features/menu/**/funcs/**", + "!@/features/menu/**/hooks/**" ] } ], @@ -677,6 +794,7 @@ "jotai": { "message": "This Jotai API is not allowed in this layer. See docs/design/frontend-architecture.md.", "importNames": [ + "*", "createStore", "getDefaultStore", "Provider" @@ -690,10 +808,76 @@ } } }, - // Feature tray + // Feature menu: store + { + "includes": [ + "src/features/menu/store/**", + "!src/**/*.test.ts", + "!src/**/*.test.tsx" + ], + "linter": { + "rules": { + "style": { + "noRestrictedImports": { + "level": "error", + "options": { + "patterns": [ + { + "message": "This import crosses a frontend layer boundary. See docs/design/frontend-architecture.md.", + "group": [ + "@/features/**", + "@/hooks/**", + "@/components/**", + "@/app/**", + "@/App", + "@/lazyScreens", + "../**", + "react", + "react/**", + "react-dom", + "react-dom/**", + "jotai/react", + "jotai/react/**", + "!@/features/menu/store/**", + "!@/features/menu/**/types", + "!@/features/menu/**/types/**", + "!@/features/menu/**/consts", + "!@/features/menu/**/consts/**", + "!@/features/menu/**/utils", + "!@/features/menu/**/utils/**", + "!@/features/menu/**/funcs", + "!@/features/menu/**/funcs/**" + ] + } + ], + "paths": { + "jotai": { + "message": "This Jotai API is not allowed in this layer. See docs/design/frontend-architecture.md.", + "importNames": [ + "*", + "createStore", + "getDefaultStore", + "Provider", + "useAtom", + "useAtomValue", + "useSetAtom", + "useStore" + ] + } + } + } + } + } + } + } + }, + // Feature tray: ui { "includes": [ "src/features/tray/**", + "!src/features/tray/store/**", + "!src/features/tray/hooks/**", + "!src/features/tray/**/hooks/**", "!src/**/*.test.ts", "!src/**/*.test.tsx" ], @@ -721,6 +905,7 @@ "jotai": { "message": "This Jotai API is not allowed in this layer. See docs/design/frontend-architecture.md.", "importNames": [ + "*", "atom", "useAtom", "useSetAtom", @@ -737,7 +922,126 @@ } } }, - // Feature updater: UI + // Feature tray: hooks + { + "includes": [ + "src/features/tray/hooks/**", + "src/features/tray/**/hooks/**", + "!src/**/*.test.ts", + "!src/**/*.test.tsx" + ], + "linter": { + "rules": { + "style": { + "noRestrictedImports": { + "level": "error", + "options": { + "patterns": [ + { + "message": "This import crosses a frontend layer boundary. See docs/design/frontend-architecture.md.", + "group": [ + "@/features/**", + "@/components/**", + "@/app/**", + "@/App", + "@/lazyScreens", + "../**", + "jotai/**", + "!@/features/tray/store/**", + "!@/features/tray/**/types", + "!@/features/tray/**/types/**", + "!@/features/tray/**/consts", + "!@/features/tray/**/consts/**", + "!@/features/tray/**/utils", + "!@/features/tray/**/utils/**", + "!@/features/tray/**/funcs", + "!@/features/tray/**/funcs/**", + "!@/features/tray/**/hooks/**" + ] + } + ], + "paths": { + "jotai": { + "message": "This Jotai API is not allowed in this layer. See docs/design/frontend-architecture.md.", + "importNames": [ + "*", + "createStore", + "getDefaultStore", + "Provider", + "atom" + ] + } + } + } + } + } + } + } + }, + // Feature tray: store + { + "includes": [ + "src/features/tray/store/**", + "!src/**/*.test.ts", + "!src/**/*.test.tsx" + ], + "linter": { + "rules": { + "style": { + "noRestrictedImports": { + "level": "error", + "options": { + "patterns": [ + { + "message": "This import crosses a frontend layer boundary. See docs/design/frontend-architecture.md.", + "group": [ + "@/features/**", + "@/hooks/**", + "@/components/**", + "@/app/**", + "@/App", + "@/lazyScreens", + "../**", + "react", + "react/**", + "react-dom", + "react-dom/**", + "jotai/react", + "jotai/react/**", + "!@/features/tray/store/**", + "!@/features/tray/**/types", + "!@/features/tray/**/types/**", + "!@/features/tray/**/consts", + "!@/features/tray/**/consts/**", + "!@/features/tray/**/utils", + "!@/features/tray/**/utils/**", + "!@/features/tray/**/funcs", + "!@/features/tray/**/funcs/**" + ] + } + ], + "paths": { + "jotai": { + "message": "This Jotai API is not allowed in this layer. See docs/design/frontend-architecture.md.", + "importNames": [ + "*", + "createStore", + "getDefaultStore", + "Provider", + "useAtom", + "useAtomValue", + "useSetAtom", + "useStore" + ] + } + } + } + } + } + } + } + }, + // Feature updater: ui { "includes": [ "src/features/updater/**", @@ -771,6 +1075,7 @@ "jotai": { "message": "This Jotai API is not allowed in this layer. See docs/design/frontend-architecture.md.", "importNames": [ + "*", "atom", "useAtom", "useSetAtom", @@ -812,7 +1117,16 @@ "@/lazyScreens", "../**", "jotai/**", - "!@/features/updater/**" + "!@/features/updater/store/**", + "!@/features/updater/**/types", + "!@/features/updater/**/types/**", + "!@/features/updater/**/consts", + "!@/features/updater/**/consts/**", + "!@/features/updater/**/utils", + "!@/features/updater/**/utils/**", + "!@/features/updater/**/funcs", + "!@/features/updater/**/funcs/**", + "!@/features/updater/**/hooks/**" ] } ], @@ -820,6 +1134,7 @@ "jotai": { "message": "This Jotai API is not allowed in this layer. See docs/design/frontend-architecture.md.", "importNames": [ + "*", "createStore", "getDefaultStore", "Provider", @@ -833,7 +1148,70 @@ } } }, - // App + // Feature updater: store + { + "includes": [ + "src/features/updater/store/**", + "!src/**/*.test.ts", + "!src/**/*.test.tsx" + ], + "linter": { + "rules": { + "style": { + "noRestrictedImports": { + "level": "error", + "options": { + "patterns": [ + { + "message": "This import crosses a frontend layer boundary. See docs/design/frontend-architecture.md.", + "group": [ + "@/features/**", + "@/hooks/**", + "@/components/**", + "@/app/**", + "@/App", + "@/lazyScreens", + "../**", + "react", + "react/**", + "react-dom", + "react-dom/**", + "jotai/react", + "jotai/react/**", + "!@/features/updater/store/**", + "!@/features/updater/**/types", + "!@/features/updater/**/types/**", + "!@/features/updater/**/consts", + "!@/features/updater/**/consts/**", + "!@/features/updater/**/utils", + "!@/features/updater/**/utils/**", + "!@/features/updater/**/funcs", + "!@/features/updater/**/funcs/**" + ] + } + ], + "paths": { + "jotai": { + "message": "This Jotai API is not allowed in this layer. See docs/design/frontend-architecture.md.", + "importNames": [ + "*", + "createStore", + "getDefaultStore", + "Provider", + "useAtom", + "useAtomValue", + "useSetAtom", + "useStore" + ] + } + } + } + } + } + } + } + }, + // App: listed files are what S4 moves to src/app/; keep this override last { "includes": [ "src/App.tsx", @@ -863,6 +1241,7 @@ "jotai": { "message": "This Jotai API is not allowed in this layer. See docs/design/frontend-architecture.md.", "importNames": [ + "*", "atom", // "useAtom", -- pending S5 (#2325) // "useSetAtom", -- pending S5 (#2325) diff --git a/docs/design/frontend-architecture.md b/docs/design/frontend-architecture.md index e3c0423dc..324151770 100644 --- a/docs/design/frontend-architecture.md +++ b/docs/design/frontend-architecture.md @@ -25,8 +25,16 @@ listed below it. | Feature | `src/features/` | All of the above and its own feature. Never another feature. | | App | `src/app` (planned), `src/App.tsx`, `src/lazyScreens.tsx`, `src/main*.tsx` | Anything | -Within a feature, `hooks/` may not import components, and `store/` may not -import hooks, components, or React. +Within a feature, `store/` and `hooks/` import their own feature through a +closed allow-list rather than an open exemption: + +- `store/` may import the feature's `store`, `types`, `consts`, `utils`, and + `funcs` modules. +- `hooks/` may import the same modules plus the feature's `hooks`. +- Feature root modules such as `hardware/gpuIdentity` are allowed by name. + +An allow-list is used because specifiers carry no file extension, so a deny +pattern cannot tell a screen component from a logic module. Two rules close the obvious bypasses: @@ -69,6 +77,7 @@ plugin `.config/biome/no-module-scope-let.grit`. | Alias-only cross-directory imports | `../**` in every scope's group | | Jotai API placement | `paths.jotai.importNames` per scope, plus `jotai/**` subpaths banned outside store modules | | UI primitives never call IPC | `paths["@/rspc/bindings"].importNames` = `commands`, `events` | +| No bypass through namespace imports | `*` is listed wherever specific names are restricted | | No module-scope `let` outside store modules | GritQL plugin through an override | | No cycles, barrels, or `export *` | `noImportCycles`, `noBarrelFile`, `noReExportAll` | @@ -78,9 +87,10 @@ Test files are outside the boundary scopes. They legitimately seed a `src/main*.tsx` and `src/e2e/**` are composition roots and test infrastructure, so they are also unscoped. -A feature directory with no override of its own falls into a catch-all that -bans every `@/features/**` import, including its own. A new feature therefore -fails lint until it gets its own override. +Every feature has three overrides: UI, `hooks/`, and `store/`. A feature +directory with no overrides of its own falls into a catch-all that bans every +`@/features/**` import, including its own. A new feature therefore fails lint +until it gets its own overrides. ### Pending exceptions From 319e7f7711d316ec9830b0c5437920ef44bcab2d Mon Sep 17 00:00:00 2001 From: shm11C3 Date: Thu, 8 Oct 2026 00:09:02 +0900 Subject: [PATCH 3/4] chore(frontend): restrict jotai 3 APIs added since the boundary rules The develop merge brought jotai 3.0.1 and Biome 2.5.15. - jotai 3 adds `INTERNAL_overrideCreateStore`, now restricted wherever `createStore` is, and the read-only `useAtomValueRaw` / `useAtomValueRawSync`, now restricted in store modules alongside `useAtomValue`. An export missing from the lists is silently allowed, so the design doc asks to re-check them on every jotai upgrade. - Reformat the GritQL plugin for Biome 2.5.15's formatter. Re-proven on Biome 2.5.15 with 154 reject/allow canary checks. --- .config/biome/frontend-boundaries.jsonc | 33 +++++++++++++++++++++++++ .config/biome/no-module-scope-let.grit | 2 +- docs/design/frontend-architecture.md | 5 ++++ 3 files changed, 39 insertions(+), 1 deletion(-) diff --git a/.config/biome/frontend-boundaries.jsonc b/.config/biome/frontend-boundaries.jsonc index ce67df18f..94f76d3b4 100644 --- a/.config/biome/frontend-boundaries.jsonc +++ b/.config/biome/frontend-boundaries.jsonc @@ -107,10 +107,13 @@ "importNames": [ "*", "createStore", + "INTERNAL_overrideCreateStore", "getDefaultStore", "Provider", "useAtom", "useAtomValue", + "useAtomValueRaw", + "useAtomValueRawSync", "useSetAtom", "useStore" ] @@ -152,6 +155,7 @@ "importNames": [ "*", "createStore", + "INTERNAL_overrideCreateStore", "getDefaultStore", "Provider" // "atom", -- pending S5 (#2325) @@ -247,6 +251,7 @@ "useSetAtom", "useStore", "createStore", + "INTERNAL_overrideCreateStore", "getDefaultStore", "Provider" ] @@ -296,6 +301,7 @@ // "useSetAtom", -- pending S5 (#2325) "useStore", "createStore", + "INTERNAL_overrideCreateStore", "getDefaultStore", "Provider" ] @@ -343,6 +349,7 @@ "useSetAtom", "useStore", "createStore", + "INTERNAL_overrideCreateStore", "getDefaultStore", "Provider" ] @@ -395,6 +402,7 @@ "useSetAtom", "useStore", "createStore", + "INTERNAL_overrideCreateStore", "getDefaultStore", "Provider" ] @@ -452,6 +460,7 @@ "importNames": [ "*", "createStore", + "INTERNAL_overrideCreateStore", "getDefaultStore", "Provider" // "atom", -- pending S5 (#2325) @@ -513,10 +522,13 @@ "importNames": [ "*", "createStore", + "INTERNAL_overrideCreateStore", "getDefaultStore", "Provider", "useAtom", "useAtomValue", + "useAtomValueRaw", + "useAtomValueRawSync", "useSetAtom", "useStore" ] @@ -570,6 +582,7 @@ // "useSetAtom", -- pending S5 (#2325) "useStore", "createStore", + "INTERNAL_overrideCreateStore", "getDefaultStore", "Provider" ] @@ -626,6 +639,7 @@ "importNames": [ "*", "createStore", + "INTERNAL_overrideCreateStore", "getDefaultStore", "Provider" // "atom", -- pending S5 (#2325) @@ -686,10 +700,13 @@ "importNames": [ "*", "createStore", + "INTERNAL_overrideCreateStore", "getDefaultStore", "Provider", "useAtom", "useAtomValue", + "useAtomValueRaw", + "useAtomValueRawSync", "useSetAtom", "useStore" ] @@ -741,6 +758,7 @@ "useSetAtom", "useStore", "createStore", + "INTERNAL_overrideCreateStore", "getDefaultStore", "Provider" ] @@ -796,6 +814,7 @@ "importNames": [ "*", "createStore", + "INTERNAL_overrideCreateStore", "getDefaultStore", "Provider" // "atom", -- pending S5 (#2325) @@ -856,10 +875,13 @@ "importNames": [ "*", "createStore", + "INTERNAL_overrideCreateStore", "getDefaultStore", "Provider", "useAtom", "useAtomValue", + "useAtomValueRaw", + "useAtomValueRawSync", "useSetAtom", "useStore" ] @@ -911,6 +933,7 @@ "useSetAtom", "useStore", "createStore", + "INTERNAL_overrideCreateStore", "getDefaultStore", "Provider" ] @@ -966,6 +989,7 @@ "importNames": [ "*", "createStore", + "INTERNAL_overrideCreateStore", "getDefaultStore", "Provider", "atom" @@ -1026,10 +1050,13 @@ "importNames": [ "*", "createStore", + "INTERNAL_overrideCreateStore", "getDefaultStore", "Provider", "useAtom", "useAtomValue", + "useAtomValueRaw", + "useAtomValueRawSync", "useSetAtom", "useStore" ] @@ -1081,6 +1108,7 @@ "useSetAtom", "useStore", "createStore", + "INTERNAL_overrideCreateStore", "getDefaultStore", "Provider" ] @@ -1136,6 +1164,7 @@ "importNames": [ "*", "createStore", + "INTERNAL_overrideCreateStore", "getDefaultStore", "Provider", "atom" @@ -1196,10 +1225,13 @@ "importNames": [ "*", "createStore", + "INTERNAL_overrideCreateStore", "getDefaultStore", "Provider", "useAtom", "useAtomValue", + "useAtomValueRaw", + "useAtomValueRawSync", "useSetAtom", "useStore" ] @@ -1247,6 +1279,7 @@ // "useSetAtom", -- pending S5 (#2325) "useStore", "createStore", + "INTERNAL_overrideCreateStore", "getDefaultStore", "Provider" ] diff --git a/.config/biome/no-module-scope-let.grit b/.config/biome/no-module-scope-let.grit index ec4a78c41..05b3d5617 100644 --- a/.config/biome/no-module-scope-let.grit +++ b/.config/biome/no-module-scope-let.grit @@ -12,6 +12,6 @@ JsVariableDeclaration() as $declaration where { JsFunctionExpression(), JsClassDeclaration(), JsClassExpression() - } , + }, register_diagnostic(span=$declaration, message="Module-scope `let` is state outside the Jotai store. Declare it in a `store/` module (an atom, or store-keyed state) so it is scoped per Provider and resets between tests.") } diff --git a/docs/design/frontend-architecture.md b/docs/design/frontend-architecture.md index 324151770..a7df0f4cf 100644 --- a/docs/design/frontend-architecture.md +++ b/docs/design/frontend-architecture.md @@ -78,6 +78,11 @@ plugin `.config/biome/no-module-scope-let.grit`. | Jotai API placement | `paths.jotai.importNames` per scope, plus `jotai/**` subpaths banned outside store modules | | UI primitives never call IPC | `paths["@/rspc/bindings"].importNames` = `commands`, `events` | | No bypass through namespace imports | `*` is listed wherever specific names are restricted | + +The restricted Jotai names follow jotai's root exports. Jotai 3 added +`useAtomValueRaw`, `useAtomValueRawSync`, and `INTERNAL_overrideCreateStore`, +and a new export is allowed until it is listed. Re-check the lists against +the package's exports whenever jotai is upgraded. | No module-scope `let` outside store modules | GritQL plugin through an override | | No cycles, barrels, or `export *` | `noImportCycles`, `noBarrelFile`, `noReExportAll` | From 9cd838f423f6e15cec77a0c8671738c7760fe52a Mon Sep 17 00:00:00 2001 From: shm11C3 Date: Fri, 9 Oct 2026 02:13:43 +0900 Subject: [PATCH 4/4] docs(frontend): keep the enforcement table contiguous The jotai-exports note was inserted between table rows, so the module-scope let and cycle/barrel rows stopped rendering as table rows. --- docs/design/frontend-architecture.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/design/frontend-architecture.md b/docs/design/frontend-architecture.md index a7df0f4cf..390353ba6 100644 --- a/docs/design/frontend-architecture.md +++ b/docs/design/frontend-architecture.md @@ -78,13 +78,13 @@ plugin `.config/biome/no-module-scope-let.grit`. | Jotai API placement | `paths.jotai.importNames` per scope, plus `jotai/**` subpaths banned outside store modules | | UI primitives never call IPC | `paths["@/rspc/bindings"].importNames` = `commands`, `events` | | No bypass through namespace imports | `*` is listed wherever specific names are restricted | +| No module-scope `let` outside store modules | GritQL plugin through an override | +| No cycles, barrels, or `export *` | `noImportCycles`, `noBarrelFile`, `noReExportAll` | The restricted Jotai names follow jotai's root exports. Jotai 3 added `useAtomValueRaw`, `useAtomValueRawSync`, and `INTERNAL_overrideCreateStore`, and a new export is allowed until it is listed. Re-check the lists against the package's exports whenever jotai is upgraded. -| No module-scope `let` outside store modules | GritQL plugin through an override | -| No cycles, barrels, or `export *` | `noImportCycles`, `noBarrelFile`, `noReExportAll` | Test files are outside the boundary scopes. They legitimately seed a `createStore` and import across layers to build fixtures.