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