test: add unresolved local import scanner
This commit is contained in:
1 parent
cabafb4ea6
commit
96ed768f14
3 files changed
+115
No files matched your search
@@ -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;
|
||||||
@@ -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",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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),
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in new issue
Block a user