///
/**
* Runs the `cf-package/no-self-import` rule over short files and checks which
* of the two messages, if either, it reports. The rule reads the file's package
* out of the nearest Deno configuration on disk, so each case writes a package
* into a temporary directory and names a file inside it.
*/
import { afterAll, describe, it } from "@std/testing/bdd";
import { expect } from "@std/expect";
import { resolve } from "@std/path";
import plugin from "./lint-self-import.ts";
/** A package written to disk, ready for the rule to read. */
interface Fixture {
/** The absolute directory the package sits in. */
readonly root: string;
/** Lints `source` as the package file at `path`, returning the messages. */
diagnose(path: string, source: string): string[];
}
/** Every directory `fixture` has made, for `afterAll` to remove. */
const fixtureRoots: string[] = [];
/**
* Writes a package whose configuration is `config`, and returns a handle that
* lints files against it. The directory goes under a fresh temporary root, so
* the walk up from a file cannot reach this repository's own configurations.
*/
function fixture(config: Record): Fixture {
const root = Deno.makeTempDirSync({ prefix: "lint-self-import-" });
fixtureRoots.push(root);
Deno.writeTextFileSync(
resolve(root, "deno.jsonc"),
`// a comment, because this is JSONC\n${JSON.stringify(config)}\n`,
);
return {
root,
diagnose(path, source) {
return Deno.lint.runPlugin(plugin, resolve(root, path), source)
.map((diagnostic) => diagnostic.message);
},
};
}
const WIDGETS = {
name: "@example/widgets",
exports: {
".": "./src/index.ts",
"./sprocket": "./src/sprocket.ts",
},
};
/** A package with a `@/` alias over `src/` and a `@` alias for its barrel. */
const ALIASED = {
name: "@example/aliased",
exports: {
".": "./src/index.ts",
"./knobs": "./src/knobs/index.ts",
},
imports: {
"@": "./src/index.ts",
"@/": "./src/",
"@/knobs": "./src/knobs/index.ts",
"@std/path": "jsr:@std/path@^1",
},
};
/** The distinguishing phrase of each of the rule's two messages. */
const BARREL = "is the entry point of the package this file belongs to";
const SUBPATH = "gives one module two spellings";
describe("lint-self-import", () => {
afterAll(() => {
for (const root of fixtureRoots) Deno.removeSync(root, { recursive: true });
});
it("reports an import of the package's own entry point", () => {
const messages = fixture(WIDGETS).diagnose(
"src/runtime.ts",
`import { RuntimeTelemetry } from "@example/widgets";`,
);
expect(messages.length).toBe(1);
expect(messages[0]).toContain(BARREL);
});
it("gives general advice for an entry-point import, whose path is the barrel", () => {
const messages = fixture(WIDGETS).diagnose(
"src/storage/interface.ts",
`import { Runtime } from "@example/widgets";`,
);
expect(messages[0]).toContain(
"Import the module that defines it by relative path instead.",
);
expect(messages[0]).not.toContain("index.ts");
});
it("reports an import of the package's own subpath export", () => {
const messages = fixture(WIDGETS).diagnose(
"src/schema.ts",
`import { spin } from "@example/widgets/sprocket";`,
);
expect(messages.length).toBe(1);
expect(messages[0]).toContain(SUBPATH);
expect(messages[0]).toContain("Import `./sprocket.ts` instead.");
});
it("gives general advice for a subpath the exports map omits", () => {
const messages = fixture(WIDGETS).diagnose(
"src/schema.ts",
`import { spin } from "@example/widgets/nowhere";`,
);
expect(messages.length).toBe(1);
expect(messages[0]).toContain(
"Import the module that defines it by relative path instead.",
);
});
it("reports a re-export of the package's own entry point", () => {
const messages = fixture(WIDGETS).diagnose(
"src/shared.ts",
`export * from "@example/widgets";`,
);
expect(messages.length).toBe(1);
expect(messages[0]).toContain(BARREL);
});
it("reports a named re-export from the package's own name", () => {
const messages = fixture(WIDGETS).diagnose(
"src/shared.ts",
`export { Runtime } from "@example/widgets";`,
);
expect(messages.length).toBe(1);
expect(messages[0]).toContain(BARREL);
});
it("reports a dynamic import of the package's own name", () => {
const messages = fixture(WIDGETS).diagnose(
"src/lazy.ts",
`export const load = () => import("@example/widgets");`,
);
expect(messages.length).toBe(1);
expect(messages[0]).toContain(BARREL);
});
it("reports a type-only import of the package's own name", () => {
const messages = fixture(WIDGETS).diagnose(
"src/runtime.ts",
`import type { Runtime } from "@example/widgets";`,
);
expect(messages.length).toBe(1);
expect(messages[0]).toContain(BARREL);
});
it("reports an exact alias for the package's own entry point", () => {
const messages = fixture(ALIASED).diagnose(
"src/maker.ts",
`import { Gizmo } from "@";`,
);
expect(messages.length).toBe(1);
expect(messages[0]).toContain(BARREL);
});
it("advises no form of specifier for an alias of the entry point", () => {
const messages = fixture(ALIASED).diagnose(
"src/maker.ts",
`import { Gizmo } from "@";`,
);
expect(messages[0]).toContain("Import the module that defines it instead.");
expect(messages[0]).not.toContain("relative path");
});
it("reports a prefix alias that reaches the package's own entry point", () => {
const messages = fixture(ALIASED).diagnose(
"src/maker.ts",
`import { Gizmo } from "@/index.ts";`,
);
expect(messages.length).toBe(1);
expect(messages[0]).toContain(BARREL);
});
it("returns nothing for a prefix alias that reaches another module", () => {
const messages = fixture(ALIASED).diagnose(
"src/maker.ts",
`import { Gizmo } from "@/gizmo.ts";`,
);
expect(messages).toEqual([]);
});
it("returns nothing for an exact alias that reaches another module", () => {
const messages = fixture(ALIASED).diagnose(
"src/maker.ts",
`import type { GizmoValue } from "@/knobs";`,
);
expect(messages).toEqual([]);
});
it("returns nothing for an alias to the entry point in a test file", () => {
const messages = fixture(ALIASED).diagnose(
"test/maker.test.ts",
`import { Gizmo } from "@";`,
);
expect(messages).toEqual([]);
});
it("returns nothing for an imports entry that names another package", () => {
const messages = fixture(ALIASED).diagnose(
"src/maker.ts",
`import { resolve } from "@std/path";`,
);
expect(messages).toEqual([]);
});
it("takes the longest prefix alias, as an import map does", () => {
// Under `@/` alone, `@/deep/index.ts` would be `src/deep/index.ts`. The
// longer key sends it to the entry point instead.
const messages = fixture({
...ALIASED,
imports: { "@/": "./src/", "@/deep/": "./src/" },
}).diagnose(
"src/maker.ts",
`import { Gizmo } from "@/deep/index.ts";`,
);
expect(messages.length).toBe(1);
expect(messages[0]).toContain(BARREL);
});
it("returns nothing for an exact entry naming another package, over a local prefix", () => {
const messages = fixture({
...ALIASED,
imports: { "@/": "./src/", "@/index.ts": "npm:elsewhere@^1" },
}).diagnose("src/maker.ts", `import { Gizmo } from "@/index.ts";`);
expect(messages).toEqual([]);
});
it("returns nothing for a longer prefix naming another package, over a local one", () => {
const messages = fixture({
...ALIASED,
imports: { "@/": "./", "@/src/": "npm:/elsewhere@^1/" },
}).diagnose("src/maker.ts", `import { Gizmo } from "@/src/index.ts";`);
expect(messages).toEqual([]);
});
it("returns nothing for an alias in a package with no entry point", () => {
const messages = fixture({
name: "@example/server",
imports: { "@/": "./" },
}).diagnose("routes/health.ts", `import { app } from "@/index.ts";`);
expect(messages).toEqual([]);
});
it("returns nothing for an import of another package", () => {
const messages = fixture(WIDGETS).diagnose(
"src/runtime.ts",
`import { isKnob } from "@example/gadgets/knobs";`,
);
expect(messages).toEqual([]);
});
it("returns nothing for a package whose name only prefixes this one's", () => {
const messages = fixture(WIDGETS).diagnose(
"src/runtime.ts",
`import { x } from "@example/widgets-client";`,
);
expect(messages).toEqual([]);
});
it("returns nothing for a relative import", () => {
const messages = fixture(WIDGETS).diagnose(
"src/runtime.ts",
`import { RuntimeTelemetry } from "./telemetry.ts";`,
);
expect(messages).toEqual([]);
});
it("returns nothing for a file under the package's test directory", () => {
const messages = fixture(WIDGETS).diagnose(
"test/runtime.test.ts",
`import { Runtime } from "@example/widgets";`,
);
expect(messages).toEqual([]);
});
it("returns nothing for a file under the package's integration directory", () => {
const messages = fixture(WIDGETS).diagnose(
"integration/boot.ts",
`import { Runtime } from "@example/widgets";`,
);
expect(messages).toEqual([]);
});
it("returns nothing for a test file beside the source it tests", () => {
const messages = fixture(WIDGETS).diagnose(
"src/runtime.test.ts",
`import { Runtime } from "@example/widgets";`,
);
expect(messages).toEqual([]);
});
it("returns nothing for a benchmark file", () => {
const messages = fixture(WIDGETS).diagnose(
"src/runtime.bench.ts",
`import { Runtime } from "@example/widgets";`,
);
expect(messages).toEqual([]);
});
it("returns nothing for a file whose package configuration does not parse", () => {
const root = Deno.makeTempDirSync({ prefix: "lint-self-import-" });
fixtureRoots.push(root);
Deno.writeTextFileSync(resolve(root, "deno.jsonc"), "{ this is not json");
const messages = Deno.lint.runPlugin(
plugin,
resolve(root, "src/main.ts"),
`import { Runtime } from "@example/widgets";`,
).map((diagnostic) => diagnostic.message);
expect(messages).toEqual([]);
});
it("returns nothing for a file whose package declares no name", () => {
const messages = fixture({ exports: { ".": "./mod.ts" } }).diagnose(
"src/main.ts",
`import { Runtime } from "@example/widgets";`,
);
expect(messages).toEqual([]);
});
it("stops the search for a package at the nearest configuration", () => {
// A workspace member with no name of its own sits inside a package that has
// one. Its files belong to the member, so the enclosing name is another
// package's and free to import.
const outer = fixture(WIDGETS);
const inner = resolve(outer.root, "nested");
Deno.mkdirSync(inner);
Deno.writeTextFileSync(resolve(inner, "deno.jsonc"), "{}\n");
const messages = Deno.lint.runPlugin(
plugin,
resolve(inner, "main.ts"),
`import { Runtime } from "@example/widgets";`,
).map((diagnostic) => diagnostic.message);
expect(messages).toEqual([]);
});
it("reports a type written as an inline `import(...)`", () => {
const messages = fixture(WIDGETS).diagnose(
"src/runtime.ts",
`type T = import("@example/widgets").Runtime;`,
);
expect(messages.length).toBe(1);
expect(messages[0]).toContain(BARREL);
});
it("reads a second file against the package it already resolved", () => {
// The owning package is cached per directory, so the second file in a
// directory takes a different path to the same answer than the first.
const pkg = fixture(WIDGETS);
const first = pkg.diagnose(
"src/runtime.ts",
`import { Runtime } from "@example/widgets";`,
);
const second = pkg.diagnose(
"src/schema.ts",
`import { spin } from "@example/widgets/sprocket";`,
);
expect(first.length).toBe(1);
expect(first[0]).toContain(BARREL);
expect(second.length).toBe(1);
expect(second[0]).toContain("Import `./sprocket.ts` instead.");
});
it("prefers deno.json to deno.jsonc, as Deno itself does", () => {
// Deno takes deno.json whole and ignores the other file, so a name in the
// ignored one is not the package's name.
const pkg = fixture({ name: "@example/ignored" });
Deno.writeTextFileSync(
resolve(pkg.root, "deno.json"),
JSON.stringify(WIDGETS),
);
const messages = pkg.diagnose(
"src/runtime.ts",
`import { Runtime } from "@example/widgets";`,
);
expect(messages.length).toBe(1);
expect(messages[0]).toContain(BARREL);
});
it("throws when a package configuration cannot be read at all", () => {
// A configuration that is unreadable is not a package without a name: Deno
// fails on the same file, and swallowing it here would quietly stop the
// rule from checking anything under that directory.
const root = Deno.makeTempDirSync({ prefix: "lint-self-import-" });
fixtureRoots.push(root);
Deno.mkdirSync(resolve(root, "deno.json"));
expect(() =>
Deno.lint.runPlugin(
plugin,
resolve(root, "src/main.ts"),
`import { Runtime } from "@example/widgets";`,
)
).toThrow();
});
it("reports a package that names itself in a bare exports string", () => {
const messages = fixture({
name: "@example/single",
exports: "./src/mod.ts",
}).diagnose(
"src/encode.ts",
`import { decode } from "@example/single";`,
);
expect(messages.length).toBe(1);
expect(messages[0]).toContain(BARREL);
});
});