# rules/eslint/require-snapshot-coverage.eslint.rule.ts

> 143 lines of code and 30 definitions.

Tree: Governance tree
Language: typescript
Canonical: https://banes-lab.com/anatomy/governance#file-governance-rules-eslint-require-snapshot-coverage-eslint-rule-ts
Source text: https://banes-lab.com/source/governance/rules/eslint/require-snapshot-coverage.eslint.rule.ts.txt

Listed in [rules/eslint](https://banes-lab.com/api/source/governance/rules/eslint.md), after [rules/eslint/require-signal-guard.eslint.rule.ts](https://banes-lab.com/source/governance/rules/eslint/require-signal-guard.eslint.rule.ts.md) and before [rules/eslint/retired-collection-id.eslint.rule.ts](https://banes-lab.com/source/governance/rules/eslint/retired-collection-id.eslint.rule.ts.md).

## Definitions

- `referencesField` (lexical_declaration, line 51)
- `calledMethodNames` (lexical_declaration, line 33)
- `methodReferencesField` (lexical_declaration, line 19)
- `uncoveredFields` (lexical_declaration, line 104)
- `isExcluded` (lexical_declaration, line 81)
- `fieldNameOf` (lexical_declaration, line 73)
- `isMethod` (lexical_declaration, line 11)
- `checkClass` (lexical_declaration, line 127, exported)
- `create` (method_definition, line 126, exported)
- `SNAPSHOT_METHOD` (lexical_declaration, line 8)
- `RESTORE_METHOD` (lexical_declaration, line 9)
- `found` (lexical_declaration, line 20)
- `names` (lexical_declaration, line 34)
- `callee` (lexical_declaration, line 39)
- `property` (lexical_declaration, line 43)
- `target` (lexical_declaration, line 65)
- `key` (lexical_declaration, line 74)
- `Persisted` (interface_declaration, line 85)
- `persistedShapeOf` (lexical_declaration, line 92)
- `members` (lexical_declaration, line 93)
- `snapshot` (lexical_declaration, line 94)
- `restore` (lexical_declaration, line 95)
- `name` (lexical_declaration, line 100)
- `out` (lexical_declaration, line 108)
- `fields` (lexical_declaration, line 109)
- `fieldName` (lexical_declaration, line 111)
- `shape` (lexical_declaration, line 128, exported)
- `id` (lexical_declaration, line 132, exported)
- `className` (lexical_declaration, line 133, exported)
- `payload` (lexical_declaration, line 135, exported)

## Uses

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

## Records this file checks

- [Memento Pattern](https://banes-lab.com/records/architecture/memento-pattern.md)

## Source

```typescript
import type { LocalRule, RuleContext, RuleListener } from "../../types/rule.types.ts";
import { isType, literalString, locOf, nameOf, nodeAt, nodesAt, walk } from "../../shared/selectors/syntax.selector.ts";
import type { AstNode } from "../../types/syntax.types.ts";
import { SNAPSHOT_COVERAGE_EXCLUSIONS } from "../../shared/exclusions/snapshot.exclusions.ts";
import { defineCheck } from "@govlab/context/check";
import { listener } from "../../shared/factories/listener.factory.ts";

const SNAPSHOT_METHOD = "snapshot";
const RESTORE_METHOD = "restore";

const isMethod = function isMethod(node: AstNode, name: string): boolean {
    if (node.type !== "MethodDefinition") {
        return false;
    }
    const key = nodeAt(node, "key");
    return isType(key, "Identifier") && nameOf(key) === name;
};

const methodReferencesField = function methodReferencesField(method: AstNode, fieldName: string): boolean {
    let found = false;
    walk(method, (n) => {
        if (found || n.type !== "MemberExpression" || !isType(nodeAt(n, "object"), "ThisExpression")) {
            return;
        }
        const property = nodeAt(n, "property");
        if (isType(property, "Identifier") && nameOf(property) === fieldName) {
            found = true;
        }
    });
    return found;
};

const calledMethodNames = function calledMethodNames(method: AstNode): Set<string> {
    const names = new Set<string>();
    walk(method, (n) => {
        if (n.type !== "CallExpression") {
            return;
        }
        const callee = nodeAt(n, "callee");
        if (!isType(callee, "MemberExpression") || !isType(nodeAt(callee, "object"), "ThisExpression")) {
            return;
        }
        const property = nodeAt(callee, "property");
        if (isType(property, "Identifier")) {
            names.add(nameOf(property));
        }
    });
    return names;
};

const referencesField = function referencesField(
    method: AstNode | null,
    fieldName: string,
    members: readonly AstNode[],
    seen: Set<AstNode>,
): boolean {
    if (method === null || seen.has(method)) {
        return false;
    }
    seen.add(method);
    if (methodReferencesField(method, fieldName)) {
        return true;
    }
    for (const name of calledMethodNames(method)) {
        const target = members.find((m) => isMethod(m, name)) ?? null;
        if (referencesField(target, fieldName, members, seen)) {
            return true;
        }
    }
    return false;
};

const fieldNameOf = function fieldNameOf(field: AstNode): string | null {
    const key = nodeAt(field, "key");
    if (key === null) {
        return null;
    }
    return isType(key, "Identifier") ? nameOf(key) : literalString(key);
};

const isExcluded = function isExcluded(className: string, fieldName: string): boolean {
    return className !== "" && SNAPSHOT_COVERAGE_EXCLUSIONS.has(`${className}.${fieldName}`);
};

interface Persisted {
    cls: string;
    members: AstNode[];
    restore: AstNode;
    snapshot: AstNode;
}

const persistedShapeOf = function persistedShapeOf(view: AstNode): Persisted | null {
    const members = nodesAt(nodeAt(view, "body"), "body");
    const snapshot = members.find((m) => isMethod(m, SNAPSHOT_METHOD));
    const restore = members.find((m) => isMethod(m, RESTORE_METHOD));
    if (snapshot === undefined || restore === undefined) {
        return null;
    }
    const id = nodeAt(view, "id");
    const name = id === null ? "" : nameOf(id);
    return { cls: name === "" ? "<anonymous>" : name, members, restore, snapshot };
};

const uncoveredFields = function uncoveredFields(
    shape: Persisted,
    className: string,
): { field: AstNode; which: string }[] {
    const out: { field: AstNode; which: string }[] = [];
    const fields = shape.members.filter((m) => m.type === "PropertyDefinition" && m["static"] !== true);
    for (const field of fields) {
        const fieldName = fieldNameOf(field);
        if (fieldName === null || isExcluded(className, fieldName)) {
            continue;
        }
        if (!referencesField(shape.snapshot, fieldName, shape.members, new Set())) {
            out.push({ field, which: "snapshot()" });
        }
        if (!referencesField(shape.restore, fieldName, shape.members, new Set())) {
            out.push({ field, which: "restore()" });
        }
    }
    return out;
};

export default {
    create(context: RuleContext): RuleListener {
        const checkClass = function checkClass(view: AstNode): void {
            const shape = persistedShapeOf(view);
            if (shape === null) {
                return;
            }
            const id = nodeAt(view, "id");
            const className = id === null ? "" : nameOf(id);
            for (const hit of uncoveredFields(shape, className)) {
                const payload = { cls: shape.cls, field: fieldNameOf(hit.field) ?? "", which: hit.which };
                context.report({ data: payload, loc: locOf(hit.field), messageId: "missing" });
            }
        };
        return listener({ classDeclaration: checkClass, classExpression: checkClass });
    },
    meta: {
        docs: {
            checks: defineCheck({ detects: [], enforces: ["architecture:memento-pattern"] }),
            description:
                "Instance fields on classes with both snapshot() and restore() methods must be referenced in both — or class-qualified-listed in the snapshot exclusions registry.",
        },
        messages: {
            missing:
                "Field '{{cls}}.{{field}}' is not referenced in {{which}}. Either include it in {{which}}, or add '{{cls}}.{{field}}' to SNAPSHOT_COVERAGE_EXCLUSIONS in the snapshot exclusions registry if it is intentionally excluded from persistence.",
        },
        schema: [],
        type: "problem",
    },
} satisfies LocalRule;
```
