diff --git a/ts_config/README.md b/ts_config/README.md index ec8fe55..c84120b 100644 --- a/ts_config/README.md +++ b/ts_config/README.md @@ -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 [src-dir] +``` + +Every `import` / `export … from` / `import()` specifier under `src/` that +starts with `../` and resolves inside `src/` becomes `@/`; +`./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 | diff --git a/ts_config/package.json b/ts_config/package.json index f453012..1e10850 100644 --- a/ts_config/package.json +++ b/ts_config/package.json @@ -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": { diff --git a/ts_config/scripts/alias-imports.mjs b/ts_config/scripts/alias-imports.mjs new file mode 100755 index 0000000..fa0a94d --- /dev/null +++ b/ts_config/scripts/alias-imports.mjs @@ -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)); diff --git a/ts_config/scripts/lib/alias-imports.mjs b/ts_config/scripts/lib/alias-imports.mjs new file mode 100644 index 0000000..5b9dc34 --- /dev/null +++ b/ts_config/scripts/lib/alias-imports.mjs @@ -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 `@/`. `./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 `/` 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 [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 [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; +} diff --git a/ts_config/test/alias-imports.test.js b/ts_config/test/alias-imports.test.js new file mode 100644 index 0000000..f4ae03e --- /dev/null +++ b/ts_config/test/alias-imports.test.js @@ -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"); + }); +}); diff --git a/ts_config/vitest.config.js b/ts_config/vitest.config.js index 3899d1e..fcc7d55 100644 --- a/ts_config/vitest.config.js +++ b/ts_config/vitest.config.js @@ -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.