# rules/eslint/closure-lifecycle-handle-must-be-stored.eslint.rule.ts

> 145 lines of code and 29 definitions.

Tree: Governance tree
Language: typescript
Canonical: https://banes-lab.com/anatomy/governance#file-governance-rules-eslint-closure-lifecycle-handle-must-be-stored-eslint-rule-ts
Source text: https://banes-lab.com/source/governance/rules/eslint/closure-lifecycle-handle-must-be-stored.eslint.rule.ts.txt

Listed in [rules/eslint](https://banes-lab.com/api/source/governance/rules/eslint.md), after [rules/eslint/closure-event-system-has-trajectory.eslint.rule.ts](https://banes-lab.com/source/governance/rules/eslint/closure-event-system-has-trajectory.eslint.rule.ts.md) and before [rules/eslint/closure-no-browser-derivation.eslint.rule.ts](https://banes-lab.com/source/governance/rules/eslint/closure-no-browser-derivation.eslint.rule.ts.md).

## Definitions

- `calleeNameOf` (lexical_declaration, line 39)
- `isReturnDiscarded` (lexical_declaration, line 68)
- `isLifecycleCall` (lexical_declaration, line 56)
- `isWrappedInVoid` (lexical_declaration, line 79)
- `returnsNothing` (lexical_declaration, line 103)
- `isExemptFile` (lexical_declaration, line 34)
- `isTypedServices` (lexical_declaration, line 89)
- `callExpression` (method_definition, line 128, exported)
- `boundLifecycleName` (lexical_declaration, line 112)
- `create` (method_definition, line 123, exported)
- `LIFECYCLE_VERBS` (lexical_declaration, line 9)
- `EXEMPT_BASENAME_SUFFIXES` (lexical_declaration, line 24)
- `BIND` (lexical_declaration, line 26)
- `basenameOf` (lexical_declaration, line 28)
- `norm` (lexical_declaration, line 29)
- `idx` (lexical_declaration, line 30)
- `basename` (lexical_declaration, line 35)
- `property` (lexical_declaration, line 47)
- `parent` (lexical_declaration, line 80)
- `TypedServices` (interface_declaration, line 84)
- `program` (lexical_declaration, line 93)
- `map` (lexical_declaration, line 94)
- `EMPTY_RETURNS` (lexical_declaration, line 101)
- `services` (lexical_declaration, line 104)
- `type` (lexical_declaration, line 108)
- `callee` (lexical_declaration, line 113)
- `target` (lexical_declaration, line 117)
- `bound` (lexical_declaration, line 129, exported)
- `name` (lexical_declaration, line 140, exported)

## Uses

- [shared/factories/listener.factory.ts](https://banes-lab.com/source/governance/shared/factories/listener.factory.ts.md)
- [shared/manifests/verb.manifest.ts](https://banes-lab.com/source/governance/shared/manifests/verb.manifest.ts.md)
- [shared/selectors/syntax.selector.ts](https://banes-lab.com/source/governance/shared/selectors/syntax.selector.ts.md)

## Source

```typescript
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;
```
