# core/fragment/eslint/fragment-single-concern.eslint.rule.ts

> 75 lines of code and 15 definitions.

Tree: GovLab Quality
Language: typescript
Canonical: https://banes-lab.com/anatomy/quality#file-quality-core-fragment-eslint-fragment-single-concern-eslint-rule-ts
Source text: https://banes-lab.com/source/quality/core/fragment/eslint/fragment-single-concern.eslint.rule.ts.txt

Listed in [core/fragment/eslint](https://banes-lab.com/api/source/quality/core/fragment/eslint.md), before [core/fragment/eslint/no-duplicate-fragment.eslint.rule.ts](https://banes-lab.com/source/quality/core/fragment/eslint/no-duplicate-fragment.eslint.rule.ts.md).

## Definitions

- `isKebabCase` (lexical_declaration, line 13)
- `isKebabChar` (lexical_declaration, line 9)
- `hasCompoundSegment` (lexical_declaration, line 25)
- `concernFinding` (lexical_declaration, line 35)
- `COMPOUND_SEGMENTS` (lexical_declaration, line 7)
- `ConcernFinding` (interface_declaration, line 29)
- `concernNode` (lexical_declaration, line 36)
- `concern` (lexical_declaration, line 37)
- `create` (method_definition, line 51, exported)
- `onCall` (lexical_declaration, line 52, exported)
- `object` (lexical_declaration, line 53, exported)
- `finding` (lexical_declaration, line 57, exported)
- `target` (lexical_declaration, line 61, exported)
- `data` (lexical_declaration, line 62, exported)
- `handlers` (lexical_declaration, line 65, exported)

## Contained in

- [core/fragment/eslint](https://banes-lab.com/anatomy/quality/folder-quality-core-fragment-eslint.md)

## Linked from

- [core/fragment/eslint](https://banes-lab.com/anatomy/quality/folder-quality-core-fragment-eslint.md)

## Source

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