Initial Commit
This commit is contained in:
157
scripts/lint/fix-migrated-imports.ts
Normal file
157
scripts/lint/fix-migrated-imports.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
#!/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();
|
||||
Reference in New Issue
Block a user