rules/eslint/no-test-without-subject.eslint.rule.ts

rules/eslint/no-test-without-subject.eslint.rule.ts is a file in GovLab Extension Host. 141 lines of code and 34 definitions.

import type { Rule } from "eslint";
import { defineCheck } from "@govlab/context/check";

defineCheck({ detects: [], enforces: ["architecture:testability"] });

interface DomNode {
    type: string;
    name?: string;
    value?: unknown;
    source?: DomNode;
    callee?: DomNode;
    object?: DomNode;
    property?: DomNode;
    arguments?: DomNode[];
}

interface FileState {
    subjectImports: number;
    testStructure: boolean;
}

const TEST_MARKER = ".test.";
const RUNTIME_ONLY_EXACT = new Set(["vitest", "chai", "sinon", "expect", "should"]);
const RUNTIME_ONLY_PREFIXES = ["node:", "@jest/", "@vitest/", "@playwright/"];
const SUITE_NAMES = new Set(["describe", "it", "test", "suite", "context"]);

const TAG = "[no_test_without_subject]";
const SUBJECTLESS_MESSAGE = `This test declares suites or cases but imports no module under test — its only imports resolve to test-runtime modules. Import the module under test through its declared specifier and assert against its real exported surface; delete any logic re-implemented inside the test. ${TAG}`;

const isDomNode = function isDomNode(value: unknown): value is DomNode {
    return value !== null && typeof value === "object" && "type" in value;
};

const asNode = function asNode(value: unknown): DomNode | null {
    return isDomNode(value) ? value : null;
};

const normalizedFilename = function normalizedFilename(context: Rule.RuleContext): string {
    return context.filename.split("\\").join("/");
};

const isTestFile = function isTestFile(filename: string): boolean {
    return filename.includes(TEST_MARKER) && !filename.endsWith(".d.ts");
};

const isRuntimeOnly = function isRuntimeOnly(specifier: string): boolean {
    if (RUNTIME_ONLY_EXACT.has(specifier)) {
        return true;
    }
    for (const prefix of RUNTIME_ONLY_PREFIXES) {
        if (specifier.startsWith(prefix)) {
            return true;
        }
    }
    return false;
};

const specifierOf = function specifierOf(node: DomNode | null): string | null {
    const source = node?.source;
    return source?.type === "Literal" && typeof source.value === "string" ? source.value : null;
};

const callSpecifier = function callSpecifier(call: DomNode): string | null {
    const first = call.arguments?.[0];
    return first?.type === "Literal" && typeof first.value === "string" ? first.value : null;
};

const isRequireCall = function isRequireCall(call: DomNode): boolean {
    return call.callee?.type === "Identifier" && call.callee.name === "require";
};

const isImportExpression = function isImportExpression(call: DomNode): boolean {
    return call.callee?.type === "Import";
};

const suiteName = function suiteName(callee: DomNode | undefined): string | null {
    if (callee?.type === "Identifier" && typeof callee.name === "string") {
        return callee.name;
    }
    if (
        callee?.type === "MemberExpression" &&
        callee.object?.type === "Identifier" &&
        typeof callee.object.name === "string"
    ) {
        return callee.object.name;
    }
    return null;
};

const countSubject = function countSubject(state: FileState, specifier: string | null): void {
    if (specifier !== null && !isRuntimeOnly(specifier)) {
        state.subjectImports += 1;
    }
};

const handleCall = function handleCall(state: FileState, call: DomNode): void {
    if (isRequireCall(call) || isImportExpression(call)) {
        countSubject(state, callSpecifier(call));
        return;
    }
    const name = suiteName(call.callee);
    if (name !== null && SUITE_NAMES.has(name)) {
        state.testStructure = true;
    }
};

const onImport = function onImport(state: FileState, node: Rule.Node): void {
    countSubject(state, specifierOf(asNode(node)));
};

const onCall = function onCall(state: FileState, node: Rule.Node): void {
    const call = asNode(node);
    if (call !== null) {
        handleCall(state, call);
    }
};

const onProgramExit = function onProgramExit(state: FileState, context: Rule.RuleContext, node: Rule.Node): void {
    if (state.testStructure && state.subjectImports === 0) {
        context.report({ messageId: "subjectless", node });
    }
};

const buildHandlers = function buildHandlers(
    state: FileState,
    context: Rule.RuleContext,
): [string, (node: Rule.Node) => void][] {
    const callHandler = (node: Rule.Node): void => {
        onCall(state, node);
    };
    const importHandler = (node: Rule.Node): void => {
        onImport(state, node);
    };
    const exitHandler = (node: Rule.Node): void => {
        onProgramExit(state, context, node);
    };
    return [
        ["CallExpression", callHandler],
        ["ImportDeclaration", importHandler],
        ["ImportExpression", importHandler],
        ["Program:exit", exitHandler],
    ];
};

const rule: Rule.RuleModule = {
    create(context: Rule.RuleContext): Rule.RuleListener {
        if (!isTestFile(normalizedFilename(context))) {
            return {};
        }
        const state: FileState = { subjectImports: 0, testStructure: false };
        return Object.fromEntries(buildHandlers(state, context));
    },
    meta: {
        docs: {
            description:
                "A test file that declares suites or cases must import the module it exercises. Imports that resolve only to test-runtime modules do not count as a subject.",
        },
        messages: { subjectless: SUBJECTLESS_MESSAGE },
        schema: [],
        type: "problem",
    },
};

export default { plugins: { "govlab-test": { rules: { "no-test-without-subject": rule } } }, tool: "eslint" };