scripts/find-unused-src.ts
Rama main · réplica de lectura del repositorio original.
/**
* Finds src/ exports that are only used in test/ files but never in other src/ files.
*
* Three checks:
* 1. File-level: src/ files imported by test/ but never by other src/ files
* 2. Export-level: individual named exports from src/ only used in test/
* 3. Dead code: src/ files not imported by anything
*
* Handles both static imports and dynamic `await import("...")` calls.
* Also scans scripts/ for build entry points.
*
* Usage: deno task find-unused-src
*/
import { dirname, relative, resolve } from "@std/path";
import { flatMap } from "#fp";
import { collectSourceFiles } from "./walk-files.ts";
const ROOT = resolve(import.meta.dirname!, "..");
// Read and parse deno.json import map
const denoConfig = JSON.parse(
await Deno.readTextFile(resolve(ROOT, "deno.json")),
);
const importMapRaw: Record<string, string> = denoConfig.imports ?? {};
// Build alias-to-path mapping for local aliases
const aliasMap: { alias: string; path: string }[] = [];
for (const [alias, target] of Object.entries(importMapRaw)) {
if (typeof target === "string" && target.startsWith("./")) {
aliasMap.push({ alias, path: target.replace(/^\.\//, "") });
}
}
// Sort by longest alias first so more specific aliases match first
aliasMap.sort((a, b) => b.alias.length - a.alias.length);
/** Resolve an import specifier to a relative file path (from project root) */
/** Try to resolve a specifier via the alias map */
function resolveAlias(specifier: string): string | null {
for (const { alias, path: aliasPath } of aliasMap) {
if (alias.endsWith("/") && specifier.startsWith(alias)) {
return aliasPath + specifier.slice(alias.length);
}
if (specifier === alias) return aliasPath;
}
return null;
}
function resolveImport(specifier: string, fromFile: string): string | null {
const aliased = resolveAlias(specifier);
if (aliased) return aliased;
if (specifier.startsWith("./") || specifier.startsWith("../")) {
const fromDir = dirname(fromFile);
const resolved = resolve(ROOT, fromDir, specifier);
return relative(ROOT, resolved);
}
return null;
}
/** Read a project-relative source file as UTF-8 text. */
const readSource = async (filePath: string): Promise<string> =>
Deno.readTextFile(resolve(ROOT, filePath));
/** Split a comma-separated `a, b as c` list into bare names, dropping aliases. */
const splitAliasedNames = (raw: string): string[] =>
raw
.split(",")
.map((n: string) =>
n
.trim()
.replace(/\s+as\s+\w+/, "")
.trim(),
)
.filter(Boolean);
/** Collect every project-relative .ts/.tsx file beneath `dir`, sorted. */
const collectTreeFiles = async (dir: string): Promise<string[]> =>
(await collectSourceFiles(resolve(ROOT, dir))).map((file) =>
relative(ROOT, file),
);
/** Collect one `{ names, specifier }` entry per match of an import regex;
* `read` says which capture groups carry the named imports and the specifier. */
const importsFromMatches = (
content: string,
regex: RegExp,
read: (match: RegExpMatchArray) => {
namedImports: string;
specifier: string;
},
): { specifier: string; names: string[] }[] =>
[...content.matchAll(regex)].map((match) => {
const { namedImports, specifier } = read(match);
return { names: splitAliasedNames(namedImports), specifier };
});
// Static imports/exports:
// import { a, b } from "specifier"
// import name from "specifier"
// import * as name from "specifier"
// import "specifier"
// import type { a } from "specifier"
// export { a } from "specifier"
const staticRegex =
/(?:import|export)\s+(?:type\s+)?(?:\{([^}]*)\}|(\*\s+as\s+\w+)|\w+)?\s*(?:,\s*\{([^}]*)\})?\s*(?:from\s+)?["']([^"']+)["']/g;
// Dynamic imports:
// const { a, b } = await import("specifier")
// await import("specifier")
// () => import("specifier")
const dynamicRegex =
/(?:const\s+\{([^}]*)\}\s*=\s*(?:await\s+)?)?import\(\s*["']([^"']+)["']\s*\)/g;
/** Extract both static and dynamic import specifiers from a file */
async function extractImports(
filePath: string,
): Promise<{ specifier: string; names: string[] }[]> {
const content = await readSource(filePath);
return [
...importsFromMatches(content, staticRegex, (match) => ({
namedImports: (match[1] || "") + (match[3] || ""),
specifier: match[4]!,
})),
...importsFromMatches(content, dynamicRegex, (match) => ({
namedImports: match[1] || "",
specifier: match[2]!,
})),
];
}
/** Patterns that match named export declarations */
const EXPORT_PATTERNS: RegExp[] = [
/export\s+function\s+(\w+)/g,
/export\s+(?:const|let|var)\s+(\w+)/g,
/export\s+class\s+(\w+)/g,
/export\s+(?:type|interface)\s+(\w+)/g,
/export\s+enum\s+(\w+)/g,
];
/** Extract names from `export { a, b as c }` blocks (not re-exports) */
const extractBraceExports = (content: string): string[] =>
flatMap((m: RegExpMatchArray) => splitAliasedNames(m[1]!))([
...content.matchAll(/export\s+\{([^}]+)\}(?!\s*from)/g),
]);
/** Extract exported names from a file */
async function extractExports(filePath: string): Promise<string[]> {
const content = await readSource(filePath);
const exports: string[] = [];
for (const pattern of EXPORT_PATTERNS) {
for (const m of content.matchAll(pattern)) {
exports.push(m[1]!);
}
}
exports.push(...extractBraceExports(content));
if (/export\s+default\s/.test(content)) exports.push("default");
return [...new Set(exports)];
}
// ---- Main analysis ----
console.log("Scanning codebase...\n");
const srcFiles = (await collectTreeFiles("src")).filter(
(f) => !f.startsWith("src/ui/static/") && !f.endsWith(".d.ts"),
);
// Test utilities now live under test/ (test/test-utils/), so they are part of
// the test file set rather than a separate src group.
const testFiles = await collectTreeFiles("test");
const scriptFiles = await collectTreeFiles("scripts");
const allFiles = [...srcFiles, ...testFiles, ...scriptFiles];
// Known entry points that are used outside the import graph
const entryPoints = new Set([
"src/index.ts", // deno task start
"src/edge.ts", // esbuild entry for Bunny CDN
"src/fp.ts", // import map root alias
"src/doc.ts", // deno doc generation
"src/shared/jsx/jsx-dev-runtime.ts", // jsxImportSource compiler config
]);
// Docs files are used by deno doc via src/doc.ts - mark as known
const docsFiles = srcFiles.filter((f) => f.startsWith("src/docs/"));
for (const f of docsFiles) entryPoints.add(f);
type ImportInfo = { file: string; names: string[] };
const importedBySrc = new Map<string, ImportInfo[]>();
const importedByTest = new Map<string, ImportInfo[]>();
/** The src/ and test/ importers recorded for one source file. */
const importersOf = (
srcFile: string,
): { src: ImportInfo[]; test: ImportInfo[] } => ({
src: importedBySrc.get(srcFile) ?? [],
test: importedByTest.get(srcFile) ?? [],
});
// Files eligible for the "imported only by tests" and "dead code" checks:
// entry points and browser-only client code are expected to sit outside the
// module graph, so they are never reported.
const candidateSrcFiles = srcFiles.filter(
(f) => !entryPoints.has(f) && !f.startsWith("src/ui/client/"),
);
/** True when `srcFile` is imported by neither src/ nor test/ files. */
const isNeverImported = (srcFile: string): boolean => {
const { src, test } = importersOf(srcFile);
return src.length === 0 && test.length === 0;
};
for (const file of allFiles) {
const imports = await extractImports(file);
for (const { specifier, names } of imports) {
const resolved = resolveImport(specifier, file);
if (!resolved?.startsWith("src/")) continue;
let target = resolved;
const allSrcFiles = srcFiles;
if (!allSrcFiles.includes(target)) {
if (allSrcFiles.includes(`${target}.ts`)) target = `${target}.ts`;
else if (allSrcFiles.includes(`${target}.tsx`)) target = `${target}.tsx`;
else if (allSrcFiles.includes(`${target}/index.ts`)) {
target = `${target}/index.ts`;
}
}
const info: ImportInfo = { file, names };
if (file.startsWith("test/")) {
if (!importedByTest.has(target)) importedByTest.set(target, []);
importedByTest.get(target)!.push(info);
} else if (file.startsWith("src/") || file.startsWith("scripts/")) {
if (!importedBySrc.has(target)) importedBySrc.set(target, []);
importedBySrc.get(target)!.push(info);
}
}
}
/** Print one check's banner heading. */
const printHeading = (title: string): void => {
const bar = "═".repeat(63);
console.log(bar);
console.log(` ${title}`);
console.log(`${bar}\n`);
};
/** Close a check's section: "None found." or its total, e.g. "Total: 3 file(s)".
* `prefix` carries the dead-code check's extra blank line before its total. */
const printTotal = (count: number, unit: string, prefix = ""): void => {
console.log(
count === 0 ? " None found.\n" : `${prefix} Total: ${count} ${unit}(s)\n`,
);
};
// ---- Check 1: Files only imported from tests ----
printHeading("FILES imported by test/ but NEVER by other src/ files");
let fileCount = 0;
for (const srcFile of candidateSrcFiles) {
const { src: srcImporters, test: testImporters } = importersOf(srcFile);
const testOnly = srcImporters.length === 0 && testImporters.length > 0;
if (testOnly) {
fileCount++;
const testFileList = [...new Set(testImporters.map((i) => i.file))];
console.log(` ${srcFile}`);
for (const tf of testFileList) {
console.log(` <- ${tf}`);
}
console.log();
}
}
printTotal(fileCount, "file");
// ---- Check 2: Individual exports only used in tests ----
printHeading("EXPORTS used in test/ but NEVER in other src/ files");
let exportCount = 0;
for (const srcFile of srcFiles) {
const { src: srcImporters, test: testImporters } = importersOf(srcFile);
if (srcImporters.length === 0) continue;
if (testImporters.length === 0) continue;
const fileExports = await extractExports(srcFile);
if (fileExports.length === 0) continue;
const namesUsedBySrc = new Set(srcImporters.flatMap((i) => i.names));
const namesUsedByTest = new Set(testImporters.flatMap((i) => i.names));
const testOnlyExports = fileExports.filter(
(name) => !namesUsedBySrc.has(name) && namesUsedByTest.has(name),
);
if (testOnlyExports.length > 0) {
exportCount += testOnlyExports.length;
console.log(` ${srcFile}:`);
for (const name of testOnlyExports) {
const usedIn = testImporters
.filter((i) => i.names.includes(name))
.map((i) => i.file);
console.log(` ${name}`);
for (const tf of usedIn) {
console.log(` <- ${tf}`);
}
}
console.log();
}
}
printTotal(exportCount, "export");
// ---- Check 3: Files not imported by anything (dead code) ----
printHeading("FILES not imported by anything (potential dead code)");
let deadCount = 0;
for (const srcFile of candidateSrcFiles) {
if (!isNeverImported(srcFile)) continue;
deadCount++;
console.log(` ${srcFile}`);
}
printTotal(deadCount, "file", "\n");