/// /** * A lint rule that stops a file from naming its own package in an import. * * A workspace package is meant to be named from outside it. From inside, the * name resolves through the package's `exports` map and lands back on a file * that a relative specifier would have reached directly. Two things follow, and * the rule reports both: * * - The bare package name is the entry point, and the entry point reaches * every module the package exports. Naming it from inside adds the edge * back, so the module graph gains a cycle. When the import brings in a * value rather than a type that cycle is there at run time, and the order * in which the modules of the package initialize starts to depend on the * order in which the entry point lists its exports. An alias from the * package's own `imports` map that resolves to the entry point's file, such * as `@` or `@/index.ts` where `@/` maps to `./src/`, is the same import * and is reported the same way. * - A subpath such as `@scope/pkg/thing` names one module rather than the * whole package, so it adds no cycle. It still leaves the package and comes * back, so the same file arrives under two spellings, and the shorter one * stops being a reliable way to find who depends on what. * * The rule reads every form that names a module: the `import` and `export ... * from` declarations, a dynamic `import()`, and a type written * `import("...").Name`. That last one is reported for completeness rather than * because it is expected — `cf-imports/no-inline-type-import` rejects the form * outright, so one reaches here only in a file that has suppressed that rule. * * A file under a package's `test/`, `integration/`, or `bench/` directory is * exempt, as is one named `*.test.ts` or `*.bench.ts` anywhere in the package; * `test-files.ts` is what draws that line. A test that names its own package * is reaching for the surface a consumer sees, which is the thing it is there * to check. * * See docs/development/DEVELOPMENT.md. */ import { parse as parseJsonc } from "@std/jsonc"; import { dirname, relative, resolve } from "@std/path"; import { isObjectOrArray } from "@commonfabric/utils/types"; import { isTestFile } from "./test-files.ts"; /** The file names a Deno configuration is allowed to take. */ const CONFIG_FILE_NAMES = ["deno.json", "deno.jsonc"] as const; /** * A type written `import("...").Name`, which holds its specifier inside a * literal type rather than directly as the declaration forms do. */ interface InlineTypeImport { readonly argument?: { readonly literal?: { readonly value?: unknown } }; } /** The package a file belongs to, as much of it as this rule reads. */ interface OwningPackage { /** The directory holding the package's Deno configuration. */ readonly root: string; /** The name that configuration declares. */ readonly name: string; /** The `exports` map, from specifier suffix to a path under `root`. */ readonly exports: ReadonlyMap; /** The `imports` map, from key to the address it stands for. */ readonly imports: ReadonlyMap; } /** * Resolved owners, keyed by directory. A lint run reads many files of the same * package, and this is what keeps that to one pass up the tree per directory. */ const ownerByDirectory = new Map(); /** * The Deno configuration written in `directory`, or null when there is none. * A file that is there but does not parse reads as an empty configuration: * Deno reports that itself, and a lint rule that threw over it would take the * whole run down over a file it only wanted a name from. */ function readConfig(directory: string): Record | null { for (const name of CONFIG_FILE_NAMES) { let text: string; try { text = Deno.readTextFileSync(resolve(directory, name)); } catch (error) { if (error instanceof Deno.errors.NotFound) continue; throw error; } let parsed: unknown; try { parsed = parseJsonc(text); } catch { return {}; } return isObjectOrArray(parsed) ? parsed as Record : {}; } return null; } /** The `exports` map of a configuration, keeping the entries that are paths. */ function exportMap(exports: unknown): ReadonlyMap { const map = new Map(); if (typeof exports === "string") { map.set(".", exports); } else if (isObjectOrArray(exports)) { for (const [key, value] of Object.entries(exports)) { if (typeof value === "string") map.set(key, value); } } return map; } /** The `imports` map of a configuration, keeping the entries that are strings. */ function importMap(imports: unknown): ReadonlyMap { const map = new Map(); if (isObjectOrArray(imports)) { for (const [key, value] of Object.entries(imports)) { if (typeof value === "string") map.set(key, value); } } return map; } /** * The package that owns files in `directory`. The nearest enclosing directory * with a Deno configuration is the package, and a configuration that declares * no name gives a package that cannot be named, so the walk stops there rather * than crediting the files to an enclosing package that does have a name. */ function ownerOf(directory: string): OwningPackage | null { if (ownerByDirectory.has(directory)) { return ownerByDirectory.get(directory) ?? null; } const owner = computeOwner(directory); ownerByDirectory.set(directory, owner); return owner; } function computeOwner(directory: string): OwningPackage | null { const config = readConfig(directory); if (config === null) { const parent = dirname(directory); return parent === directory ? null : ownerOf(parent); } const name = config.name; if (typeof name !== "string" || name === "") return null; return { root: directory, name, exports: exportMap(config.exports), imports: importMap(config.imports), }; } /** * The file `specifier` names through the package's `imports` map, or null when * it names none. The match is the one an import map makes: a key equal to the * specifier, and failing that the longest key ending in `/` that the specifier * starts with. The winning entry is chosen from the whole map, and only then * asked whether it is a path relative to the configuration, so an entry naming * another package shadows a shorter one naming a directory here. */ function aliasTarget(owner: OwningPackage, specifier: string): string | null { let key = owner.imports.has(specifier) ? specifier : null; if (key === null) { for (const candidate of owner.imports.keys()) { if (!candidate.endsWith("/") || !specifier.startsWith(candidate)) { continue; } if (key === null || candidate.length > key.length) key = candidate; } } if (key === null) return null; const address = owner.imports.get(key); if (address === undefined || !address.startsWith("./")) return null; return resolve(owner.root, address, specifier.slice(key.length)); } /** True when `specifier` is an alias for the package's entry point. */ function isEntryPointAlias(owner: OwningPackage, specifier: string): boolean { const entryPoint = owner.exports.get("."); if (entryPoint === undefined) return false; return aliasTarget(owner, specifier) === resolve(owner.root, entryPoint); } /** The specifier that reaches `to` from the file at `from`. */ function relativeSpecifier(from: string, to: string): string { const path = relative(dirname(from), to).replaceAll("\\", "/"); return path.startsWith(".") ? path : `./${path}`; } /** The advice given when the rule cannot name the file to import. */ const GENERAL_ADVICE = "Import the module that defines it by relative path instead."; /** * The advice given for an alias of the entry point. A package that has aliases * addresses its own modules through them as readily as by relative path, so * this names no form. */ const ALIAS_ADVICE = "Import the module that defines it instead."; /** * What to import instead: the module the offending subpath resolves to, named * relative to the offending file. A subpath the `exports` map does not carry * resolves to nothing, and the advice stays general. */ function suggestion( owner: OwningPackage, filename: string, specifier: string, ): string { const target = owner.exports.get(`.${specifier.slice(owner.name.length)}`); if (target === undefined) return GENERAL_ADVICE; const path = relativeSpecifier(filename, resolve(owner.root, target)); return `Import \`${path}\` instead.`; } function message( owner: OwningPackage, filename: string, specifier: string, ): string { // The entry point gets no file named for it: the relative path to it reaches // the same barrel, so the fix is a path to whichever module defines the // imported name, which the rule does not know. const isAlias = isEntryPointAlias(owner, specifier); if (specifier === owner.name || isAlias) { const advice = isAlias ? ALIAS_ADVICE : GENERAL_ADVICE; return `\`${specifier}\` is the entry point of the package this file ` + "belongs to. Importing it from inside the package puts a cycle in the " + "module graph, and makes the order in which this module initializes " + `depend on the order the entry point lists its exports. ${advice}`; } return `\`${specifier}\` is an export of the package this file belongs to, ` + "and it resolves back to a file inside that package. Naming it this way " + `gives one module two spellings. ${suggestion(owner, filename, specifier)}`; } export default { name: "cf-package", rules: { "no-self-import": { create(context) { const owner = ownerOf(dirname(context.filename)); if (owner === null) return {}; if (isTestFile(owner.root, context.filename)) return {}; // Every node that carries a module specifier carries it as `source`, // which is a string literal except in the dynamic-import form, where it // is an arbitrary expression that only sometimes is one. const check = (node: Deno.lint.Node, source: unknown) => { const value = (source as { value?: unknown } | null)?.value; if (typeof value !== "string") return; if ( value !== owner.name && !value.startsWith(`${owner.name}/`) && !isEntryPointAlias(owner, value) ) { return; } context.report({ node, message: message(owner, context.filename, value), }); }; return { ImportDeclaration: (node) => check(node, node.source), ExportAllDeclaration: (node) => check(node, node.source), ExportNamedDeclaration: (node) => check(node, node.source), ImportExpression: (node) => check(node, node.source), TSImportType: (node) => check( node, (node as unknown as InlineTypeImport).argument?.literal, ), }; }, }, }, } satisfies Deno.lint.Plugin;