mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage
synced 2026-04-11 18:28:15 +00:00
228 lines
7.7 KiB
JavaScript
228 lines
7.7 KiB
JavaScript
/**
|
|
* 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" : ""}.`);
|