# rules/eslint/closure-no-cross-member-relative.eslint.rule.ts

> 80 lines of code and 19 definitions.

Tree: Governance tree
Language: typescript
Canonical: https://banes-lab.com/anatomy/governance#file-governance-rules-eslint-closure-no-cross-member-relative-eslint-rule-ts
Source text: https://banes-lab.com/source/governance/rules/eslint/closure-no-cross-member-relative.eslint.rule.ts.txt

Listed in [rules/eslint](https://banes-lab.com/api/source/governance/rules/eslint.md), after [rules/eslint/closure-no-business-inheritance.eslint.rule.ts](https://banes-lab.com/source/governance/rules/eslint/closure-no-business-inheritance.eslint.rule.ts.md) and before [rules/eslint/closure-no-cross-subsystem-import.eslint.rule.ts](https://banes-lab.com/source/governance/rules/eslint/closure-no-cross-subsystem-import.eslint.rule.ts.md).

## Definitions

- `relative` (lexical_declaration, line 37)
- `packageNameOf` (lexical_declaration, line 15)
- `buildMembers` (lexical_declaration, line 23)
- `create` (method_definition, line 42, exported)
- `check` (lexical_declaration, line 43, exported)
- `Member` (interface_declaration, line 10)
- `{ name }` (lexical_declaration, line 16)
- `MEMBERS` (lexical_declaration, line 29)
- `ROOT_POSIX` (lexical_declaration, line 30)
- `memberOf` (lexical_declaration, line 32)
- `posix` (lexical_declaration, line 33)
- `source` (lexical_declaration, line 44, exported)
- `specifier` (lexical_declaration, line 45, exported)
- `filename` (lexical_declaration, line 52, exported)
- `fromDir` (lexical_declaration, line 53, exported)
- `target` (lexical_declaration, line 54, exported)
- `from` (lexical_declaration, line 55, exported)
- `to` (lexical_declaration, line 56, exported)
- `payload` (lexical_declaration, line 60, exported)

## Uses

- [shared/factories/listener.factory.ts](https://banes-lab.com/source/governance/shared/factories/listener.factory.ts.md)
- [shared/loaders/manifest.loader.ts](https://banes-lab.com/source/governance/shared/loaders/manifest.loader.ts.md)
- [shared/selectors/syntax.selector.ts](https://banes-lab.com/source/governance/shared/selectors/syntax.selector.ts.md)
- [shared/strings/manifest.strings.ts](https://banes-lab.com/source/governance/shared/strings/manifest.strings.ts.md)

## Used by

- [codemods/analyzers/field.analyzer.ts](https://banes-lab.com/source/governance/codemods/analyzers/field.analyzer.ts.md)
- [codemods/analyzers/field.vocabulary.analyzer.ts](https://banes-lab.com/source/governance/codemods/analyzers/field.vocabulary.analyzer.ts.md)
- [codemods/selectors/program.selector.ts](https://banes-lab.com/source/governance/codemods/selectors/program.selector.ts.md)

## Records this file checks

- [Explicit Boundaries](https://banes-lab.com/records/architecture/explicit-boundaries.md)
- [Boundary Leakage](https://banes-lab.com/records/architecture/boundary-leakage.md)

## Source

```typescript
import type { LocalRule, NodeHandler, RuleContext, RuleListener } from "../../types/rule.types.ts";
import { WORKSPACE_ROOT, collapsePath, normalizePath } from "../../shared/resolvers/anchor.resolver.ts";
import { literalString, locOf, nodeAt } from "../../shared/selectors/syntax.selector.ts";
import { manifestAt, memberRels } from "../../shared/loaders/manifest.loader.ts";
import { defineCheck } from "@govlab/context/check";
import { listener } from "../../shared/factories/listener.factory.ts";
import { noPackageName } from "../../shared/strings/manifest.strings.ts";
import path from "node:path";

interface Member {
    name: string;
    rel: string;
}

const packageNameOf = function packageNameOf(memberRel: string): string {
    const { name } = manifestAt(path.join(WORKSPACE_ROOT, memberRel));
    if (typeof name !== "string" || name.length === 0) {
        throw new TypeError(noPackageName(memberRel));
    }
    return name;
};

const buildMembers = function buildMembers(): Member[] {
    return memberRels()
        .map((memberRel) => ({ name: packageNameOf(memberRel), rel: memberRel }))
        .toSorted((a, b) => b.rel.length - a.rel.length);
};

const MEMBERS = buildMembers();
const ROOT_POSIX = normalizePath(WORKSPACE_ROOT);

const memberOf = function memberOf(absolute: string): Member | null {
    const posix = normalizePath(absolute);
    if (!posix.startsWith(`${ROOT_POSIX}/`)) {
        return null;
    }
    const relative = posix.slice(ROOT_POSIX.length + 1);
    return MEMBERS.find((member) => relative === member.rel || relative.startsWith(`${member.rel}/`)) ?? null;
};

export default {
    create(context: RuleContext): RuleListener {
        const check: NodeHandler = function check(view, node) {
            const source = nodeAt(view, "source");
            const specifier = literalString(source);
            if (specifier === null) {
                return;
            }
            if (!specifier.startsWith(".")) {
                return;
            }
            const filename = normalizePath(context.filename);
            const fromDir = filename.slice(0, filename.lastIndexOf("/"));
            const target = collapsePath(`${fromDir}/${specifier}`);
            const from = memberOf(filename);
            const to = memberOf(target);
            if (from === null || to === null || from.rel === to.rel) {
                return;
            }
            const payload = { specifier: to.name.length > 0 ? to.name : to.rel, target: to.rel };
            if (source === null) {
                context.report({ data: payload, messageId: "crossMember", node });
                return;
            }
            context.report({ data: payload, loc: locOf(source), messageId: "crossMember" });
        };
        return listener({ exportAllDeclaration: check, exportNamedDeclaration: check, importDeclaration: check });
    },
    meta: {
        docs: {
            checks: defineCheck({
                detects: ["architecture:boundary-leakage"],
                enforces: ["architecture:explicit-boundaries"],
            }),
            description:
                "A relative import addresses a sibling by hop count, which is only stable inside one workspace member. Escaping the member with `../` encodes the distance between two trees that move independently: nothing fails when the count goes stale, the specifier just resolves somewhere else or stops resolving. Cross-member references go through the declared package specifier, which is the module SSOT and survives either end moving.",
            workspaceWide: true,
        },
        messages: {
            crossMember:
                "This relative import leaves its workspace member and reaches into `{{ target }}` by hop count, so it breaks silently the moment either tree moves. Import from `{{ specifier }}` instead, adding the subpath to that package's `exports` map if it is not published yet.",
        },
        schema: [],
        type: "problem",
    },
} satisfies LocalRule;
```
