diff --git a/scripts/check-local-imports.ts b/scripts/check-local-imports.ts new file mode 100644 index 00000000..5597d7be --- /dev/null +++ b/scripts/check-local-imports.ts @@ -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; diff --git a/src/lib/local-imports.test.ts b/src/lib/local-imports.test.ts new file mode 100644 index 00000000..3473fac6 --- /dev/null +++ b/src/lib/local-imports.test.ts @@ -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", + }, + ]); + }); +}); diff --git a/src/lib/local-imports.ts b/src/lib/local-imports.ts new file mode 100644 index 00000000..0d2b1f2d --- /dev/null +++ b/src/lib/local-imports.ts @@ -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 { + 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 { + try { + await access(path); + return true; + } catch { + return false; + } +} + +async function resolvesToLocalFile(base: string): Promise { + 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 { + 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), + ); +}