From ecdc8ebf46d70e434114157a903a80e1e88c5ac4 Mon Sep 17 00:00:00 2001 From: KCM Date: Mon, 29 Dec 2025 13:48:52 -0600 Subject: [PATCH 1/6] feat: cli. --- README.md | 29 +- docs/cli.md | 87 ++++ docs/globals-only.md | 45 ++ docs/roadmap.md | 9 +- oxlint.json | 2 +- package-lock.json | 211 +++++----- package.json | 13 +- src/cli.ts | 746 +++++++++++++++++++++++++++++++++ src/formatters/metaProperty.ts | 3 + test/cli.ts | 479 +++++++++++++++++++++ test/fixtures/cli/input.cjs | 4 + 11 files changed, 1489 insertions(+), 139 deletions(-) create mode 100644 docs/cli.md create mode 100644 docs/globals-only.md create mode 100644 src/cli.ts create mode 100644 test/cli.ts create mode 100644 test/fixtures/cli/input.cjs diff --git a/README.md b/README.md index ceea844..53c4311 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ Highlights - Configurable lowering modes: full syntax transforms or globals-only. - Specifier tools: add extensions, add directory indexes, or map with a custom callback. - Output control: write to disk (`out`/`inPlace`) or return the transformed string. +- CLI: `dub` for batch transforms, dry-run/list/summary, stdin/stdout, and colorized diagnostics. See [docs/cli.md](docs/cli.md). > [!IMPORTANT] > All parsing logic is applied under the assumption the code is in [strict mode](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Strict_mode) which [modules run under by default](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Modules#other_differences_between_modules_and_classic_scripts). @@ -143,7 +144,7 @@ type ModuleOptions = { ### Behavior notes (defaults in parentheses) - `target` (`commonjs`): output module system. -- `transformSyntax` (true): enable/disable the ESM↔CJS lowering pass; set to `'globals-only'` to rewrite module globals (`import.meta.*`, `__dirname`, `__filename`, `require.main` shims) while leaving import/export syntax untouched. In `'globals-only'`, no helpers are injected (e.g., `__requireResolve`), `require.resolve` rewrites to `import.meta.resolve`, and `idiomaticExports` is skipped. See [globals-only](#globals-only-scope). +- `transformSyntax` (true): enable/disable the ESM↔CJS lowering pass; set to `'globals-only'` to rewrite module globals (`import.meta.*`, `__dirname`, `__filename`, `require.main` shims) while leaving import/export syntax untouched. In `'globals-only'`, no helpers are injected (e.g., `__requireResolve`), `require.resolve` rewrites to `import.meta.resolve`, and `idiomaticExports` is skipped. See [globals-only](docs/globals-only.md). - `liveBindings` (`strict`): getter-based live bindings, or snapshot (`loose`/`off`). - `appendJsExtension` (`relative-only` when targeting ESM): append `.js` to relative specifiers; never touches bare specifiers. - `appendDirectoryIndex` (`index.js`): when a relative specifier ends with a slash, append this index filename (set `false` to disable). @@ -170,13 +171,6 @@ See [docs/esm-to-cjs.md](docs/esm-to-cjs.md) for deeper notes on live bindings, > [!NOTE] > Known limitations: `with` and unshadowed `eval` are rejected when raising CJS to ESM because the rewrite would be unsound; bare specifiers are not rewritten—only relative specifiers participate in `rewriteSpecifier`. -### Globals-only scope - -- Rewrites module globals (`import.meta.*`, `__dirname`, `__filename`, `require.main` shims) for the target side. -- Optional specifier rewrites still run (`rewriteSpecifier`, `appendJsExtension`, `appendDirectoryIndex`). -- Leaves imports/exports and interop untouched (no export bag, no idiomaticExports, no live-binding synthesis, no helpers like `__requireResolve`). -- CJS→ESM: `require.resolve` maps to `import.meta.resolve` (URL return, ESM resolver) and may differ from CJS resolution. ESM→CJS: `import.meta` maps to CJS globals; no import lowering. - ### Diagnostics callback example Pass a `diagnostics` callback to surface CJS→ESM edge cases (mixed `module.exports`/`exports`, top-level `return`, legacy `require.cache`/`require.extensions`, live-binding reassignments, string-literal export names): @@ -213,20 +207,9 @@ TypeScript reports asymmetric module-global errors (e.g., `import.meta` in CJS, Minimal flow: -```js -import { glob } from 'glob' -import { transform } from '@knighted/module' - -const files = await glob('src/**/*.{ts,js,mts,cts}', { ignore: 'node_modules/**' }) - -for (const file of files) { - await transform(file, { - target: 'commonjs', // or 'module' when raising CJS → ESM - inPlace: true, - transformSyntax: true, - }) -} -// then run `tsc` +```bash +dub -t commonjs "src/**/*.{ts,js,mts,cts}" --ignore node_modules/** --transform-syntax globals-only --in-place +tsc ``` -This pre-`tsc` step removes the flagged globals in the compiled orientation; runtime semantics still match the target build. +This pre-`tsc` step rewrites globals-only (keeps import/export syntax) so the TypeScript checker sees already-rewritten sources; runtime semantics still match the target build. diff --git a/docs/cli.md b/docs/cli.md new file mode 100644 index 0000000..8a043f5 --- /dev/null +++ b/docs/cli.md @@ -0,0 +1,87 @@ +# CLI: `dub` + +Command-line wrapper around `@knighted/module` for transforming files between ESM and CommonJS. + +## Requirements + +- Node >= 22.21.1 (or Node 24+) + +## Installation + +The CLI is shipped with the package. Install locally or use `npx`: + +```bash +npm install @knighted/module +npx dub --help +``` + +## Usage + +```bash +dub [options] +``` + +Examples: + +- Transform CJS to ESM into an output directory: + + ```bash + dub -t module src/**/*.cjs --out-dir dist + ``` + +- In-place ESM to CJS rewrite: + + ```bash + dub -t commonjs src/**/*.mjs --in-place + ``` + +- Preview changes without writing: + + ```bash + dub -t module src/**/*.cjs --out-dir dist --dry-run --list --summary + ``` + +- Stdin/stdout pipeline: + + ```bash + cat input.cjs | dub -t module --stdin-filename input.cjs > output.mjs + ``` + +## Options + +Short and long forms are supported. + + +| Short | Long | Description | +| ----- | -------------------------- | --------------------------------------------------------------- | +| -t | --target | Output format (module \| commonjs) | +| -x | --transform-syntax | Syntax transforms (true \| false \| globals-only) | +| -r | --rewrite-specifier | Rewrite import specifiers (.js/.mjs/.cjs/.ts/.mts/.cts) | +| -j | --append-js-extension | Append .js to relative imports (off \| relative-only \| all) | +| -i | --append-directory-index | Append directory index (e.g. index.js) or false | +| -c | --detect-circular-requires | Warn/error on circular require (off \| warn \| error) | +| -a | --top-level-await | TLA handling (error \| wrap \| preserve) | +| -d | --cjs-default | Default interop (module-exports \| auto \| none) | +| -e | --idiomatic-exports | Emit idiomatic exports when safe (off \| safe \| aggressive) | +| -m | --import-meta-prelude | Emit import.meta prelude (off \| auto \| on) | +| -n | --nested-require-strategy | Rewrite nested require (create-require \| dynamic-import) | +| -R | --require-main-strategy | Detect main (import-meta-main \| realpath) | +| -l | --live-bindings | Live binding strategy (strict \| loose \| off) | +| -o | --out-dir | Write outputs to a directory mirror | +| -p | --in-place | Rewrite files in place | +| -y | --dry-run | Do not write files; report planned changes | +| -L | --list | List files that would change | +| -s | --summary | Print a summary of work performed | +| -J | --json | Emit machine-readable JSON summary/diagnostics | +| -C | --cwd | Working directory for resolving files/out paths | +| -f | --stdin-filename | Virtual filename when reading from stdin | +| -g | --ignore | Glob pattern(s) to ignore (repeatable) | +| -h | --help | Show help | +| -v | --version | Show version | + + +Notes: + +- When reading from stdin, output is sent to stdout; `--out-dir` or `--in-place` are not allowed in that mode. +- Specify either `--out-dir` or `--in-place` for file inputs; stdout is used only when a single file is given and neither flag is set. +- Diagnostics are printed to stderr; use `--json` for machine-readable output. diff --git a/docs/globals-only.md b/docs/globals-only.md new file mode 100644 index 0000000..cb63246 --- /dev/null +++ b/docs/globals-only.md @@ -0,0 +1,45 @@ +# Globals-only transform + +`transformSyntax: 'globals-only'` rewrites well-known module globals lexically without emitting any runtime helpers or preludes. Imports/exports and interop are left untouched; runtime correctness depends on the target environment providing the globals you opt into. + +## Scope + +- Pure identifier/member rewrites; no injected helpers, shims, or prelude imports. +- Works in both directions (CJS ➜ ESM, ESM ➜ CJS). +- Diagnostics still surface for legacy constructs (e.g., `require.extensions`, `module.parent`). + +## Rewrites at a glance + + +| Direction | Input | Output | Notes | +| --- | --- | --- | --- | +| CJS ➜ ESM | `__dirname` | `import.meta.dirname` | lexical swap | +| CJS ➜ ESM | `__filename` | `import.meta.filename` | lexical swap | +| CJS ➜ ESM | `require.main` | `import.meta.main` | skipped inside equality checks | +| CJS ➜ ESM | `require.resolve(...)` | `import.meta.resolve(...)` | no helper emitted | +| CJS ➜ ESM | `module.require(...)` | `require(...)` | collapses to ambient require | +| CJS ➜ ESM | `require.cache` | `{}` | best-effort stub + warning | +| CJS ➜ ESM | `require.extensions` | (unchanged) | warning emitted | +| CJS ➜ ESM | `module.parent / module.children` | (unchanged) | warning emitted | +| ESM ➜ CJS | `import.meta` | `module` | bare meta expression | +| ESM ➜ CJS | `import.meta.url` | `require("node:url").pathToFileURL(__filename).href` | URL-shaped contract | +| ESM ➜ CJS | `import.meta.filename` | `__filename` | lexical swap | +| ESM ➜ CJS | `import.meta.dirname` | `__dirname` | lexical swap | +| ESM ➜ CJS | `import.meta.resolve(...)` | `require.resolve(...)` | string-path semantics | +| ESM ➜ CJS | `import.meta.main` | `process.argv[1] === __filename` | inline check | +| ESM ➜ CJS | other `import.meta.*` | `module.*` | no extra objects created | + + +## When to use it + +- Pre-`tsc` mitigation for TypeScript’s [asymmetry on module globals](https://github.com/microsoft/TypeScript/issues/58658) (see `test/cli.ts`): rewrite globals so the checker sees the target-side shapes without altering import/export syntax. +- Tooling pipelines that only need syntax-level compatibility and will supply the required globals at runtime. + +## When not to use it + +- When you need runtime shims (e.g., `createRequire`, import.meta polyfills) or interop helpers: use `transformSyntax: true` instead. + +## Caveats + +- Behavior relies on the target runtime’s built-ins; no guarantees are made about availability or semantics of the rewritten globals. +- Legacy fields like `require.cache`, `require.extensions`, `module.parent`, and `module.children` only receive warnings/stubs—behavior may differ from Node. diff --git a/docs/roadmap.md b/docs/roadmap.md index 76bb57f..1769ce0 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -9,15 +9,14 @@ Shipped: `idiomaticExports: 'safe'` is now the default for CJS → ESM, with fal Next: - Explore a true `'aggressive'` mode (mixed exports/module.exports, limited reassignments, identifier-safe computed keys) with guarded semantics and explicit diagnostics. +- In `'auto'`, allow idiomatic `module.exports = { foo, bar }` when the object literal is simple: single top-level assignment, no spreads/getters/computed/duplicate keys, only identifier keys, RHS values limited to safe literals/identifiers/function|class expressions, no `require()` inside RHS, and no `__proto__`/`prototype` keys. Add a shorthand-object fixture test and keep falling back to the helper for anything more complex. - Consider a constrained ESM → CJS “pretty” path where live-binding and TLA semantics permit it. ## CLI -- Deliver a `knighted-module` CLI that wraps the core transform with parity to API options (targets, rewriteSpecifier, appendJsExtension/appendDirectoryIndex, detectCircularRequires, topLevelAwait, cjsDefault, diagnostics hooks, out/in-place). -- Input handling: accept file/glob lists plus stdin/stdout piping; respect `package.json` `type` and `.cjs/.mjs` extensions; allow per-invocation overrides via flags and a config file. -- Output handling: in-place rewrite or out-dir mirroring with extension rewriting; emit diagnostics to stderr and machine-readable JSON when requested; non-zero exit on diagnostics of severity error. -- Performance ergonomics: batch parse/format where possible, optional concurrency flag, and a `--watch` mode that rebuilds on change with minimal restarts. -- DX: `--dry-run` to preview planned rewrites, `--list` to show which files would change, `--summary` to print counts of transformed specifiers/globals, and `--help`/`--version` aligned with package metadata. +- Shipped parity CLI wrapping the core transform (targets, rewriteSpecifier, appendJsExtension/appendDirectoryIndex, detectCircularRequires, topLevelAwait, cjsDefault, diagnostics hooks, out/in-place) with stdin/stdout support, JSON/summary, and list/dry-run paths. +- Next: optional concurrency flag, `--watch` mode with minimal restarts, and a tiny stream type surface to keep test stubs and embedding clean. +- DX polish: keep help/examples in sync with tests; retain single-fixture CLI coverage unless new CLI-specific behaviors emerge (e.g., multi-ext glob ordering, large-input streaming), since transform semantics are already exercised in module fixtures. ## Tooling & Diagnostics diff --git a/oxlint.json b/oxlint.json index 85c88d4..2614e79 100644 --- a/oxlint.json +++ b/oxlint.json @@ -31,7 +31,7 @@ } }, { - "files": ["test/**"], + "files": ["test/**", "!test/cli.ts"], "rules": { "@typescript-eslint/no-explicit-any": "off" } diff --git a/package-lock.json b/package-lock.json index f62f2c6..e08b0c9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,21 +1,25 @@ { "name": "@knighted/module", - "version": "1.2.1", + "version": "1.3.0-rc.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@knighted/module", - "version": "1.2.1", + "version": "1.3.0-rc.0", "license": "MIT", "dependencies": { + "glob": "^13.0.0", "magic-string": "^0.30.21", "oxc-parser": "^0.105.0", "periscopic": "^4.0.2" }, + "bin": { + "dub": "dist/cli.js" + }, "devDependencies": { "@knighted/dump": "^1.0.3", - "@types/node": "^22.13.17", + "@types/node": "^22.19.3", "babel-dual-package": "^1.2.3", "c8": "^10.1.3", "husky": "^9.1.7", @@ -2251,7 +2255,6 @@ "version": "4.0.1", "resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz", "integrity": "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==", - "dev": true, "license": "MIT", "engines": { "node": "20 || >=22" @@ -2261,7 +2264,6 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.0.tgz", "integrity": "sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==", - "dev": true, "license": "MIT", "dependencies": { "@isaacs/balanced-match": "^4.0.1" @@ -2289,9 +2291,9 @@ } }, "node_modules/@isaacs/cliui/node_modules/ansi-regex": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", - "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", "dev": true, "license": "MIT", "engines": { @@ -2302,9 +2304,9 @@ } }, "node_modules/@isaacs/cliui/node_modules/strip-ansi": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", - "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", "dev": true, "license": "MIT", "dependencies": { @@ -2882,13 +2884,13 @@ "dev": true }, "node_modules/@types/node": { - "version": "22.13.17", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.17.tgz", - "integrity": "sha512-nAJuQXoyPj04uLgu+obZcSmsfOenUg6DxPKogeUy6yNCFwWaj5sBF8/G/pNo8EtBJjAfSVgfIlugR/BCOleO+g==", + "version": "22.19.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.3.tgz", + "integrity": "sha512-1N9SBnWYOJTrNZCdh/yJE+t910Y128BoyY+zBLWhL3r0TYzlTmFdXrPwHL9DyFZmlEXNQQolTZh3KHV31QDhyA==", "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~6.20.0" + "undici-types": "~6.21.0" } }, "node_modules/@types/normalize-package-data": { @@ -3144,67 +3146,6 @@ "node": ">=16.19.0" } }, - "node_modules/babel-dual-package/node_modules/glob": { - "version": "13.0.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.0.tgz", - "integrity": "sha512-tvZgpqk6fz4BaNZ66ZsRaZnbHvP/jG3uKJvAZOwEVUL4RTA5nJeeLYfyN9/VA8NX/V3IBG+hkeuGpKjvELkVhA==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "minimatch": "^10.1.1", - "minipass": "^7.1.2", - "path-scurry": "^2.0.0" - }, - "engines": { - "node": "20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/babel-dual-package/node_modules/lru-cache": { - "version": "11.2.2", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.2.tgz", - "integrity": "sha512-F9ODfyqML2coTIsQpSkRHnLSZMtkU8Q+mSfcaIyKwy58u+8k5nvAYeiNhsyMARvzNcXJ9QfWVrcPsC9e9rAxtg==", - "dev": true, - "license": "ISC", - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/babel-dual-package/node_modules/minimatch": { - "version": "10.1.1", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.1.1.tgz", - "integrity": "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "@isaacs/brace-expansion": "^5.0.0" - }, - "engines": { - "node": "20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/babel-dual-package/node_modules/path-scurry": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.1.tgz", - "integrity": "sha512-oWyT4gICAu+kaA7QWk/jvCHWarMKNs6pXOGWKDTr7cw4IGcUbW+PeTfbaQiLGheFRpjo6O9J0PmyMfQPjH71oA==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "lru-cache": "^11.0.0", - "minipass": "^7.1.2" - }, - "engines": { - "node": "20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/babel-plugin-polyfill-corejs2": { "version": "0.4.14", "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.14.tgz", @@ -4295,21 +4236,57 @@ } }, "node_modules/glob": { - "version": "10.5.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", - "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", - "dev": true, - "license": "ISC", + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.0.tgz", + "integrity": "sha512-tvZgpqk6fz4BaNZ66ZsRaZnbHvP/jG3uKJvAZOwEVUL4RTA5nJeeLYfyN9/VA8NX/V3IBG+hkeuGpKjvELkVhA==", + "license": "BlueOak-1.0.0", "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^3.1.2", - "minimatch": "^9.0.4", + "minimatch": "^10.1.1", "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", - "path-scurry": "^1.11.1" + "path-scurry": "^2.0.0" }, - "bin": { - "glob": "dist/esm/bin.mjs" + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob/node_modules/lru-cache": { + "version": "11.2.4", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.4.tgz", + "integrity": "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==", + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "10.1.1", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.1.1.tgz", + "integrity": "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==", + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/brace-expansion": "^5.0.0" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob/node_modules/path-scurry": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.1.tgz", + "integrity": "sha512-oWyT4gICAu+kaA7QWk/jvCHWarMKNs6pXOGWKDTr7cw4IGcUbW+PeTfbaQiLGheFRpjo6O9J0PmyMfQPjH71oA==", + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" @@ -5080,7 +5057,6 @@ "version": "7.1.2", "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", - "dev": true, "license": "ISC", "engines": { "node": ">=16 || 14 >=14.17" @@ -6207,9 +6183,9 @@ "license": "MIT" }, "node_modules/string-width/node_modules/ansi-regex": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", - "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", "dev": true, "license": "MIT", "engines": { @@ -6220,9 +6196,9 @@ } }, "node_modules/string-width/node_modules/strip-ansi": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", - "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", "dev": true, "license": "MIT", "dependencies": { @@ -6376,6 +6352,27 @@ "node": ">=18" } }, + "node_modules/test-exclude/node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -6515,9 +6512,9 @@ } }, "node_modules/undici-types": { - "version": "6.20.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz", - "integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==", + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "dev": true, "license": "MIT" }, @@ -6734,9 +6731,9 @@ } }, "node_modules/wrap-ansi/node_modules/ansi-regex": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", - "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", "dev": true, "license": "MIT", "engines": { @@ -6747,9 +6744,9 @@ } }, "node_modules/wrap-ansi/node_modules/ansi-styles": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", - "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", "dev": true, "license": "MIT", "engines": { @@ -6760,9 +6757,9 @@ } }, "node_modules/wrap-ansi/node_modules/strip-ansi": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", - "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", "dev": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 361e31e..17126b7 100644 --- a/package.json +++ b/package.json @@ -1,9 +1,12 @@ { "name": "@knighted/module", - "version": "1.2.1", + "version": "1.3.0-rc.0", "description": "Bidirectional transform for ES modules and CommonJS.", "type": "module", "main": "dist/module.js", + "bin": { + "dub": "dist/cli.js" + }, "exports": { ".": { "import": { @@ -28,7 +31,10 @@ "prettier:check": "prettier -c .", "lint": "oxlint --config oxlint.json .", "prepare": "husky", - "test": "c8 --reporter=text --reporter=text-summary --reporter=lcov tsx --test --test-reporter=spec test/*.ts", + "test:base": "tsx --test --test-reporter=spec", + "test:cli": "npm run test:base -- test/cli.ts", + "test:module": "npm run test:base -- test/module.ts", + "test": "c8 --reporter=text --reporter=text-summary --reporter=lcov npm run test:base -- test/*.ts", "build:types": "tsc --emitDeclarationOnly", "build:dual": "babel-dual-package src --extensions .ts", "build": "npm run build:types && npm run build:dual", @@ -59,7 +65,7 @@ }, "devDependencies": { "@knighted/dump": "^1.0.3", - "@types/node": "^22.13.17", + "@types/node": "^22.19.3", "babel-dual-package": "^1.2.3", "c8": "^10.1.3", "husky": "^9.1.7", @@ -71,6 +77,7 @@ "typescript": "^5.9.3" }, "dependencies": { + "glob": "^13.0.0", "magic-string": "^0.30.21", "oxc-parser": "^0.105.0", "periscopic": "^4.0.2" diff --git a/src/cli.ts b/src/cli.ts new file mode 100644 index 0000000..83bfb2a --- /dev/null +++ b/src/cli.ts @@ -0,0 +1,746 @@ +#!/usr/bin/env node +import { + stdin as defaultStdin, + stdout as defaultStdout, + stderr as defaultStderr, +} from 'node:process' +import { parseArgs } from 'node:util' +import { readFile, mkdir } from 'node:fs/promises' +import { dirname, resolve, relative, join } from 'node:path' +import { builtinModules } from 'node:module' +import { glob } from 'glob' + +import { transform } from './module.js' +import { parse } from './parse.js' +import { format } from './format.js' +import { specifier } from './specifier.js' +import { getLangFromExt } from './utils/lang.js' +import type { ModuleOptions, Diagnostic } from './types.js' + +const defaultOptions: ModuleOptions = { + target: 'commonjs', + sourceType: 'auto', + transformSyntax: true, + liveBindings: 'strict', + rewriteSpecifier: undefined, + appendJsExtension: undefined, + appendDirectoryIndex: 'index.js', + dirFilename: 'inject', + importMeta: 'shim', + importMetaMain: 'shim', + requireMainStrategy: 'import-meta-main', + detectCircularRequires: 'off', + requireSource: 'builtin', + nestedRequireStrategy: 'create-require', + cjsDefault: 'auto', + idiomaticExports: 'safe', + importMetaPrelude: 'auto', + topLevelAwait: 'error', + cwd: undefined, + out: undefined, + inPlace: false, +} + +type LogWriter = { + stdout: StreamLike + stderr: StreamLike +} + +type StreamLike = { + isTTY?: boolean + write: (chunk: string | Uint8Array) => unknown +} + +type CliOptions = { + argv?: string[] + stdin?: typeof defaultStdin + stdout?: StreamLike + stderr?: StreamLike +} + +type IconKind = 'info' | 'warn' | 'error' | 'success' + +type FileResult = { + filePath: string + changed: boolean + diagnostics: Diagnostic[] +} + +const icons: Record = { + info: 'i', + warn: '⚠', + error: '✖', + success: '✔', +} + +const codes = { + reset: '\u001b[0m', + bold: '\u001b[1m', + dim: '\u001b[2m', + red: '\u001b[31m', + yellow: '\u001b[33m', + green: '\u001b[32m', + cyan: '\u001b[36m', +} + +const colorize = (enabled: boolean) => { + if (!enabled) { + return { + bold: (v: string) => v, + dim: (v: string) => v, + red: (v: string) => v, + yellow: (v: string) => v, + green: (v: string) => v, + cyan: (v: string) => v, + } + } + + const wrap = (code: string) => (v: string) => `${code}${v}${codes.reset}` + return { + bold: wrap(codes.bold), + dim: wrap(codes.dim), + red: wrap(codes.red), + yellow: wrap(codes.yellow), + green: wrap(codes.green), + cyan: wrap(codes.cyan), + } +} + +const builtinSpecifiers = new Set( + builtinModules + .map(mod => (mod.startsWith('node:') ? mod.slice(5) : mod)) + .flatMap(mod => { + const parts = mod.split('/') + const base = parts[0] + return parts.length > 1 ? [mod, base] : [mod] + }), +) + +const collapseSpecifier = (value: string) => value.replace(/['"`+)\s]|new String\(/g, '') + +const appendExtensionIfNeeded = ( + value: string, + mode: NonNullable, + dirIndex: string | false, +) => { + if (mode === 'off') return + + const collapsed = collapseSpecifier(value) + const isRelative = /^(?:\.\.?)\//.test(collapsed) + if (!isRelative) return + + const base = collapsed.split(/[?#]/)[0] + if (!base) return + + if (base.endsWith('/')) { + if (!dirIndex) return + return `${value}${dirIndex}` + } + + const lastSegment = base.split('/').pop() ?? '' + if (lastSegment.includes('.')) return + + return `${value}.js` +} + +const rewriteSpecifierValue = ( + value: string, + rewriteSpecifier: ModuleOptions['rewriteSpecifier'], +) => { + if (!rewriteSpecifier) return + + if (typeof rewriteSpecifier === 'function') { + return rewriteSpecifier(value) ?? undefined + } + + const collapsed = collapseSpecifier(value) + const relative = /^(?:\.\.?\/)/ + + if (relative.test(collapsed)) { + return value.replace(/(.+)\.(?:m|c)?(?:j|t)sx?([)'"]*)?$/, `$1${rewriteSpecifier}$2`) + } +} + +const normalizeBuiltinSpecifier = (value: string) => { + const collapsed = collapseSpecifier(value) + if (!collapsed) return + + const specPart = collapsed.split(/[?#]/)[0] ?? '' + if (/^(?:\.\.?(?:\/)|\/)/.test(specPart)) return + if (/^[a-zA-Z][a-zA-Z+.-]*:/.test(specPart) && !specPart.startsWith('node:')) return + + const bare = specPart.startsWith('node:') ? specPart.slice(5) : specPart + const base = bare.split('/')[0] ?? '' + + if (!builtinSpecifiers.has(bare) && !builtinSpecifiers.has(base)) return + if (specPart.startsWith('node:')) return + + const quote = /^['"`]/.exec(value)?.[0] ?? '' + return quote ? `${quote}node:${value.slice(quote.length)}` : `node:${value}` +} + +const optionsTable = [ + { long: 'target', short: 't', type: 'string', desc: 'Output format (module|commonjs)' }, + { + long: 'transform-syntax', + short: 'x', + type: 'string', + desc: 'Syntax transforms (true|false|globals-only)', + }, + { + long: 'rewrite-specifier', + short: 'r', + type: 'string', + desc: 'Rewrite import specifiers (.js/.mjs/.cjs/.ts/.mts/.cts)', + }, + { + long: 'append-js-extension', + short: 'j', + type: 'string', + desc: 'Append .js to relative imports (off|relative-only|all)', + }, + { + long: 'append-directory-index', + short: 'i', + type: 'string', + desc: 'Append directory index (e.g. index.js) or false', + }, + { + long: 'detect-circular-requires', + short: 'c', + type: 'string', + desc: 'Warn/error on circular require (off|warn|error)', + }, + { + long: 'top-level-await', + short: 'a', + type: 'string', + desc: 'TLA handling (error|wrap|preserve)', + }, + { + long: 'cjs-default', + short: 'd', + type: 'string', + desc: 'Default interop (module-exports|auto|none)', + }, + { + long: 'idiomatic-exports', + short: 'e', + type: 'string', + desc: 'Emit idiomatic exports when safe (off|safe|aggressive)', + }, + { + long: 'import-meta-prelude', + short: 'm', + type: 'string', + desc: 'Emit import.meta prelude (off|auto|on)', + }, + { + long: 'nested-require-strategy', + short: 'n', + type: 'string', + desc: 'Rewrite nested require (create-require|dynamic-import)', + }, + { + long: 'require-main-strategy', + short: 'R', + type: 'string', + desc: 'Detect main (import-meta-main|realpath)', + }, + { + long: 'live-bindings', + short: 'l', + type: 'string', + desc: 'Live binding strategy (strict|loose|off)', + }, + { + long: 'out-dir', + short: 'o', + type: 'string', + desc: 'Write outputs to a directory mirror', + }, + { long: 'in-place', short: 'p', type: 'boolean', desc: 'Rewrite files in place' }, + { + long: 'dry-run', + short: 'y', + type: 'boolean', + desc: 'Do not write files; report planned changes', + }, + { long: 'list', short: 'L', type: 'boolean', desc: 'List files that would change' }, + { + long: 'summary', + short: 's', + type: 'boolean', + desc: 'Print a summary of work performed', + }, + { + long: 'json', + short: 'J', + type: 'boolean', + desc: 'Emit machine-readable JSON summary/diagnostics', + }, + { + long: 'cwd', + short: 'C', + type: 'string', + desc: 'Working directory for resolving files/out paths', + }, + { + long: 'stdin-filename', + short: 'f', + type: 'string', + desc: 'Virtual filename when reading from stdin', + }, + { + long: 'ignore', + short: 'g', + type: 'string', + desc: 'Glob pattern(s) to ignore (repeatable)', + }, + { long: 'help', short: 'h', type: 'boolean', desc: 'Show help' }, + { long: 'version', short: 'v', type: 'boolean', desc: 'Show version' }, +] + +type Parsed = ReturnType + +type ParsedValues = Parsed['values'] + +const buildHelp = (enableColor: boolean) => { + const c = colorize(enableColor) + const maxFlagLength = Math.max( + ...optionsTable.map(opt => ` -${opt.short}, --${opt.long}`.length), + ) + const lines = [ + `${c.bold('Usage:')} dub [options] `, + '', + 'Examples:', + ' dub -t module src/index.cjs --out-dir dist', + ' dub -t commonjs src/**/*.mjs -p', + ' cat input.cjs | dub -t module --stdin-filename input.cjs', + '', + 'Options:', + ] + + for (const opt of optionsTable) { + const flag = ` -${opt.short}, --${opt.long}` + const pad = ' '.repeat(Math.max(2, maxFlagLength - flag.length + 2)) + lines.push(`${c.bold(flag)}${pad}${opt.desc}`) + } + + return `${lines.join('\n')}\n` +} + +const parseEnum = ( + value: string | undefined, + allowed: readonly T[], +): T | undefined => { + if (value === undefined) return undefined + return allowed.includes(value as T) ? (value as T) : undefined +} + +const parseTransformSyntax = ( + value: string | undefined, +): ModuleOptions['transformSyntax'] => { + if (value === undefined) return defaultOptions.transformSyntax + if (value === 'globals-only') return 'globals-only' + if (value === 'false') return false + if (value === 'true') return true + return defaultOptions.transformSyntax +} + +const parseAppendDirectoryIndex = (value: string | undefined) => { + if (value === undefined) return undefined + if (value === 'false') return false + return value +} + +const toModuleOptions = (values: ParsedValues): ModuleOptions => { + const target = + parseEnum(values.target as string | undefined, ['module', 'commonjs'] as const) ?? + defaultOptions.target + const transformSyntax = parseTransformSyntax( + values['transform-syntax'] as string | undefined, + ) + const appendJsExtension = parseEnum( + values['append-js-extension'] as string | undefined, + ['off', 'relative-only', 'all'] as const, + ) + const appendDirectoryIndex = parseAppendDirectoryIndex( + values['append-directory-index'] as string | undefined, + ) + + const opts: ModuleOptions = { + ...defaultOptions, + target, + transformSyntax, + rewriteSpecifier: + (values['rewrite-specifier'] as ModuleOptions['rewriteSpecifier']) ?? undefined, + appendJsExtension: appendJsExtension, + appendDirectoryIndex, + detectCircularRequires: + parseEnum( + values['detect-circular-requires'] as string | undefined, + ['off', 'warn', 'error'] as const, + ) ?? defaultOptions.detectCircularRequires, + topLevelAwait: + parseEnum( + values['top-level-await'] as string | undefined, + ['error', 'wrap', 'preserve'] as const, + ) ?? defaultOptions.topLevelAwait, + cjsDefault: + parseEnum( + values['cjs-default'] as string | undefined, + ['module-exports', 'auto', 'none'] as const, + ) ?? defaultOptions.cjsDefault, + idiomaticExports: + parseEnum( + values['idiomatic-exports'] as string | undefined, + ['off', 'safe', 'aggressive'] as const, + ) ?? defaultOptions.idiomaticExports, + importMetaPrelude: + parseEnum( + values['import-meta-prelude'] as string | undefined, + ['off', 'auto', 'on'] as const, + ) ?? defaultOptions.importMetaPrelude, + nestedRequireStrategy: + parseEnum( + values['nested-require-strategy'] as string | undefined, + ['create-require', 'dynamic-import'] as const, + ) ?? defaultOptions.nestedRequireStrategy, + requireMainStrategy: + parseEnum( + values['require-main-strategy'] as string | undefined, + ['import-meta-main', 'realpath'] as const, + ) ?? defaultOptions.requireMainStrategy, + liveBindings: + parseEnum( + values['live-bindings'] as string | undefined, + ['strict', 'loose', 'off'] as const, + ) ?? defaultOptions.liveBindings, + cwd: values.cwd ? resolve(String(values.cwd)) : defaultOptions.cwd, + } + + return opts +} + +const readStdin = async (stdin: typeof defaultStdin) => { + const chunks: Buffer[] = [] + for await (const chunk of stdin) { + chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : chunk) + } + return Buffer.concat(chunks).toString('utf8') +} + +const expandFiles = async (patterns: string[], cwd: string, ignore?: string[]) => { + const files = new Set() + for (const pattern of patterns) { + const matches = await glob(pattern, { + cwd, + absolute: true, + nodir: true, + windowsPathsNoEscape: true, + ignore, + }) + for (const m of matches) files.add(resolve(m)) + } + return [...files] +} + +const makeLogger = (stdout: LogWriter['stdout'], stderr: LogWriter['stderr']) => { + const enableColor = stdout.isTTY ?? stderr.isTTY ?? false + const c = colorize(enableColor) + + const log = ( + kind: IconKind, + message: string, + stream: LogWriter['stdout'] | LogWriter['stderr'], + ) => { + const icon = icons[kind] + const colored = + kind === 'error' + ? c.red(message) + : kind === 'warn' + ? c.yellow(message) + : kind === 'success' + ? c.green(message) + : c.cyan(message) + stream.write(`${icon} ${colored}\n`) + } + + return { + info: (msg: string) => log('info', msg, stdout), + warn: (msg: string) => log('warn', msg, stderr), + error: (msg: string) => log('error', msg, stderr), + success: (msg: string) => log('success', msg, stdout), + color: c, + } +} + +const applySpecifierUpdates = async ( + source: string, + filename: string, + opts: ModuleOptions, + appendMode: NonNullable, + dirIndex: string | false, +) => { + if (!opts.rewriteSpecifier && appendMode === 'off' && !dirIndex) return source + + const lang = getLangFromExt(filename) + const updated = await specifier.updateSrc(source, lang, spec => { + const normalized = normalizeBuiltinSpecifier(spec.value) + const rewritten = rewriteSpecifierValue( + normalized ?? spec.value, + opts.rewriteSpecifier, + ) + const baseValue = rewritten ?? normalized ?? spec.value + const appended = appendExtensionIfNeeded(baseValue, appendMode, dirIndex) + return appended ?? rewritten ?? normalized ?? undefined + }) + + return updated +} + +const transformVirtual = async ( + source: string, + filename: string, + opts: ModuleOptions, +) => { + const ast = parse(filename, source) + let output = await format(source, ast, { ...opts, filePath: filename }) + + const appendMode: NonNullable = + opts.appendJsExtension ?? (opts.target === 'module' ? 'relative-only' : 'off') + const dirIndex = + opts.appendDirectoryIndex === undefined ? 'index.js' : opts.appendDirectoryIndex + + output = await applySpecifierUpdates(output, filename, opts, appendMode, dirIndex) + return output +} + +const summarizeDiagnostics = (diags: Diagnostic[]) => { + let warnings = 0 + let errors = 0 + for (const d of diags) { + if (d.level === 'warning') warnings += 1 + if (d.level === 'error') errors += 1 + } + return { warnings, errors } +} + +const runFiles = async ( + files: string[], + moduleOpts: ModuleOptions, + io: LogWriter, + flags: { + dryRun: boolean + list: boolean + summary: boolean + json: boolean + outDir?: string + inPlace: boolean + allowStdout: boolean + }, +) => { + const results: FileResult[] = [] + const logger = makeLogger(io.stdout, io.stderr) + + for (const file of files) { + const diagnostics: Diagnostic[] = [] + const original = await readFile(file, 'utf8') + const outPath = flags.outDir + ? join(flags.outDir, relative(moduleOpts.cwd ?? process.cwd(), file)) + : undefined + const perFileOpts: ModuleOptions = { + ...moduleOpts, + diagnostics: diag => diagnostics.push(diag), + out: undefined, + inPlace: false, + filePath: file, + } + + let writeTarget: string | undefined + if (!flags.dryRun && !flags.list) { + if (flags.inPlace) { + perFileOpts.inPlace = true + } else if (outPath) { + writeTarget = outPath + perFileOpts.out = outPath + await mkdir(dirname(outPath), { recursive: true }) + } else if (!flags.allowStdout) { + logger.error('Specify --out-dir or --in-place when transforming files') + return { code: 2, results } + } + } + + const output = await transform(file, perFileOpts) + const changed = output !== original + + if (flags.list && changed) { + logger.info(file) + } + + if (!flags.dryRun && !flags.list && !writeTarget && !perFileOpts.inPlace) { + io.stdout.write(output) + } + + results.push({ filePath: file, changed, diagnostics }) + + const counts = summarizeDiagnostics(diagnostics) + if (!flags.json) { + for (const diag of diagnostics) { + const prefix = diag.level === 'error' ? logger.error : logger.warn + const loc = diag.loc ? ` [${diag.loc.start}-${diag.loc.end}]` : '' + prefix(`${diag.code}: ${diag.message}${loc}`) + } + } + + if (counts.errors > 0) { + return { code: 1, results } + } + } + + if (flags.summary && !flags.json) { + const changedCount = results.filter(r => r.changed).length + logger.success(`Processed ${results.length} file(s); changed ${changedCount}`) + } + + return { code: 0, results } +} + +const runCli = async ({ + argv = process.argv.slice(2), + stdin = defaultStdin, + stdout = defaultStdout, + stderr = defaultStderr, +}: CliOptions = {}) => { + const { values, positionals } = parseArgs({ + args: argv, + allowPositionals: true, + options: Object.fromEntries( + optionsTable.map(opt => [ + opt.long, + { type: opt.type as 'string' | 'boolean', short: opt.short }, + ]), + ), + }) + + const logger = makeLogger(stdout, stderr) + + if (values.help) { + stdout.write(buildHelp(stdout.isTTY ?? false)) + return 0 + } + + if (values.version) { + const pkg = JSON.parse( + await readFile(new URL('../package.json', import.meta.url), 'utf8'), + ) + stdout.write(`${pkg.version}\n`) + return 0 + } + + const moduleOpts = toModuleOptions(values) + const cwd = moduleOpts.cwd ?? process.cwd() + const allowStdout = positionals.length <= 1 + const fromStdin = positionals.length === 0 || positionals.includes('-') + const patterns = positionals.filter(p => p !== '-') + const ignoreValues = values.ignore + const ignore = ignoreValues + ? (Array.isArray(ignoreValues) ? ignoreValues : [ignoreValues]).map(String) + : undefined + + const outDir = values['out-dir'] ? resolve(cwd, String(values['out-dir'])) : undefined + const inPlace = Boolean(values['in-place']) + const dryRun = Boolean(values['dry-run']) + const list = Boolean(values.list) + const summary = Boolean(values.summary) + const json = Boolean(values.json) + + if (outDir && inPlace) { + logger.error('Choose either --out-dir or --in-place, not both') + return 2 + } + + if (fromStdin && (outDir || inPlace)) { + logger.error( + 'Cannot combine stdin with --out-dir or --in-place; output goes to stdout', + ) + return 2 + } + + const files = await expandFiles(patterns, cwd, ignore) + + if (!fromStdin && files.length === 0) { + logger.error('No input files were provided or matched') + return 2 + } + + const tasks: FileResult[] = [] + + if (fromStdin) { + const virtualName = (values['stdin-filename'] as string | undefined) ?? 'stdin.js' + const source = await readStdin(stdin) + const diagnostics: Diagnostic[] = [] + const output = await transformVirtual(source, virtualName, { + ...moduleOpts, + diagnostics: diag => diagnostics.push(diag), + filePath: virtualName, + cwd, + }) + tasks.push({ filePath: virtualName, changed: true, diagnostics }) + stdout.write(output) + + if (!json) { + for (const diag of diagnostics) { + const prefix = diag.level === 'error' ? logger.error : logger.warn + const loc = diag.loc ? ` [${diag.loc.start}-${diag.loc.end}]` : '' + prefix(`${diag.code}: ${diag.message}${loc}`) + } + } + + const diagSummary = summarizeDiagnostics(diagnostics) + if (diagSummary.errors > 0) return 1 + } + + if (files.length) { + const result = await runFiles( + files, + { ...moduleOpts, cwd }, + { stdout, stderr }, + { + dryRun, + list, + summary, + json, + outDir, + inPlace, + allowStdout, + }, + ) + + if (typeof result.code === 'number' && result.code !== 0) return result.code + tasks.push(...result.results) + } + + if (json) { + const summaryDiag = summarizeDiagnostics(tasks.flatMap(t => t.diagnostics)) + stdout.write(`${JSON.stringify({ files: tasks, summary: summaryDiag }, null, 2)}\n`) + } + + return 0 +} + +if (import.meta.main) { + runCli().then( + code => { + if (code !== 0) process.exit(code) + }, + err => { + // eslint-disable-next-line no-console -- CLI surface + console.error(err) + process.exit(1) + }, + ) +} + +export { runCli } diff --git a/src/formatters/metaProperty.ts b/src/formatters/metaProperty.ts index 0a88a99..26991ec 100644 --- a/src/formatters/metaProperty.ts +++ b/src/formatters/metaProperty.ts @@ -59,6 +59,9 @@ export const metaProperty = ( case 'main': src.update(parent.start, parent.end, importMetaMainExpr(options.importMetaMain)) break + default: + src.update(parent.start, parent.end, `module.${parent.property.name}`) + break } } } diff --git a/test/cli.ts b/test/cli.ts new file mode 100644 index 0000000..6097cac --- /dev/null +++ b/test/cli.ts @@ -0,0 +1,479 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { spawnSync } from 'node:child_process' +import { resolve, join, relative } from 'node:path' +import { tmpdir } from 'node:os' +import { mkdtemp, copyFile, readFile, rm, stat, mkdir, writeFile } from 'node:fs/promises' +import { createRequire } from 'node:module' + +import { transform } from '../src/module.js' +import { runCli as runCliEntry } from '../src/cli.js' + +const require = createRequire(import.meta.url) +const tsxImport = require.resolve('tsx/esm') +const projectRoot = resolve(import.meta.dirname, '..') +const cliEntry = resolve(projectRoot, 'src/cli.ts') +const fixture = resolve(projectRoot, 'test/fixtures/cli/input.cjs') +const fixtureRel = relative(projectRoot, fixture) +const pkgPath = resolve(projectRoot, 'package.json') +const tscBin = resolve(projectRoot, 'node_modules', '.bin', 'tsc') + +const runCli = (args: string[], input?: string, opts?: { cwd?: string }) => + spawnSync(process.execPath, ['--import', tsxImport, cliEntry, ...args], { + cwd: opts?.cwd ?? projectRoot, + input, + encoding: 'utf8', + env: { ...process.env, FORCE_COLOR: '0' }, + }) + +const runCodeInNode = async ( + code: string, + opts: { type?: 'module' | 'commonjs'; cwd?: string; dir?: string } = {}, +) => { + const dir = opts.dir ?? (await mkdtemp(join(tmpdir(), 'module-cli-run-'))) + const ext = opts.type === 'module' ? '.mjs' : '.cjs' + const filePath = join(dir, `out${ext}`) + + await writeFile(filePath, code, 'utf8') + + const { status, stderr } = spawnSync(process.execPath, [filePath], { + cwd: opts.cwd ?? dir, + encoding: 'utf8', + env: { ...process.env, NODE_OPTIONS: undefined }, + }) + + if (opts.dir) { + await rm(filePath, { force: true }) + } else { + await rm(dir, { recursive: true, force: true }) + } + + assert.equal(status, 0, stderr) + return filePath +} + +test('--help shows usage', async () => { + const result = runCli(['--help']) + assert.equal(result.status, 0) + assert.match(result.stdout, /Usage: dub/) + assert.equal(result.stderr, '') +}) + +test('--help emits color when TTY', async () => { + let output = '' + const stdout = { + isTTY: true, + write: (chunk: string | Uint8Array) => { + output += chunk.toString() + return true + }, + } + const stderr = { isTTY: true, write: () => true } + + const code = await runCliEntry({ argv: ['--help'], stdout, stderr }) + assert.equal(code, 0) + assert.ok(output.includes('\u001b[')) +}) + +test('--version prints package version', async () => { + const pkg = JSON.parse(await readFile(pkgPath, 'utf8')) + const result = runCli(['--version']) + assert.equal(result.status, 0) + assert.equal(result.stdout.trim(), pkg.version) +}) + +test('--list reports files without writing', async () => { + const before = await readFile(fixture, 'utf8') + const result = runCli(['--list', '--target', 'module', fixtureRel]) + assert.equal(result.status, 0) + assert.ok(result.stdout.includes('input.cjs')) + const after = await readFile(fixture, 'utf8') + assert.equal(after, before) +}) + +test('--ignore excludes glob matches', async () => { + const temp = await mkdtemp(join(tmpdir(), 'module-cli-ignore-')) + const keep = join(temp, 'keep.cjs') + const ignoredDir = join(temp, 'node_modules', 'pkg') + const ignored = join(ignoredDir, 'index.cjs') + await mkdir(ignoredDir, { recursive: true }) + await copyFile(fixture, keep) + await copyFile(fixture, ignored) + + try { + const result = runCli([ + '--list', + '--target', + 'module', + '--cwd', + temp, + '**/*.cjs', + '--ignore', + 'node_modules/**', + ]) + + assert.equal(result.status, 0) + assert.ok(result.stdout.includes('keep.cjs')) + assert.ok(!result.stdout.includes('node_modules')) + } finally { + await rm(temp, { recursive: true, force: true }) + } +}) + +test('rewrites __dirname for ESM TS projects (NodeNext)', async () => { + const temp = await mkdtemp(join(tmpdir(), 'module-cli-ts-node-next-')) + const srcDir = join(temp, 'src') + const file = join(srcDir, 'file.ts') + + await mkdir(srcDir, { recursive: true }) + await writeFile( + join(temp, 'package.json'), + JSON.stringify({ type: 'module' }, null, 2), + 'utf8', + ) + await writeFile( + join(temp, 'tsconfig.json'), + JSON.stringify( + { compilerOptions: { module: 'NodeNext' }, include: ['src'] }, + null, + 2, + ), + 'utf8', + ) + await writeFile(file, 'console.log(__dirname)\n', 'utf8') + + try { + const result = runCli([ + '--target', + 'commonjs', + '--transform-syntax', + 'globals-only', + '--cwd', + temp, + 'src/file.ts', + ]) + + assert.equal(result.status, 0) + assert.ok(!result.stdout.includes('fileURLToPath')) + assert.ok(result.stdout.includes('console.log(__dirname)')) + await runCodeInNode(result.stdout, { type: 'commonjs' }) + } finally { + await rm(temp, { recursive: true, force: true }) + } +}) + +test('globals-only CJS output runs without ESM imports', async () => { + const temp = await mkdtemp(join(tmpdir(), 'module-cli-globals-only-run-')) + const file = join(temp, 'mod.cjs') + + await writeFile(file, 'console.log(__dirname)\n', 'utf8') + + try { + const result = runCli([ + '--target', + 'commonjs', + '--transform-syntax', + 'globals-only', + file, + ]) + + assert.equal(result.status, 0) + assert.ok(!result.stdout.includes('import ')) + await runCodeInNode(result.stdout, { type: 'commonjs' }) + } finally { + await rm(temp, { recursive: true, force: true }) + } +}) + +/* + * End-to-end reproduction of the README pre-tsc flow: confirm TS fails on + * import.meta/__filename under CJS, run globals-only rewrite in-place, then + * confirm TS passes after the lexical globals swap. + * @see https://github.com/microsoft/TypeScript/issues/58658 + */ +test('globals-only pre-tsc flow matches README example', async () => { + const temp = await mkdtemp(join(tmpdir(), 'module-cli-pre-tsc-')) + const srcDir = join(temp, 'src') + const file = join(srcDir, 'index.ts') + + await mkdir(srcDir, { recursive: true }) + await writeFile( + join(temp, 'tsconfig.json'), + JSON.stringify( + { + compilerOptions: { + target: 'ES2020', + module: 'commonjs', + outDir: 'dist', + strict: false, + esModuleInterop: true, + types: ['node'], + typeRoots: [join(projectRoot, 'node_modules', '@types')], + }, + include: ['src'], + }, + null, + 2, + ), + 'utf8', + ) + await writeFile( + file, + [ + "import fs from 'node:fs'", + 'export const meta = import.meta.url', + 'export const size = fs.statSync(__filename).size', + '', + ].join('\n'), + 'utf8', + ) + + try { + const before = spawnSync(tscBin, ['-p', temp], { encoding: 'utf8' }) + assert.notEqual(before.status, 0) + + const result = runCli( + [ + '--target', + 'commonjs', + '--transform-syntax', + 'globals-only', + '--ignore', + 'node_modules/**', + '--in-place', + file, + ], + undefined, + { cwd: temp }, + ) + + assert.equal(result.status, 0) + const transformed = await readFile(file, 'utf8') + assert.ok(!transformed.includes('import.meta')) + + const after = spawnSync(tscBin, ['-p', temp], { encoding: 'utf8' }) + assert.equal(after.status, 0, after.stderr || after.stdout) + } finally { + await rm(temp, { recursive: true, force: true }) + } +}) + +test('--dry-run does not create outputs when out-dir provided', async () => { + const temp = await mkdtemp(join(tmpdir(), 'module-cli-')) + const outDir = join(temp, 'out') + const result = runCli([ + '--dry-run', + '--out-dir', + outDir, + '--target', + 'module', + fixtureRel, + ]) + assert.equal(result.status, 0) + const outFile = join(outDir, relative(projectRoot, fixture)) + await assert.rejects(stat(outFile)) + await rm(temp, { recursive: true, force: true }) +}) + +test('errors on conflicting out-dir and in-place', () => { + const result = runCli(['--out-dir', 'dist', '--in-place', fixtureRel]) + assert.equal(result.status, 2) + assert.match(result.stderr, /Choose either --out-dir or --in-place/) +}) + +test('errors on multiple files without output destination', async () => { + const temp = await mkdtemp(join(tmpdir(), 'module-cli-no-dest-')) + const first = join(temp, 'a.cjs') + const second = join(temp, 'b.cjs') + await copyFile(fixture, first) + await copyFile(fixture, second) + + try { + const result = runCli(['--target', 'module', first, second]) + assert.equal(result.status, 2) + assert.match(result.stderr, /Specify --out-dir or --in-place/) + } finally { + await rm(temp, { recursive: true, force: true }) + } +}) + +test('errors on stdin combined with out-dir', () => { + const result = runCli(['--out-dir', 'dist', '-'], 'console.log(1)') + assert.equal(result.status, 2) + assert.match(result.stderr, /Cannot combine stdin/) +}) + +test('errors when no inputs match', () => { + const result = runCli(['does-not-exist-123.js']) + assert.equal(result.status, 2) + assert.match(result.stderr, /No input files were provided or matched/) +}) + +test('--json emits summary without intermixed output', async () => { + const temp = await mkdtemp(join(tmpdir(), 'module-cli-json-')) + const tempFile = join(temp, 'input.mjs') + await writeFile(tempFile, 'console.log(1)\n', 'utf8') + + try { + const result = runCli(['--target', 'module', '--json', '--dry-run', tempFile]) + + assert.equal(result.status, 0) + const parsed = JSON.parse(result.stdout) + assert.equal(parsed.files[0].filePath, tempFile) + assert.equal(parsed.summary.errors, 0) + assert.ok(Number.isInteger(parsed.summary.warnings)) + } finally { + await rm(temp, { recursive: true, force: true }) + } +}) + +test('--summary prints work counts', async () => { + const temp = await mkdtemp(join(tmpdir(), 'module-cli-summary-')) + const tempFile = join(temp, 'input.cjs') + await copyFile(fixture, tempFile) + + try { + const result = runCli(['--target', 'module', '--summary', '--dry-run', tempFile]) + assert.equal(result.status, 0) + assert.match(result.stdout, /Processed 1 file/) + } finally { + await rm(temp, { recursive: true, force: true }) + } +}) + +test('--append-directory-index=false keeps trailing slash', () => { + const source = "import mod from './lib/'\n" + const result = runCli( + [ + '--target', + 'module', + '--stdin-filename', + 'input.mjs', + '--append-directory-index=false', + ], + source, + ) + + assert.equal(result.status, 0) + assert.match(result.stdout, /from '\.\/lib\/'/) +}) + +test('stdin errors bubble to exit code', () => { + const result = runCli( + ['--target', 'commonjs', '--stdin-filename', 'input.mjs'], + 'await 1', + ) + + assert.equal(result.status, 1) + assert.equal(result.stdout, '') + assert.match(result.stderr, /Top-level await is not supported/) +}) + +test('normalizes builtin specifiers to node: protocol', async () => { + const source = "import fs from 'fs'\n" + const result = runCli(['--target', 'module', '--stdin-filename', 'input.mjs'], source) + assert.equal(result.status, 0) + assert.match(result.stdout, /from 'node:fs'/) + await runCodeInNode(result.stdout, { type: 'module' }) +}) + +test('writes transformed file to stdout when allowed', async () => { + const expected = await transform(fixture, { target: 'module' }) + const result = runCli(['--target', 'module', fixtureRel]) + + assert.equal(result.status, 0) + assert.equal(result.stdout, expected) + await runCodeInNode(result.stdout, { type: 'module' }) +}) + +test('appends directory index for trailing slash imports', () => { + const source = "import mod from './lib/'\n" + const result = runCli(['--target', 'module', '--stdin-filename', 'input.mjs'], source) + assert.equal(result.status, 0) + assert.match(result.stdout, /\.\/lib\/index\.js'/) +}) + +test('rewrites specifiers with --rewrite-specifier', () => { + const source = "import x from './foo.ts'\n" + const result = runCli( + ['--target', 'module', '--stdin-filename', 'input.mjs', '--rewrite-specifier', '.js'], + source, + ) + assert.equal(result.status, 0) + assert.match(result.stdout, /\.\/foo\.js'/) +}) + +test('help example: out-dir mirror', async t => { + const temp = await mkdtemp(join(tmpdir(), 'module-cli-')) + const srcDir = join(temp, 'src') + const input = join(srcDir, 'index.cjs') + await mkdir(srcDir, { recursive: true }) + await copyFile(fixture, input) + + t.after(() => rm(temp, { recursive: true, force: true })) + + const result = runCli([ + '-t', + 'module', + '--cwd', + temp, + 'src/index.cjs', + '--out-dir', + 'dist', + ]) + + assert.equal(result.status, 0) + const outFile = join(temp, 'dist', 'src', 'index.cjs') + const expected = await transform(input, { target: 'module', out: outFile }) + const written = await readFile(outFile, 'utf8') + assert.equal(written, expected) + await runCodeInNode(written, { type: 'module' }) +}) + +test('--in-place rewrites files', async t => { + const temp = await mkdtemp(join(tmpdir(), 'module-cli-')) + const tempFile = join(temp, 'input.cjs') + await copyFile(fixture, tempFile) + + t.after(() => rm(temp, { recursive: true, force: true })) + + const expected = await transform(tempFile, { target: 'module' }) + const result = runCli(['--in-place', '--target', 'module', tempFile]) + + assert.equal(result.status, 0) + const written = await readFile(tempFile, 'utf8') + assert.equal(written, expected) + await runCodeInNode(written, { type: 'module' }) +}) + +test('help example: glob + in-place', async t => { + const temp = await mkdtemp(join(tmpdir(), 'module-cli-')) + const srcDir = join(temp, 'src', 'nested') + const input = join(srcDir, 'example.mjs') + await mkdir(srcDir, { recursive: true }) + await copyFile(resolve(projectRoot, 'test/fixtures/esmDefault.mjs'), input) + await copyFile( + resolve(projectRoot, 'test/fixtures/esmProvider.cjs'), + join(srcDir, 'esmProvider.cjs'), + ) + + t.after(() => rm(temp, { recursive: true, force: true })) + + const result = runCli(['-t', 'commonjs', '--cwd', temp, 'src/**/*.mjs', '-p']) + + assert.equal(result.status, 0) + const written = await readFile(input, 'utf8') + const expected = await transform(input, { target: 'commonjs', inPlace: true }) + assert.equal(written, expected) + await runCodeInNode(written, { type: 'commonjs', dir: srcDir, cwd: srcDir }) +}) + +test('stdin/stdout transforms content', async () => { + const source = await readFile(fixture, 'utf8') + const expected = await transform(fixture, { target: 'module' }) + const result = runCli(['--target', 'module', '--stdin-filename', 'input.cjs'], source) + + assert.equal(result.status, 0) + assert.equal(result.stdout, expected) + await runCodeInNode(result.stdout, { type: 'module' }) +}) diff --git a/test/fixtures/cli/input.cjs b/test/fixtures/cli/input.cjs new file mode 100644 index 0000000..62cf4b5 --- /dev/null +++ b/test/fixtures/cli/input.cjs @@ -0,0 +1,4 @@ +module.exports = { + foo: 1, + bar: () => 'ok', +} From ad66bea80652573b080513c822c483e76ca2f4bc Mon Sep 17 00:00:00 2001 From: KCM Date: Mon, 29 Dec 2025 14:01:42 -0600 Subject: [PATCH 2/6] docs: clarify out, inPlace opts. --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 53c4311..d01d2e9 100644 --- a/README.md +++ b/README.md @@ -160,7 +160,7 @@ type ModuleOptions = { - `requireSource` (`builtin`): whether `require` comes from Node or `createRequire`. - `cjsDefault` (`auto`): bundler-style default interop vs direct `module.exports`. - `idiomaticExports` (`safe`): when raising CJS to ESM, attempt to synthesize `export` statements directly when it is safe. `off` always uses the helper bag; `aggressive` currently matches `safe` heuristics. -- `out`/`inPlace`: write the transformed code to a file; otherwise the function returns the transformed string only. +- `out`/`inPlace`: choose output location. Default returns the transformed string (CLI emits to stdout). `out` writes to the provided path. `inPlace` overwrites the input files on disk and does not return/emit the code. - `cwd` (`process.cwd()`): Base directory used to resolve relative `out` paths. > [!NOTE] From efd23e69b65cd770a978fd8053ff5af865109f86 Mon Sep 17 00:00:00 2001 From: KCM Date: Mon, 29 Dec 2025 14:05:50 -0600 Subject: [PATCH 3/6] fix: windows. --- test/cli.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/test/cli.ts b/test/cli.ts index 6097cac..fc336c5 100644 --- a/test/cli.ts +++ b/test/cli.ts @@ -2,6 +2,7 @@ import { test } from 'node:test' import assert from 'node:assert/strict' import { spawnSync } from 'node:child_process' import { resolve, join, relative } from 'node:path' +import { pathToFileURL } from 'node:url' import { tmpdir } from 'node:os' import { mkdtemp, copyFile, readFile, rm, stat, mkdir, writeFile } from 'node:fs/promises' import { createRequire } from 'node:module' @@ -11,6 +12,7 @@ import { runCli as runCliEntry } from '../src/cli.js' const require = createRequire(import.meta.url) const tsxImport = require.resolve('tsx/esm') +const tsxImportUrl = pathToFileURL(tsxImport).href const projectRoot = resolve(import.meta.dirname, '..') const cliEntry = resolve(projectRoot, 'src/cli.ts') const fixture = resolve(projectRoot, 'test/fixtures/cli/input.cjs') @@ -19,7 +21,7 @@ const pkgPath = resolve(projectRoot, 'package.json') const tscBin = resolve(projectRoot, 'node_modules', '.bin', 'tsc') const runCli = (args: string[], input?: string, opts?: { cwd?: string }) => - spawnSync(process.execPath, ['--import', tsxImport, cliEntry, ...args], { + spawnSync(process.execPath, ['--import', tsxImportUrl, cliEntry, ...args], { cwd: opts?.cwd ?? projectRoot, input, encoding: 'utf8', From 4f6a6a13f5fbe10b8c826b2919664325453c6bca Mon Sep 17 00:00:00 2001 From: KCM Date: Mon, 29 Dec 2025 14:11:33 -0600 Subject: [PATCH 4/6] fix: windows use execPath. --- test/cli.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test/cli.ts b/test/cli.ts index fc336c5..31e5dfc 100644 --- a/test/cli.ts +++ b/test/cli.ts @@ -18,7 +18,7 @@ const cliEntry = resolve(projectRoot, 'src/cli.ts') const fixture = resolve(projectRoot, 'test/fixtures/cli/input.cjs') const fixtureRel = relative(projectRoot, fixture) const pkgPath = resolve(projectRoot, 'package.json') -const tscBin = resolve(projectRoot, 'node_modules', '.bin', 'tsc') +const tscBin = require.resolve('typescript/bin/tsc') const runCli = (args: string[], input?: string, opts?: { cwd?: string }) => spawnSync(process.execPath, ['--import', tsxImportUrl, cliEntry, ...args], { @@ -231,7 +231,7 @@ test('globals-only pre-tsc flow matches README example', async () => { ) try { - const before = spawnSync(tscBin, ['-p', temp], { encoding: 'utf8' }) + const before = spawnSync(process.execPath, [tscBin, '-p', temp], { encoding: 'utf8' }) assert.notEqual(before.status, 0) const result = runCli( @@ -253,7 +253,7 @@ test('globals-only pre-tsc flow matches README example', async () => { const transformed = await readFile(file, 'utf8') assert.ok(!transformed.includes('import.meta')) - const after = spawnSync(tscBin, ['-p', temp], { encoding: 'utf8' }) + const after = spawnSync(process.execPath, [tscBin, '-p', temp], { encoding: 'utf8' }) assert.equal(after.status, 0, after.stderr || after.stdout) } finally { await rm(temp, { recursive: true, force: true }) From 73c9723d9445103f2f6402a5fa8f53347a9a07ff Mon Sep 17 00:00:00 2001 From: KCM Date: Mon, 29 Dec 2025 14:20:21 -0600 Subject: [PATCH 5/6] docs: add footnote about no emitted helpers. --- docs/globals-only.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/globals-only.md b/docs/globals-only.md index cb63246..a7cd324 100644 --- a/docs/globals-only.md +++ b/docs/globals-only.md @@ -16,7 +16,7 @@ | CJS ➜ ESM | `__dirname` | `import.meta.dirname` | lexical swap | | CJS ➜ ESM | `__filename` | `import.meta.filename` | lexical swap | | CJS ➜ ESM | `require.main` | `import.meta.main` | skipped inside equality checks | -| CJS ➜ ESM | `require.resolve(...)` | `import.meta.resolve(...)` | no helper emitted | +| CJS ➜ ESM | `require.resolve(...)` | `import.meta.resolve(...)` | no helper emitted* | | CJS ➜ ESM | `module.require(...)` | `require(...)` | collapses to ambient require | | CJS ➜ ESM | `require.cache` | `{}` | best-effort stub + warning | | CJS ➜ ESM | `require.extensions` | (unchanged) | warning emitted | @@ -30,6 +30,8 @@ | ESM ➜ CJS | other `import.meta.*` | `module.*` | no extra objects created | +\* In globals-only, we do not inject the CJS-style `require.resolve` helper. The rewrite relies on the host's native `import.meta.resolve`, whose semantics differ (URL-based, parent handling). Use full transforms if you need the helper that preserves CJS resolution behavior. + ## When to use it - Pre-`tsc` mitigation for TypeScript’s [asymmetry on module globals](https://github.com/microsoft/TypeScript/issues/58658) (see `test/cli.ts`): rewrite globals so the checker sees the target-side shapes without altering import/export syntax. From 334768ae2d34739dee9fba340efc8eee4424db9e Mon Sep 17 00:00:00 2001 From: KCM Date: Mon, 29 Dec 2025 14:24:51 -0600 Subject: [PATCH 6/6] docs: fix code formatting. --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index d01d2e9..352c361 100644 --- a/README.md +++ b/README.md @@ -144,7 +144,7 @@ type ModuleOptions = { ### Behavior notes (defaults in parentheses) - `target` (`commonjs`): output module system. -- `transformSyntax` (true): enable/disable the ESM↔CJS lowering pass; set to `'globals-only'` to rewrite module globals (`import.meta.*`, `__dirname`, `__filename`, `require.main` shims) while leaving import/export syntax untouched. In `'globals-only'`, no helpers are injected (e.g., `__requireResolve`), `require.resolve` rewrites to `import.meta.resolve`, and `idiomaticExports` is skipped. See [globals-only](docs/globals-only.md). +- `transformSyntax` (`true`): enable/disable the ESM↔CJS lowering pass; set to `'globals-only'` to rewrite module globals (`import.meta.*`, `__dirname`, `__filename`, `require.main` shims) while leaving import/export syntax untouched. In `'globals-only'`, no helpers are injected (e.g., `__requireResolve`), `require.resolve` rewrites to `import.meta.resolve`, and `idiomaticExports` is skipped. See [globals-only](docs/globals-only.md). - `liveBindings` (`strict`): getter-based live bindings, or snapshot (`loose`/`off`). - `appendJsExtension` (`relative-only` when targeting ESM): append `.js` to relative specifiers; never touches bare specifiers. - `appendDirectoryIndex` (`index.js`): when a relative specifier ends with a slash, append this index filename (set `false` to disable).