mirror of
https://github.com/kuhyx/utils.git
synced 2026-11-10 18:07:24 +01:00
ts_config 0.1.1: alias-imports codemod rewrites ../ imports under src/ to @/
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
6f8471196f
commit
8cfa96d0ed
@ -16,7 +16,7 @@ files in three different strictness tiers.
|
||||
```jsonc
|
||||
// package.json
|
||||
"devDependencies": {
|
||||
"@kuhyx/ts-config": "github:kuhyx/utils#ts_config-v0.1.0&path:/ts_config",
|
||||
"@kuhyx/ts-config": "github:kuhyx/utils#ts_config-v0.1.1&path:/ts_config",
|
||||
"eslint": "10.10.0",
|
||||
"typescript": "6.0.3",
|
||||
"typescript-eslint": "8.70.0"
|
||||
@ -55,6 +55,24 @@ export default defineConfig({
|
||||
`jscpd.base.json` (jscpd has no extends). `stryker.config.mjs`:
|
||||
`import { strykerBase } from "@kuhyx/ts-config/stryker"; export default { ...strykerBase };`
|
||||
|
||||
## Adopting the alias rule in an existing repo
|
||||
|
||||
`base()` bans parent-relative imports, so a repo with dozens of `../` paths
|
||||
needs them rewritten before the rule can go on. That is a script, not a
|
||||
session's worth of hand edits:
|
||||
|
||||
```sh
|
||||
node ~/src/utils/ts_config/scripts/alias-imports.mjs <repo-root> [src-dir]
|
||||
```
|
||||
|
||||
Every `import` / `export … from` / `import()` specifier under `src/` that
|
||||
starts with `../` and resolves inside `src/` becomes `@/<path from src>`;
|
||||
`./sibling`, package imports and a `../` that leaves `src/` are left alone
|
||||
(the last one is the lint report's to surface). Extensions are carried over
|
||||
untouched. Idempotent. The repo still needs the alias itself: `paths` in
|
||||
tsconfig (see above) and `resolve.alias` in vite.config, which vitest reads.
|
||||
`test/alias-imports.test.js` runs it over a fixture tree.
|
||||
|
||||
## What is in it
|
||||
|
||||
| Export | Content |
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@kuhyx/ts-config",
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.1",
|
||||
"private": false,
|
||||
"type": "module",
|
||||
"description": "The one lint/TypeScript bar every kuhyx TypeScript repo extends: a strict tsconfig base, ESLint flat-config presets (base, react, vitest, playwright, layer boundaries, a fast editor subset), and Knip / jscpd / Stryker bases. Plain JS, no build step -- consumable straight from a git tag.",
|
||||
@ -16,6 +16,7 @@
|
||||
"knip.base.json",
|
||||
"jscpd.base.json",
|
||||
"stryker.base.mjs",
|
||||
"scripts",
|
||||
"README.md"
|
||||
],
|
||||
"exports": {
|
||||
|
||||
5
ts_config/scripts/alias-imports.mjs
Executable file
5
ts_config/scripts/alias-imports.mjs
Executable file
@ -0,0 +1,5 @@
|
||||
#!/usr/bin/env node
|
||||
// CLI shim; the logic and its tests live in lib/alias-imports.mjs.
|
||||
import { main } from "./lib/alias-imports.mjs";
|
||||
|
||||
process.exitCode = main(process.argv.slice(2));
|
||||
114
ts_config/scripts/lib/alias-imports.mjs
Normal file
114
ts_config/scripts/lib/alias-imports.mjs
Normal file
@ -0,0 +1,114 @@
|
||||
// Rewrite parent-relative imports under src/ to the `@/` alias.
|
||||
//
|
||||
// The preset bans `../` specifiers (imports.js): they encode the importer's
|
||||
// depth, and a model that guesses the depth wrong lands on the wrong module.
|
||||
// Turning the rule on in a repo with dozens of them by hand is the kind of
|
||||
// mechanical step that gets done slightly differently each time, so it is a
|
||||
// script: every `import`/`export … from "../x"` (and dynamic `import("../x")`)
|
||||
// whose target resolves under src/ becomes `@/<path from src>`. `./sibling`
|
||||
// and package imports are left alone, and so is a `../` that resolves OUTSIDE
|
||||
// src/ -- there is no alias for it, and the lint report is the right place
|
||||
// for that one to surface.
|
||||
//
|
||||
// The extension is carried over untouched: a repo on NodeNext resolution
|
||||
// needs its `.js` suffixes, a bundler repo has none, and this script has no
|
||||
// business changing which of the two it is.
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const SOURCE_EXTENSIONS = new Set([".cts", ".js", ".jsx", ".mts", ".ts", ".tsx"]);
|
||||
|
||||
// A string literal that is an import/export specifier: preceded by `from`,
|
||||
// by `import` (side-effect form), or by `import(` (dynamic form).
|
||||
const SPECIFIER = /(\bfrom\s*|\bimport\s*\(\s*|\bimport\s+)(['"])(\.\.\/[^'"\n]*)\2/g;
|
||||
|
||||
/** Every source file below `directory`, depth-first, sorted for a stable report. */
|
||||
export function listSourceFiles(directory) {
|
||||
const out = [];
|
||||
const entries = fs.readdirSync(directory, { withFileTypes: true }).toSorted((a, b) => a.name.localeCompare(b.name));
|
||||
for (const entry of entries) {
|
||||
const full = path.join(directory, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
out.push(...listSourceFiles(full));
|
||||
} else if (SOURCE_EXTENSIONS.has(path.extname(entry.name))) {
|
||||
out.push(full);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* The alias form of one specifier, or null when it must stay as written.
|
||||
*
|
||||
* @param {string} file Absolute path of the importing file.
|
||||
* @param {string} specifier The `../…` string as it appears in the source.
|
||||
* @param {string} sourceDirectory Absolute path of the aliased directory.
|
||||
*/
|
||||
export function aliasFor(file, specifier, sourceDirectory) {
|
||||
const resolved = path.resolve(path.dirname(file), specifier);
|
||||
const fromSource = path.relative(sourceDirectory, resolved);
|
||||
const outside = fromSource === "" || fromSource.startsWith("..") || path.isAbsolute(fromSource);
|
||||
return outside ? null : `@/${fromSource.split(path.sep).join("/")}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrite one file's text. Pure: returns the new text and how many
|
||||
* specifiers changed, touches nothing on disk.
|
||||
*/
|
||||
export function rewriteSource(file, text, sourceDirectory) {
|
||||
let count = 0;
|
||||
const rewritten = text.replaceAll(SPECIFIER, (match, lead, quote, specifier) => {
|
||||
const alias = aliasFor(file, specifier, sourceDirectory);
|
||||
if (alias === null) {
|
||||
return match;
|
||||
}
|
||||
count += 1;
|
||||
return `${lead}${quote}${alias}${quote}`;
|
||||
});
|
||||
return { count, text: rewritten };
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrite every source file under `<root>/<source>` in place.
|
||||
*
|
||||
* @param {string} root Repo root.
|
||||
* @param {string} [source] Aliased directory, relative to root.
|
||||
* @returns {{ files: number, imports: number }} What changed.
|
||||
*/
|
||||
export function aliasImports(root, source = "src") {
|
||||
const sourceDirectory = path.resolve(root, source);
|
||||
if (!fs.existsSync(sourceDirectory)) {
|
||||
throw new Error(`alias-imports: no such directory ${sourceDirectory}`);
|
||||
}
|
||||
let files = 0;
|
||||
let imports = 0;
|
||||
for (const file of listSourceFiles(sourceDirectory)) {
|
||||
const before = fs.readFileSync(file, "utf8");
|
||||
const { count, text } = rewriteSource(file, before, sourceDirectory);
|
||||
if (count === 0) {
|
||||
continue;
|
||||
}
|
||||
fs.writeFileSync(file, text);
|
||||
files += 1;
|
||||
imports += count;
|
||||
}
|
||||
return { files, imports };
|
||||
}
|
||||
|
||||
/**
|
||||
* CLI body: `alias-imports <repo-root> [src-dir]`. Returns the exit code
|
||||
* and writes the one-line report, so the shim stays at two lines.
|
||||
*
|
||||
* @param {string[]} argv Arguments after the script name.
|
||||
* @param {{ log: (line: string) => void }} [io]
|
||||
*/
|
||||
export function main(argv, io = console) {
|
||||
const [root, source] = argv;
|
||||
if (root === undefined) {
|
||||
io.log("usage: alias-imports <repo-root> [src-dir=src]");
|
||||
return 2;
|
||||
}
|
||||
const { files, imports } = aliasImports(root, source);
|
||||
io.log(`alias-imports: rewrote ${imports} import(s) in ${files} file(s) under ${path.resolve(root, source ?? "src")}`);
|
||||
return 0;
|
||||
}
|
||||
137
ts_config/test/alias-imports.test.js
Normal file
137
ts_config/test/alias-imports.test.js
Normal file
@ -0,0 +1,137 @@
|
||||
// The alias codemod against a fixture tree built in a temp dir: one-level,
|
||||
// two-level, `export … from`, dynamic import, single AND double quotes, a
|
||||
// `./sibling` that must stay, and a `../` that resolves OUTSIDE src/ (no
|
||||
// alias exists for it, so it must be left for the lint report).
|
||||
import { execFileSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { aliasFor, aliasImports, listSourceFiles, main, rewriteSource } from "../scripts/lib/alias-imports.mjs";
|
||||
|
||||
const CLI = path.join(import.meta.dirname, "..", "scripts", "alias-imports.mjs");
|
||||
|
||||
const FIXTURE = {
|
||||
"src/components/deep/card.tsx": [
|
||||
'import { api } from "../../services/api";',
|
||||
"import type { User } from '../../types/user';",
|
||||
'import { helper } from "./helper";',
|
||||
'export { format } from "../../utils/format";',
|
||||
'const lazy = () => import("../../utils/lazy");',
|
||||
'import "../../styles/global.css";',
|
||||
'import { outside } from "../../../tooling/outside";',
|
||||
"export const card = { api, helper, lazy, outside };",
|
||||
].join("\n"),
|
||||
"src/components/deep/helper.ts": 'export const helper = 1;\nimport "./nothing-to-do";\n',
|
||||
"src/services/api.test.ts": 'import { api } from "./api";\nexport const t = api;\n',
|
||||
"src/services/api.ts": 'import { format } from "../utils/format";\nexport const api = format;\n',
|
||||
"src/services/notes.md": "not source: [x](../utils/format)\n",
|
||||
"src/utils/format.ts": "export const format = 1;\n",
|
||||
"tooling/outside.ts": "export const outside = 1;\n",
|
||||
};
|
||||
|
||||
// One fixture tree per test, so a rewriting test cannot leak into the next.
|
||||
const state = { root: "" };
|
||||
const rootOf = () => state.root;
|
||||
const sourceOf = () => path.join(rootOf(), "src");
|
||||
const read = (relative) => fs.readFileSync(path.join(rootOf(), relative), "utf8");
|
||||
const collect = (lines) => ({ log: (line) => { lines.push(line); } });
|
||||
|
||||
beforeEach(() => {
|
||||
state.root = fs.mkdtempSync(path.join(os.tmpdir(), "alias-imports-"));
|
||||
for (const [relative, content] of Object.entries(FIXTURE)) {
|
||||
const full = path.join(rootOf(), relative);
|
||||
fs.mkdirSync(path.dirname(full), { recursive: true });
|
||||
fs.writeFileSync(full, content);
|
||||
}
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(rootOf(), { force: true, recursive: true });
|
||||
});
|
||||
|
||||
describe("aliasFor", () => {
|
||||
const from = (relative, specifier) => aliasFor(path.join(sourceOf(), relative), specifier, sourceOf());
|
||||
|
||||
it("maps a parent-relative target under src to @/", () => {
|
||||
expect(from("a/b/c.ts", "../../x/y")).toBe("@/x/y");
|
||||
expect(from("a/b/c.ts", "../../x/y.js")).toBe("@/x/y.js");
|
||||
});
|
||||
|
||||
it("refuses targets outside src and src itself", () => {
|
||||
expect(from("a/c.ts", "../../tooling/x")).toBeNull();
|
||||
expect(from("a/c.ts", "..")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("rewriteSource", () => {
|
||||
it("rewrites every import form and keeps the quote style", () => {
|
||||
const file = path.join(sourceOf(), "components/deep/card.tsx");
|
||||
const { count, text } = rewriteSource(file, FIXTURE["src/components/deep/card.tsx"], sourceOf());
|
||||
expect(count).toBe(5);
|
||||
expect(text).toContain('import { api } from "@/services/api";');
|
||||
expect(text).toContain("import type { User } from '@/types/user';");
|
||||
expect(text).toContain('export { format } from "@/utils/format";');
|
||||
expect(text).toContain('import("@/utils/lazy")');
|
||||
expect(text).toContain('import "@/styles/global.css";');
|
||||
// Untouched: the sibling and the import that leaves src/.
|
||||
expect(text).toContain('from "./helper"');
|
||||
expect(text).toContain('from "../../../tooling/outside"');
|
||||
});
|
||||
|
||||
it("reports zero for a file with nothing to do", () => {
|
||||
const file = path.join(sourceOf(), "components/deep/helper.ts");
|
||||
expect(rewriteSource(file, FIXTURE["src/components/deep/helper.ts"], sourceOf())).toEqual({
|
||||
count: 0,
|
||||
text: FIXTURE["src/components/deep/helper.ts"],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("aliasImports", () => {
|
||||
it("walks only source files and writes only changed ones", () => {
|
||||
expect(listSourceFiles(sourceOf()).map((file) => path.relative(rootOf(), file))).toEqual([
|
||||
"src/components/deep/card.tsx",
|
||||
"src/components/deep/helper.ts",
|
||||
"src/services/api.test.ts",
|
||||
"src/services/api.ts",
|
||||
"src/utils/format.ts",
|
||||
]);
|
||||
expect(aliasImports(rootOf())).toEqual({ files: 2, imports: 6 });
|
||||
expect(read("src/services/api.ts")).toContain('from "@/utils/format"');
|
||||
expect(read("src/services/api.test.ts")).toBe(FIXTURE["src/services/api.test.ts"]);
|
||||
expect(read("src/services/notes.md")).toBe(FIXTURE["src/services/notes.md"]);
|
||||
// Idempotent: a second pass finds nothing.
|
||||
expect(aliasImports(rootOf())).toEqual({ files: 0, imports: 0 });
|
||||
});
|
||||
|
||||
it("honours a non-default source dir", () => {
|
||||
fs.renameSync(sourceOf(), path.join(rootOf(), "lib"));
|
||||
expect(aliasImports(rootOf(), "lib")).toEqual({ files: 2, imports: 6 });
|
||||
});
|
||||
|
||||
it("fails loudly on a missing source dir", () => {
|
||||
expect(() => aliasImports(rootOf(), "nope")).toThrow(/no such directory/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("main", () => {
|
||||
it("prints usage and exits 2 without a root", () => {
|
||||
const lines = [];
|
||||
expect(main([], collect(lines))).toBe(2);
|
||||
expect(lines[0]).toMatch(/^usage:/);
|
||||
});
|
||||
|
||||
it("reports the count and exits 0", () => {
|
||||
const lines = [];
|
||||
expect(main([rootOf()], collect(lines))).toBe(0);
|
||||
expect(lines[0]).toBe(`alias-imports: rewrote 6 import(s) in 2 file(s) under ${sourceOf()}`);
|
||||
});
|
||||
|
||||
it("runs as a CLI", () => {
|
||||
const stdout = execFileSync(process.execPath, [CLI, rootOf()], { encoding: "utf8" });
|
||||
expect(stdout).toContain("rewrote 6 import(s) in 2 file(s)");
|
||||
expect(read("src/services/api.ts")).toContain("@/utils/format");
|
||||
});
|
||||
});
|
||||
@ -3,7 +3,7 @@ import { defineConfig } from "vitest/config";
|
||||
export default defineConfig({
|
||||
test: {
|
||||
coverage: {
|
||||
include: ["eslint/**/*.js", "stryker.base.mjs"],
|
||||
include: ["eslint/**/*.js", "scripts/lib/**/*.mjs", "stryker.base.mjs"],
|
||||
provider: "v8",
|
||||
// The same bar as ts_core and web_ui: the package every repo's lint
|
||||
// runs through does not get to be the least-tested code in the tree.
|
||||
|
||||
Loading…
Reference in New Issue
Block a user