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/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
Expand Down
86 changes: 86 additions & 0 deletions src/analyze/rules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<string>;
readonly setters: Set<string>;
Expand Down Expand Up @@ -2156,6 +2241,7 @@ const parseErrorRule: AnalyzeRule = {
export const ANALYZE_RULES: readonly AnalyzeRule[] = [
parseErrorRule,
stableRenderRule,
testingContractRule,
stateAccessRule,
stateRenderWriteRule,
resourceCancellationRule,
Expand Down
35 changes: 35 additions & 0 deletions tests/analyze.rules.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;',
Expand Down