test: add unresolved local import scanner

This commit is contained in:
Simo committed 2026-07-11 20:52:55 +02:00
1 parent cabafb4ea6
commit 96ed768f14
3 files changed
+115

No files matched your search

+12
View File
@@ -0,0 +1,12 @@
import { findMissingLocalImports } from "../src/lib/local-imports";
const missing = await findMissingLocalImports(process.cwd());
if (missing.length === 0) {
console.log("No unresolved local imports.");
process.exit(0);
}
for (const item of missing) console.error(`${item.importer}: ${item.specifier}`);
console.error(`${missing.length} unresolved local import(s).`);
process.exitCode = 1;
+36
View File
@@ -0,0 +1,36 @@
import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { findMissingLocalImports } from "./local-imports";
const roots: string[] = [];
afterEach(async () => {
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
});
describe("findMissingLocalImports", () => {
it("reports unresolved alias imports and ignores modules that exist", async () => {
const root = await mkdtemp(join(tmpdir(), "epicnext-imports-"));
roots.push(root);
await mkdir(join(root, "src", "features"), { recursive: true });
await mkdir(join(root, "src", "components", "ui"), { recursive: true });
await writeFile(join(root, "src", "components", "ui", "card.tsx"), "export const Card = {};\n");
await writeFile(
join(root, "src", "features", "page.tsx"),
[
'import { Card } from "@/components/ui/card";',
'import { Button } from "@/components/ui/button";',
"export default Card;",
].join("\n"),
);
await expect(findMissingLocalImports(root)).resolves.toEqual([
{
importer: "src/features/page.tsx",
specifier: "@/components/ui/button",
},
]);
});
});
+67
View File
@@ -0,0 +1,67 @@
import { access, readdir, readFile } from "node:fs/promises";
import { dirname, extname, join, relative, resolve, sep } from "node:path";
export interface MissingLocalImport {
importer: string;
specifier: string;
}
const sourceExtensions = new Set([".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"]);
const resolutionSuffixes = [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", "/index.ts", "/index.tsx", "/index.js", "/index.jsx"];
const importPattern = /(?:import|export)\s+(?:[^"']*?\s+from\s+)?["']([^"']+)["']|import\(\s*["']([^"']+)["']\s*\)|require\(\s*["']([^"']+)["']\s*\)/g;
async function collectSourceFiles(directory: string): Promise<string[]> {
const entries = await readdir(directory, { withFileTypes: true });
const files = await Promise.all(
entries.map(async (entry) => {
const path = join(directory, entry.name);
if (entry.isDirectory()) return collectSourceFiles(path);
return sourceExtensions.has(extname(entry.name)) ? [path] : [];
}),
);
return files.flat();
}
async function exists(path: string): Promise<boolean> {
try {
await access(path);
return true;
} catch {
return false;
}
}
async function resolvesToLocalFile(base: string): Promise<boolean> {
if (sourceExtensions.has(extname(base)) && (await exists(base))) return true;
for (const suffix of resolutionSuffixes) {
if (await exists(`${base}${suffix}`)) return true;
}
return false;
}
export async function findMissingLocalImports(root: string): Promise<MissingLocalImport[]> {
const src = resolve(root, "src");
const files = await collectSourceFiles(src);
const missing: MissingLocalImport[] = [];
for (const importer of files) {
const source = await readFile(importer, "utf8");
for (const match of source.matchAll(importPattern)) {
const specifier = match[1] ?? match[2] ?? match[3];
if (!specifier?.startsWith("@/") && !specifier?.startsWith("./") && !specifier?.startsWith("../")) continue;
const base = specifier.startsWith("@/")
? resolve(src, specifier.slice(2))
: resolve(dirname(importer), specifier);
if (!(await resolvesToLocalFile(base))) {
missing.push({
importer: relative(root, importer).split(sep).join("/"),
specifier,
});
}
}
}
return missing.sort((a, b) =>
a.importer.localeCompare(b.importer) || a.specifier.localeCompare(b.specifier),
);
}