158 lines
5.3 KiB
TypeScript
158 lines
5.3 KiB
TypeScript
#!/usr/bin/env bun
|
|
/**
|
|
* Fixes broken relative imports inside files that were just migrated from
|
|
* src/<feature>.ts to src/<feature>/<feature>.ts.
|
|
*
|
|
* The migrate-feature-layout script moved files one level deeper but only
|
|
* rewrote the imports OF the moved files, not imports WITHIN them. Any
|
|
* `./X.js` (or `./X/index.js`) inside a moved file now points at
|
|
* `src/<feature>/X.js` (which doesn't exist) instead of the intended
|
|
* `src/X.js`.
|
|
*
|
|
* For each `src/<feature>/{<feature>.ts, <feature>.test.ts}`, walks its
|
|
* relative imports and rewrites `./X` → `../X` when the target resolves at
|
|
* the package's src/ root (one level up). Leaves intra-folder imports
|
|
* alone (`./<feature>.types.js` etc.) since those are still correct.
|
|
*
|
|
* Idempotent: a second run finds nothing to fix.
|
|
*/
|
|
|
|
import {
|
|
existsSync,
|
|
readdirSync,
|
|
readFileSync,
|
|
statSync,
|
|
writeFileSync,
|
|
} from "node:fs";
|
|
import { dirname, join } from "node:path";
|
|
|
|
const PACKAGES_DIR = "packages";
|
|
|
|
interface MovedFile {
|
|
readonly file: string;
|
|
readonly srcDir: string;
|
|
readonly featureDir: string;
|
|
readonly feature: string;
|
|
}
|
|
|
|
function listMovedFiles(): MovedFile[] {
|
|
const out: MovedFile[] = [];
|
|
for (const cat of readdirSync(PACKAGES_DIR)) {
|
|
const catDir = join(PACKAGES_DIR, cat);
|
|
if (!statSync(catDir).isDirectory()) continue;
|
|
for (const plugin of readdirSync(catDir)) {
|
|
const pluginDir = join(catDir, plugin);
|
|
if (!statSync(pluginDir).isDirectory()) continue;
|
|
for (const pkg of readdirSync(pluginDir)) {
|
|
const pkgDir = join(pluginDir, pkg);
|
|
if (!statSync(pkgDir).isDirectory()) continue;
|
|
const srcDir = join(pkgDir, "src");
|
|
try {
|
|
statSync(srcDir);
|
|
} catch {
|
|
continue;
|
|
}
|
|
for (const entry of readdirSync(srcDir)) {
|
|
const featureDir = join(srcDir, entry);
|
|
try {
|
|
if (!statSync(featureDir).isDirectory()) continue;
|
|
} catch {
|
|
continue;
|
|
}
|
|
const feature = entry;
|
|
const impl = join(featureDir, `${feature}.ts`);
|
|
const test = join(featureDir, `${feature}.test.ts`);
|
|
if (existsSync(impl))
|
|
out.push({ file: impl, srcDir, featureDir, feature });
|
|
if (existsSync(test))
|
|
out.push({ file: test, srcDir, featureDir, feature });
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function targetResolves(targetBase: string, lookupDir: string): boolean {
|
|
// targetBase is e.g. "./errors/serialize-error" — strip leading ./, then
|
|
// look for matching candidates from lookupDir.
|
|
const stripped = targetBase.replace(/^\.\//, "");
|
|
const candidates = [
|
|
join(lookupDir, `${stripped}.ts`),
|
|
join(lookupDir, `${stripped}.tsx`),
|
|
join(lookupDir, stripped, "index.ts"),
|
|
join(lookupDir, stripped, "index.tsx"),
|
|
join(lookupDir, stripped), // for asset / json imports
|
|
];
|
|
return candidates.some((c) => {
|
|
try {
|
|
statSync(c);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
});
|
|
}
|
|
|
|
function fixFile(moved: MovedFile): number {
|
|
const src = readFileSync(moved.file, "utf8");
|
|
// Match every `"./<something>.js"` whether in import or side-effect.
|
|
const re = /"(\.\/[^"]+\.js)"/g;
|
|
let updated = src;
|
|
const touches = new Set<string>();
|
|
|
|
for (const m of src.matchAll(re)) {
|
|
const fullPath = m[1] as string; // e.g. "./foo.js" or "./foo/bar.js"
|
|
const withoutJs = fullPath.replace(/\.js$/, "");
|
|
const base = withoutJs.replace(/\/index$/, "");
|
|
|
|
// Skip imports that target THE feature folder itself or its siblings
|
|
// — those resolve correctly inside the new feature dir.
|
|
const intraFolder =
|
|
base === `./${moved.feature}` ||
|
|
base.startsWith(`./${moved.feature}.`) ||
|
|
// sibling files inside the same feature folder
|
|
!base.includes("/", 2);
|
|
|
|
// Step 1: does it currently resolve from the file's location?
|
|
const fileDir = dirname(moved.file);
|
|
if (targetResolves(base, fileDir)) continue;
|
|
|
|
// Step 2: does it resolve at src/ root (one level up)?
|
|
if (targetResolves(base, moved.srcDir)) {
|
|
const newPath = fullPath.replace(/^\.\//, "../");
|
|
updated = updated.split(`"${fullPath}"`).join(`"${newPath}"`);
|
|
touches.add(fullPath);
|
|
continue;
|
|
}
|
|
|
|
// Avoid lint warning about unused variable
|
|
void intraFolder;
|
|
}
|
|
|
|
if (updated !== src) {
|
|
writeFileSync(moved.file, updated);
|
|
return touches.size;
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
function main(): void {
|
|
const moved = listMovedFiles();
|
|
let filesChanged = 0;
|
|
let importsRewritten = 0;
|
|
for (const m of moved) {
|
|
const n = fixFile(m);
|
|
if (n > 0) {
|
|
filesChanged++;
|
|
importsRewritten += n;
|
|
console.log(`FIX ${m.file} (${n} imports)`);
|
|
}
|
|
}
|
|
console.log(
|
|
`\nFixed ${importsRewritten} import path${importsRewritten === 1 ? "" : "s"} across ${filesChanged} file${filesChanged === 1 ? "" : "s"}.`
|
|
);
|
|
}
|
|
|
|
main();
|