# rules/eslint/no-undeclared-dependency.eslint.rule.ts

> 176 lines of code and 43 definitions.

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

Listed in [rules/eslint](https://banes-lab.com/api/source/governance/rules/eslint.md), after [rules/eslint/no-test-without-subject.eslint.rule.ts](https://banes-lab.com/source/governance/rules/eslint/no-test-without-subject.eslint.rule.ts.md) and before [rules/eslint/require-cache-coverage.eslint.rule.ts](https://banes-lab.com/source/governance/rules/eslint/require-cache-coverage.eslint.rule.ts.md).

## Definitions

- `reportUndeclared` (lexical_declaration, line 121)
- `isRecord` (lexical_declaration, line 46)
- `addDepsFrom` (lexical_declaration, line 79)
- `collectDeclared` (lexical_declaration, line 94)
- `isAllowed` (lexical_declaration, line 113)
- `isNode` (lexical_declaration, line 26)
- `isReportNode` (lexical_declaration, line 34)
- `isExternal` (lexical_declaration, line 66)
- `makeFromSource` (lexical_declaration, line 140)
- `asNode` (lexical_declaration, line 30)
- `asReportNode` (lexical_declaration, line 38)
- `makeOnCall` (lexical_declaration, line 149)
- `makeOnImportExpression` (lexical_declaration, line 161)
- `AstNode` (interface_declaration, line 11)
- `BUILTINS` (lexical_declaration, line 20)
- `DEP_FIELDS` (lexical_declaration, line 21)
- `MESSAGE` (lexical_declaration, line 23)
- `normalize` (lexical_declaration, line 42)
- `packageRoot` (lexical_declaration, line 50)
- `firstSlash` (lexical_declaration, line 53)
- `secondSlash` (lexical_declaration, line 57)
- `slash` (lexical_declaration, line 60)
- `SELF_DECLARING_SCHEMES` (lexical_declaration, line 64)
- `[first]` (lexical_declaration, line 70)
- `declaredCache` (lexical_declaration, line 77)
- `parsed` (lexical_declaration, line 83)
- `deps` (lexical_declaration, line 85)
- `cached` (lexical_declaration, line 95)
- `declared` (lexical_declaration, line 99)
- `dir` (lexical_declaration, line 100)
- `parent` (lexical_declaration, line 103)
- `bare` (lexical_declaration, line 114)
- `{ node, specifier }` (lexical_declaration, line 126)
- `pkg` (lexical_declaration, line 130)
- `reportNode` (lexical_declaration, line 134)
- `call` (lexical_declaration, line 151)
- `first` (lexical_declaration, line 153)
- `source` (lexical_declaration, line 166)
- `noUndeclaredDependency` (lexical_declaration, line 173)
- `create` (method_definition, line 174)
- `fileDir` (lexical_declaration, line 175)
- `fromSource` (lexical_declaration, line 176)
- `handlers` (lexical_declaration, line 177)

## Records this file checks

- [Explicit Contracts](https://banes-lab.com/records/architecture/explicit-contracts.md)

## Source

```typescript
import type { Rule } from "eslint";
import { builtinModules } from "node:module";

defineCheck({ detects: [], enforces: ["architecture:explicit-contracts"] });

import { defineCheck } from "@govlab/context/check";
import fs from "node:fs";
import { jsonRecordAt } from "../../shared/loaders/manifest.loader.ts";
import path from "node:path";

interface AstNode {
    type: string;
    name?: string;
    value?: unknown;
    callee?: AstNode;
    arguments?: AstNode[];
    source?: AstNode;
}

const BUILTINS = new Set(builtinModules);
const DEP_FIELDS = ["dependencies", "devDependencies", "peerDependencies", "optionalDependencies"];

const MESSAGE =
    "'{{pkg}}' is imported but declared in no package.json (dependencies/devDependencies/peer/optional) up the tree. An undeclared external package is not installed by `npm install`, so the import throws ERR_MODULE_NOT_FOUND at runtime — even when an ambient `declare module` shim hides it from the type-checker. Add '{{pkg}}' to the owning package.json (and install it), or remove the import. [no_undeclared_dependency]";

const isNode = function isNode(value: unknown): value is AstNode {
    return value !== null && typeof value === "object" && "type" in value;
};

const asNode = function asNode(value: unknown): AstNode | null {
    return isNode(value) ? value : null;
};

const isReportNode = function isReportNode(value: unknown): value is Rule.Node {
    return value !== null && typeof value === "object" && "type" in value;
};

const asReportNode = function asReportNode(value: unknown): Rule.Node | null {
    return isReportNode(value) ? value : null;
};

const normalize = function normalize(filename: string): string {
    return filename.split("\\").join("/");
};

const isRecord = function isRecord(value: unknown): value is Record<string, unknown> {
    return typeof value === "object" && value !== null;
};

const packageRoot = function packageRoot(specifier: string): string {
    const bare = specifier.startsWith("node:") ? specifier.slice("node:".length) : specifier;
    if (bare.startsWith("@")) {
        const firstSlash = bare.indexOf("/");
        if (firstSlash === -1) {
            return bare;
        }
        const secondSlash = bare.indexOf("/", firstSlash + 1);
        return secondSlash === -1 ? bare : bare.slice(0, secondSlash);
    }
    const slash = bare.indexOf("/");
    return slash === -1 ? bare : bare.slice(0, slash);
};

const SELF_DECLARING_SCHEMES = ["data:", "file:", "npm:", "jsr:", "http:", "https:"];

const isExternal = function isExternal(specifier: string): boolean {
    if (specifier.length === 0) {
        return false;
    }
    const [first] = specifier;
    if (first === "." || first === "/" || first === "#") {
        return false;
    }
    return !SELF_DECLARING_SCHEMES.some((scheme) => specifier.startsWith(scheme));
};

const declaredCache = new Map<string, Set<string>>();

const addDepsFrom = function addDepsFrom(pkgPath: string, declared: Set<string>): void {
    if (!fs.existsSync(pkgPath)) {
        return;
    }
    const parsed = jsonRecordAt(pkgPath);
    for (const field of DEP_FIELDS) {
        const deps = parsed[field];
        if (isRecord(deps)) {
            for (const name of Object.keys(deps)) {
                declared.add(name);
            }
        }
    }
};

const collectDeclared = function collectDeclared(startDir: string): Set<string> {
    const cached = declaredCache.get(startDir);
    if (cached) {
        return cached;
    }
    const declared = new Set<string>();
    let dir = startDir;
    for (;;) {
        addDepsFrom(path.join(dir, "package.json"), declared);
        const parent = path.dirname(dir);
        if (parent === dir) {
            break;
        }
        dir = parent;
    }
    declaredCache.set(startDir, declared);
    return declared;
};

const isAllowed = function isAllowed(pkg: string, fileDir: string): boolean {
    const bare = pkg.startsWith("node:") ? pkg.slice("node:".length) : pkg;
    if (BUILTINS.has(bare) || BUILTINS.has(`node:${bare}`)) {
        return true;
    }
    return collectDeclared(fileDir).has(pkg);
};

const reportUndeclared = function reportUndeclared(
    context: Rule.RuleContext,
    fileDir: string,
    target: { node: AstNode; specifier: unknown },
): void {
    const { node, specifier } = target;
    if (typeof specifier !== "string" || !isExternal(specifier)) {
        return;
    }
    const pkg = packageRoot(specifier);
    if (isAllowed(pkg, fileDir)) {
        return;
    }
    const reportNode = asReportNode(node);
    if (reportNode !== null) {
        context.report({ data: { pkg }, messageId: "undeclared", node: reportNode });
    }
};

const makeFromSource = function makeFromSource(context: Rule.RuleContext, fileDir: string): (node: Rule.Node) => void {
    return function fromSource(node: Rule.Node): void {
        const source = asNode(node)?.source;
        if (isRecord(source)) {
            reportUndeclared(context, fileDir, { node: source, specifier: source.value });
        }
    };
};

const makeOnCall = function makeOnCall(context: Rule.RuleContext, fileDir: string): (node: Rule.Node) => void {
    return function onCall(node: Rule.Node): void {
        const call = asNode(node);
        if (call?.callee?.type === "Identifier" && call.callee.name === "require") {
            const first = call.arguments?.[0];
            if (first?.type === "Literal") {
                reportUndeclared(context, fileDir, { node: first, specifier: first.value });
            }
        }
    };
};

const makeOnImportExpression = function makeOnImportExpression(
    context: Rule.RuleContext,
    fileDir: string,
): (node: Rule.Node) => void {
    return function onImportExpression(node: Rule.Node): void {
        const source = asNode(node)?.source;
        if (source?.type === "Literal") {
            reportUndeclared(context, fileDir, { node: source, specifier: source.value });
        }
    };
};

const noUndeclaredDependency: Rule.RuleModule = {
    create(context: Rule.RuleContext): Rule.RuleListener {
        const fileDir = path.dirname(normalize(context.filename));
        const fromSource = makeFromSource(context, fileDir);
        const handlers: [string, (node: Rule.Node) => void][] = [
            ["CallExpression", makeOnCall(context, fileDir)],
            ["ExportAllDeclaration", fromSource],
            ["ExportNamedDeclaration", fromSource],
            ["ImportDeclaration", fromSource],
            ["ImportExpression", makeOnImportExpression(context, fileDir)],
        ];
        return Object.fromEntries(handlers);
    },
    meta: {
        docs: {
            description:
                "Every imported package is declared by a manifest up the tree. An undeclared import can still resolve — through a hoisted transitive install, a workspace symlink, or an ambient shim — so it type-checks and runs locally while a clean install has nothing to resolve it against.",
        },
        messages: { undeclared: MESSAGE },
        schema: [],
        type: "problem",
    },
};

export default {
    plugins: { "govlab-deps": { rules: { "no-undeclared-dependency": noUndeclaredDependency } } },
    tool: "eslint",
};
```
