/** * 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 = 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 => 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 => (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 { 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(); const importedByTest = new Map(); /** 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");