Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/analyze.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
29 changes: 29 additions & 0 deletions src/analyze/rules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>;
readonly setters: Set<string>;
Expand Down Expand Up @@ -2156,6 +2184,7 @@ const parseErrorRule: AnalyzeRule = {
export const ANALYZE_RULES: readonly AnalyzeRule[] = [
parseErrorRule,
stableRenderRule,
stableModuleIdentityRule,
stateAccessRule,
stateRenderWriteRule,
resourceCancellationRule,
Expand Down
7 changes: 3 additions & 4 deletions templates/startkit/src/routes/auth.ts
Original file line number Diff line number Diff line change
@@ -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);
}
4 changes: 3 additions & 1 deletion templates/startkit/src/routes/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -19,7 +21,7 @@ export function registerAppRoutes(): void {
registerWorkspaceRoutes();
});

fallback(lazy(() => import('../pages/not-found')));
fallback(NotFoundPage);
});
}

Expand Down
7 changes: 3 additions & 4 deletions templates/startkit/src/routes/public.ts
Original file line number Diff line number Diff line change
@@ -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);
}
7 changes: 3 additions & 4 deletions templates/startkit/src/routes/workspace/accounts.ts
Original file line number Diff line number Diff line change
@@ -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);
}
13 changes: 5 additions & 8 deletions templates/startkit/src/routes/workspace/index.ts
Original file line number Diff line number Diff line change
@@ -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);
}
27 changes: 27 additions & 0 deletions tests/analyze.rules.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <LocalScope><LocalSettings /></LocalScope>;
}
export const makeScope = () => defineScope("nested");
void AppScope;
void Settings;
`,
"src/settings.tsx": "export default function Settings() { return <div />; }",
});

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": `
Expand Down