From e3b24bb89a3c031879256670ea8006cd4d9a912e Mon Sep 17 00:00:00 2001 From: Loi Nguyen Date: Thu, 30 Jul 2026 23:03:23 +0700 Subject: [PATCH] fix(analyze): require flush before assertions --- docs/analyze.md | 2 + src/analyze/rules.ts | 86 +++++++++++++++++++++++++++++++++++++ tests/analyze.rules.test.ts | 35 +++++++++++++++ 3 files changed, 123 insertions(+) diff --git a/docs/analyze.md b/docs/analyze.md index 804e4a8..15e8782 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/testing-contract` requires an awaited `flush()` between testing + `dispatch()` calls and the next assertion in the same block. - `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..80335c5 100644 --- a/src/analyze/rules.ts +++ b/src/analyze/rules.ts @@ -167,6 +167,24 @@ function visit(sourceFile: ts.SourceFile, callback: (node: ts.Node) => void): vo walk(sourceFile); } +function statementContains( + statement: ts.Statement, + predicate: (node: ts.Node) => boolean, +): boolean { + let found = false; + const walk = (node: ts.Node): void => { + if (found) return; + if (node !== statement && ts.isFunctionLike(node)) return; + if (predicate(node)) { + found = true; + return; + } + ts.forEachChild(node, walk); + }; + walk(statement); + return found; +} + function containingFunction(node: ts.Node): ts.SignatureDeclaration | null { for (let current = node.parent; current; current = current.parent) { if (ts.isFunctionLike(current)) return current; @@ -261,6 +279,73 @@ const stableRenderRule: AnalyzeRule = { }, }; +const testingContractRule: AnalyzeRule = { + id: "askr/testing-contract", + category: "correctness", + severity: "warning", + description: "Testing interactions must flush scheduled updates before assertions.", + analyze(context) { + const diagnostics: AnalyzeDiagnostic[] = []; + for (const sourceFile of context.sourceFiles) { + const importsTesting = sourceFile.statements.some( + (statement) => + ts.isImportDeclaration(statement) && + ts.isStringLiteral(statement.moduleSpecifier) && + statement.moduleSpecifier.text === "@askrjs/askr/testing", + ); + if (!importsTesting) continue; + const bindings = sourceBindings(sourceFile); + visit(sourceFile, (node) => { + if (!ts.isBlock(node)) return; + for (let index = 0; index < node.statements.length; index += 1) { + const statement = node.statements[index]; + if ( + !ts.isExpressionStatement(statement) || + !ts.isCallExpression(statement.expression) || + canonicalCallName(statement.expression.expression, bindings) !== "dispatch" + ) { + continue; + } + for (const next of node.statements.slice(index + 1)) { + const awaitedFlush = statementContains( + next, + (candidate) => + ts.isAwaitExpression(candidate) && + ts.isCallExpression(candidate.expression) && + canonicalCallName(candidate.expression.expression, bindings) === "flush", + ); + if (awaitedFlush) break; + const assertion = statementContains(next, (candidate) => { + if (!ts.isCallExpression(candidate)) return false; + const expression = candidate.expression; + if (ts.isIdentifier(expression)) { + return expression.text === "expect" || expression.text === "assert"; + } + return ( + ts.isPropertyAccessExpression(expression) && + ts.isIdentifier(expression.expression) && + expression.expression.text === "assert" + ); + }); + if (!assertion) continue; + diagnostics.push( + diagnostic( + context, + statement.expression, + this, + "dispatch() is followed by an assertion before awaited flush().", + "Await flush() after dispatch() and before reading updated state or DOM.", + ), + ); + break; + } + } + }); + } + return diagnostics; + }, +}; + interface StateBindings { readonly getters: Set; readonly setters: Set; @@ -2156,6 +2241,7 @@ const parseErrorRule: AnalyzeRule = { export const ANALYZE_RULES: readonly AnalyzeRule[] = [ parseErrorRule, stableRenderRule, + testingContractRule, stateAccessRule, stateRenderWriteRule, resourceCancellationRule, diff --git a/tests/analyze.rules.test.ts b/tests/analyze.rules.test.ts index 9ae5e30..3a94e95 100644 --- a/tests/analyze.rules.test.ts +++ b/tests/analyze.rules.test.ts @@ -227,6 +227,41 @@ describe("analyzer rules", () => { ).toBe(true); }); + it("requires an awaited testing flush before the next assertion", async () => { + const root = await fixture({ + "src/page.test.tsx": ` + import { dispatch as fire, flush } from "@askrjs/askr/testing"; + declare const button: EventTarget; + test("stale assertion", async () => { + fire(button, new Event("click")); + expect(button).toBeDefined(); + }); + test("unawaited flush", async () => { + fire(button, new Event("click")); + flush(); + expect(button).toBeDefined(); + }); + test("committed assertion", async () => { + fire(button, new Event("click")); + await flush(); + expect(button).toBeDefined(); + }); + `, + "src/unrelated.test.ts": ` + function dispatch() {} + dispatch(); + expect(true).toBe(true); + `, + }); + + const found = (await diagnostics(root)).filter( + (entry) => entry.ruleId === "askr/testing-contract", + ); + expect(found).toHaveLength(2); + expect(found.every((entry) => entry.file === "src/page.test.tsx")).toBe(true); + expect(found.every((entry) => entry.severity === "warning")).toBe(true); + }); + it("keeps dependency declaration graphs out of analysis programs", async () => { const root = await fixture({ "src/page.ts": 'import type { Huge } from "huge-package"; export type Page = Huge;',