/** * fix-duplicate-imports.mjs * * Merges duplicate import statements from the same module across all TypeScript * source files in the project. Run this whenever `npm run lint` reports * `no-duplicate-imports` errors. * * Usage: * node scripts/fix-duplicate-imports.mjs * * Handles: * import type { A, B } from 'module' — type-only imports * import Default from 'module' — default imports * import Default, { A, B } from 'mod' — mixed default + named * import { A, B } from 'module' — named imports * import {\n A,\n B\n} from 'mod' — multi-line named imports * * When merging a `import type` with a value import from the same module, * type-only specifiers are inlined as `type Name` in the merged statement. */ import { readFileSync, readdirSync, statSync, writeFileSync } from "fs"; import { extname, join, resolve } from "path"; // --------------------------------------------------------------------------- // Config // --------------------------------------------------------------------------- const projectRoot = resolve(new URL(".", import.meta.url).pathname, ".."); const srcDir = join(projectRoot, "src"); const extensions = new Set([".ts", ".tsx"]); // --------------------------------------------------------------------------- // File discovery — recursively find all TS/TSX files under src/ // --------------------------------------------------------------------------- function findFiles(dir) { const results = []; for (const entry of readdirSync(dir)) { const full = join(dir, entry); if (statSync(full).isDirectory()) { results.push(...findFiles(full)); } else if (extensions.has(extname(entry))) { results.push(full); } } return results; } // --------------------------------------------------------------------------- // Import extraction // --------------------------------------------------------------------------- /** * Extracts all top-level import statements from file content. * Returns array of { start, end, raw, module, isType, defaultImport, namedImports } * where start/end are character positions in the content. */ function extractImports(content) { const results = []; const importRe = /^import\s+([\s\S]*?)from\s+['"]([^'"]+)['"]\s*;?/gm; let match; while ((match = importRe.exec(content)) !== null) { const raw = match[0]; const specifierPart = match[1]; const moduleName = match[2]; const isType = /^type\s+/.test(specifierPart.trimStart()); const cleanSpec = specifierPart.replace(/^type\s+/, "").trim(); let defaultImport = null; let namedStr = null; const namespaceMatch = cleanSpec.match(/^\*\s+as\s+(\w+)/); if (namespaceMatch) { defaultImport = `* as ${namespaceMatch[1]}`; } else { const braceIdx = cleanSpec.indexOf("{"); if (braceIdx === -1) { const def = cleanSpec.replace(/,$/, "").trim(); if (def) defaultImport = def; } else { const beforeBrace = cleanSpec.slice(0, braceIdx).replace(/,$/, "").trim(); if (beforeBrace) defaultImport = beforeBrace; const closeBrace = cleanSpec.lastIndexOf("}"); namedStr = cleanSpec.slice(braceIdx + 1, closeBrace); } } const namedImports = namedStr ? namedStr.split(",").map((s) => s.trim()).filter(Boolean) : []; results.push({ start: match.index, end: match.index + raw.length, raw, module: moduleName, isType, defaultImport, namedImports, }); } return results; } // --------------------------------------------------------------------------- // Import merging // --------------------------------------------------------------------------- /** * Build a single merged import statement from multiple imports of the same module. * * - All type imports → merged `import type { ... }` * - Mixed type + value → merged value import with inline `type Name` specifiers */ function buildMergedImport(moduleName, importList) { const allType = importList.every((i) => i.isType); if (allType) { const allNamed = new Set(importList.flatMap((i) => i.namedImports)); const defaultImport = importList.map((i) => i.defaultImport).find(Boolean) ?? null; const parts = []; if (defaultImport) parts.push(defaultImport); if (allNamed.size > 0) parts.push(`{ ${[...allNamed].join(", ")} }`); if (parts.length === 0) return `import type "${moduleName}";`; return `import type ${parts.join(", ")} from "${moduleName}";`; } // Mixed: collect value default, type-only named, value named separately let valueDefault = null; const typeNamed = new Set(); const valueNamed = new Set(); for (const imp of importList) { if (imp.defaultImport && !imp.isType) valueDefault = imp.defaultImport; for (const n of imp.namedImports) { (imp.isType ? typeNamed : valueNamed).add(n); } } // Build named specifiers: `type X` for type-only, plain for value const typeSpecifiers = [...typeNamed].filter((n) => !valueNamed.has(n)).map((n) => `type ${n}`); const valueSpecifiers = [...valueNamed]; const namedParts = [...typeSpecifiers, ...valueSpecifiers]; const parts = []; if (valueDefault) parts.push(valueDefault); if (namedParts.length > 0) parts.push(`{ ${namedParts.join(", ")} }`); if (parts.length === 0) return `import "${moduleName}";`; return `import ${parts.join(", ")} from "${moduleName}";`; } // --------------------------------------------------------------------------- // File fixer // --------------------------------------------------------------------------- function fixFile(filePath) { let content = readFileSync(filePath, "utf-8"); const imports = extractImports(content); if (imports.length === 0) return null; // Group by module const byModule = new Map(); for (const imp of imports) { if (!byModule.has(imp.module)) byModule.set(imp.module, []); byModule.get(imp.module).push(imp); } if (![...byModule.values()].some((v) => v.length > 1)) return null; // Build merged text for each module const mergedMap = new Map(); for (const [mod, imps] of byModule) { mergedMap.set(mod, imps.length === 1 ? imps[0].raw : buildMergedImport(mod, imps)); } // Build replacement list (process in reverse order to preserve character positions) imports.sort((a, b) => a.start - b.start); const placedModules = new Set(); const replacements = []; for (const imp of imports) { if (!placedModules.has(imp.module)) { replacements.push({ start: imp.start, end: imp.end, replacement: mergedMap.get(imp.module) }); placedModules.add(imp.module); } else { // Remove duplicate, including its trailing newline const end = content[imp.end] === "\n" ? imp.end + 1 : imp.end; replacements.push({ start: imp.start, end, replacement: "" }); } } replacements.sort((a, b) => b.start - a.start); for (const { start, end, replacement } of replacements) { content = content.slice(0, start) + replacement + content.slice(end); } return content; } // --------------------------------------------------------------------------- // Main // --------------------------------------------------------------------------- const files = findFiles(srcDir); let fixedCount = 0; for (const filePath of files) { try { const original = readFileSync(filePath, "utf-8"); const fixed = fixFile(filePath); if (fixed && fixed !== original) { writeFileSync(filePath, fixed, "utf-8"); const rel = filePath.replace(projectRoot + "/", ""); console.log(`Fixed: ${rel}`); fixedCount++; } } catch (err) { const rel = filePath.replace(projectRoot + "/", ""); console.error(`Error: ${rel} — ${err.message}`); } } console.log(`\nDone. Fixed ${fixedCount} file${fixedCount !== 1 ? "s" : ""}.`);