core/quality/eslint/no-conditional-dispatch.eslint.rule.ts

core/quality/eslint/no-conditional-dispatch.eslint.rule.ts is a file in GovLab Quality. 150 lines of code and 31 definitions.

import type { Rule } from "eslint";
import { govlabMeta } from "#core/factories/eslint.factory";

interface AstNode {
    type: string;
    name?: string;
    operator?: string;
    computed?: boolean;
    object?: AstNode;
    property?: AstNode;
    left?: AstNode;
    right?: AstNode;
    test?: AstNode | null;
    consequent?: AstNode | null;
    alternate?: AstNode | null;
    parent?: AstNode;
}

const DEFAULT_THRESHOLD = 3;
const EQUALITY_OPERATORS = new Set(["===", "==", "!==", "!="]);
const CONTROL_FLOW = new Set(["ReturnStatement", "BreakStatement", "ContinueStatement", "ThrowStatement"]);
const FUNCTION_NODES = new Set(["FunctionDeclaration", "FunctionExpression", "ArrowFunctionExpression"]);
const IF_SELECTOR = "IfStatement";

const isRecord = (value: unknown): value is Record<string, unknown> => typeof value === "object" && value !== null;

const isAstNode = (value: unknown): value is AstNode => isRecord(value) && "type" in value;

const asNode = (value: unknown): AstNode | null => (isAstNode(value) ? value : null);

const resolveThreshold = (raw: unknown): number => {
    const threshold = isRecord(raw) ? raw["threshold"] : DEFAULT_THRESHOLD;
    return typeof threshold === "number" ? threshold : DEFAULT_THRESHOLD;
};

const memberPath = (object: string | null, property: string | undefined): string | null =>
    typeof object === "string" && typeof property === "string" ? `${object}.${property}` : null;

const serialize = (node: AstNode | null | undefined): string | null => {
    if (!node) {
        return null;
    }
    if (node.type === "Identifier") {
        return node.name ?? null;
    }
    if (node.type === "ThisExpression") {
        return "this";
    }
    if (node.type !== "MemberExpression" || node.computed === true) {
        return null;
    }
    return memberPath(serialize(node.object), node.property?.name);
};

const discriminantOf = (test: AstNode | null | undefined): string | null => {
    if (test?.type !== "BinaryExpression" || !EQUALITY_OPERATORS.has(test.operator ?? "")) {
        return null;
    }
    const leftLiteral = test.left?.type === "Literal";
    const rightLiteral = test.right?.type === "Literal";
    if (leftLiteral === rightLiteral) {
        return null;
    }
    return serialize(leftLiteral ? test.right : test.left);
};

const isChainHead = (node: AstNode): boolean => {
    const { parent } = node;
    return !(parent?.type === "IfStatement" && parent.alternate === node);
};

const chainDiscriminants = (head: AstNode): (string | null)[] => {
    const out: (string | null)[] = [];
    let node: AstNode | null | undefined = head;
    while (node?.type === "IfStatement") {
        out.push(discriminantOf(node.test));
        node = node.alternate;
    }
    return out;
};

const hasEscapingControlFlow = (value: unknown): boolean => {
    const node = asNode(value);
    if (node === null) {
        return false;
    }
    if (CONTROL_FLOW.has(node.type)) {
        return true;
    }
    if (FUNCTION_NODES.has(node.type)) {
        return false;
    }
    return Object.entries(node).some(([key, child]): boolean => {
        if (key === "parent") {
            return false;
        }
        if (Array.isArray(child)) {
            return child.some((entry) => hasEscapingControlFlow(entry));
        }
        return hasEscapingControlFlow(child);
    });
};

const chainHasControlFlow = (head: AstNode): boolean => {
    let node: AstNode | null | undefined = head;
    while (node?.type === "IfStatement") {
        if (hasEscapingControlFlow(node.consequent)) {
            return true;
        }
        node = node.alternate;
    }
    return hasEscapingControlFlow(node);
};

const dominant = (discriminants: (string | null)[]): { count: number; name: string } | null => {
    const counts = new Map<string, number>();
    let best: { count: number; name: string } | null = null;
    for (const value of discriminants.filter((entry): entry is string => entry !== null)) {
        const count = (counts.get(value) ?? 0) + 1;
        counts.set(value, count);
        if (best === null || count > best.count) {
            best = { count, name: value };
        }
    }
    return best;
};

export default {
    create(context: Rule.RuleContext): Rule.RuleListener {
        const threshold = resolveThreshold(context.options[0]);
        const onIf = (node: Rule.Node): void => {
            const head = asNode(node);
            if (head === null || !isChainHead(head)) {
                return;
            }
            const best = dominant(chainDiscriminants(head));
            if (best !== null && best.count >= threshold && !chainHasControlFlow(head)) {
                context.report({
                    data: { count: String(best.count), discriminant: best.name },
                    messageId: "conditionalDispatch",
                    node,
                });
            }
        };
        const listeners: Rule.RuleListener = { [IF_SELECTOR]: onIf };
        return listeners;
    },
    meta: govlabMeta({
        canonical: ["open-closed"],
        description:
            "Disallow an if/else chain dispatching on one discriminant — mandate a data-driven dispatch table or polymorphism",
        messages: {
            conditionalDispatch:
                "This if/else chain branches {{count}} times on '{{discriminant}}' compared to literals — a hardcoded conditional dispatch that must be edited to add a case (an open/closed violation). Replace it with a data-driven dispatch: a lookup table (an object or Map from the '{{discriminant}}' value to a handler) or polymorphism (Strategy), so a new case is a new table entry, not an edited conditional.",
        },
        ruleId: "conditional_dispatch_table",
        schema: [
            {
                additionalProperties: false,
                properties: { threshold: { maximum: 12, minimum: 3, type: "integer" } },
                type: "object",
            },
        ],
    }),
} satisfies Rule.RuleModule;