diff --git a/docs/analyze.md b/docs/analyze.md index 804e4a8..1cfe78c 100644 --- a/docs/analyze.md +++ b/docs/analyze.md @@ -35,6 +35,8 @@ another package or local module is not treated as an Askr API. - `askr/stable-render-call` enforces stable top-level calls for state, derived values, selectors, resources, lifecycle operations, actions, queries, and mutations where the AST establishes a component render context. +- `askr/stable-module-identity` requires `lazy()` and `defineScope()` + declarations to remain at module scope so their identities survive renders. - `askr/state-access` reports state getters used without calling them and setters called without a value or updater. - `askr/state-render-write` reports state mutation during the owning component's diff --git a/src/analyze/rules.ts b/src/analyze/rules.ts index 02785c6..7d39a27 100644 --- a/src/analyze/rules.ts +++ b/src/analyze/rules.ts @@ -261,6 +261,34 @@ const stableRenderRule: AnalyzeRule = { }, }; +const stableModuleIdentityRule: AnalyzeRule = { + id: "askr/stable-module-identity", + category: "correctness", + severity: "error", + description: "Module identity primitives must be declared outside functions.", + analyze(context) { + const diagnostics: AnalyzeDiagnostic[] = []; + for (const sourceFile of context.sourceFiles) { + const bindings = sourceBindings(sourceFile); + visit(sourceFile, (node) => { + if (!ts.isCallExpression(node) || !containingFunction(node)) return; + const name = canonicalCallName(node.expression, bindings); + if (name !== "lazy" && name !== "defineScope") return; + diagnostics.push( + diagnostic( + context, + node.expression, + this, + `${name}() creates stable identity and must be declared at module scope.`, + `Move the ${name}() declaration outside every function.`, + ), + ); + }); + } + return diagnostics; + }, +}; + interface StateBindings { readonly getters: Set; readonly setters: Set; @@ -2156,6 +2184,7 @@ const parseErrorRule: AnalyzeRule = { export const ANALYZE_RULES: readonly AnalyzeRule[] = [ parseErrorRule, stableRenderRule, + stableModuleIdentityRule, stateAccessRule, stateRenderWriteRule, resourceCancellationRule, diff --git a/templates/startkit/src/routes/auth.ts b/templates/startkit/src/routes/auth.ts index bb7ace4..4ce683d 100644 --- a/templates/startkit/src/routes/auth.ts +++ b/templates/startkit/src/routes/auth.ts @@ -1,8 +1,7 @@ import { lazy, route } from '@askrjs/askr/router'; +const LoginPage = lazy(() => import('../pages/auth/login')); + export function registerAuthRoutes(): void { - route( - '/login', - lazy(() => import('../pages/auth/login')) - ); + route('/login', LoginPage); } diff --git a/templates/startkit/src/routes/index.ts b/templates/startkit/src/routes/index.ts index 3b869a7..42da944 100644 --- a/templates/startkit/src/routes/index.ts +++ b/templates/startkit/src/routes/index.ts @@ -7,6 +7,8 @@ import { registerAuthRoutes } from './auth'; import { registerPublicRoutes } from './public'; import { registerWorkspaceRoutes } from './workspace'; +const NotFoundPage = lazy(() => import('../pages/not-found')); + export function registerAppRoutes(): void { group({ layout: App }, () => { registerPublicRoutes(); @@ -19,7 +21,7 @@ export function registerAppRoutes(): void { registerWorkspaceRoutes(); }); - fallback(lazy(() => import('../pages/not-found'))); + fallback(NotFoundPage); }); } diff --git a/templates/startkit/src/routes/public.ts b/templates/startkit/src/routes/public.ts index fb3ec9e..a6d0dd1 100644 --- a/templates/startkit/src/routes/public.ts +++ b/templates/startkit/src/routes/public.ts @@ -1,8 +1,7 @@ import { lazy, route } from '@askrjs/askr/router'; +const HomePage = lazy(() => import('../pages/home')); + export function registerPublicRoutes(): void { - route( - '/', - lazy(() => import('../pages/home')) - ); + route('/', HomePage); } diff --git a/templates/startkit/src/routes/workspace/accounts.ts b/templates/startkit/src/routes/workspace/accounts.ts index 2421eda..7b3f3aa 100644 --- a/templates/startkit/src/routes/workspace/accounts.ts +++ b/templates/startkit/src/routes/workspace/accounts.ts @@ -1,8 +1,7 @@ import { lazy, route } from '@askrjs/askr/router'; +const AccountsPage = lazy(() => import('../../pages/workspace/accounts')); + export function registerAccountRoutes(): void { - route( - '/accounts', - lazy(() => import('../../pages/workspace/accounts')) - ); + route('/accounts', AccountsPage); } diff --git a/templates/startkit/src/routes/workspace/index.ts b/templates/startkit/src/routes/workspace/index.ts index a37fa24..46db62b 100644 --- a/templates/startkit/src/routes/workspace/index.ts +++ b/templates/startkit/src/routes/workspace/index.ts @@ -1,14 +1,11 @@ import { lazy, route } from '@askrjs/askr/router'; import { registerAccountRoutes } from './accounts'; +const DashboardPage = lazy(() => import('../../pages/workspace/dashboard')); +const SettingsPage = lazy(() => import('../../pages/workspace/settings')); + export function registerWorkspaceRoutes(): void { - route( - '/dashboard', - lazy(() => import('../../pages/workspace/dashboard')) - ); + route('/dashboard', DashboardPage); registerAccountRoutes(); - route( - '/settings', - lazy(() => import('../../pages/workspace/settings')) - ); + route('/settings', SettingsPage); } diff --git a/tests/analyze.rules.test.ts b/tests/analyze.rules.test.ts index 9ae5e30..81c38f2 100644 --- a/tests/analyze.rules.test.ts +++ b/tests/analyze.rules.test.ts @@ -96,6 +96,33 @@ describe("analyzer rules", () => { }); }); + it("requires lazy and defineScope identities to be declared at module scope", async () => { + const root = await fixture({ + "src/page.tsx": ` + import { defineScope } from "@askrjs/askr"; + import { lazy } from "@askrjs/askr/router"; + const AppScope = defineScope("light"); + const Settings = lazy(() => import("./settings")); + export function Page() { + const LocalScope = defineScope("dark"); + const LocalSettings = lazy(() => import("./settings")); + return ; + } + export const makeScope = () => defineScope("nested"); + void AppScope; + void Settings; + `, + "src/settings.tsx": "export default function Settings() { return
; }", + }); + + const found = (await diagnostics(root)).filter( + (entry) => entry.ruleId === "askr/stable-module-identity", + ); + expect(found).toHaveLength(3); + expect(found.every((entry) => entry.severity === "error")).toBe(true); + expect(found.every((entry) => /module scope/.test(entry.message))).toBe(true); + }); + it("checks resource cancellation and stable dependencies while accepting forwarded signals", async () => { const root = await fixture({ "src/page.tsx": `