core/quality/eslint/no-mixed-concerns.eslint.rule.ts
core/quality/eslint/no-mixed-concerns.eslint.rule.ts is a file in GovLab Quality. 185 lines of code and 40 definitions.
import {
API_GLOBALS,
API_METHODS,
DOMAIN_LABELS,
DOM_DOCUMENT_METHODS,
DOM_MUTATION_METHODS,
DOM_OBJECTS,
DOM_PROPERTIES,
EVENT_METHODS,
EVENT_OBJECTS,
STORAGE_OBJECTS,
TIMER_GLOBALS,
} from "#configuration/constants/concern.domain.constants";
import type { Rule } from "eslint";
import { govlabMeta } from "#core/factories/eslint.factory";
interface AstNode {
type: string;
name?: string;
id?: AstNode;
key?: AstNode;
object?: AstNode;
property?: AstNode;
callee?: AstNode;
parent?: AstNode;
}
interface FrameState {
concerns: Set<string>;
node: Rule.Node;
}
const DEFAULT_THRESHOLD = 3;
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 identName = (node: AstNode | undefined): string | null =>
node?.type === "Identifier" && typeof node.name === "string" ? node.name : null;
const labelFor = (domain: string): string => DOMAIN_LABELS[domain] ?? domain;
const resolveThreshold = (raw: unknown): number => {
const threshold = isRecord(raw) ? raw["threshold"] : DEFAULT_THRESHOLD;
return typeof threshold === "number" ? threshold : DEFAULT_THRESHOLD;
};
const parentName = (parent: AstNode | undefined): string => {
if (parent?.type === "VariableDeclarator") {
return identName(parent.id) ?? "<anonymous>";
}
if (parent?.type === "MethodDefinition" || parent?.type === "Property") {
return identName(parent.key) ?? "<anonymous>";
}
return "<anonymous>";
};
const functionName = (node: Rule.Node): string => {
const ast = asNode(node);
if (ast === null) {
return "<anonymous>";
}
return identName(ast.id) ?? parentName(ast.parent);
};
const isDomConcern = (objName: string | null, propName: string, isCall: boolean): boolean => {
if (objName !== null && DOM_OBJECTS.has(objName)) {
return true;
}
if (objName === "document" && DOM_DOCUMENT_METHODS.has(propName)) {
return true;
}
if (isCall && DOM_MUTATION_METHODS.has(propName)) {
return true;
}
return DOM_PROPERTIES.has(propName);
};
const isStorageConcern = (objName: string | null, propName: string): boolean => {
const named = objName !== null && STORAGE_OBJECTS.has(objName);
return named || (objName === "document" && propName === "cookie");
};
const isEventConcern = (objName: string | null, propName: string, isCall: boolean): boolean => {
const named = objName !== null && EVENT_OBJECTS.has(objName);
return named || (isCall && EVENT_METHODS.has(propName));
};
const memberConcern = (objName: string | null, propName: string, isCall: boolean): string | null => {
if (isDomConcern(objName, propName, isCall)) {
return "dom";
}
if (isStorageConcern(objName, propName)) {
return "storage";
}
if (isEventConcern(objName, propName, isCall)) {
return "events";
}
return isCall && API_METHODS.has(propName) ? "api" : null;
};
const addConcern = (stack: FrameState[], domain: string): void => {
const top = stack.at(-1);
if (top) {
top.concerns.add(domain);
}
};
const classifyMember = (node: AstNode, stack: FrameState[]): void => {
const propName = identName(node.property);
if (propName === null) {
return;
}
const isCall = node.parent?.type === "CallExpression" && node.parent.callee === node;
const concern = memberConcern(identName(node.object), propName, isCall);
if (concern !== null) {
addConcern(stack, concern);
}
};
const reportFrame = (context: Rule.RuleContext, frame: FrameState | undefined, threshold: number): void => {
if (!frame || frame.concerns.size < threshold) {
return;
}
const domains = [...frame.concerns].map((domain) => labelFor(domain)).join(", ");
context.report({
data: { count: String(frame.concerns.size), domains, name: functionName(frame.node) },
messageId: "mixedConcerns",
node: frame.node,
});
};
const onCallNode = (stack: FrameState[], node: Rule.Node): void => {
const name = stack.length > 0 ? identName(asNode(node)?.callee) : null;
if (name === null) {
return;
}
if (API_GLOBALS.has(name)) {
addConcern(stack, "api");
return;
}
if (TIMER_GLOBALS.has(name)) {
addConcern(stack, "timer");
}
};
const onMemberNode = (stack: FrameState[], node: Rule.Node): void => {
const member = stack.length > 0 ? asNode(node) : null;
if (member !== null) {
classifyMember(member, stack);
}
};
const onNewNode = (stack: FrameState[], node: Rule.Node): void => {
if (stack.length > 0 && identName(asNode(node)?.callee) === "XMLHttpRequest") {
addConcern(stack, "api");
}
};
const buildListeners = (context: Rule.RuleContext, threshold: number): Rule.RuleListener => {
const stack: FrameState[] = [];
const enter = (node: Rule.Node): void => {
stack.push({ concerns: new Set(), node });
};
const exit = (): void => {
reportFrame(context, stack.pop(), threshold);
};
const onCall = onCallNode.bind(null, stack);
const onMember = onMemberNode.bind(null, stack);
const onNew = onNewNode.bind(null, stack);
const handlers: [string, (node: Rule.Node) => void][] = [
["ArrowFunctionExpression", enter],
["ArrowFunctionExpression:exit", exit],
["CallExpression", onCall],
["FunctionDeclaration", enter],
["FunctionDeclaration:exit", exit],
["FunctionExpression", enter],
["FunctionExpression:exit", exit],
["MemberExpression", onMember],
["NewExpression", onNew],
];
return Object.fromEntries(handlers);
};
export default {
create(context: Rule.RuleContext): Rule.RuleListener {
return buildListeners(context, resolveThreshold(context.options[0]));
},
meta: govlabMeta({
canonical: ["separation-of-concerns"],
description: "Disallow functions that mix 3+ concern domains (god-method detection)",
messages: {
mixedConcerns:
"Function '{{name}}' mixes {{count}} concern domains ({{domains}}). Split it into single-responsibility functions — one function, one concern.",
},
ruleId: "bounded_complexity",
schema: [
{
additionalProperties: false,
properties: { threshold: { maximum: 5, minimum: 2, type: "integer" } },
type: "object",
},
],
}),
} satisfies Rule.RuleModule;