rules/eslint/closure-lifecycle-handle-must-be-stored.eslint.rule.ts
rules/eslint/closure-lifecycle-handle-must-be-stored.eslint.rule.ts is a file in GovLab Extension Host. 145 lines of code and 29 definitions.
import type { LocalRule, RuleContext, RuleListener } from "../../types/rule.types.ts";
import { isType, nameOf, nodeAt, stringAt } from "../../shared/selectors/syntax.selector.ts";
import type { AstNode } from "../../types/syntax.types.ts";
import { defineCheck } from "@govlab/context/check";
import { isVerbOrOpensWith } from "../../shared/manifests/verb.manifest.ts";
import { listener } from "../../shared/factories/listener.factory.ts";
import ts from "typescript";
const LIFECYCLE_VERBS: readonly string[] = [
"attach",
"connect",
"init",
"initialize",
"listen",
"mount",
"observe",
"open",
"run",
"start",
"subscribe",
"watch",
];
const EXEMPT_BASENAME_SUFFIXES = [".test.ts", ".spec.ts"];
const BIND = "bind";
const basenameOf = function basenameOf(path: string): string {
const norm = path.split("\\").join("/");
const idx = norm.lastIndexOf("/");
return idx === -1 ? norm : norm.slice(idx + 1);
};
const isExemptFile = function isExemptFile(filename: string): boolean {
const basename = basenameOf(filename);
return EXEMPT_BASENAME_SUFFIXES.some((suffix) => basename.endsWith(suffix));
};
const calleeNameOf = function calleeNameOf(callee: AstNode | null): string {
if (callee === null) {
return "";
}
if (callee.type === "Identifier") {
return nameOf(callee);
}
if (callee.type === "MemberExpression") {
const property = nodeAt(callee, "property");
return isType(property, "Identifier") ? nameOf(property) : "";
}
if (callee.type === "ChainExpression") {
return calleeNameOf(nodeAt(callee, "expression"));
}
return "";
};
const isLifecycleCall = function isLifecycleCall(node: AstNode): boolean {
const callee = nodeAt(node, "callee");
const name = calleeNameOf(callee);
if (name === "" || callee === null) {
return false;
}
if (callee.type === "Identifier") {
return isVerbOrOpensWith(name, LIFECYCLE_VERBS);
}
return LIFECYCLE_VERBS.includes(name);
};
const isReturnDiscarded = function isReturnDiscarded(node: AstNode): boolean {
const parent = nodeAt(node, "parent");
if (parent === null) {
return false;
}
if (parent.type === "ExpressionStatement") {
return true;
}
return parent.type === "ChainExpression" && isType(nodeAt(parent, "parent"), "ExpressionStatement");
};
const isWrappedInVoid = function isWrappedInVoid(node: AstNode): boolean {
const parent = nodeAt(node, "parent");
return parent?.type === "UnaryExpression" && stringAt(parent, "operator") === "void";
};
interface TypedServices {
readonly program: ts.Program;
readonly esTreeNodeToTSNodeMap: { readonly get: (node: unknown) => ts.Node };
}
const isTypedServices = function isTypedServices(value: unknown): value is TypedServices {
if (typeof value !== "object" || value === null) {
return false;
}
const program: unknown = Reflect.get(value, "program");
const map: unknown = Reflect.get(value, "esTreeNodeToTSNodeMap");
if (typeof program !== "object" || program === null) {
return false;
}
return typeof map === "object" && map !== null && typeof Reflect.get(map, "get") === "function";
};
const EMPTY_RETURNS: ReadonlySet<ts.TypeFlags> = new Set([ts.TypeFlags.Void, ts.TypeFlags.Undefined]);
const returnsNothing = function returnsNothing(context: RuleContext, node: unknown): boolean {
const services: unknown = Reflect.get(context.sourceCode, "parserServices");
if (!isTypedServices(services)) {
return false;
}
const type = services.program.getTypeChecker().getTypeAtLocation(services.esTreeNodeToTSNodeMap.get(node));
return EMPTY_RETURNS.has(type.getFlags());
};
const boundLifecycleName = function boundLifecycleName(node: AstNode): string {
const callee = nodeAt(node, "callee");
if (!isType(callee, "MemberExpression") || calleeNameOf(callee) !== BIND) {
return "";
}
const target = nodeAt(callee, "object");
const name = isType(target, "MemberExpression") ? calleeNameOf(target) : "";
return LIFECYCLE_VERBS.includes(name) ? name : "";
};
export default {
create(context: RuleContext): RuleListener {
if (isExemptFile(context.filename)) {
return {};
}
return listener({
callExpression(view, node) {
const bound = boundLifecycleName(view);
if (bound !== "") {
context.report({ data: { name: bound }, messageId: "lifecycleBound", node });
return;
}
if (!isLifecycleCall(view) || !isReturnDiscarded(view) || isWrappedInVoid(view)) {
return;
}
if (returnsNothing(context, node)) {
return;
}
const name = calleeNameOf(nodeAt(view, "callee"));
context.report({ data: { name }, messageId: "handleDiscarded", node });
},
});
},
meta: {
docs: {
checks: defineCheck({ detects: [], enforces: [] }),
description:
"A lifecycle call that returns a handle must have that return captured. Which calls are lifecycle calls is DERIVED from the verb the name opens with, never from a list of known functions — a list omits every runner added after it was written, and this rule's whole value is catching the new one. A free function matches when its name begins with a lifecycle verb at a camel-case boundary, so a runner named for what it starts is caught the day it is written. A method matches only when its name IS the bare verb, which keeps the platform's verb-prefixed accessors out without naming any of them. Discarding the return throws away the dispose path and creates silent lifecycle drift: either store the handle and call it on teardown, or mark the discard intentional with an explicit void. Where the type checker is available, a call whose type says it returns nothing has no handle to lose and is not reported; without type information every discarded lifecycle call is reported. Binding a lifecycle method to call it later hides the call from this check, so the bind itself is reported.",
},
messages: {
handleDiscarded:
"Lifecycle call `{{ name }}(...)` discards its return value. If the function returns a handle (`{ unmount }` / `{ stop }` / `{ dispose }`), the cleanup path is lost. Either: (a) `const handle = {{ name }}(...)` and call `handle.unmount/stop/dispose` in your teardown, OR (b) wrap as `void {{ name }}(...)` to declare the discard intentional.",
lifecycleBound:
"The lifecycle method `{{ name }}` is bound to be called later, which hides the call from the handle check. Call the method directly, so the check can see whether its return carries a handle.",
},
schema: [],
type: "problem",
},
} satisfies LocalRule;