core/fragment/eslint/fragment-single-concern.eslint.rule.ts
core/fragment/eslint/fragment-single-concern.eslint.rule.ts is a file in GovLab Quality. 75 lines of code and 15 definitions.
import { fragmentObject, property, stringValue } from "#core/selectors/context.fragment.selector";
import { isDefineFragmentCall, isRuleNode } from "#core/predicates/context.fragment.predicate";
import { isDigit, isLowerAlpha } from "@govlab/constants";
import type { AstNode } from "#types/context.types";
import type { Rule } from "eslint";
const COMPOUND_SEGMENTS = new Set(["and", "or"]);
const isKebabChar = function isKebabChar(ch: string): boolean {
return isLowerAlpha(ch) || isDigit(ch) || ch === "-";
};
const isKebabCase = function isKebabCase(value: string): boolean {
if (value.length === 0 || value.startsWith("-") || value.endsWith("-")) {
return false;
}
for (const ch of value) {
if (!isKebabChar(ch)) {
return false;
}
}
return true;
};
const hasCompoundSegment = function hasCompoundSegment(value: string): boolean {
return value.split("-").some((segment) => COMPOUND_SEGMENTS.has(segment));
};
interface ConcernFinding {
messageId: "compound" | "notKebab" | "notString";
concern?: string;
node: AstNode | null;
}
const concernFinding = function concernFinding(object: AstNode): ConcernFinding | null {
const concernNode = property(object, "concern");
const concern = stringValue(concernNode);
if (concern === null) {
return { messageId: "notString", node: concernNode };
}
if (!isKebabCase(concern)) {
return { concern, messageId: "notKebab", node: concernNode };
}
if (hasCompoundSegment(concern)) {
return { concern, messageId: "compound", node: concernNode };
}
return null;
};
export default {
create(context) {
const onCall = function onCall(node: Rule.Node): void {
const object = isDefineFragmentCall(node) ? fragmentObject(node) : null;
if (object === null) {
return;
}
const finding = concernFinding(object);
if (finding === null) {
return;
}
const target = isRuleNode(finding.node) ? finding.node : node;
const data = typeof finding.concern === "string" ? { concern: finding.concern } : {};
context.report({ data, messageId: finding.messageId, node: target });
};
const handlers: [string, (node: Rule.Node) => void][] = [["CallExpression", onCall]];
return Object.fromEntries(handlers);
},
meta: {
docs: {
description: "A context fragment declares exactly one kebab-case concern; compound concerns must be split.",
},
messages: {
compound:
"Fragment concern '{{concern}}' names more than one concern. One concern per fragment — split it. [fragment_single_concern]",
notKebab:
"Fragment concern '{{concern}}' must be kebab-case (lowercase letters, digits, hyphens). [fragment_single_concern]",
notString: "defineContextFragment 'concern' must be a kebab-case string literal. [fragment_single_concern]",
},
schema: [],
type: "problem",
},
} satisfies Rule.RuleModule;