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

> 141 lines of code and 34 definitions.

Tree: Governance tree
Language: typescript
Canonical: https://banes-lab.com/anatomy/governance#file-governance-rules-eslint-no-test-without-subject-eslint-rule-ts
Source text: https://banes-lab.com/source/governance/rules/eslint/no-test-without-subject.eslint.rule.ts.txt

Listed in [rules/eslint](https://banes-lab.com/api/source/governance/rules/eslint.md), after [rules/eslint/no-swallowed-failure.eslint.rule.ts](https://banes-lab.com/source/governance/rules/eslint/no-swallowed-failure.eslint.rule.ts.md) and before [rules/eslint/no-undeclared-dependency.eslint.rule.ts](https://banes-lab.com/source/governance/rules/eslint/no-undeclared-dependency.eslint.rule.ts.md).

## Definitions

- `countSubject` (lexical_declaration, line 90)
- `handleCall` (lexical_declaration, line 96)
- `onImport` (lexical_declaration, line 107)
- `asNode` (lexical_declaration, line 34)
- `onCall` (lexical_declaration, line 111)
- `isDomNode` (lexical_declaration, line 30)
- `normalizedFilename` (lexical_declaration, line 38)
- `isTestFile` (lexical_declaration, line 42)
- `isRuntimeOnly` (lexical_declaration, line 46)
- `specifierOf` (lexical_declaration, line 58)
- `callSpecifier` (lexical_declaration, line 63)
- `isRequireCall` (lexical_declaration, line 68)
- `isImportExpression` (lexical_declaration, line 72)
- `onProgramExit` (lexical_declaration, line 118)
- `buildHandlers` (lexical_declaration, line 124)
- `create` (method_definition, line 146)
- `callHandler` (lexical_declaration, line 128)
- `importHandler` (lexical_declaration, line 131)
- `exitHandler` (lexical_declaration, line 134)
- `DomNode` (interface_declaration, line 6)
- `FileState` (interface_declaration, line 17)
- `TEST_MARKER` (lexical_declaration, line 22)
- `RUNTIME_ONLY_EXACT` (lexical_declaration, line 23)
- `RUNTIME_ONLY_PREFIXES` (lexical_declaration, line 24)
- `SUITE_NAMES` (lexical_declaration, line 25)
- `TAG` (lexical_declaration, line 27)
- `SUBJECTLESS_MESSAGE` (lexical_declaration, line 28)
- `source` (lexical_declaration, line 59)
- `first` (lexical_declaration, line 64)
- `suiteName` (lexical_declaration, line 76)
- `name` (lexical_declaration, line 101)
- `call` (lexical_declaration, line 112)
- `rule` (lexical_declaration, line 145)
- `state` (lexical_declaration, line 150)

## Records this file checks

- [Testability](https://banes-lab.com/records/architecture/testability.md)

## Source

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