From 04f27977e7c5f2feca01c425e6746820fbe8171b Mon Sep 17 00:00:00 2001 From: "MK (fengmk2)" Date: Thu, 22 Oct 2026 01:02:37 +0800 Subject: [PATCH 01/23] feat(migrate): migrate tsx scripts to vpx `vp migrate` now offers to move tsx commands to `vpx`, like the Prettier and tsup migrations: always on a fresh project, and with `--full` on one already using Vite+. It rewrites package.json scripts, lint-staged tasks, JSON lint-staged files, and `#!/usr/bin/env tsx` shebangs: `tsx a.ts` becomes `vpx a.ts`, `tsx watch` becomes `vpx --watch`, `node --import tsx a.ts` becomes `vpx a.ts`, and wrappers such as `npx tsx` are dropped. Forms `vpx` cannot run keep tsx with a warning. The tsx devDependency is removed only when no script, import, shebang, CI workflow, git hook, or shell script uses tsx anymore. Co-Authored-By: Claude Opus 5.5 --- .../migration_tsx/.fixture/tsx/package.json | 4 + .../fixtures/migration_tsx/package.json | 14 + .../fixtures/migration_tsx/scripts/bench.ts | 1 + .../fixtures/migration_tsx/scripts/release.ts | 3 + .../fixtures/migration_tsx/scripts/seed.ts | 1 + .../fixtures/migration_tsx/snapshots.toml | 31 + .../migration_tsx_already_vite_plus.md | 59 ++ .../snapshots/migration_tsx_kept.md | 51 ++ .../snapshots/migration_tsx_scripts.md | 48 ++ .../fixtures/migration_tsx/src/server.ts | 1 + docs/guide/migrate-rules.md | 24 +- docs/guide/migrate.md | 2 +- docs/guide/vpx.md | 2 +- .../cli/src/migration/__tests__/tsx.spec.ts | 255 ++++++++ packages/cli/src/migration/bin.ts | 40 ++ packages/cli/src/migration/migrator.ts | 1 + packages/cli/src/migration/migrator/README.md | 1 + .../cli/src/migration/migrator/source-scan.ts | 10 +- packages/cli/src/migration/migrator/tsx.ts | 590 ++++++++++++++++++ packages/cli/src/migration/report.ts | 2 + rfcs/vpx-script-execution.md | 11 +- 21 files changed, 1143 insertions(+), 8 deletions(-) create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_tsx/.fixture/tsx/package.json create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_tsx/package.json create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_tsx/scripts/bench.ts create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_tsx/scripts/release.ts create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_tsx/scripts/seed.ts create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_tsx/snapshots.toml create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_tsx/snapshots/migration_tsx_already_vite_plus.md create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_tsx/snapshots/migration_tsx_kept.md create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_tsx/snapshots/migration_tsx_scripts.md create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_tsx/src/server.ts create mode 100644 packages/cli/src/migration/__tests__/tsx.spec.ts create mode 100644 packages/cli/src/migration/migrator/tsx.ts diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_tsx/.fixture/tsx/package.json b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_tsx/.fixture/tsx/package.json new file mode 100644 index 0000000000..e900a90163 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_tsx/.fixture/tsx/package.json @@ -0,0 +1,4 @@ +{ + "name": "tsx", + "version": "4.20.0" +} diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_tsx/package.json b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_tsx/package.json new file mode 100644 index 0000000000..6cc7fefa28 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_tsx/package.json @@ -0,0 +1,14 @@ +{ + "name": "migration-tsx", + "type": "module", + "scripts": { + "dev": "tsx watch --clear-screen=false src/server.ts", + "seed": "cross-env NODE_ENV=development npx tsx scripts/seed.ts", + "bench": "node --import tsx ./scripts/bench.ts", + "start": "concurrently \"tsx watch src/server.ts\" \"vp dev\"" + }, + "devDependencies": { + "tsx": "file:.fixture/tsx", + "vite": "^7.0.0" + } +} diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_tsx/scripts/bench.ts b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_tsx/scripts/bench.ts new file mode 100644 index 0000000000..52f3425d93 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_tsx/scripts/bench.ts @@ -0,0 +1 @@ +export const runs: number = 100; diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_tsx/scripts/release.ts b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_tsx/scripts/release.ts new file mode 100644 index 0000000000..ae8626311e --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_tsx/scripts/release.ts @@ -0,0 +1,3 @@ +#!/usr/bin/env tsx +const version: string = '1.0.0'; +console.log(version); diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_tsx/scripts/seed.ts b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_tsx/scripts/seed.ts new file mode 100644 index 0000000000..acb8e841ed --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_tsx/scripts/seed.ts @@ -0,0 +1 @@ +export const rows: number = 10; diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_tsx/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_tsx/snapshots.toml new file mode 100644 index 0000000000..d1728c08a3 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_tsx/snapshots.toml @@ -0,0 +1,31 @@ +[[case]] +name = "migration_tsx_scripts" +vp = "local" +comment = "tsx comes from a local stub package so the install stays offline" +steps = [ + { argv = ["vp", "migrate", "--no-interactive", "--no-hooks", "--no-agent", "--no-editor"], comment = "tsx scripts should migrate to vpx automatically", continue-on-failure = true }, + { argv = ["vpt", "print-file", "package.json"], comment = "scripts run vpx, and the tsx devDependency is gone", continue-on-failure = true }, + { argv = ["vpt", "print-file", "scripts/release.ts"], comment = "the shebang runs vpx", continue-on-failure = true }, +] + +[[case]] +name = "migration_tsx_kept" +vp = "local" +comment = "tsx comes from a local stub package so the install stays offline" +steps = [ + { argv = ["vpt", "json-edit", "package.json", "scripts.repl", "tsx"], snapshot = false }, + { argv = ["vpt", "json-edit", "package.json", "scripts.calc", "tsx -e 'console.log(1 + 1)'"], snapshot = false }, + { argv = ["vp", "migrate", "--no-interactive", "--no-hooks", "--no-agent", "--no-editor"], comment = "scripts vpx cannot run keep tsx, with a warning for each", continue-on-failure = true }, + { argv = ["vpt", "print-file", "package.json"], comment = "the other scripts run vpx, and the tsx devDependency stays", continue-on-failure = true }, +] + +[[case]] +name = "migration_tsx_already_vite_plus" +vp = "local" +comment = "On a project already using Vite+, the tsx migration belongs to `vp migrate --full`." +steps = [ + { argv = ["vpt", "json-edit", "package.json", "devDependencies.vite-plus", "latest"], snapshot = false }, + { argv = ["vp", "migrate", "--no-interactive"], comment = "a plain upgrade leaves tsx scripts to --full", continue-on-failure = true }, + { argv = ["vp", "migrate", "--no-interactive", "--full", "--no-hooks", "--no-agent", "--no-editor"], comment = "--full migrates tsx scripts to vpx", continue-on-failure = true }, + { argv = ["vpt", "print-file", "package.json"], comment = "scripts run vpx, and the tsx devDependency is gone", continue-on-failure = true }, +] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_tsx/snapshots/migration_tsx_already_vite_plus.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_tsx/snapshots/migration_tsx_already_vite_plus.md new file mode 100644 index 0000000000..924cf81287 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_tsx/snapshots/migration_tsx_already_vite_plus.md @@ -0,0 +1,59 @@ +# migration_tsx_already_vite_plus + +On a project already using Vite+, the tsx migration belongs to `vp migrate --full`. + +## `vpt json-edit package.json devDependencies.vite-plus latest` + + +## `vp migrate --no-interactive` + +a plain upgrade leaves tsx scripts to --full + +``` +VITE+ - The Unified Toolchain for the Web + +◇ Updated . to Vite+ +• Node pnpm +• Dependencies: + vite-plus latest → + vite → +• Package manager settings configured +• Skipped editor, hooks, and lint setup. Run `vp migrate --full` to apply them. +``` + +## `vp migrate --no-interactive --full --no-hooks --no-agent --no-editor` + +--full migrates tsx scripts to vpx + +``` +VITE+ - The Unified Toolchain for the Web + +▲ No package manager is declared in package.json; using pnpm for this migration without adding a pin. Run `vp env pin` to declare it explicitly. +● tsx scripts detected. Auto-migrating to vpx... +◇ Updated . to Vite+ +• Node pnpm +• Dependencies: + vite → +• tsx scripts migrated to `vpx` +``` + +## `vpt print-file package.json` + +scripts run vpx, and the tsx devDependency is gone + +``` +{ + "devDependencies": { + "vite": "catalog:", + "vite-plus": "catalog:" + }, + "name": "migration-tsx", + "scripts": { + "bench": "vpx ./scripts/bench.ts", + "dev": "vpx --watch --clear-screen=false src/server.ts", + "seed": "cross-env NODE_ENV=development vpx scripts/seed.ts", + "start": "concurrently \"vpx --watch src/server.ts\" \"vp dev\"" + }, + "type": "module" +} +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_tsx/snapshots/migration_tsx_kept.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_tsx/snapshots/migration_tsx_kept.md new file mode 100644 index 0000000000..969f6dd8e9 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_tsx/snapshots/migration_tsx_kept.md @@ -0,0 +1,51 @@ +# migration_tsx_kept + +tsx comes from a local stub package so the install stays offline + +## `vpt json-edit package.json scripts.repl tsx` + + +## `vpt json-edit package.json scripts.calc 'tsx -e '\''console.log(1 + 1)'\'''` + + +## `vp migrate --no-interactive --no-hooks --no-agent --no-editor` + +scripts vpx cannot run keep tsx, with a warning for each + +``` +VITE+ - The Unified Toolchain for the Web + +● tsx scripts detected. Auto-migrating to vpx... +◇ Migrated . to Vite+ +• Node pnpm +• 1 config update applied +• tsx scripts migrated to `vpx` +! Warnings: + - Kept tsx in package.json scripts "calc": `vpx` does not run TypeScript passed to --eval or --print. + - Kept tsx in package.json scripts "repl": `vpx` does not start a TypeScript REPL. + - Kept the tsx devDependency in package.json: scripts in package.json still run tsx. +``` + +## `vpt print-file package.json` + +the other scripts run vpx, and the tsx devDependency stays + +``` +{ + "devDependencies": { + "tsx": "file:.fixture/tsx", + "vite": "catalog:", + "vite-plus": "catalog:" + }, + "name": "migration-tsx", + "scripts": { + "bench": "vpx ./scripts/bench.ts", + "calc": "tsx -e 'console.log(1 + 1)'", + "dev": "vpx --watch --clear-screen=false src/server.ts", + "repl": "tsx", + "seed": "cross-env NODE_ENV=development vpx scripts/seed.ts", + "start": "concurrently \"vpx --watch src/server.ts\" \"vp dev\"" + }, + "type": "module" +} +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_tsx/snapshots/migration_tsx_scripts.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_tsx/snapshots/migration_tsx_scripts.md new file mode 100644 index 0000000000..6bbb17a93e --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_tsx/snapshots/migration_tsx_scripts.md @@ -0,0 +1,48 @@ +# migration_tsx_scripts + +tsx comes from a local stub package so the install stays offline + +## `vp migrate --no-interactive --no-hooks --no-agent --no-editor` + +tsx scripts should migrate to vpx automatically + +``` +VITE+ - The Unified Toolchain for the Web + +● tsx scripts detected. Auto-migrating to vpx... +◇ Migrated . to Vite+ +• Node pnpm +• 1 config update applied +• tsx scripts migrated to `vpx` +``` + +## `vpt print-file package.json` + +scripts run vpx, and the tsx devDependency is gone + +``` +{ + "name": "migration-tsx", + "type": "module", + "scripts": { + "dev": "vpx --watch --clear-screen=false src/server.ts", + "seed": "cross-env NODE_ENV=development vpx scripts/seed.ts", + "bench": "vpx ./scripts/bench.ts", + "start": "concurrently \"vpx --watch src/server.ts\" \"vp dev\"" + }, + "devDependencies": { + "vite": "catalog:", + "vite-plus": "catalog:" + } +} +``` + +## `vpt print-file scripts/release.ts` + +the shebang runs vpx + +``` +#!/usr/bin/env vpx +const version: string = '1.0.0'; +console.log(version); +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_tsx/src/server.ts b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_tsx/src/server.ts new file mode 100644 index 0000000000..dd8aae924a --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_tsx/src/server.ts @@ -0,0 +1 @@ +export const port: number = 3000; diff --git a/docs/guide/migrate-rules.md b/docs/guide/migrate-rules.md index 7f46795dcf..69709c6cab 100644 --- a/docs/guide/migrate-rules.md +++ b/docs/guide/migrate-rules.md @@ -18,7 +18,7 @@ Migration is idempotent: running it again after a successful migration should no On a project that already depends on `vite-plus`, `vp migrate` performs an upgrade only: it updates dependencies and package-manager configuration and finalizes imports. It does not touch project setup. -- `--full` also runs the setup actions: git hooks, editor config, agent files, ESLint, Prettier, and tsup migration, framework shims, the tsconfig `baseUrl` fix, and the Volta to `.node-version` conversion. +- `--full` also runs the setup actions: git hooks, editor config, agent files, ESLint, Prettier, tsup, and tsx migration, framework shims, the tsconfig `baseUrl` fix, and the Volta to `.node-version` conversion. - `--hooks`, `--agent`, and `--editor` opt into a single setup action without `--full`. When a default upgrade skips setup actions that would apply, it prints a hint to run `vp migrate --full`. Fresh (non Vite+) projects always run the full migration. @@ -261,6 +261,7 @@ Migration rewrites commands provided by the Vite+ toolchain in `package.json` sc | `eslint` | `vp lint`, when its optional migration runs | | `prettier` | `vp fmt`, when its optional migration runs | | `tsup` | `vp pack`, when its optional migration runs | +| `tsx` | `vpx`, when its optional migration runs | For commands launched through `bunx`, migration preserves `bunx` and its `--bun` flag (keeping the user's chosen runtime) and rewrites only the managed command. This also works when `bunx` follows a command-launcher delimiter such as `run` or `--`: @@ -273,6 +274,27 @@ For commands launched through `bunx`, migration preserves `bunx` and its `--bun` Unrelated `bunx` commands and other package-executor forms remain unchanged. +### tsx to `vpx` + +When a `package.json` script or lint-staged task runs [tsx](https://tsx.is), migration offers to move it to [`vpx`](/guide/vpx), Vite+'s TypeScript script runner. It rewrites scripts and lint-staged tasks in `package.json` and JSON lint-staged files, and `#!/usr/bin/env tsx` shebangs in script files: + +| Before | After | +| ---------------------------------------- | ------------------------------------------ | +| `tsx src/seed.ts --rows 10` | `vpx src/seed.ts --rows 10` | +| `tsx watch --include "*.json" src/a.ts` | `vpx --watch --include "*.json" src/a.ts` | +| `npx tsx a.ts`, `pnpm exec tsx a.ts` | `vpx a.ts` | +| `node --import tsx ./a.ts` | `vpx ./a.ts` | +| `cross-env NODE_ENV=dev tsx a.ts` | `cross-env NODE_ENV=dev vpx a.ts` | +| `concurrently "tsx watch a.ts" "vp dev"` | `concurrently "vpx --watch a.ts" "vp dev"` | + +`tsx watch` becomes `vpx --watch`, never `vpx watch`, which runs the npm package `watch`. Node.js options and tsx watch options keep their order, and `--no-cache` is dropped. `vpx` needs the same Node.js versions as Vite+ itself, and migration already upgrades incompatible runtime pins to them (see [Vitest 5](/guide/vitest-v5)). + +Migration keeps tsx, with a warning, for a command `vpx` cannot run: `--eval` or `--print` code, the REPL, `--test` without files, an entry without a file extension, and commands with pipes, variables, or subshells. It also keeps tsx in a package that lists it in `dependencies` or `peerDependencies`, since `vpx` comes from the `vite-plus` devDependency. + +Migration removes the `tsx` devDependency only when nothing uses tsx anymore: no kept script, no import of `tsx` or its subpaths such as `tsx/esm/api`, no shebang, and no tsx command in CI workflows, git hooks, or shell scripts. The root's dependency stays while any workspace package still uses tsx. When tsx stays, the warning names a file that still uses it. + +A script that uses `import` and `__dirname`, `__filename`, or `require()` in a package without `"type": "module"` gets a warning: tsx ran it as CommonJS, and `vpx` runs it as an ES module. See [Coming from tsx](/guide/vpx#coming-from-tsx) for the other differences. + ## Continuous Integration Rules Migration replaces exact `voidzero-dev/setup-vp@v1` references in GitHub Actions workflows and composite actions under `.github` with the latest exact `setup-vp` release known to that Vite+ version. The frozen `v1` tag does not receive new releases. Existing exact versions and commit SHAs are left unchanged. diff --git a/docs/guide/migrate.md b/docs/guide/migrate.md index 61305d7228..798b48dade 100644 --- a/docs/guide/migrate.md +++ b/docs/guide/migrate.md @@ -8,7 +8,7 @@ import { migrationPrompt, upgradePrompt } from '../.vitepress/theme/data/migrati ## Overview -This command is the starting point for consolidating separate Vite, Vitest, Oxlint, Oxfmt, ESLint, Prettier, tsdown, and tsup setups into Vite+. +This command is the starting point for consolidating separate Vite, Vitest, Oxlint, Oxfmt, ESLint, Prettier, tsdown, tsup, and tsx setups into Vite+. Use it when you want to take an existing project and move it onto the Vite+ defaults instead of wiring each tool by hand. diff --git a/docs/guide/vpx.md b/docs/guide/vpx.md index fb534ffb50..5f3fdcf4a4 100644 --- a/docs/guide/vpx.md +++ b/docs/guide/vpx.md @@ -183,7 +183,7 @@ JSX that depends on a compiler plugin is not supported. `vpx` compiles JSX only ### Coming from tsx -Most scripts that run with tsx run with `vpx` unchanged. These differences can need a change: +`vp migrate` rewrites tsx commands in `package.json` scripts to `vpx` and removes the `tsx` devDependency once nothing uses it; see [tsx to `vpx`](/guide/migrate-rules#tsx-to-vpx). Most scripts that run with tsx run with `vpx` unchanged. These differences can need a change: - **JSX default**: without a tsconfig `jsx` option, tsx compiles JSX to `React.createElement()`, which needs `React` in scope, while `vpx` uses the automatic runtime. Set `"jsx": "react"` to keep the classic output. Because of its default, tsx also ignores a lone `/** @jsxImportSource … */` comment unless the file has `/** @jsxRuntime automatic */` or the tsconfig sets `"jsx": "react-jsx"`; `vpx` applies the comment on its own. - **`.cts` files with `import` or `export`**: tsx compiles them to CommonJS, and `vpx` reports an error. Rename the file to `.mts`, or write it with `import x = require()` and `export =`. diff --git a/packages/cli/src/migration/__tests__/tsx.spec.ts b/packages/cli/src/migration/__tests__/tsx.spec.ts new file mode 100644 index 0000000000..41fd176db0 --- /dev/null +++ b/packages/cli/src/migration/__tests__/tsx.spec.ts @@ -0,0 +1,255 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('@voidzero-dev/vite-plus-prompts', () => ({ + log: { info: vi.fn(), success: vi.fn(), warn: vi.fn() }, +})); + +import { detectTsxProject, migrateTsxToVpx, rewriteTsxCommand } from '../migrator/tsx.ts'; +import { createMigrationReport } from '../report.ts'; + +describe('rewriteTsxCommand', () => { + it.each([ + ['tsx ./src/seed.ts --flag', 'vpx ./src/seed.ts --flag'], + ['tsx src/seed.ts', 'vpx src/seed.ts'], + ['tsx watch src/server.ts', 'vpx --watch src/server.ts'], + [ + 'tsx watch --clear-screen=false --include "config/*.json" --exclude src/gen.ts --tsconfig tsconfig.app.json src/server.ts --port 3000', + 'vpx --watch --clear-screen=false --include "config/*.json" --exclude src/gen.ts --tsconfig tsconfig.app.json src/server.ts --port 3000', + ], + ['tsx watch --no-cache src/server.ts', 'vpx --watch src/server.ts'], + ['tsx --no-cache ./a.ts', 'vpx ./a.ts'], + ['tsx --watch ./a.ts', 'vpx --watch ./a.ts'], + ['tsx --env-file=.env --inspect ./a.ts', 'vpx --env-file=.env --inspect ./a.ts'], + ['tsx --env-file .env ./a.ts arg', 'vpx --env-file .env ./a.ts arg'], + ['tsx --test ./a.test.ts', 'vpx --test ./a.test.ts'], + ["tsx 'my script.ts'", "vpx 'my script.ts'"], + ['npx tsx ./a.ts', 'vpx ./a.ts'], + ['pnpm exec tsx ./a.ts', 'vpx ./a.ts'], + ['pnpm tsx ./a.ts', 'vpx ./a.ts'], + ['yarn tsx watch ./a.ts', 'vpx --watch ./a.ts'], + ['bunx tsx ./a.ts', 'vpx ./a.ts'], + ['NODE_ENV=test tsx ./a.ts', 'NODE_ENV=test vpx ./a.ts'], + ['cross-env NODE_ENV=production tsx ./a.ts', 'cross-env NODE_ENV=production vpx ./a.ts'], + ['tsx ./a.ts && tsx watch ./b.ts; vp build', 'vpx ./a.ts && vpx --watch ./b.ts; vp build'], + ['node --import tsx ./a.ts', 'vpx ./a.ts'], + ['node --import=tsx --inspect ./a.ts', 'vpx --inspect ./a.ts'], + ['node --loader tsx/esm --watch ./a.ts', 'vpx --watch ./a.ts'], + ['node -r tsx/cjs ./a.cts', 'vpx ./a.cts'], + ['node --import tsx --import ./setup.ts ./a.ts', 'vpx --import ./setup.ts ./a.ts'], + [ + 'concurrently "tsx watch src/api.ts" "vp dev"', + 'concurrently "vpx --watch src/api.ts" "vp dev"', + ], + ["nodemon --exec 'tsx src/a.ts'", "nodemon --exec 'vpx src/a.ts'"], + ])('rewrites %s', (command, expected) => { + const result = rewriteTsxCommand(command); + expect(result.command).toBe(expected); + expect(result.kept).toEqual([]); + // Running the migration again changes nothing. + expect(rewriteTsxCommand(expected)).toEqual({ + command: expected, + entries: [], + kept: [], + }); + }); + + it.each([ + ['vpx ./a.ts'], + ['vp build'], + ['oxlint src/app.tsx'], + ['node ./a.js'], + ['tsx-foo ./a.ts'], + ])('leaves %s alone', (command) => { + expect(rewriteTsxCommand(command)).toEqual({ command, entries: [], kept: [] }); + }); + + it.each([ + ["tsx -e 'console.log(1)'", '--eval or --print'], + ['tsx --print 1', '--eval or --print'], + ['tsx', 'REPL'], + ['tsx watch', 'REPL'], + ['tsx --test', 'list the test files'], + ['tsx src/index', '`src/index` has no file extension'], + ['tsx --watch-path=src ./a.ts', '--watch-path'], + ['tsx ./a.ts | tee log', 'shell syntax'], + ['tsx $ENTRY', 'shell syntax'], + ['node --import tsx', 'REPL'], + ['node --import tsx -e 1', '--eval or --print'], + ["NODE_OPTIONS='--import tsx' node ./a.ts", 'does not rewrite this use of tsx'], + ['pnpm dlx tsx ./a.ts', 'does not rewrite this use of tsx'], + ])('keeps %s', (command, reason) => { + const result = rewriteTsxCommand(command); + expect(result.command).toBe(command); + expect(result.kept).toEqual([expect.stringContaining(reason)]); + }); + + it('rewrites the parts of a command list that vpx can run', () => { + const result = rewriteTsxCommand("tsx ./a.ts && tsx -e 'run()'"); + expect(result.command).toBe("vpx ./a.ts && tsx -e 'run()'"); + expect(result.kept).toEqual([expect.stringContaining('--eval')]); + }); + + it('reports the scripts the rewritten commands run', () => { + expect( + rewriteTsxCommand('tsx watch --include x.json src/a.ts && node --import tsx ./b.ts').entries, + ).toEqual(['src/a.ts', './b.ts']); + expect(rewriteTsxCommand('tsx --test ./a.test.ts').entries).toEqual([]); + }); +}); + +describe('migrateTsxToVpx', () => { + let root: string; + + const write = (file: string, content: string | object) => { + const target = path.join(root, file); + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.writeFileSync( + target, + typeof content === 'string' ? content : `${JSON.stringify(content, null, 2)}\n`, + ); + }; + const read = (file: string) => fs.readFileSync(path.join(root, file), 'utf8'); + const readJson = (file: string) => JSON.parse(read(file)); + + beforeEach(() => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'vp-test-tsx-')); + vi.spyOn(process, 'cwd').mockReturnValue(root); + }); + + afterEach(() => { + vi.restoreAllMocks(); + fs.rmSync(root, { recursive: true, force: true }); + }); + + it('rewrites scripts, lint-staged tasks, and shebangs, then removes tsx', () => { + write('package.json', { + type: 'module', + scripts: { dev: 'tsx watch src/server.ts', seed: 'npx tsx scripts/seed.ts' }, + 'lint-staged': { '*.ts': ['tsx scripts/check.ts'] }, + devDependencies: { tsx: '^4.20.0', vite: '^7.0.0' }, + }); + write('.lintstagedrc.json', { '*.md': 'tsx scripts/docs.ts' }); + write('scripts/release.ts', '#!/usr/bin/env tsx\nconsole.log("release");\n'); + write('src/server.ts', 'export {};\n'); + const report = createMigrationReport(); + + expect(detectTsxProject(root)).toBe(true); + expect(migrateTsxToVpx(root, undefined, report)).toEqual({ + changed: true, + dependencyRemoved: true, + }); + + expect(readJson('package.json')).toEqual({ + type: 'module', + scripts: { dev: 'vpx --watch src/server.ts', seed: 'vpx scripts/seed.ts' }, + 'lint-staged': { '*.ts': ['vpx scripts/check.ts'] }, + devDependencies: { vite: '^7.0.0' }, + }); + expect(readJson('.lintstagedrc.json')).toEqual({ '*.md': 'vpx scripts/docs.ts' }); + expect(read('scripts/release.ts')).toBe('#!/usr/bin/env vpx\nconsole.log("release");\n'); + expect(report.tsxMigrated).toBe(true); + expect(report.warnings).toEqual([]); + + // A second run has nothing left to do. + expect(detectTsxProject(root)).toBe(false); + expect(migrateTsxToVpx(root, undefined, createMigrationReport())).toEqual({ + changed: false, + dependencyRemoved: false, + }); + }); + + it.each([ + ['a kept script', 'package.json', undefined, 'scripts in package.json still run tsx'], + [ + 'an import of the tsx API', + 'src/load.ts', + "import { tsImport } from 'tsx/esm/api';\n", + 'src/load.ts still uses tsx', + ], + [ + 'a CI workflow', + '.github/workflows/ci.yml', + 'jobs:\n run: npx tsx scripts/ci.ts\n', + '.github/workflows/ci.yml still uses tsx', + ], + [ + 'a git hook', + '.husky/pre-commit', + 'npx tsx scripts/check.ts\n', + '.husky/pre-commit still uses tsx', + ], + [ + 'an extensionless shebang file', + 'bin/release', + '#!/usr/bin/env tsx\nexport {};\n', + 'bin/release still uses tsx', + ], + ])('keeps the tsx dependency used by %s', (_, file, content, user) => { + write('package.json', { + scripts: { + dev: 'tsx watch src/server.ts', + ...(content === undefined ? { repl: 'tsx' } : {}), + }, + devDependencies: { tsx: '^4.20.0' }, + }); + if (content !== undefined) { + write(file, content); + } + const report = createMigrationReport(); + + expect(migrateTsxToVpx(root, undefined, report).dependencyRemoved).toBe(false); + expect(readJson('package.json').scripts.dev).toBe('vpx --watch src/server.ts'); + expect(readJson('package.json').devDependencies).toEqual({ tsx: '^4.20.0' }); + expect(report.warnings).toContain(`Kept the tsx devDependency in package.json: ${user}.`); + }); + + it('keeps tsx in a package that depends on it at runtime', () => { + write('package.json', { + scripts: { start: 'tsx src/server.ts' }, + dependencies: { tsx: '^4.20.0' }, + }); + const report = createMigrationReport(); + + expect(detectTsxProject(root)).toBe(false); + expect(migrateTsxToVpx(root, undefined, report).changed).toBe(false); + expect(readJson('package.json').scripts.start).toBe('tsx src/server.ts'); + expect(report.warnings).toEqual([expect.stringContaining('Kept tsx in package.json')]); + }); + + it('keeps the root tsx while a workspace package still uses it', () => { + write('package.json', { private: true, devDependencies: { tsx: '^4.20.0' } }); + write('packages/a/package.json', { scripts: { gen: 'tsx gen.ts' } }); + write('packages/b/package.json', { scripts: { calc: "tsx -e 'run()'" } }); + const packages = [ + { name: 'a', path: 'packages/a' }, + { name: 'b', path: 'packages/b' }, + ]; + const report = createMigrationReport(); + + migrateTsxToVpx(root, packages, report); + + expect(readJson('packages/a/package.json').scripts.gen).toBe('vpx gen.ts'); + expect(readJson('packages/b/package.json').scripts.calc).toBe("tsx -e 'run()'"); + expect(readJson('package.json').devDependencies).toEqual({ tsx: '^4.20.0' }); + expect(report.warnings).toEqual([ + expect.stringContaining('Kept tsx in packages/b/package.json scripts "calc"'), + 'Kept the tsx devDependency in package.json: scripts in packages/b/package.json still run tsx.', + ]); + }); + + it('warns about CommonJS globals in an entry that now runs as an ES module', () => { + write('package.json', { scripts: { build: 'tsx scripts/build.ts' } }); + write('scripts/build.ts', "import fs from 'node:fs';\nfs.readdirSync(__dirname);\n"); + const report = createMigrationReport(); + + migrateTsxToVpx(root, undefined, report); + + expect(report.warnings).toEqual([ + expect.stringContaining('scripts/build.ts uses ES module syntax with __dirname'), + ]); + }); +}); diff --git a/packages/cli/src/migration/bin.ts b/packages/cli/src/migration/bin.ts index d374bb2b52..29cc62b1a2 100644 --- a/packages/cli/src/migration/bin.ts +++ b/packages/cli/src/migration/bin.ts @@ -49,12 +49,14 @@ import { checkViteVersion, collectToolchainVersionChanges, confirmPrettierMigration, + confirmTsxMigration, detectEslintProject, detectFramework, detectNodeVersionManagerFile, detectPendingCoreMigration, detectPrettierProject, detectTsupProject, + detectTsxProject, detectVitePlusBootstrapPending, detectYarnPnpMode, ensureVitePlusBootstrap, @@ -72,6 +74,7 @@ import { migratePrettierToOxfmt, migrateSetupVpVersion, migrateTsupToTsdown, + migrateTsxToVpx, configureYarnNodeModulesMode, rewriteMonorepo, rewriteStandaloneProject, @@ -281,6 +284,7 @@ interface MigrationPlan extends MigrationSetupPlan { prettierConfigFile?: string; migrateTsup: boolean; tsupConfigFile?: string; + migrateTsx: boolean; fixBaseUrl: boolean; migrateNodeVersionFile: boolean; nodeVersionDetection?: NodeVersionManagerDetection; @@ -377,6 +381,7 @@ function hasExistingVitePlusMigrationCandidates( eslintProject.hasDependency || prettierProject.hasDependency || tsupProject.hasDependency || + detectTsxProject(workspaceInfo.rootDir, workspaceInfo.packages) || detectNodeVersionManagerFile(workspaceInfo.rootDir) !== undefined || getFrameworkShimCandidates(workspaceInfo.rootDir, workspaceInfo.packages).length > 0 ); @@ -436,6 +441,10 @@ async function collectMigrationPlan( packages, ); + // 3c. tsx detection + prompt + const migrateTsx = + detectTsxProject(rootDir, packages) && (await confirmTsxMigration(options.interactive)); + // 9. tsconfig baseUrl prompt const fixBaseUrl = hasBaseUrlInWorkspace({ rootDir, packages }) ? await confirmBaseUrlFix(options.interactive) @@ -463,6 +472,7 @@ async function collectMigrationPlan( prettierConfigFile: prettierProject.configFile, migrateTsup, tsupConfigFile, + migrateTsx, fixBaseUrl, migrateNodeVersionFile, nodeVersionDetection, @@ -571,6 +581,9 @@ function showMigrationSummary(options: { if (report.tsupMigrated) { log(`${styleText('gray', '•')} tsup config migrated to tsdown (\`vp pack\`)`); } + if (report.tsxMigrated) { + log(`${styleText('gray', '•')} tsx scripts migrated to \`vpx\``); + } if (report.nodeVersionFileMigrated) { log(`${styleText('gray', '•')} Node version manager file migrated to .node-version`); } @@ -846,6 +859,12 @@ async function executeMigrationPlan( } } + // 6c. tsx → vpx (before main rewrite so rewritten lint-staged tasks get merged) + if (plan.migrateTsx) { + updateMigrationProgress('Migrating tsx scripts'); + migrateTsxToVpx(workspaceInfo.rootDir, workspaceInfo.packages, report); + } + // Earlier setup and tool migrations can abort or change the preflight inputs. // Only now apply Vitest compatibility edits, using the original runner version. vitestV5Plan = applyRefreshedVitestV5Migration(vitestV5Plan); @@ -1373,6 +1392,27 @@ async function main() { } } + if ( + fullSetup && + detectTsxProject(workspaceInfoOptional.rootDir, workspaceInfoOptional.packages) + ) { + // Interactive only: stop any active spinner so it does not animate beneath + // the confirm prompt. + if (options.interactive) { + clearMigrationProgress(); + } + if (await confirmTsxMigration(options.interactive)) { + updateMigrationProgress('Migrating tsx scripts'); + const tsxResult = migrateTsxToVpx( + workspaceInfoOptional.rootDir, + workspaceInfoOptional.packages, + report, + ); + didMigrate ||= tsxResult.changed; + needsInstall ||= tsxResult.dependencyRemoved; + } + } + // Check if node version manager file migration is needed (full setup only) if (fullSetup) { const nodeVersionDetection = detectNodeVersionManagerFile(workspaceInfoOptional.rootDir); diff --git a/packages/cli/src/migration/migrator.ts b/packages/cli/src/migration/migrator.ts index 4433d1d157..c31f63449d 100644 --- a/packages/cli/src/migration/migrator.ts +++ b/packages/cli/src/migration/migrator.ts @@ -2,6 +2,7 @@ export * from './migrator/shared.ts'; export * from './migrator/eslint.ts'; export * from './migrator/prettier.ts'; export * from './migrator/tsup.ts'; +export * from './migrator/tsx.ts'; export * from './migrator/tsconfig.ts'; export * from './migrator/framework-shim.ts'; export * from './migrator/vitest-ecosystem.ts'; diff --git a/packages/cli/src/migration/migrator/README.md b/packages/cli/src/migration/migrator/README.md index b93087cfde..e1c9624115 100644 --- a/packages/cli/src/migration/migrator/README.md +++ b/packages/cli/src/migration/migrator/README.md @@ -34,6 +34,7 @@ Pick the file by what a function _does_, not by where it happens to be called. | `eslint.ts` | ESLint → Oxlint migration, oxlint JS-plugin namespace handling, ESLint prompts/warnings. | | `prettier.ts` | Prettier → Oxfmt migration and its prompts/warnings. | | `tsup.ts` | tsup → tsdown (`vp pack`) migration via `tsdown-migrate` and its prompts/warnings. | +| `tsx.ts` | tsx → `vpx` script migration, tsx dependency retention, and its prompt/warnings. | | `tsconfig.ts` | `tsconfig.json` cleanup and `types` rewriting. | | `framework-shim.ts` | Framework (Vue/Astro) shim detection and injection. | | `git-hooks.ts` | Vite+ hook defaults, project-owned hook preservation, and conservative detection of existing hook tools. | diff --git a/packages/cli/src/migration/migrator/source-scan.ts b/packages/cli/src/migration/migrator/source-scan.ts index 6d01d0b872..1c2e8aca97 100644 --- a/packages/cli/src/migration/migrator/source-scan.ts +++ b/packages/cli/src/migration/migrator/source-scan.ts @@ -196,13 +196,14 @@ const OXLINT_RETENTION_SKIP_DIRS = new Set(['node_modules', '.git', '.hg', '.svn * is a separate package that the migration scans on its own pass, so the root * package must not inherit a browser-mode signal from a sub-package. */ -function sourceTreeMatches( +export function sourceTreeMatches( projectPath: string, - matchesContent: (content: string) => boolean, + matchesContent: (content: string, file: string) => boolean, options: { // Nested examples can resolve the root's dependency through source or // package imports/scripts, even when they are not workspace members. crossPackageBoundaries?: boolean; + extensions?: ReadonlySet; includePackageReferences?: boolean; includeExtensionless?: boolean; matchesPackage?: (projectPath: string, pkg: DependencyBag) => boolean; @@ -210,6 +211,7 @@ function sourceTreeMatches( } = {}, ): boolean { const skipDirs = options.skipDirs ?? VITEST_SCAN_SKIP_DIRS; + const extensions = options.extensions ?? VITEST_SCAN_EXTENSIONS; function scanDir(dir: string, isRoot: boolean): boolean { let entries: fs.Dirent[]; try { @@ -238,7 +240,7 @@ function sourceTreeMatches( } else if ( entry.isFile() && !['.pnp.cjs', '.pnp.loader.mjs'].includes(entry.name) && - (VITEST_SCAN_EXTENSIONS.has(path.extname(entry.name)) || + (extensions.has(path.extname(entry.name)) || (options.includeExtensionless && path.extname(entry.name) === '') || (options.includePackageReferences && entry.name === 'package.json')) ) { @@ -256,7 +258,7 @@ function sourceTreeMatches( } content = JSON.stringify({ imports: pkg.imports, scripts: pkg.scripts }); } - if (matchesContent(content)) { + if (matchesContent(content, entryPath)) { return true; } } catch { diff --git a/packages/cli/src/migration/migrator/tsx.ts b/packages/cli/src/migration/migrator/tsx.ts new file mode 100644 index 0000000000..4f73424a6f --- /dev/null +++ b/packages/cli/src/migration/migrator/tsx.ts @@ -0,0 +1,590 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { styleText } from 'node:util'; + +import * as prompts from '@voidzero-dev/vite-plus-prompts'; + +import { type LiteralCommandToken, parseLiteralCommands } from '../../../binding/index.js'; +import { type WorkspacePackage } from '../../types/index.ts'; +import { editJsonFile, isJsonFile } from '../../utils/json.ts'; +import { displayRelative } from '../../utils/path.ts'; +import { cancelAndExit } from '../../utils/prompts.ts'; +import { + NODE_OPTIONS_WITH_VALUE, + SCRIPT_EXTENSIONS, + WATCH_OPTIONS_WITH_VALUE, +} from '../../vpx-script.ts'; +import { sourceTreeMatches } from '../migrator.ts'; +import { type MigrationReport } from '../report.ts'; +import { + type DependencyBag, + getCommandStartIndex, + isPlainRecord, + LINT_STAGED_JSON_CONFIG_FILES, + readPackageJsonIfExists, + warnMigration, +} from './shared.ts'; + +// tsx in command position: `tsx a.ts`, `npx tsx`, `--import tsx`, `--import=tsx`, +// `-r tsx/cjs`, or inside a quoted command. Not `.tsx` files or packages such as `tsx-foo`. +const TSX_COMMAND = /(?:^|[\s;&|("'=/`])tsx(?:\/(?:esm|cjs))?(?=$|[\s;&|)"'`])/; + +// Node.js options that load tsx's hooks, as in `node --import tsx ./a.ts`. +const LOADER_OPTIONS = new Set([ + '--import', + '--loader', + '--experimental-loader', + '--require', + '-r', +]); +const TSX_LOADER = /^tsx(?:\/(?:esm|cjs))?$/; +const EVAL_OPTIONS = new Set(['-e', '--eval', '-p', '--print', '-pe']); + +const KEEP_EVAL = '`vpx` does not run TypeScript passed to --eval or --print'; +const KEEP_REPL = '`vpx` does not start a TypeScript REPL'; +const KEEP_TEST_GLOBS = + '`--test` without files finds `.ts` tests with tsx globs; list the test files for `vpx --test`'; +const KEEP_WATCH_PATH = '`--watch-path` has no `vpx` equivalent; use `--include`'; +const KEEP_SHELL = + 'the command uses shell syntax that vp migrate does not rewrite, such as pipes, variables, or subshells'; +const KEEP_OTHER = 'vp migrate does not rewrite this use of tsx'; + +// A tsx shebang that `vpx` can take over: `#!/usr/bin/env tsx`, `#!/usr/bin/env -S tsx --tsconfig x.json`. +const TSX_ENV_SHEBANG = /^(#!\s*\S*\benv\s+(?:-S\s+)?)tsx(?=\s|$)/; +// Any first line that runs tsx, including `#!/usr/local/bin/tsx`. +const TSX_SHEBANG = /^#![^\n]*[\s/]tsx(?=\s|$)/; +// In code: an import of tsx or a subpath such as `tsx/esm/api`, or a string that starts a tsx command. +const TSX_CODE_REFERENCE = + /(?:\bfrom|\bimport|\brequire(?:\.resolve)?)\s*\(?\s*['"]tsx(?:\/[^'"]*)?['"]|['"`](?:npx |pnpm (?:exec )?|yarn |bunx )?tsx\s/; +// Elsewhere (CI workflows, git hooks, shell scripts, JSON configs): tsx run with an +// argument on the same line, or loaded into Node.js. +const TSX_TEXT_REFERENCE = + /(?:^|[\s;&|("'=/`])tsx(?:\/(?:esm|cjs))?[ \t]+\S|(?:--import|--loader|--experimental-loader|--require|-r)[= \t]+['"]?tsx(?:\/(?:esm|cjs))?(?![\w./-])/m; +const CODE_EXTENSIONS: ReadonlySet = new Set(SCRIPT_EXTENSIONS); +const REFERENCE_EXTENSIONS: ReadonlySet = new Set([ + ...SCRIPT_EXTENSIONS, + '.json', + '.yml', + '.yaml', + '.sh', +]); +// Lockfiles name tsx without running it. +const LOCKFILES = new Set([ + 'bun.lock', + 'npm-shrinkwrap.json', + 'package-lock.json', + 'pnpm-lock.yaml', + 'yarn.lock', +]); + +// ES module syntax, and the CommonJS globals an ES module does not have. +const ESM_SYNTAX = /^\s*(?:import\s*[\w*{'"]|export\s)|\bimport\.meta\b/m; +const COMMONJS_GLOBALS = /\b(?:__dirname|__filename)\b|\brequire\s*\(/; + +export interface TsxCommandRewrite { + command: string; + /** The scripts the rewritten command runs, as written. */ + entries: string[]; + /** Why tsx stays in part of the command. */ + kept: string[]; +} + +interface Edit { + start: number; + end: number; + text: string; +} + +/** + * Rewrite the tsx invocations in a command to `vpx`: `tsx a.ts` becomes `vpx a.ts`, + * `tsx watch a.ts` becomes `vpx --watch a.ts` (`vpx watch` runs the npm package `watch`), + * and `node --import tsx a.ts` becomes `vpx a.ts`. Package-manager wrappers such as + * `npx tsx` go away with tsx, since `vpx` is a bin of vite-plus. A form `vpx` cannot run + * stays as it is, with the reason in `kept`. + */ +export function rewriteTsxCommand(command: string): TsxCommandRewrite { + const result: TsxCommandRewrite = { command, entries: [], kept: [] }; + if (!TSX_COMMAND.test(command)) { + return result; + } + const segments = parseLiteralCommands(command, true); + if (!segments) { + result.kept.push(KEEP_SHELL); + return result; + } + const edits: Edit[] = []; + for (const tokens of segments) { + rewriteSegment(command, tokens, edits, result); + } + for (const edit of edits.toSorted((a, b) => b.start - a.start)) { + result.command = + result.command.slice(0, edit.start) + edit.text + result.command.slice(edit.end); + } + if (!result.kept.length && TSX_COMMAND.test(result.command)) { + result.kept.push(KEEP_OTHER); + } + return result; +} + +function rewriteSegment( + command: string, + tokens: LiteralCommandToken[], + edits: Edit[], + result: TsxCommandRewrite, +): void { + const values = tokens.map(({ value }) => value); + // Environment assignments and cross-env stay in front of the command. + let first = 0; + const skipAssignments = () => { + while (/^[A-Za-z_]\w*=/.test(values[first] ?? '')) { + first++; + } + }; + skipAssignments(); + if (values[first] === 'cross-env' || values[first] === 'cross-env-shell') { + first++; + skipAssignments(); + } + if (first >= values.length) { + return; + } + const start = first + getCommandStartIndex(values.slice(first)); + if (values[start] === 'tsx') { + rewriteTsx(tokens, first, start, edits, result); + } else if (values[start] === 'node') { + rewriteNodeLoader(tokens, first, start, edits, result); + } else { + // A command another tool runs, given as one quoted argument: `concurrently "tsx watch a.ts"`. + for (const token of tokens.slice(start)) { + rewriteQuotedCommand(command, token, edits, result); + } + } +} + +/** Remove `tokens[index]` with the space before it. */ +function removal(tokens: LiteralCommandToken[], index: number): Edit { + return { start: tokens[index - 1].end, end: tokens[index].end, text: '' }; +} + +function hasScriptExtension(entry: string): boolean { + return SCRIPT_EXTENSIONS.some((extension) => entry.endsWith(extension)); +} + +/** The reason to keep a run of `entry`, or undefined when `vpx` runs it. */ +function entryKeepReason(entry: string | undefined, test: boolean): string | undefined { + if (entry === undefined) { + return test ? KEEP_TEST_GLOBS : KEEP_REPL; + } + // tsx finds `src/index.ts` for `src/index`; `vpx` reads a name without an extension as a package. + if (!test && !hasScriptExtension(entry)) { + return `\`${entry}\` has no file extension, which \`vpx\` needs to run it as a script`; + } + return undefined; +} + +function rewriteTsx( + tokens: LiteralCommandToken[], + first: number, + start: number, + edits: Edit[], + result: TsxCommandRewrite, +): void { + let index = start + 1; + const watch = tokens[index]?.value === 'watch'; + if (watch) { + index++; + } + const local: Edit[] = [ + { start: tokens[first].start, end: tokens[index - 1].end, text: watch ? 'vpx --watch' : 'vpx' }, + ]; + let entry: string | undefined; + let test = false; + for (; index < tokens.length; index++) { + const { value } = tokens[index]; + if (value === '--' || !value.startsWith('-')) { + entry = value === '--' ? tokens[index + 1]?.value : value; + break; + } + const name = value.split('=', 1)[0]; + if (name === '--no-cache') { + // `vpx` has no transform cache, and Node.js would reject the flag. + local.push(removal(tokens, index)); + continue; + } + if (EVAL_OPTIONS.has(name)) { + result.kept.push(KEEP_EVAL); + return; + } + if (name === '-i' || name === '--interactive') { + result.kept.push(KEEP_REPL); + return; + } + if (name === '--watch-path') { + result.kept.push(KEEP_WATCH_PATH); + return; + } + test ||= name === '--test'; + if ( + !value.includes('=') && + (NODE_OPTIONS_WITH_VALUE.has(name) || + WATCH_OPTIONS_WITH_VALUE.has(name) || + name === '--tsconfig') + ) { + index++; + } + } + const reason = entryKeepReason(entry, test); + if (reason) { + result.kept.push(reason); + return; + } + edits.push(...local); + if (!test) { + result.entries.push(entry!); + } +} + +function rewriteNodeLoader( + tokens: LiteralCommandToken[], + first: number, + start: number, + edits: Edit[], + result: TsxCommandRewrite, +): void { + const loaders: Edit[] = []; + let entry: string | undefined; + let evaluates = false; + let test = false; + for (let index = start + 1; index < tokens.length; index++) { + const { value } = tokens[index]; + if (value === '--' || !value.startsWith('-')) { + entry = value === '--' ? tokens[index + 1]?.value : value; + break; + } + const name = value.split('=', 1)[0]; + const inline = value.includes('='); + if (LOADER_OPTIONS.has(name)) { + const loader = inline ? value.slice(name.length + 1) : tokens[index + 1]?.value; + if (loader !== undefined && TSX_LOADER.test(loader)) { + loaders.push({ ...removal(tokens, index), end: tokens[inline ? index : index + 1].end }); + } + if (!inline) { + index++; + } + continue; + } + evaluates ||= EVAL_OPTIONS.has(name); + test ||= name === '--test'; + if (!inline && NODE_OPTIONS_WITH_VALUE.has(name)) { + index++; + } + } + if (!loaders.length) { + return; + } + const reason = evaluates ? KEEP_EVAL : entryKeepReason(entry, test); + if (reason) { + result.kept.push(reason); + return; + } + edits.push({ start: tokens[first].start, end: tokens[start].end, text: 'vpx' }, ...loaders); + if (!test) { + result.entries.push(entry!); + } +} + +function rewriteQuotedCommand( + command: string, + token: LiteralCommandToken, + edits: Edit[], + result: TsxCommandRewrite, +): void { + const raw = command.slice(token.start, token.end); + const quote = raw[0]; + if ((quote !== '"' && quote !== "'") || raw.length < 2 || !raw.endsWith(quote)) { + return; + } + const inner = rewriteTsxCommand(token.value); + result.kept.push(...inner.kept); + if (inner.command === token.value || inner.command.includes(quote)) { + return; + } + result.entries.push(...inner.entries); + edits.push({ start: token.start, end: token.end, text: `${quote}${inner.command}${quote}` }); +} + +/** The commands a package.json runs: its scripts and lint-staged tasks. */ +function packageCommands(pkg: Record): string[] { + const commands: string[] = []; + for (const field of ['scripts', 'lint-staged']) { + const record = pkg[field]; + if (!isPlainRecord(record)) { + continue; + } + for (const value of Object.values(record)) { + for (const command of Array.isArray(value) ? value : [value]) { + if (typeof command === 'string') { + commands.push(command); + } + } + } + } + return commands; +} + +/** + * A package that ships tsx as a dependency or peer dependency keeps it: its published + * code or production scripts can need tsx, and `vpx` comes from a devDependency. + */ +function ownsTsxAtRuntime(pkg: DependencyBag): boolean { + return pkg.dependencies?.tsx !== undefined || pkg.peerDependencies?.tsx !== undefined; +} + +function packageDirs(rootDir: string, packages?: readonly WorkspacePackage[]): string[] { + return [rootDir, ...(packages ?? []).map((pkg) => path.join(rootDir, pkg.path))]; +} + +/** Whether a package.json script or lint-staged task runs tsx in a form `vpx` replaces. */ +export function detectTsxProject(rootDir: string, packages?: readonly WorkspacePackage[]): boolean { + return packageDirs(rootDir, packages).some((dir) => { + const pkg = readPackageJsonIfExists(path.join(dir, 'package.json')); + return ( + pkg !== undefined && + !ownsTsxAtRuntime(pkg) && + packageCommands(pkg).some((command) => rewriteTsxCommand(command).command !== command) + ); + }); +} + +/** Rewrite each command of a scripts or lint-staged record; returns whether one changed. */ +function rewriteCommandRecord( + record: Record, + label: string, + entries: string[], + report?: MigrationReport, +): boolean { + let edited = false; + for (const [key, value] of Object.entries(record)) { + const commands: unknown[] = Array.isArray(value) ? value : [value]; + const rewritten = commands.map((command) => { + if (typeof command !== 'string') { + return command; + } + const result = rewriteTsxCommand(command); + entries.push(...result.entries); + for (const reason of result.kept) { + warnMigration(`Kept tsx in ${label} "${key}": ${reason}.`, report); + } + return result.command; + }); + if (rewritten.some((command, index) => command !== commands[index])) { + record[key] = Array.isArray(value) ? rewritten : rewritten[0]; + edited = true; + } + } + return edited; +} + +/** Point `#!/usr/bin/env tsx` shebangs of a package's script files at `vpx`. */ +function rewriteTsxShebangs(dir: string): string[] { + const files: string[] = []; + // The callback never reports a match, so the walk visits every file. + sourceTreeMatches(dir, (content, file) => { + if (TSX_ENV_SHEBANG.test(content)) { + fs.writeFileSync(file, content.replace(TSX_ENV_SHEBANG, '$1vpx')); + files.push(file); + } + return false; + }); + return files; +} + +/** + * The first file under `dir` that still runs or imports tsx, or undefined. For the + * workspace root, the scan crosses into packages, which can use the root's tsx. + */ +function findTsxReference(dir: string, crossPackageBoundaries: boolean): string | undefined { + let found: string | undefined; + sourceTreeMatches( + dir, + (content, file) => { + if ( + LOCKFILES.has(path.basename(file)) || + !( + TSX_SHEBANG.test(content) || + (CODE_EXTENSIONS.has(path.extname(file)) ? TSX_CODE_REFERENCE : TSX_TEXT_REFERENCE).test( + content, + ) + ) + ) { + return false; + } + found = file; + return true; + }, + { + crossPackageBoundaries, + extensions: REFERENCE_EXTENSIONS, + includeExtensionless: true, + includePackageReferences: true, + matchesPackage: (packageDir, pkg) => { + if (!packageCommands(pkg).some((c) => TSX_COMMAND.test(c))) { + return false; + } + found = path.join(packageDir, 'package.json'); + return true; + }, + }, + ); + return found; +} + +/** + * tsx runs a file with ES module syntax as CommonJS in a package without + * `"type": "module"`; `vpx`, like Node.js, runs it as an ES module, which has no + * `__dirname`, `__filename`, or `require`. Check the scripts the commands now run. + */ +function warnCommonJsEntries(dir: string, entries: readonly string[], report?: MigrationReport) { + const pkg = readPackageJsonIfExists(path.join(dir, 'package.json')) as + | { type?: unknown } + | undefined; + if (pkg?.type === 'module') { + return; + } + for (const entry of new Set(entries)) { + if (!/\.(?:ts|tsx|js|jsx)$/.test(entry)) { + continue; + } + const file = path.resolve(dir, entry); + let source: string; + try { + source = fs.readFileSync(file, 'utf8'); + } catch { + continue; + } + if (ESM_SYNTAX.test(source) && COMMONJS_GLOBALS.test(source)) { + warnMigration( + `${displayRelative(file)} uses ES module syntax with __dirname, __filename, or require() in a package without "type": "module". tsx ran it as CommonJS, and vpx runs it as an ES module, as Node.js does; use import.meta.dirname, import.meta.filename, or createRequire().`, + report, + ); + } + } +} + +/** + * Move tsx scripts to `vpx`: rewrite package.json scripts, lint-staged tasks, and + * `#!/usr/bin/env tsx` shebangs, then remove the tsx devDependency from each package + * where nothing uses tsx anymore. + */ +export function migrateTsxToVpx( + rootDir: string, + packages?: readonly WorkspacePackage[], + report?: MigrationReport, +): { changed: boolean; dependencyRemoved: boolean } { + let changed = false; + const migrated: string[] = []; + for (const dir of packageDirs(rootDir, packages)) { + const packageJsonPath = path.join(dir, 'package.json'); + const pkg = readPackageJsonIfExists(packageJsonPath); + if (!pkg) { + continue; + } + if (ownsTsxAtRuntime(pkg)) { + if (packageCommands(pkg).some((c) => TSX_COMMAND.test(c))) { + warnMigration( + `Kept tsx in ${displayRelative(packageJsonPath)}: it is a dependency or peer dependency there, and \`vpx\` comes from the vite-plus devDependency.`, + report, + ); + } + continue; + } + migrated.push(dir); + const entries: string[] = []; + editJsonFile(packageJsonPath, (json) => { + let edited = false; + for (const field of ['scripts', 'lint-staged']) { + const record = json[field]; + if ( + isPlainRecord(record) && + rewriteCommandRecord( + record, + `${displayRelative(packageJsonPath)} ${field}`, + entries, + report, + ) + ) { + edited = true; + } + } + changed ||= edited; + return edited ? json : undefined; + }); + for (const name of LINT_STAGED_JSON_CONFIG_FILES) { + const file = path.join(dir, name); + if (!fs.existsSync(file) || !isJsonFile(file)) { + continue; + } + editJsonFile(file, (config) => { + const edited = rewriteCommandRecord(config, displayRelative(file), entries, report); + changed ||= edited; + return edited ? config : undefined; + }); + } + for (const file of rewriteTsxShebangs(dir)) { + entries.push(path.relative(dir, file)); + changed = true; + } + warnCommonJsEntries(dir, entries, report); + } + + // Remove the dependency once every package is rewritten: the root's tsx can serve + // any package, so its scan covers the whole workspace. + let dependencyRemoved = false; + for (const dir of migrated) { + const packageJsonPath = path.join(dir, 'package.json'); + if (readPackageJsonIfExists(packageJsonPath)?.devDependencies?.tsx === undefined) { + continue; + } + const reference = findTsxReference(dir, dir === rootDir); + if (reference) { + const user = + path.basename(reference) === 'package.json' + ? `scripts in ${displayRelative(reference)} still run tsx` + : `${displayRelative(reference)} still uses tsx`; + warnMigration( + `Kept the tsx devDependency in ${displayRelative(packageJsonPath)}: ${user}.`, + report, + ); + continue; + } + editJsonFile(packageJsonPath, (json) => { + delete json.devDependencies!.tsx; + return json; + }); + dependencyRemoved = true; + } + if (report && changed) { + report.tsxMigrated = true; + } + return { changed: changed || dependencyRemoved, dependencyRemoved }; +} + +export async function confirmTsxMigration(interactive: boolean): Promise { + if (interactive) { + const confirmed = await prompts.confirm({ + message: + 'Migrate tsx scripts to vpx?\n ' + + styleText( + 'gray', + "vpx is Vite+'s built-in TypeScript script runner: `tsx ` becomes `vpx `, `tsx watch` becomes `vpx --watch`, and the tsx dependency is removed once nothing uses it.", + ), + initialValue: true, + }); + if (prompts.isCancel(confirmed)) { + cancelAndExit(); + } + return confirmed; + } + prompts.log.info('tsx scripts detected. Auto-migrating to vpx...'); + return true; +} diff --git a/packages/cli/src/migration/report.ts b/packages/cli/src/migration/report.ts index eebf554c9f..9893f56790 100644 --- a/packages/cli/src/migration/report.ts +++ b/packages/cli/src/migration/report.ts @@ -25,6 +25,7 @@ export interface MigrationReport { eslintMigrated: boolean; prettierMigrated: boolean; tsupMigrated: boolean; + tsxMigrated: boolean; nodeVersionFileMigrated: boolean; setupVpVersionUpdatedFileCount: number; gitHooksConfigured: boolean; @@ -50,6 +51,7 @@ export function createMigrationReport(): MigrationReport { rewrittenImportErrors: [], eslintMigrated: false, tsupMigrated: false, + tsxMigrated: false, prettierMigrated: false, nodeVersionFileMigrated: false, setupVpVersionUpdatedFileCount: 0, diff --git a/rfcs/vpx-script-execution.md b/rfcs/vpx-script-execution.md index c633457a17..f89c086bf9 100644 --- a/rfcs/vpx-script-execution.md +++ b/rfcs/vpx-script-execution.md @@ -181,6 +181,15 @@ The loader is resolved from the project's `vite-plus` first (`JsExecutor::resolv For a script, the local bin and the global shim resolve the same loader and Node.js; the difference is the local bin's own Node.js startup before it execs the script, about 23–30 ms against the global shim's 5–10 ms ([Performance](#performance)). +## Migration from tsx + +`vp migrate` rewrites tsx commands to `vpx` as an optional migration, like Prettier and tsup: always on a fresh project, and with `--full` on one already using Vite+ (`packages/cli/src/migration/migrator/tsx.ts`; behavior in [Migration Rules](../docs/guide/migrate-rules.md#tsx-to-vpx)). + +- It reads commands with the same literal-command parser as the other script rewrites and finds the script the way `vpx` does, with the shared value-taking option lists. Commands it cannot read, and forms `vpx` cannot run (eval, the REPL, `--test` without files, an entry without an extension), keep tsx with a warning. +- It has no Node.js check of its own: `vpx` needs the `engines.node` range of `vite-plus` (`SUPPORTED_NODE_RANGE` in `script-hooks.ts`, which a unit test keeps equal), and migration already upgrades incompatible runtime pins to that range for Vite+ itself. +- The `tsx` devDependency goes only when no scripts, imports, shebangs, CI workflows, git hooks, or shell scripts use tsx anymore. A package that lists tsx in `dependencies` or `peerDependencies` keeps it, since `vpx` comes from a devDependency. +- An entry that mixes `import` with `__dirname`, `__filename`, or `require()` in a package without `"type": "module"` gets a warning, since `vpx` runs it as an ES module. + ## Native Side oxc-node is vendored the way Rolldown is: @@ -356,7 +365,7 @@ Package mode through the global `vpx` is unchanged. What changes: ## Future Enhancements -- **`vp migrate` support**: rewrite `tsx` invocations in `package.json` scripts to `vpx`, only when the project's Node.js satisfies the script-mode range and for the cases under "Coming from tsx" in the guide, and remove the devDependency. `ts-node` (which type-checks by default), `esno`, and `jiti` need their own rules. +- **`vp migrate` rules for other runners**: `ts-node` (which type-checks by default), `esno`, and `jiti` need their own rules. - **Public loader entry**: `node --import vite-plus/register ./a.ts`, for tools that must start `node` themselves. - **TypeScript eval and REPL**, once oxc-node transforms eval input. - **Opt-in transform cache**, keyed like tsx's. From 2d74237f60ac63ace0c595d59265705995f056a6 Mon Sep 17 00:00:00 2001 From: "MK (fengmk2)" Date: Thu, 22 Oct 2026 02:05:21 +0800 Subject: [PATCH 02/23] fix(migrate): keep tsx where vpx would fail or lose it Address review feedback on the tsx migration: - Read the scripts a command runs, including `--test` files, and keep tsx for a `.cts` file with `import`/`export` and for a file that mixes `import` with `__dirname`, `__filename`, or `require()` in a CommonJS package, instead of rewriting a command that would fail. - Keep tsx for `-c`/`--check`, which `vpx` reads as `--shell-mode`, and normalize `_` in option names as Node.js and `vpx` do. - Rewrite shebang options with the command rules, so `-S tsx --no-cache` becomes `-S vpx`. - Treat tsx in `optionalDependencies` as a runtime dependency. - Detect tsx tasks in lint-staged JSON files. - Before removing tsx, scan nested packages of every package and more shell script extensions. Co-Authored-By: Claude Opus 5.5 --- docs/guide/migrate-rules.md | 4 +- .../cli/src/migration/__tests__/tsx.spec.ts | 173 ++++++-- packages/cli/src/migration/migrator/tsx.ts | 387 ++++++++++-------- rfcs/vpx-script-execution.md | 6 +- 4 files changed, 364 insertions(+), 206 deletions(-) diff --git a/docs/guide/migrate-rules.md b/docs/guide/migrate-rules.md index 69709c6cab..3af0794052 100644 --- a/docs/guide/migrate-rules.md +++ b/docs/guide/migrate-rules.md @@ -289,11 +289,11 @@ When a `package.json` script or lint-staged task runs [tsx](https://tsx.is), mig `tsx watch` becomes `vpx --watch`, never `vpx watch`, which runs the npm package `watch`. Node.js options and tsx watch options keep their order, and `--no-cache` is dropped. `vpx` needs the same Node.js versions as Vite+ itself, and migration already upgrades incompatible runtime pins to them (see [Vitest 5](/guide/vitest-v5)). -Migration keeps tsx, with a warning, for a command `vpx` cannot run: `--eval` or `--print` code, the REPL, `--test` without files, an entry without a file extension, and commands with pipes, variables, or subshells. It also keeps tsx in a package that lists it in `dependencies` or `peerDependencies`, since `vpx` comes from the `vite-plus` devDependency. +Migration keeps tsx, with a warning, for a command `vpx` cannot run: `--eval`, `--print`, or `--check`, the REPL, `--test` without files, an entry without a file extension, and commands with pipes, variables, or subshells. It also reads the scripts a command runs and keeps tsx when `vpx` would fail on them, because tsx compiles their ES module syntax to CommonJS: a `.cts` file with `import` or `export`, and a file that mixes `import` with `__dirname`, `__filename`, or `require()` in a package without `"type": "module"`. A package that lists tsx in `dependencies`, `optionalDependencies`, or `peerDependencies` keeps it too, since `vpx` comes from the `vite-plus` devDependency. Migration removes the `tsx` devDependency only when nothing uses tsx anymore: no kept script, no import of `tsx` or its subpaths such as `tsx/esm/api`, no shebang, and no tsx command in CI workflows, git hooks, or shell scripts. The root's dependency stays while any workspace package still uses tsx. When tsx stays, the warning names a file that still uses it. -A script that uses `import` and `__dirname`, `__filename`, or `require()` in a package without `"type": "module"` gets a warning: tsx ran it as CommonJS, and `vpx` runs it as an ES module. See [Coming from tsx](/guide/vpx#coming-from-tsx) for the other differences. +See [Coming from tsx](/guide/vpx#coming-from-tsx) for the other differences. ## Continuous Integration Rules diff --git a/packages/cli/src/migration/__tests__/tsx.spec.ts b/packages/cli/src/migration/__tests__/tsx.spec.ts index 41fd176db0..c1bfe19775 100644 --- a/packages/cli/src/migration/__tests__/tsx.spec.ts +++ b/packages/cli/src/migration/__tests__/tsx.spec.ts @@ -22,6 +22,7 @@ describe('rewriteTsxCommand', () => { ], ['tsx watch --no-cache src/server.ts', 'vpx --watch src/server.ts'], ['tsx --no-cache ./a.ts', 'vpx ./a.ts'], + ['tsx --no_cache ./a.ts', 'vpx ./a.ts'], ['tsx --watch ./a.ts', 'vpx --watch ./a.ts'], ['tsx --env-file=.env --inspect ./a.ts', 'vpx --env-file=.env --inspect ./a.ts'], ['tsx --env-file .env ./a.ts arg', 'vpx --env-file .env ./a.ts arg'], @@ -50,11 +51,7 @@ describe('rewriteTsxCommand', () => { expect(result.command).toBe(expected); expect(result.kept).toEqual([]); // Running the migration again changes nothing. - expect(rewriteTsxCommand(expected)).toEqual({ - command: expected, - entries: [], - kept: [], - }); + expect(rewriteTsxCommand(expected)).toEqual({ command: expected, kept: [] }); }); it.each([ @@ -64,21 +61,24 @@ describe('rewriteTsxCommand', () => { ['node ./a.js'], ['tsx-foo ./a.ts'], ])('leaves %s alone', (command) => { - expect(rewriteTsxCommand(command)).toEqual({ command, entries: [], kept: [] }); + expect(rewriteTsxCommand(command)).toEqual({ command, kept: [] }); }); it.each([ ["tsx -e 'console.log(1)'", '--eval or --print'], ['tsx --print 1', '--eval or --print'], + ['tsx -c ./a.js', '-c or --check'], ['tsx', 'REPL'], ['tsx watch', 'REPL'], ['tsx --test', 'list the test files'], ['tsx src/index', '`src/index` has no file extension'], ['tsx --watch-path=src ./a.ts', '--watch-path'], + ['tsx --watch_path=src ./a.ts', '--watch-path'], ['tsx ./a.ts | tee log', 'shell syntax'], ['tsx $ENTRY', 'shell syntax'], ['node --import tsx', 'REPL'], ['node --import tsx -e 1', '--eval or --print'], + ['node --import tsx --watch-path src ./a.ts', '--watch-path'], ["NODE_OPTIONS='--import tsx' node ./a.ts", 'does not rewrite this use of tsx'], ['pnpm dlx tsx ./a.ts', 'does not rewrite this use of tsx'], ])('keeps %s', (command, reason) => { @@ -93,11 +93,54 @@ describe('rewriteTsxCommand', () => { expect(result.kept).toEqual([expect.stringContaining('--eval')]); }); - it('reports the scripts the rewritten commands run', () => { - expect( - rewriteTsxCommand('tsx watch --include x.json src/a.ts && node --import tsx ./b.ts').entries, - ).toEqual(['src/a.ts', './b.ts']); - expect(rewriteTsxCommand('tsx --test ./a.test.ts').entries).toEqual([]); + describe('with the scripts it runs', () => { + let dir: string; + + beforeEach(() => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'vp-test-tsx-scripts-')); + fs.writeFileSync(path.join(dir, 'package.json'), '{}\n'); + fs.writeFileSync(path.join(dir, 'esm.cts'), 'export const a = 1;\n'); + fs.writeFileSync(path.join(dir, 'cjs.cts'), 'exports.a = 1;\n'); + fs.writeFileSync( + path.join(dir, 'dirname.ts'), + "import fs from 'node:fs';\nfs.readdirSync(__dirname);\n", + ); + fs.writeFileSync( + path.join(dir, 'plain.ts'), + "import fs from 'node:fs';\nfs.readdirSync('.');\n", + ); + }); + + afterEach(() => { + fs.rmSync(dir, { recursive: true, force: true }); + }); + + it.each([ + ['tsx esm.cts', '`esm.cts` uses import or export'], + ['node --import tsx ./esm.cts', '`./esm.cts` uses import or export'], + ['tsx watch dirname.ts', '`dirname.ts` mixes import with __dirname'], + ['tsx --test plain.ts dirname.ts', '`dirname.ts` mixes import with __dirname'], + ['concurrently "tsx dirname.ts"', '`dirname.ts` mixes import with __dirname'], + ])('keeps %s', (command, reason) => { + expect(rewriteTsxCommand(command, dir)).toEqual({ + command, + kept: [expect.stringContaining(reason)], + }); + }); + + it.each([ + ['tsx cjs.cts', 'vpx cjs.cts'], + ['tsx plain.ts', 'vpx plain.ts'], + ['tsx --test plain.ts', 'vpx --test plain.ts'], + ])('rewrites %s', (command, expected) => { + expect(rewriteTsxCommand(command, dir)).toEqual({ command: expected, kept: [] }); + }); + + it('rewrites a script with __dirname in an ES module package', () => { + fs.writeFileSync(path.join(dir, 'package.json'), '{ "type": "module" }\n'); + // `"type": "module"` already ran it as an ES module under tsx. + expect(rewriteTsxCommand('tsx dirname.ts', dir).command).toBe('vpx dirname.ts'); + }); }); }); @@ -182,12 +225,24 @@ describe('migrateTsxToVpx', () => { 'npx tsx scripts/check.ts\n', '.husky/pre-commit still uses tsx', ], + [ + 'a bash script', + 'tools/release.bash', + 'tsx scripts/release.ts\n', + 'tools/release.bash still uses tsx', + ], [ 'an extensionless shebang file', 'bin/release', '#!/usr/bin/env tsx\nexport {};\n', 'bin/release still uses tsx', ], + [ + 'a nested package', + 'examples/demo/package.json', + '{ "scripts": { "start": "tsx index.ts" } }\n', + 'scripts in examples/demo/package.json still run tsx', + ], ])('keeps the tsx dependency used by %s', (_, file, content, user) => { write('package.json', { scripts: { @@ -207,17 +262,87 @@ describe('migrateTsxToVpx', () => { expect(report.warnings).toContain(`Kept the tsx devDependency in package.json: ${user}.`); }); - it('keeps tsx in a package that depends on it at runtime', () => { - write('package.json', { - scripts: { start: 'tsx src/server.ts' }, - dependencies: { tsx: '^4.20.0' }, + it.each(['dependencies', 'optionalDependencies', 'peerDependencies'])( + 'keeps tsx in a package that lists it in %s', + (field) => { + write('package.json', { + scripts: { start: 'tsx src/server.ts' }, + [field]: { tsx: '^4.20.0' }, + }); + const report = createMigrationReport(); + + expect(detectTsxProject(root)).toBe(false); + expect(migrateTsxToVpx(root, undefined, report).changed).toBe(false); + expect(readJson('package.json').scripts.start).toBe('tsx src/server.ts'); + expect(report.warnings).toEqual([expect.stringContaining('Kept tsx in package.json')]); + }, + ); + + it('keeps the tsx dependency of a workspace package used by its nested example', () => { + write('package.json', { private: true }); + write('packages/a/package.json', { + scripts: { gen: 'tsx gen.ts' }, + devDependencies: { tsx: '^4.20.0' }, }); + write('packages/a/examples/demo/main.ts', "import { tsImport } from 'tsx/esm/api';\n"); + write('packages/a/examples/demo/package.json', { private: true }); const report = createMigrationReport(); + migrateTsxToVpx(root, [{ name: 'a', path: 'packages/a' }], report); + + expect(readJson('packages/a/package.json')).toEqual({ + scripts: { gen: 'vpx gen.ts' }, + devDependencies: { tsx: '^4.20.0' }, + }); + expect(report.warnings).toEqual([ + 'Kept the tsx devDependency in packages/a/package.json: packages/a/examples/demo/main.ts still uses tsx.', + ]); + }); + + it('detects tsx tasks in a lint-staged JSON file', () => { + write('package.json', { scripts: { prepare: 'husky' } }); + write('.lintstagedrc.json', { '*.ts': 'tsx scripts/check.ts' }); + + expect(detectTsxProject(root)).toBe(true); + migrateTsxToVpx(root); + expect(readJson('.lintstagedrc.json')).toEqual({ '*.ts': 'vpx scripts/check.ts' }); expect(detectTsxProject(root)).toBe(false); - expect(migrateTsxToVpx(root, undefined, report).changed).toBe(false); - expect(readJson('package.json').scripts.start).toBe('tsx src/server.ts'); - expect(report.warnings).toEqual([expect.stringContaining('Kept tsx in package.json')]); + }); + + it('rewrites shebang options as it rewrites commands', () => { + write('package.json', { type: 'module' }); + write('scripts/a.ts', '#!/usr/bin/env -S tsx --no-cache --tsconfig cli.json\nexport {};\n'); + write('scripts/b.ts', '#!/usr/bin/env -S tsx -e x\nexport {};\n'); + const report = createMigrationReport(); + + migrateTsxToVpx(root, undefined, report); + + expect(read('scripts/a.ts')).toBe('#!/usr/bin/env -S vpx --tsconfig cli.json\nexport {};\n'); + expect(read('scripts/b.ts')).toBe('#!/usr/bin/env -S tsx -e x\nexport {};\n'); + expect(report.warnings).toEqual([ + expect.stringContaining('Kept tsx in the shebang of scripts/b.ts: `vpx` does not run'), + ]); + }); + + it('keeps tsx for a script that would lose CommonJS globals under vpx', () => { + write('package.json', { scripts: { build: 'tsx scripts/build.ts' } }); + write('scripts/build.ts', "import fs from 'node:fs';\nfs.readdirSync(__dirname);\n"); + write('scripts/release.ts', '#!/usr/bin/env tsx\nimport fs from "node:fs";\nrequire("x");\n'); + const report = createMigrationReport(); + + expect(detectTsxProject(root)).toBe(false); + migrateTsxToVpx(root, undefined, report); + + expect(readJson('package.json').scripts.build).toBe('tsx scripts/build.ts'); + expect(read('scripts/release.ts')).toMatch(/^#!\/usr\/bin\/env tsx\n/); + expect(report.warnings).toEqual([ + expect.stringContaining( + 'Kept tsx in package.json scripts "build": `scripts/build.ts` mixes import with __dirname', + ), + expect.stringContaining( + 'Kept tsx in the shebang of scripts/release.ts: `./release.ts` mixes import with __dirname', + ), + ]); }); it('keeps the root tsx while a workspace package still uses it', () => { @@ -240,16 +365,4 @@ describe('migrateTsxToVpx', () => { 'Kept the tsx devDependency in package.json: scripts in packages/b/package.json still run tsx.', ]); }); - - it('warns about CommonJS globals in an entry that now runs as an ES module', () => { - write('package.json', { scripts: { build: 'tsx scripts/build.ts' } }); - write('scripts/build.ts', "import fs from 'node:fs';\nfs.readdirSync(__dirname);\n"); - const report = createMigrationReport(); - - migrateTsxToVpx(root, undefined, report); - - expect(report.warnings).toEqual([ - expect.stringContaining('scripts/build.ts uses ES module syntax with __dirname'), - ]); - }); }); diff --git a/packages/cli/src/migration/migrator/tsx.ts b/packages/cli/src/migration/migrator/tsx.ts index 4f73424a6f..bb22a6d9c9 100644 --- a/packages/cli/src/migration/migrator/tsx.ts +++ b/packages/cli/src/migration/migrator/tsx.ts @@ -6,7 +6,8 @@ import * as prompts from '@voidzero-dev/vite-plus-prompts'; import { type LiteralCommandToken, parseLiteralCommands } from '../../../binding/index.js'; import { type WorkspacePackage } from '../../types/index.ts'; -import { editJsonFile, isJsonFile } from '../../utils/json.ts'; +import { editJsonFile, isJsonFile, readJsonFile } from '../../utils/json.ts'; +import { readNearestPackageJson } from '../../utils/package.ts'; import { displayRelative } from '../../utils/path.ts'; import { cancelAndExit } from '../../utils/prompts.ts'; import { @@ -38,9 +39,9 @@ const LOADER_OPTIONS = new Set([ '-r', ]); const TSX_LOADER = /^tsx(?:\/(?:esm|cjs))?$/; -const EVAL_OPTIONS = new Set(['-e', '--eval', '-p', '--print', '-pe']); const KEEP_EVAL = '`vpx` does not run TypeScript passed to --eval or --print'; +const KEEP_CHECK = '`vpx` does not transform TypeScript for -c or --check'; const KEEP_REPL = '`vpx` does not start a TypeScript REPL'; const KEEP_TEST_GLOBS = '`--test` without files finds `.ts` tests with tsx globs; list the test files for `vpx --test`'; @@ -49,8 +50,25 @@ const KEEP_SHELL = 'the command uses shell syntax that vp migrate does not rewrite, such as pipes, variables, or subshells'; const KEEP_OTHER = 'vp migrate does not rewrite this use of tsx'; -// A tsx shebang that `vpx` can take over: `#!/usr/bin/env tsx`, `#!/usr/bin/env -S tsx --tsconfig x.json`. -const TSX_ENV_SHEBANG = /^(#!\s*\S*\benv\s+(?:-S\s+)?)tsx(?=\s|$)/; +// Options that `vpx` does not run as tsx does. `-c` is Node.js' `--check`, but `vpx -c` +// is `--shell-mode`. +const KEEP_OPTIONS = new Map([ + ['-e', KEEP_EVAL], + ['--eval', KEEP_EVAL], + ['-p', KEEP_EVAL], + ['--print', KEEP_EVAL], + ['-pe', KEEP_EVAL], + ['-c', KEEP_CHECK], + ['--check', KEEP_CHECK], + ['-i', KEEP_REPL], + ['--interactive', KEEP_REPL], + ['--watch-path', KEEP_WATCH_PATH], +]); + +// A tsx shebang `vpx` can take over: `#!/usr/bin/env tsx`, `#!/usr/bin/env -S tsx --tsconfig x.json`. +const TSX_ENV_SHEBANG = /^(#!\s*\S*\benv\s+(?:-S\s+)?)(tsx(?=[ \t\r\n]|$)[^\r\n]*)/; +// Stands for the file that the kernel appends to a shebang's command. +const SHEBANG_ENTRY = './shebang.ts'; // Any first line that runs tsx, including `#!/usr/local/bin/tsx`. const TSX_SHEBANG = /^#![^\n]*[\s/]tsx(?=\s|$)/; // In code: an import of tsx or a subpath such as `tsx/esm/api`, or a string that starts a tsx command. @@ -67,6 +85,12 @@ const REFERENCE_EXTENSIONS: ReadonlySet = new Set([ '.yml', '.yaml', '.sh', + '.bash', + '.zsh', + '.fish', + '.ps1', + '.cmd', + '.bat', ]); // Lockfiles name tsx without running it. const LOCKFILES = new Set([ @@ -83,8 +107,6 @@ const COMMONJS_GLOBALS = /\b(?:__dirname|__filename)\b|\brequire\s*\(/; export interface TsxCommandRewrite { command: string; - /** The scripts the rewritten command runs, as written. */ - entries: string[]; /** Why tsx stays in part of the command. */ kept: string[]; } @@ -95,43 +117,44 @@ interface Edit { text: string; } +interface Rewrite { + command: string; + cwd?: string; + edits: Edit[]; + kept: string[]; +} + /** * Rewrite the tsx invocations in a command to `vpx`: `tsx a.ts` becomes `vpx a.ts`, * `tsx watch a.ts` becomes `vpx --watch a.ts` (`vpx watch` runs the npm package `watch`), * and `node --import tsx a.ts` becomes `vpx a.ts`. Package-manager wrappers such as * `npx tsx` go away with tsx, since `vpx` is a bin of vite-plus. A form `vpx` cannot run - * stays as it is, with the reason in `kept`. + * stays as it is, with the reason in `kept`. With `cwd`, the directory the command runs + * in, the scripts it runs are checked for syntax `vpx` runs differently. */ -export function rewriteTsxCommand(command: string): TsxCommandRewrite { - const result: TsxCommandRewrite = { command, entries: [], kept: [] }; +export function rewriteTsxCommand(command: string, cwd?: string): TsxCommandRewrite { if (!TSX_COMMAND.test(command)) { - return result; + return { command, kept: [] }; } const segments = parseLiteralCommands(command, true); if (!segments) { - result.kept.push(KEEP_SHELL); - return result; + return { command, kept: [KEEP_SHELL] }; } - const edits: Edit[] = []; + const rewrite: Rewrite = { command, cwd, edits: [], kept: [] }; for (const tokens of segments) { - rewriteSegment(command, tokens, edits, result); + rewriteSegment(rewrite, tokens); } - for (const edit of edits.toSorted((a, b) => b.start - a.start)) { - result.command = - result.command.slice(0, edit.start) + edit.text + result.command.slice(edit.end); + let rewritten = command; + for (const edit of rewrite.edits.toSorted((a, b) => b.start - a.start)) { + rewritten = rewritten.slice(0, edit.start) + edit.text + rewritten.slice(edit.end); } - if (!result.kept.length && TSX_COMMAND.test(result.command)) { - result.kept.push(KEEP_OTHER); + if (!rewrite.kept.length && TSX_COMMAND.test(rewritten)) { + rewrite.kept.push(KEEP_OTHER); } - return result; + return { command: rewritten, kept: rewrite.kept }; } -function rewriteSegment( - command: string, - tokens: LiteralCommandToken[], - edits: Edit[], - result: TsxCommandRewrite, -): void { +function rewriteSegment(rewrite: Rewrite, tokens: LiteralCommandToken[]): void { const values = tokens.map(({ value }) => value); // Environment assignments and cross-env stay in front of the command. let first = 0; @@ -150,77 +173,120 @@ function rewriteSegment( } const start = first + getCommandStartIndex(values.slice(first)); if (values[start] === 'tsx') { - rewriteTsx(tokens, first, start, edits, result); + rewriteTsx(rewrite, tokens, first, start); } else if (values[start] === 'node') { - rewriteNodeLoader(tokens, first, start, edits, result); + rewriteNodeLoader(rewrite, tokens, first, start); } else { // A command another tool runs, given as one quoted argument: `concurrently "tsx watch a.ts"`. for (const token of tokens.slice(start)) { - rewriteQuotedCommand(command, token, edits, result); + rewriteQuotedCommand(rewrite, token); } } } +/** + * An option's name without `=value`. Node.js reads `_` as `-` in long option names, as + * in `--watch_path`, and so does `vpx`. + */ +function optionName(arg: string): string { + const name = arg.split('=', 1)[0]; + return name.startsWith('--') ? `--${name.slice(2).replaceAll('_', '-')}` : name; +} + /** Remove `tokens[index]` with the space before it. */ function removal(tokens: LiteralCommandToken[], index: number): Edit { return { start: tokens[index - 1].end, end: tokens[index].end, text: '' }; } -function hasScriptExtension(entry: string): boolean { - return SCRIPT_EXTENSIONS.some((extension) => entry.endsWith(extension)); +/** + * Why `vpx` would fail on a script that tsx runs, judged from its source. tsx compiles ES + * module syntax to CommonJS in a `.cts` file and in a package without `"type": "module"`. + * `vpx` rejects it in a `.cts` file and runs the other files as ES modules, as Node.js + * does, so `__dirname`, `__filename`, and `require` are not defined. + */ +function scriptKeepReason(cwd: string | undefined, script: string): string | undefined { + if (cwd === undefined || !/\.(?:c?ts|tsx|jsx?)$/.test(script)) { + return undefined; + } + const file = path.resolve(cwd, script); + let source: string; + try { + source = fs.readFileSync(file, 'utf8'); + } catch { + return undefined; + } + if (!ESM_SYNTAX.test(source)) { + return undefined; + } + if (script.endsWith('.cts')) { + return `\`${script}\` uses import or export, which \`vpx\` does not compile to CommonJS; rename it to .mts`; + } + if ( + COMMONJS_GLOBALS.test(source) && + readNearestPackageJson(path.dirname(file))?.type !== 'module' + ) { + return `\`${script}\` mixes import with __dirname, __filename, or require(), and \`vpx\` runs it as an ES module; use import.meta.dirname, import.meta.filename, or createRequire()`; + } + return undefined; } -/** The reason to keep a run of `entry`, or undefined when `vpx` runs it. */ -function entryKeepReason(entry: string | undefined, test: boolean): string | undefined { +/** + * Why `vpx` cannot run what tsx or `node --import tsx` runs: `positionals` are the entry + * and its arguments, or with `--test`, the test files. + */ +function runKeepReason( + cwd: string | undefined, + test: boolean, + positionals: readonly string[], +): string | undefined { + const [entry] = positionals; if (entry === undefined) { return test ? KEEP_TEST_GLOBS : KEEP_REPL; } // tsx finds `src/index.ts` for `src/index`; `vpx` reads a name without an extension as a package. - if (!test && !hasScriptExtension(entry)) { + if (!test && !SCRIPT_EXTENSIONS.some((extension) => entry.endsWith(extension))) { return `\`${entry}\` has no file extension, which \`vpx\` needs to run it as a script`; } + for (const script of test ? positionals.filter((value) => !value.startsWith('-')) : [entry]) { + const reason = scriptKeepReason(cwd, script); + if (reason) { + return reason; + } + } return undefined; } function rewriteTsx( + rewrite: Rewrite, tokens: LiteralCommandToken[], first: number, start: number, - edits: Edit[], - result: TsxCommandRewrite, ): void { let index = start + 1; const watch = tokens[index]?.value === 'watch'; if (watch) { index++; } - const local: Edit[] = [ + const edits: Edit[] = [ { start: tokens[first].start, end: tokens[index - 1].end, text: watch ? 'vpx --watch' : 'vpx' }, ]; - let entry: string | undefined; + let entryIndex = tokens.length; let test = false; for (; index < tokens.length; index++) { const { value } = tokens[index]; if (value === '--' || !value.startsWith('-')) { - entry = value === '--' ? tokens[index + 1]?.value : value; + entryIndex = value === '--' ? index + 1 : index; break; } - const name = value.split('=', 1)[0]; + const name = optionName(value); if (name === '--no-cache') { // `vpx` has no transform cache, and Node.js would reject the flag. - local.push(removal(tokens, index)); + edits.push(removal(tokens, index)); continue; } - if (EVAL_OPTIONS.has(name)) { - result.kept.push(KEEP_EVAL); - return; - } - if (name === '-i' || name === '--interactive') { - result.kept.push(KEEP_REPL); - return; - } - if (name === '--watch-path') { - result.kept.push(KEEP_WATCH_PATH); + const keep = KEEP_OPTIONS.get(name); + if (keep) { + rewrite.kept.push(keep); return; } test ||= name === '--test'; @@ -233,38 +299,38 @@ function rewriteTsx( index++; } } - const reason = entryKeepReason(entry, test); + const reason = runKeepReason( + rewrite.cwd, + test, + tokens.slice(entryIndex).map(({ value }) => value), + ); if (reason) { - result.kept.push(reason); + rewrite.kept.push(reason); return; } - edits.push(...local); - if (!test) { - result.entries.push(entry!); - } + rewrite.edits.push(...edits); } function rewriteNodeLoader( + rewrite: Rewrite, tokens: LiteralCommandToken[], first: number, start: number, - edits: Edit[], - result: TsxCommandRewrite, ): void { const loaders: Edit[] = []; - let entry: string | undefined; - let evaluates = false; + let entryIndex = tokens.length; + let optionReason: string | undefined; let test = false; for (let index = start + 1; index < tokens.length; index++) { const { value } = tokens[index]; if (value === '--' || !value.startsWith('-')) { - entry = value === '--' ? tokens[index + 1]?.value : value; + entryIndex = value === '--' ? index + 1 : index; break; } - const name = value.split('=', 1)[0]; + const name = optionName(value); const inline = value.includes('='); if (LOADER_OPTIONS.has(name)) { - const loader = inline ? value.slice(name.length + 1) : tokens[index + 1]?.value; + const loader = inline ? value.slice(value.indexOf('=') + 1) : tokens[index + 1]?.value; if (loader !== undefined && TSX_LOADER.test(loader)) { loaders.push({ ...removal(tokens, index), end: tokens[inline ? index : index + 1].end }); } @@ -273,7 +339,7 @@ function rewriteNodeLoader( } continue; } - evaluates ||= EVAL_OPTIONS.has(name); + optionReason ??= KEEP_OPTIONS.get(name); test ||= name === '--test'; if (!inline && NODE_OPTIONS_WITH_VALUE.has(name)) { index++; @@ -282,77 +348,89 @@ function rewriteNodeLoader( if (!loaders.length) { return; } - const reason = evaluates ? KEEP_EVAL : entryKeepReason(entry, test); + const reason = + optionReason ?? + runKeepReason( + rewrite.cwd, + test, + tokens.slice(entryIndex).map(({ value }) => value), + ); if (reason) { - result.kept.push(reason); + rewrite.kept.push(reason); return; } - edits.push({ start: tokens[first].start, end: tokens[start].end, text: 'vpx' }, ...loaders); - if (!test) { - result.entries.push(entry!); - } + rewrite.edits.push( + { start: tokens[first].start, end: tokens[start].end, text: 'vpx' }, + ...loaders, + ); } -function rewriteQuotedCommand( - command: string, - token: LiteralCommandToken, - edits: Edit[], - result: TsxCommandRewrite, -): void { - const raw = command.slice(token.start, token.end); +function rewriteQuotedCommand(rewrite: Rewrite, token: LiteralCommandToken): void { + const raw = rewrite.command.slice(token.start, token.end); const quote = raw[0]; if ((quote !== '"' && quote !== "'") || raw.length < 2 || !raw.endsWith(quote)) { return; } - const inner = rewriteTsxCommand(token.value); - result.kept.push(...inner.kept); - if (inner.command === token.value || inner.command.includes(quote)) { - return; + const inner = rewriteTsxCommand(token.value, rewrite.cwd); + rewrite.kept.push(...inner.kept); + if (inner.command !== token.value && !inner.command.includes(quote)) { + rewrite.edits.push({ + start: token.start, + end: token.end, + text: `${quote}${inner.command}${quote}`, + }); } - result.entries.push(...inner.entries); - edits.push({ start: token.start, end: token.end, text: `${quote}${inner.command}${quote}` }); +} + +/** The commands in a scripts or lint-staged record. */ +function recordCommands(record: unknown): string[] { + if (!isPlainRecord(record)) { + return []; + } + return Object.values(record) + .flatMap((value) => (Array.isArray(value) ? value : [value])) + .filter((command): command is string => typeof command === 'string'); } /** The commands a package.json runs: its scripts and lint-staged tasks. */ function packageCommands(pkg: Record): string[] { - const commands: string[] = []; - for (const field of ['scripts', 'lint-staged']) { - const record = pkg[field]; - if (!isPlainRecord(record)) { - continue; - } - for (const value of Object.values(record)) { - for (const command of Array.isArray(value) ? value : [value]) { - if (typeof command === 'string') { - commands.push(command); - } - } - } - } - return commands; + return [...recordCommands(pkg.scripts), ...recordCommands(pkg['lint-staged'])]; +} + +function lintStagedJsonFiles(dir: string): string[] { + return LINT_STAGED_JSON_CONFIG_FILES.map((name) => path.join(dir, name)).filter( + (file) => fs.existsSync(file) && isJsonFile(file), + ); } /** - * A package that ships tsx as a dependency or peer dependency keeps it: its published - * code or production scripts can need tsx, and `vpx` comes from a devDependency. + * A package that lists tsx outside devDependencies keeps it: its published code or + * production scripts can need tsx, and `vpx` comes from the vite-plus devDependency. */ function ownsTsxAtRuntime(pkg: DependencyBag): boolean { - return pkg.dependencies?.tsx !== undefined || pkg.peerDependencies?.tsx !== undefined; + return ( + pkg.dependencies?.tsx !== undefined || + pkg.optionalDependencies?.tsx !== undefined || + pkg.peerDependencies?.tsx !== undefined + ); } function packageDirs(rootDir: string, packages?: readonly WorkspacePackage[]): string[] { return [rootDir, ...(packages ?? []).map((pkg) => path.join(rootDir, pkg.path))]; } -/** Whether a package.json script or lint-staged task runs tsx in a form `vpx` replaces. */ +/** Whether a script or lint-staged task runs tsx in a form `vpx` replaces. */ export function detectTsxProject(rootDir: string, packages?: readonly WorkspacePackage[]): boolean { return packageDirs(rootDir, packages).some((dir) => { const pkg = readPackageJsonIfExists(path.join(dir, 'package.json')); - return ( - pkg !== undefined && - !ownsTsxAtRuntime(pkg) && - packageCommands(pkg).some((command) => rewriteTsxCommand(command).command !== command) - ); + if (!pkg || ownsTsxAtRuntime(pkg)) { + return false; + } + const commands = [ + ...packageCommands(pkg), + ...lintStagedJsonFiles(dir).flatMap((file) => recordCommands(readJsonFile(file))), + ]; + return commands.some((command) => rewriteTsxCommand(command, dir).command !== command); }); } @@ -360,7 +438,7 @@ export function detectTsxProject(rootDir: string, packages?: readonly WorkspaceP function rewriteCommandRecord( record: Record, label: string, - entries: string[], + cwd: string, report?: MigrationReport, ): boolean { let edited = false; @@ -370,8 +448,7 @@ function rewriteCommandRecord( if (typeof command !== 'string') { return command; } - const result = rewriteTsxCommand(command); - entries.push(...result.entries); + const result = rewriteTsxCommand(command, cwd); for (const reason of result.kept) { warnMigration(`Kept tsx in ${label} "${key}": ${reason}.`, report); } @@ -386,24 +463,36 @@ function rewriteCommandRecord( } /** Point `#!/usr/bin/env tsx` shebangs of a package's script files at `vpx`. */ -function rewriteTsxShebangs(dir: string): string[] { - const files: string[] = []; +function rewriteTsxShebangs(dir: string, report?: MigrationReport): boolean { + let changed = false; // The callback never reports a match, so the walk visits every file. sourceTreeMatches(dir, (content, file) => { - if (TSX_ENV_SHEBANG.test(content)) { - fs.writeFileSync(file, content.replace(TSX_ENV_SHEBANG, '$1vpx')); - files.push(file); + const match = TSX_ENV_SHEBANG.exec(content); + if (!match) { + return false; + } + const [line, prefix, command] = match; + // The kernel runs the shebang's command with the file appended. + const result = rewriteTsxCommand(`${command.trimEnd()} ${SHEBANG_ENTRY}`); + const reason = + result.kept[0] ?? scriptKeepReason(path.dirname(file), `./${path.basename(file)}`); + if (reason) { + warnMigration(`Kept tsx in the shebang of ${displayRelative(file)}: ${reason}.`, report); + return false; } + const vpxCommand = result.command.slice(0, -` ${SHEBANG_ENTRY}`.length); + fs.writeFileSync(file, `${prefix}${vpxCommand}${content.slice(line.length)}`); + changed = true; return false; }); - return files; + return changed; } /** - * The first file under `dir` that still runs or imports tsx, or undefined. For the - * workspace root, the scan crosses into packages, which can use the root's tsx. + * The first file under `dir` that still runs or imports tsx, or undefined. The scan + * crosses into nested packages, which can resolve this package's tsx. */ -function findTsxReference(dir: string, crossPackageBoundaries: boolean): string | undefined { +function findTsxReference(dir: string): string | undefined { let found: string | undefined; sourceTreeMatches( dir, @@ -423,7 +512,7 @@ function findTsxReference(dir: string, crossPackageBoundaries: boolean): string return true; }, { - crossPackageBoundaries, + crossPackageBoundaries: true, extensions: REFERENCE_EXTENSIONS, includeExtensionless: true, includePackageReferences: true, @@ -439,38 +528,6 @@ function findTsxReference(dir: string, crossPackageBoundaries: boolean): string return found; } -/** - * tsx runs a file with ES module syntax as CommonJS in a package without - * `"type": "module"`; `vpx`, like Node.js, runs it as an ES module, which has no - * `__dirname`, `__filename`, or `require`. Check the scripts the commands now run. - */ -function warnCommonJsEntries(dir: string, entries: readonly string[], report?: MigrationReport) { - const pkg = readPackageJsonIfExists(path.join(dir, 'package.json')) as - | { type?: unknown } - | undefined; - if (pkg?.type === 'module') { - return; - } - for (const entry of new Set(entries)) { - if (!/\.(?:ts|tsx|js|jsx)$/.test(entry)) { - continue; - } - const file = path.resolve(dir, entry); - let source: string; - try { - source = fs.readFileSync(file, 'utf8'); - } catch { - continue; - } - if (ESM_SYNTAX.test(source) && COMMONJS_GLOBALS.test(source)) { - warnMigration( - `${displayRelative(file)} uses ES module syntax with __dirname, __filename, or require() in a package without "type": "module". tsx ran it as CommonJS, and vpx runs it as an ES module, as Node.js does; use import.meta.dirname, import.meta.filename, or createRequire().`, - report, - ); - } - } -} - /** * Move tsx scripts to `vpx`: rewrite package.json scripts, lint-staged tasks, and * `#!/usr/bin/env tsx` shebangs, then remove the tsx devDependency from each package @@ -492,26 +549,20 @@ export function migrateTsxToVpx( if (ownsTsxAtRuntime(pkg)) { if (packageCommands(pkg).some((c) => TSX_COMMAND.test(c))) { warnMigration( - `Kept tsx in ${displayRelative(packageJsonPath)}: it is a dependency or peer dependency there, and \`vpx\` comes from the vite-plus devDependency.`, + `Kept tsx in ${displayRelative(packageJsonPath)}: it is listed in dependencies, optionalDependencies, or peerDependencies there, and \`vpx\` comes from the vite-plus devDependency.`, report, ); } continue; } migrated.push(dir); - const entries: string[] = []; editJsonFile(packageJsonPath, (json) => { let edited = false; for (const field of ['scripts', 'lint-staged']) { const record = json[field]; if ( isPlainRecord(record) && - rewriteCommandRecord( - record, - `${displayRelative(packageJsonPath)} ${field}`, - entries, - report, - ) + rewriteCommandRecord(record, `${displayRelative(packageJsonPath)} ${field}`, dir, report) ) { edited = true; } @@ -519,22 +570,16 @@ export function migrateTsxToVpx( changed ||= edited; return edited ? json : undefined; }); - for (const name of LINT_STAGED_JSON_CONFIG_FILES) { - const file = path.join(dir, name); - if (!fs.existsSync(file) || !isJsonFile(file)) { - continue; - } + for (const file of lintStagedJsonFiles(dir)) { editJsonFile(file, (config) => { - const edited = rewriteCommandRecord(config, displayRelative(file), entries, report); + const edited = rewriteCommandRecord(config, displayRelative(file), dir, report); changed ||= edited; return edited ? config : undefined; }); } - for (const file of rewriteTsxShebangs(dir)) { - entries.push(path.relative(dir, file)); + if (rewriteTsxShebangs(dir, report)) { changed = true; } - warnCommonJsEntries(dir, entries, report); } // Remove the dependency once every package is rewritten: the root's tsx can serve @@ -545,7 +590,7 @@ export function migrateTsxToVpx( if (readPackageJsonIfExists(packageJsonPath)?.devDependencies?.tsx === undefined) { continue; } - const reference = findTsxReference(dir, dir === rootDir); + const reference = findTsxReference(dir); if (reference) { const user = path.basename(reference) === 'package.json' diff --git a/rfcs/vpx-script-execution.md b/rfcs/vpx-script-execution.md index f89c086bf9..3841419ba7 100644 --- a/rfcs/vpx-script-execution.md +++ b/rfcs/vpx-script-execution.md @@ -185,10 +185,10 @@ For a script, the local bin and the global shim resolve the same loader and Node `vp migrate` rewrites tsx commands to `vpx` as an optional migration, like Prettier and tsup: always on a fresh project, and with `--full` on one already using Vite+ (`packages/cli/src/migration/migrator/tsx.ts`; behavior in [Migration Rules](../docs/guide/migrate-rules.md#tsx-to-vpx)). -- It reads commands with the same literal-command parser as the other script rewrites and finds the script the way `vpx` does, with the shared value-taking option lists. Commands it cannot read, and forms `vpx` cannot run (eval, the REPL, `--test` without files, an entry without an extension), keep tsx with a warning. +- It reads commands with the same literal-command parser as the other script rewrites and finds the script the way `vpx` does, with the shared value-taking option lists. Commands it cannot read, and forms `vpx` cannot run (eval, `--check`, the REPL, `--test` without files, an entry without an extension), keep tsx with a warning. - It has no Node.js check of its own: `vpx` needs the `engines.node` range of `vite-plus` (`SUPPORTED_NODE_RANGE` in `script-hooks.ts`, which a unit test keeps equal), and migration already upgrades incompatible runtime pins to that range for Vite+ itself. -- The `tsx` devDependency goes only when no scripts, imports, shebangs, CI workflows, git hooks, or shell scripts use tsx anymore. A package that lists tsx in `dependencies` or `peerDependencies` keeps it, since `vpx` comes from a devDependency. -- An entry that mixes `import` with `__dirname`, `__filename`, or `require()` in a package without `"type": "module"` gets a warning, since `vpx` runs it as an ES module. +- The `tsx` devDependency goes only when no scripts, imports, shebangs, CI workflows, git hooks, or shell scripts use tsx anymore, including in nested packages that can resolve it. A package that lists tsx in `dependencies`, `optionalDependencies`, or `peerDependencies` keeps it, since `vpx` comes from a devDependency. +- It reads the scripts a command runs and keeps tsx when `vpx` would fail on them: a `.cts` file with `import` or `export`, and a file that mixes `import` with `__dirname`, `__filename`, or `require()` in a package without `"type": "module"`. tsx compiles both to CommonJS; `vpx` rejects the first and runs the second as an ES module. ## Native Side From c7407e059af088e17508a53835c65c3b697ee7dc Mon Sep 17 00:00:00 2001 From: "MK (fengmk2)" Date: Thu, 22 Oct 2026 02:19:51 +0800 Subject: [PATCH 03/23] fix(migrate): follow cd and find more tsx uses before removing it Address the second round of review feedback on the tsx migration: - Follow `cd ` between command segments when checking the scripts a command runs, and keep tsx after `pushd`, `popd`, `source`, `.`, or `eval`, where the directory cannot be known. - Treat a `package.json#imports` target in tsx as a use. - Scan build output for tsx uses before removing it, sharing the oxlint retention scan's skip list (installed packages and VCS metadata only). Co-Authored-By: Claude Opus 5.5 --- docs/guide/migrate-rules.md | 2 +- .../cli/src/migration/__tests__/tsx.spec.ts | 31 ++++++++++ packages/cli/src/migration/migrator/shared.ts | 9 +++ .../cli/src/migration/migrator/source-scan.ts | 7 +-- packages/cli/src/migration/migrator/tsx.ts | 57 +++++++++++++------ rfcs/vpx-script-execution.md | 2 +- 6 files changed, 83 insertions(+), 25 deletions(-) diff --git a/docs/guide/migrate-rules.md b/docs/guide/migrate-rules.md index 3af0794052..d6e6a4f7a3 100644 --- a/docs/guide/migrate-rules.md +++ b/docs/guide/migrate-rules.md @@ -291,7 +291,7 @@ When a `package.json` script or lint-staged task runs [tsx](https://tsx.is), mig Migration keeps tsx, with a warning, for a command `vpx` cannot run: `--eval`, `--print`, or `--check`, the REPL, `--test` without files, an entry without a file extension, and commands with pipes, variables, or subshells. It also reads the scripts a command runs and keeps tsx when `vpx` would fail on them, because tsx compiles their ES module syntax to CommonJS: a `.cts` file with `import` or `export`, and a file that mixes `import` with `__dirname`, `__filename`, or `require()` in a package without `"type": "module"`. A package that lists tsx in `dependencies`, `optionalDependencies`, or `peerDependencies` keeps it too, since `vpx` comes from the `vite-plus` devDependency. -Migration removes the `tsx` devDependency only when nothing uses tsx anymore: no kept script, no import of `tsx` or its subpaths such as `tsx/esm/api`, no shebang, and no tsx command in CI workflows, git hooks, or shell scripts. The root's dependency stays while any workspace package still uses tsx. When tsx stays, the warning names a file that still uses it. +Migration removes the `tsx` devDependency only when nothing uses tsx anymore: no kept script, no import of `tsx` or its subpaths such as `tsx/esm/api` (in source, build output, or a `package.json` `imports` alias), no shebang, and no tsx command in CI workflows, git hooks, or shell scripts. The root's dependency stays while any workspace package still uses tsx, and a package's dependency stays while a nested package under it does. When tsx stays, the warning names what still uses it. See [Coming from tsx](/guide/vpx#coming-from-tsx) for the other differences. diff --git a/packages/cli/src/migration/__tests__/tsx.spec.ts b/packages/cli/src/migration/__tests__/tsx.spec.ts index c1bfe19775..dbed6a29a1 100644 --- a/packages/cli/src/migration/__tests__/tsx.spec.ts +++ b/packages/cli/src/migration/__tests__/tsx.spec.ts @@ -109,6 +109,13 @@ describe('rewriteTsxCommand', () => { path.join(dir, 'plain.ts'), "import fs from 'node:fs';\nfs.readdirSync('.');\n", ); + // Only `cd tools` finds this script. + fs.mkdirSync(path.join(dir, 'tools')); + fs.writeFileSync( + path.join(dir, 'tools/only.ts'), + "import fs from 'node:fs';\nfs.readdirSync(__dirname);\n", + ); + fs.writeFileSync(path.join(dir, 'tools/plain.ts'), 'export {};\n'); }); afterEach(() => { @@ -121,6 +128,8 @@ describe('rewriteTsxCommand', () => { ['tsx watch dirname.ts', '`dirname.ts` mixes import with __dirname'], ['tsx --test plain.ts dirname.ts', '`dirname.ts` mixes import with __dirname'], ['concurrently "tsx dirname.ts"', '`dirname.ts` mixes import with __dirname'], + ['cd tools && tsx only.ts', '`only.ts` mixes import with __dirname'], + ['pushd tools && tsx plain.ts', 'changes the directory'], ])('keeps %s', (command, reason) => { expect(rewriteTsxCommand(command, dir)).toEqual({ command, @@ -132,6 +141,7 @@ describe('rewriteTsxCommand', () => { ['tsx cjs.cts', 'vpx cjs.cts'], ['tsx plain.ts', 'vpx plain.ts'], ['tsx --test plain.ts', 'vpx --test plain.ts'], + ['cd tools && tsx plain.ts', 'cd tools && vpx plain.ts'], ])('rewrites %s', (command, expected) => { expect(rewriteTsxCommand(command, dir)).toEqual({ command: expected, kept: [] }); }); @@ -243,6 +253,12 @@ describe('migrateTsxToVpx', () => { '{ "scripts": { "start": "tsx index.ts" } }\n', 'scripts in examples/demo/package.json still run tsx', ], + [ + 'build output', + 'dist/launcher.js', + "import { register } from 'tsx/esm/api';\n", + 'dist/launcher.js still uses tsx', + ], ])('keeps the tsx dependency used by %s', (_, file, content, user) => { write('package.json', { scripts: { @@ -299,6 +315,21 @@ describe('migrateTsxToVpx', () => { ]); }); + it('keeps the tsx dependency behind a package.json imports alias', () => { + write('package.json', { + imports: { '#loader': { node: 'tsx/esm/api' } }, + scripts: { dev: 'tsx watch src/server.ts' }, + devDependencies: { tsx: '^4.20.0' }, + }); + const report = createMigrationReport(); + + expect(migrateTsxToVpx(root, undefined, report).dependencyRemoved).toBe(false); + expect(readJson('package.json').scripts.dev).toBe('vpx --watch src/server.ts'); + expect(report.warnings).toEqual([ + 'Kept the tsx devDependency in package.json: imports in package.json still map to tsx.', + ]); + }); + it('detects tsx tasks in a lint-staged JSON file', () => { write('package.json', { scripts: { prepare: 'husky' } }); write('.lintstagedrc.json', { '*.ts': 'tsx scripts/check.ts' }); diff --git a/packages/cli/src/migration/migrator/shared.ts b/packages/cli/src/migration/migrator/shared.ts index ecff286023..c126ab41ed 100644 --- a/packages/cli/src/migration/migrator/shared.ts +++ b/packages/cli/src/migration/migrator/shared.ts @@ -39,6 +39,15 @@ export function getCommandStartIndex(argv: readonly string[]): number { return start; } +// Scans that decide whether a removed tool is still used. Built output can still load +// it after migration, so skip only installed source and VCS metadata. +export const RETENTION_SCAN_SKIP_DIRS: ReadonlySet = new Set([ + 'node_modules', + '.git', + '.hg', + '.svn', +]); + // Oxlint does not inherit these fields through `extends`. export const OXLINT_OWNER_CONFIG_KEYS = new Set(['env', 'globals', 'settings', 'ignorePatterns']); diff --git a/packages/cli/src/migration/migrator/source-scan.ts b/packages/cli/src/migration/migrator/source-scan.ts index 1c2e8aca97..2b2f9f933b 100644 --- a/packages/cli/src/migration/migrator/source-scan.ts +++ b/packages/cli/src/migration/migrator/source-scan.ts @@ -13,6 +13,7 @@ import { packageOwnsOxlintApi, PLAYWRIGHT_PROVIDER, readPackageJsonIfExists, + RETENTION_SCAN_SKIP_DIRS, type DependencyBag, } from './shared.ts'; @@ -168,10 +169,6 @@ const VITEST_SCAN_SKIP_DIRS = new Set([ '.yarn', ]); -// Built plugins can still load the original API after migration. Skip installed -// source and VCS metadata; dependency peer contracts are checked separately. -const OXLINT_RETENTION_SKIP_DIRS = new Set(['node_modules', '.git', '.hg', '.svn']); - /** * Detect whether a package uses vitest's browser mode. * @@ -379,7 +376,7 @@ export function sourceTreeReferencesOxlintPluginsPackage( retainUnknownPeers: originalDependencies.has(dir), originalDependencyNames: originalDependencies.get(dir), }), - skipDirs: OXLINT_RETENTION_SKIP_DIRS, + skipDirs: RETENTION_SCAN_SKIP_DIRS, }); } diff --git a/packages/cli/src/migration/migrator/tsx.ts b/packages/cli/src/migration/migrator/tsx.ts index bb22a6d9c9..fb113bc7ab 100644 --- a/packages/cli/src/migration/migrator/tsx.ts +++ b/packages/cli/src/migration/migrator/tsx.ts @@ -23,6 +23,8 @@ import { isPlainRecord, LINT_STAGED_JSON_CONFIG_FILES, readPackageJsonIfExists, + RETENTION_SCAN_SKIP_DIRS, + SHELL_STATE_COMMANDS, warnMigration, } from './shared.ts'; @@ -49,6 +51,7 @@ const KEEP_WATCH_PATH = '`--watch-path` has no `vpx` equivalent; use `--include` const KEEP_SHELL = 'the command uses shell syntax that vp migrate does not rewrite, such as pipes, variables, or subshells'; const KEEP_OTHER = 'vp migrate does not rewrite this use of tsx'; +const KEEP_CWD = 'an earlier command changes the directory in a way vp migrate does not follow'; // Options that `vpx` does not run as tsx does. `-c` is Node.js' `--check`, but `vpx -c` // is `--shell-mode`. @@ -104,6 +107,8 @@ const LOCKFILES = new Set([ // ES module syntax, and the CommonJS globals an ES module does not have. const ESM_SYNTAX = /^\s*(?:import\s*[\w*{'"]|export\s)|\bimport\.meta\b/m; const COMMONJS_GLOBALS = /\b(?:__dirname|__filename)\b|\brequire\s*\(/; +// A `package.json#imports` target in tsx, such as `"#tsx": "tsx/esm/api"`. +const TSX_IMPORT_TARGET = /"tsx(?:\/[^"]*)?"/; export interface TsxCommandRewrite { command: string; @@ -119,7 +124,8 @@ interface Edit { interface Rewrite { command: string; - cwd?: string; + /** The directory the next segment runs in; null once a command moved it out of sight. */ + cwd?: string | null; edits: Edit[]; kept: string[]; } @@ -132,7 +138,7 @@ interface Rewrite { * stays as it is, with the reason in `kept`. With `cwd`, the directory the command runs * in, the scripts it runs are checked for syntax `vpx` runs differently. */ -export function rewriteTsxCommand(command: string, cwd?: string): TsxCommandRewrite { +export function rewriteTsxCommand(command: string, cwd?: string | null): TsxCommandRewrite { if (!TSX_COMMAND.test(command)) { return { command, kept: [] }; } @@ -142,6 +148,16 @@ export function rewriteTsxCommand(command: string, cwd?: string): TsxCommandRewr } const rewrite: Rewrite = { command, cwd, edits: [], kept: [] }; for (const tokens of segments) { + const [name, ...args] = tokens.map(({ value }) => value); + if (SHELL_STATE_COMMANDS.includes(name)) { + // Follow `cd `; after another directory change, scripts cannot be found. + if (name === 'cd' && args.length === 1 && !/^[-~]/.test(args[0])) { + rewrite.cwd &&= path.resolve(rewrite.cwd, args[0]); + } else if (!['export', 'set', 'unset'].includes(name) && rewrite.cwd !== undefined) { + rewrite.cwd = null; + } + continue; + } rewriteSegment(rewrite, tokens); } let rewritten = command; @@ -235,7 +251,7 @@ function scriptKeepReason(cwd: string | undefined, script: string): string | und * and its arguments, or with `--test`, the test files. */ function runKeepReason( - cwd: string | undefined, + cwd: string | null | undefined, test: boolean, positionals: readonly string[], ): string | undefined { @@ -247,6 +263,9 @@ function runKeepReason( if (!test && !SCRIPT_EXTENSIONS.some((extension) => entry.endsWith(extension))) { return `\`${entry}\` has no file extension, which \`vpx\` needs to run it as a script`; } + if (cwd === null) { + return KEEP_CWD; + } for (const script of test ? positionals.filter((value) => !value.startsWith('-')) : [entry]) { const reason = scriptKeepReason(cwd, script); if (reason) { @@ -489,10 +508,11 @@ function rewriteTsxShebangs(dir: string, report?: MigrationReport): boolean { } /** - * The first file under `dir` that still runs or imports tsx, or undefined. The scan - * crosses into nested packages, which can resolve this package's tsx. + * What still runs or imports tsx under `dir`, or undefined. The scan crosses into nested + * packages, which can resolve this package's tsx, and reads build output, which can + * still load it. */ -function findTsxReference(dir: string): string | undefined { +function findTsxUse(dir: string): string | undefined { let found: string | undefined; sourceTreeMatches( dir, @@ -508,7 +528,7 @@ function findTsxReference(dir: string): string | undefined { ) { return false; } - found = file; + found = `${displayRelative(file)} still uses tsx`; return true; }, { @@ -517,12 +537,17 @@ function findTsxReference(dir: string): string | undefined { includeExtensionless: true, includePackageReferences: true, matchesPackage: (packageDir, pkg) => { - if (!packageCommands(pkg).some((c) => TSX_COMMAND.test(c))) { - return false; + const file = displayRelative(path.join(packageDir, 'package.json')); + if (packageCommands(pkg).some((c) => TSX_COMMAND.test(c))) { + found = `scripts in ${file} still run tsx`; + } else if ( + TSX_IMPORT_TARGET.test(JSON.stringify((pkg as { imports?: unknown }).imports ?? null)) + ) { + found = `imports in ${file} still map to tsx`; } - found = path.join(packageDir, 'package.json'); - return true; + return found !== undefined; }, + skipDirs: RETENTION_SCAN_SKIP_DIRS, }, ); return found; @@ -590,14 +615,10 @@ export function migrateTsxToVpx( if (readPackageJsonIfExists(packageJsonPath)?.devDependencies?.tsx === undefined) { continue; } - const reference = findTsxReference(dir); - if (reference) { - const user = - path.basename(reference) === 'package.json' - ? `scripts in ${displayRelative(reference)} still run tsx` - : `${displayRelative(reference)} still uses tsx`; + const use = findTsxUse(dir); + if (use) { warnMigration( - `Kept the tsx devDependency in ${displayRelative(packageJsonPath)}: ${user}.`, + `Kept the tsx devDependency in ${displayRelative(packageJsonPath)}: ${use}.`, report, ); continue; diff --git a/rfcs/vpx-script-execution.md b/rfcs/vpx-script-execution.md index 3841419ba7..df9e19abd9 100644 --- a/rfcs/vpx-script-execution.md +++ b/rfcs/vpx-script-execution.md @@ -187,7 +187,7 @@ For a script, the local bin and the global shim resolve the same loader and Node - It reads commands with the same literal-command parser as the other script rewrites and finds the script the way `vpx` does, with the shared value-taking option lists. Commands it cannot read, and forms `vpx` cannot run (eval, `--check`, the REPL, `--test` without files, an entry without an extension), keep tsx with a warning. - It has no Node.js check of its own: `vpx` needs the `engines.node` range of `vite-plus` (`SUPPORTED_NODE_RANGE` in `script-hooks.ts`, which a unit test keeps equal), and migration already upgrades incompatible runtime pins to that range for Vite+ itself. -- The `tsx` devDependency goes only when no scripts, imports, shebangs, CI workflows, git hooks, or shell scripts use tsx anymore, including in nested packages that can resolve it. A package that lists tsx in `dependencies`, `optionalDependencies`, or `peerDependencies` keeps it, since `vpx` comes from a devDependency. +- The `tsx` devDependency goes only when no scripts, imports (including build output and `package.json#imports` aliases), shebangs, CI workflows, git hooks, or shell scripts use tsx anymore, including in nested packages that can resolve it. A package that lists tsx in `dependencies`, `optionalDependencies`, or `peerDependencies` keeps it, since `vpx` comes from a devDependency. - It reads the scripts a command runs and keeps tsx when `vpx` would fail on them: a `.cts` file with `import` or `export`, and a file that mixes `import` with `__dirname`, `__filename`, or `require()` in a package without `"type": "module"`. tsx compiles both to CommonJS; `vpx` rejects the first and runs the second as an ES module. ## Native Side From e3d65ff568b55d0ca12af4c02970d21747843744 Mon Sep 17 00:00:00 2001 From: "MK (fengmk2)" Date: Thu, 22 Oct 2026 03:32:45 +0800 Subject: [PATCH 04/23] fix(migrate): rewrite quoted tsx only for launchers, check more CommonJS uses Address the third round of review feedback on the tsx migration: - Rewrite a quoted command only for programs that run it (concurrently, nodemon, sh/bash/zsh, cross-env-shell, start-server-and-test). A quoted argument of other programs, as in `echo "tsx a.ts"`, is data and keeps tsx. - Treat `module.exports` and `exports.x` as CommonJS uses, and stop counting `__dirname`, `__filename`, and `require` that a file declares itself, as in `const require = createRequire(import.meta.url)`. - Keep the tsx dependency when code spawns it, as in `spawn('tsx', [...])`. Co-Authored-By: Claude Opus 5.5 --- docs/guide/migrate-rules.md | 4 +- .../cli/src/migration/__tests__/tsx.spec.ts | 44 +++++++++++++--- packages/cli/src/migration/migrator/tsx.ts | 50 +++++++++++++++---- rfcs/vpx-script-execution.md | 2 +- 4 files changed, 82 insertions(+), 18 deletions(-) diff --git a/docs/guide/migrate-rules.md b/docs/guide/migrate-rules.md index d6e6a4f7a3..7dbd6b1924 100644 --- a/docs/guide/migrate-rules.md +++ b/docs/guide/migrate-rules.md @@ -287,9 +287,9 @@ When a `package.json` script or lint-staged task runs [tsx](https://tsx.is), mig | `cross-env NODE_ENV=dev tsx a.ts` | `cross-env NODE_ENV=dev vpx a.ts` | | `concurrently "tsx watch a.ts" "vp dev"` | `concurrently "vpx --watch a.ts" "vp dev"` | -`tsx watch` becomes `vpx --watch`, never `vpx watch`, which runs the npm package `watch`. Node.js options and tsx watch options keep their order, and `--no-cache` is dropped. `vpx` needs the same Node.js versions as Vite+ itself, and migration already upgrades incompatible runtime pins to them (see [Vitest 5](/guide/vitest-v5)). +`tsx watch` becomes `vpx --watch`, never `vpx watch`, which runs the npm package `watch`. Node.js options and tsx watch options keep their order, and `--no-cache` is dropped. A quoted command is rewritten only for programs that run it, such as `concurrently`, `nodemon --exec`, `sh -c`, and `cross-env-shell`; a quoted argument of other programs, as in `echo "tsx a.ts"`, is data. `vpx` needs the same Node.js versions as Vite+ itself, and migration already upgrades incompatible runtime pins to them (see [Vitest 5](/guide/vitest-v5)). -Migration keeps tsx, with a warning, for a command `vpx` cannot run: `--eval`, `--print`, or `--check`, the REPL, `--test` without files, an entry without a file extension, and commands with pipes, variables, or subshells. It also reads the scripts a command runs and keeps tsx when `vpx` would fail on them, because tsx compiles their ES module syntax to CommonJS: a `.cts` file with `import` or `export`, and a file that mixes `import` with `__dirname`, `__filename`, or `require()` in a package without `"type": "module"`. A package that lists tsx in `dependencies`, `optionalDependencies`, or `peerDependencies` keeps it too, since `vpx` comes from the `vite-plus` devDependency. +Migration keeps tsx, with a warning, for a command `vpx` cannot run: `--eval`, `--print`, or `--check`, the REPL, `--test` without files, an entry without a file extension, and commands with pipes, variables, or subshells. It also reads the scripts a command runs and keeps tsx when `vpx` would fail on them, because tsx compiles their ES module syntax to CommonJS: a `.cts` file with `import` or `export`, and a file that mixes `import` with `__dirname`, `__filename`, `require()`, or `module.exports` in a package without `"type": "module"`. A package that lists tsx in `dependencies`, `optionalDependencies`, or `peerDependencies` keeps it too, since `vpx` comes from the `vite-plus` devDependency. Migration removes the `tsx` devDependency only when nothing uses tsx anymore: no kept script, no import of `tsx` or its subpaths such as `tsx/esm/api` (in source, build output, or a `package.json` `imports` alias), no shebang, and no tsx command in CI workflows, git hooks, or shell scripts. The root's dependency stays while any workspace package still uses tsx, and a package's dependency stays while a nested package under it does. When tsx stays, the warning names what still uses it. diff --git a/packages/cli/src/migration/__tests__/tsx.spec.ts b/packages/cli/src/migration/__tests__/tsx.spec.ts index dbed6a29a1..143d033b45 100644 --- a/packages/cli/src/migration/__tests__/tsx.spec.ts +++ b/packages/cli/src/migration/__tests__/tsx.spec.ts @@ -46,6 +46,8 @@ describe('rewriteTsxCommand', () => { 'concurrently "vpx --watch src/api.ts" "vp dev"', ], ["nodemon --exec 'tsx src/a.ts'", "nodemon --exec 'vpx src/a.ts'"], + ["sh -c 'tsx src/a.ts && vp build'", "sh -c 'vpx src/a.ts && vp build'"], + ['cross-env-shell "tsx src/a.ts"', 'cross-env-shell "vpx src/a.ts"'], ])('rewrites %s', (command, expected) => { const result = rewriteTsxCommand(command); expect(result.command).toBe(expected); @@ -81,6 +83,8 @@ describe('rewriteTsxCommand', () => { ['node --import tsx --watch-path src ./a.ts', '--watch-path'], ["NODE_OPTIONS='--import tsx' node ./a.ts", 'does not rewrite this use of tsx'], ['pnpm dlx tsx ./a.ts', 'does not rewrite this use of tsx'], + // A quoted argument of a program that does not run commands is data. + ['echo "tsx scripts/build.ts"', 'does not rewrite this use of tsx'], ])('keeps %s', (command, reason) => { const result = rewriteTsxCommand(command); expect(result.command).toBe(command); @@ -109,6 +113,24 @@ describe('rewriteTsxCommand', () => { path.join(dir, 'plain.ts'), "import fs from 'node:fs';\nfs.readdirSync('.');\n", ); + fs.writeFileSync( + path.join(dir, 'exports.ts'), + "import fs from 'node:fs';\nmodule.exports = fs.readdirSync('.');\n", + ); + // ES module idioms that only look like CommonJS globals. + fs.writeFileSync( + path.join(dir, 'esm-idioms.ts'), + [ + "import path from 'node:path';", + "import { createRequire } from 'node:module';", + "import { fileURLToPath } from 'node:url';", + 'const __dirname = path.dirname(fileURLToPath(import.meta.url));', + 'const require = createRequire(import.meta.url);', + "const pkg = require(path.join(__dirname, 'package.json'));", + 'console.log(pkg.exports.x);', + '', + ].join('\n'), + ); // Only `cd tools` finds this script. fs.mkdirSync(path.join(dir, 'tools')); fs.writeFileSync( @@ -125,10 +147,11 @@ describe('rewriteTsxCommand', () => { it.each([ ['tsx esm.cts', '`esm.cts` uses import or export'], ['node --import tsx ./esm.cts', '`./esm.cts` uses import or export'], - ['tsx watch dirname.ts', '`dirname.ts` mixes import with __dirname'], - ['tsx --test plain.ts dirname.ts', '`dirname.ts` mixes import with __dirname'], - ['concurrently "tsx dirname.ts"', '`dirname.ts` mixes import with __dirname'], - ['cd tools && tsx only.ts', '`only.ts` mixes import with __dirname'], + ['tsx watch dirname.ts', '`dirname.ts` mixes import with CommonJS globals'], + ['tsx --test plain.ts dirname.ts', '`dirname.ts` mixes import with CommonJS globals'], + ['concurrently "tsx dirname.ts"', '`dirname.ts` mixes import with CommonJS globals'], + ['cd tools && tsx only.ts', '`only.ts` mixes import with CommonJS globals'], + ['tsx exports.ts', '`exports.ts` mixes import with CommonJS globals'], ['pushd tools && tsx plain.ts', 'changes the directory'], ])('keeps %s', (command, reason) => { expect(rewriteTsxCommand(command, dir)).toEqual({ @@ -140,6 +163,7 @@ describe('rewriteTsxCommand', () => { it.each([ ['tsx cjs.cts', 'vpx cjs.cts'], ['tsx plain.ts', 'vpx plain.ts'], + ['tsx esm-idioms.ts', 'vpx esm-idioms.ts'], ['tsx --test plain.ts', 'vpx --test plain.ts'], ['cd tools && tsx plain.ts', 'cd tools && vpx plain.ts'], ])('rewrites %s', (command, expected) => { @@ -188,6 +212,8 @@ describe('migrateTsxToVpx', () => { write('.lintstagedrc.json', { '*.md': 'tsx scripts/docs.ts' }); write('scripts/release.ts', '#!/usr/bin/env tsx\nconsole.log("release");\n'); write('src/server.ts', 'export {};\n'); + // File extensions are not uses of tsx. + write('vite.config.ts', "export default { resolve: { extensions: ['ts', 'tsx'] } };\n"); const report = createMigrationReport(); expect(detectTsxProject(root)).toBe(true); @@ -259,6 +285,12 @@ describe('migrateTsxToVpx', () => { "import { register } from 'tsx/esm/api';\n", 'dist/launcher.js still uses tsx', ], + [ + 'a process call', + 'src/run.ts', + "import { spawn } from 'node:child_process';\nspawn('tsx', ['job.ts']);\n", + 'src/run.ts still uses tsx', + ], ])('keeps the tsx dependency used by %s', (_, file, content, user) => { write('package.json', { scripts: { @@ -368,10 +400,10 @@ describe('migrateTsxToVpx', () => { expect(read('scripts/release.ts')).toMatch(/^#!\/usr\/bin\/env tsx\n/); expect(report.warnings).toEqual([ expect.stringContaining( - 'Kept tsx in package.json scripts "build": `scripts/build.ts` mixes import with __dirname', + 'Kept tsx in package.json scripts "build": `scripts/build.ts` mixes import with CommonJS globals', ), expect.stringContaining( - 'Kept tsx in the shebang of scripts/release.ts: `./release.ts` mixes import with __dirname', + 'Kept tsx in the shebang of scripts/release.ts: `./release.ts` mixes import with CommonJS globals', ), ]); }); diff --git a/packages/cli/src/migration/migrator/tsx.ts b/packages/cli/src/migration/migrator/tsx.ts index fb113bc7ab..a8f3dc6a2f 100644 --- a/packages/cli/src/migration/migrator/tsx.ts +++ b/packages/cli/src/migration/migrator/tsx.ts @@ -74,9 +74,10 @@ const TSX_ENV_SHEBANG = /^(#!\s*\S*\benv\s+(?:-S\s+)?)(tsx(?=[ \t\r\n]|$)[^\r\n] const SHEBANG_ENTRY = './shebang.ts'; // Any first line that runs tsx, including `#!/usr/local/bin/tsx`. const TSX_SHEBANG = /^#![^\n]*[\s/]tsx(?=\s|$)/; -// In code: an import of tsx or a subpath such as `tsx/esm/api`, or a string that starts a tsx command. +// In code: an import of tsx or a subpath such as `tsx/esm/api`, a string that starts a +// tsx command, or tsx as the program of a process call, as in `spawn('tsx', [...])`. const TSX_CODE_REFERENCE = - /(?:\bfrom|\bimport|\brequire(?:\.resolve)?)\s*\(?\s*['"]tsx(?:\/[^'"]*)?['"]|['"`](?:npx |pnpm (?:exec )?|yarn |bunx )?tsx\s/; + /(?:\bfrom|\bimport|\brequire(?:\.resolve)?)\s*\(?\s*['"]tsx(?:\/[^'"]*)?['"]|['"`](?:npx |pnpm (?:exec )?|yarn |bunx )?tsx\s|\(\s*['"`]tsx['"`]\s*,/; // Elsewhere (CI workflows, git hooks, shell scripts, JSON configs): tsx run with an // argument on the same line, or loaded into Node.js. const TSX_TEXT_REFERENCE = @@ -104,9 +105,25 @@ const LOCKFILES = new Set([ 'yarn.lock', ]); -// ES module syntax, and the CommonJS globals an ES module does not have. +// ES module syntax, and uses of the CommonJS globals an ES module does not have. const ESM_SYNTAX = /^\s*(?:import\s*[\w*{'"]|export\s)|\bimport\.meta\b/m; -const COMMONJS_GLOBALS = /\b(?:__dirname|__filename)\b|\brequire\s*\(/; +const MODULE_EXPORTS = /(? + use.test(source) && !new RegExp(`\\b(?:const|let|var|function)\\s+${name}\\b`).test(source), + ) + ); +} + /** * Why `vpx` cannot run what tsx or `node --import tsx` runs: `positionals` are the entry * and its arguments, or with `--test`, the test files. diff --git a/rfcs/vpx-script-execution.md b/rfcs/vpx-script-execution.md index df9e19abd9..f465872d76 100644 --- a/rfcs/vpx-script-execution.md +++ b/rfcs/vpx-script-execution.md @@ -188,7 +188,7 @@ For a script, the local bin and the global shim resolve the same loader and Node - It reads commands with the same literal-command parser as the other script rewrites and finds the script the way `vpx` does, with the shared value-taking option lists. Commands it cannot read, and forms `vpx` cannot run (eval, `--check`, the REPL, `--test` without files, an entry without an extension), keep tsx with a warning. - It has no Node.js check of its own: `vpx` needs the `engines.node` range of `vite-plus` (`SUPPORTED_NODE_RANGE` in `script-hooks.ts`, which a unit test keeps equal), and migration already upgrades incompatible runtime pins to that range for Vite+ itself. - The `tsx` devDependency goes only when no scripts, imports (including build output and `package.json#imports` aliases), shebangs, CI workflows, git hooks, or shell scripts use tsx anymore, including in nested packages that can resolve it. A package that lists tsx in `dependencies`, `optionalDependencies`, or `peerDependencies` keeps it, since `vpx` comes from a devDependency. -- It reads the scripts a command runs and keeps tsx when `vpx` would fail on them: a `.cts` file with `import` or `export`, and a file that mixes `import` with `__dirname`, `__filename`, or `require()` in a package without `"type": "module"`. tsx compiles both to CommonJS; `vpx` rejects the first and runs the second as an ES module. +- It reads the scripts a command runs and keeps tsx when `vpx` would fail on them: a `.cts` file with `import` or `export`, and a file that mixes `import` with `__dirname`, `__filename`, `require()`, or `module.exports` in a package without `"type": "module"`. tsx compiles both to CommonJS; `vpx` rejects the first and runs the second as an ES module. ## Native Side From 0522e9a323d7096976b33440ab5648774d8f676e Mon Sep 17 00:00:00 2001 From: "MK (fengmk2)" Date: Thu, 22 Oct 2026 03:51:21 +0800 Subject: [PATCH 05/23] fix(migrate): find tsx in tool settings, keep require('./a.js') entries Address the fourth round of review feedback on the tsx migration: - Keep tsx for an entry whose `require('./a.js')` only tsx resolves to `a.ts`. An extensionless `require('./a')` already works under vpx. - Before removing tsx, check string values in every package.json field except dependency declarations and prose, such as ava's `nodeArguments: ['--import=tsx']`, parse JSON configs such as `.mocharc.json` the same way, and match loader flags in JavaScript configs and `tsx` at the end of a shell line. - Document that `bunx tsx` becomes `vpx`, unlike the other bunx rewrites. Co-Authored-By: Claude Opus 5.5 --- .../snapshots/migration_tsx_kept.md | 2 +- docs/guide/migrate-rules.md | 22 +-- .../cli/src/migration/__tests__/tsx.spec.ts | 39 +++++- packages/cli/src/migration/migrator/tsx.ts | 131 +++++++++++++----- rfcs/vpx-script-execution.md | 4 +- 5 files changed, 144 insertions(+), 54 deletions(-) diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_tsx/snapshots/migration_tsx_kept.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_tsx/snapshots/migration_tsx_kept.md index 969f6dd8e9..83ca0a570a 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_tsx/snapshots/migration_tsx_kept.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_tsx/snapshots/migration_tsx_kept.md @@ -23,7 +23,7 @@ VITE+ - The Unified Toolchain for the Web ! Warnings: - Kept tsx in package.json scripts "calc": `vpx` does not run TypeScript passed to --eval or --print. - Kept tsx in package.json scripts "repl": `vpx` does not start a TypeScript REPL. - - Kept the tsx devDependency in package.json: scripts in package.json still run tsx. + - Kept the tsx devDependency in package.json: `scripts` in package.json still uses tsx. ``` ## `vpt print-file package.json` diff --git a/docs/guide/migrate-rules.md b/docs/guide/migrate-rules.md index 7dbd6b1924..ccaa0821d1 100644 --- a/docs/guide/migrate-rules.md +++ b/docs/guide/migrate-rules.md @@ -263,7 +263,7 @@ Migration rewrites commands provided by the Vite+ toolchain in `package.json` sc | `tsup` | `vp pack`, when its optional migration runs | | `tsx` | `vpx`, when its optional migration runs | -For commands launched through `bunx`, migration preserves `bunx` and its `--bun` flag (keeping the user's chosen runtime) and rewrites only the managed command. This also works when `bunx` follows a command-launcher delimiter such as `run` or `--`: +For commands launched through `bunx`, migration preserves `bunx` and its `--bun` flag (keeping the user's chosen runtime) and rewrites only the managed command. tsx is the exception: `bunx tsx` becomes `vpx`, and `bunx --bun tsx` keeps tsx (see [tsx to `vpx`](#tsx-to-vpx)). This also works when `bunx` follows a command-launcher delimiter such as `run` or `--`: | Before | After | | ------------------------------------------------------- | -------------------------------------------------------- | @@ -278,20 +278,20 @@ Unrelated `bunx` commands and other package-executor forms remain unchanged. When a `package.json` script or lint-staged task runs [tsx](https://tsx.is), migration offers to move it to [`vpx`](/guide/vpx), Vite+'s TypeScript script runner. It rewrites scripts and lint-staged tasks in `package.json` and JSON lint-staged files, and `#!/usr/bin/env tsx` shebangs in script files: -| Before | After | -| ---------------------------------------- | ------------------------------------------ | -| `tsx src/seed.ts --rows 10` | `vpx src/seed.ts --rows 10` | -| `tsx watch --include "*.json" src/a.ts` | `vpx --watch --include "*.json" src/a.ts` | -| `npx tsx a.ts`, `pnpm exec tsx a.ts` | `vpx a.ts` | -| `node --import tsx ./a.ts` | `vpx ./a.ts` | -| `cross-env NODE_ENV=dev tsx a.ts` | `cross-env NODE_ENV=dev vpx a.ts` | -| `concurrently "tsx watch a.ts" "vp dev"` | `concurrently "vpx --watch a.ts" "vp dev"` | +| Before | After | +| ----------------------------------------------------- | ------------------------------------------ | +| `tsx src/seed.ts --rows 10` | `vpx src/seed.ts --rows 10` | +| `tsx watch --include "*.json" src/a.ts` | `vpx --watch --include "*.json" src/a.ts` | +| `npx tsx a.ts`, `pnpm exec tsx a.ts`, `bunx tsx a.ts` | `vpx a.ts` | +| `node --import tsx ./a.ts` | `vpx ./a.ts` | +| `cross-env NODE_ENV=dev tsx a.ts` | `cross-env NODE_ENV=dev vpx a.ts` | +| `concurrently "tsx watch a.ts" "vp dev"` | `concurrently "vpx --watch a.ts" "vp dev"` | `tsx watch` becomes `vpx --watch`, never `vpx watch`, which runs the npm package `watch`. Node.js options and tsx watch options keep their order, and `--no-cache` is dropped. A quoted command is rewritten only for programs that run it, such as `concurrently`, `nodemon --exec`, `sh -c`, and `cross-env-shell`; a quoted argument of other programs, as in `echo "tsx a.ts"`, is data. `vpx` needs the same Node.js versions as Vite+ itself, and migration already upgrades incompatible runtime pins to them (see [Vitest 5](/guide/vitest-v5)). -Migration keeps tsx, with a warning, for a command `vpx` cannot run: `--eval`, `--print`, or `--check`, the REPL, `--test` without files, an entry without a file extension, and commands with pipes, variables, or subshells. It also reads the scripts a command runs and keeps tsx when `vpx` would fail on them, because tsx compiles their ES module syntax to CommonJS: a `.cts` file with `import` or `export`, and a file that mixes `import` with `__dirname`, `__filename`, `require()`, or `module.exports` in a package without `"type": "module"`. A package that lists tsx in `dependencies`, `optionalDependencies`, or `peerDependencies` keeps it too, since `vpx` comes from the `vite-plus` devDependency. +Migration keeps tsx, with a warning, for a command `vpx` cannot run: `--eval`, `--print`, or `--check`, the REPL, `--test` without files, an entry without a file extension, and commands with pipes, variables, or subshells. It also reads the scripts a command runs and keeps tsx when `vpx` would fail on them: a `require()` of `./a.js` that only tsx resolves to `a.ts`, and, because tsx compiles ES module syntax to CommonJS, a `.cts` file with `import` or `export`, and a file that mixes `import` with `__dirname`, `__filename`, `require()`, or `module.exports` in a package without `"type": "module"`. A package that lists tsx in `dependencies`, `optionalDependencies`, or `peerDependencies` keeps it too, since `vpx` comes from the `vite-plus` devDependency. -Migration removes the `tsx` devDependency only when nothing uses tsx anymore: no kept script, no import of `tsx` or its subpaths such as `tsx/esm/api` (in source, build output, or a `package.json` `imports` alias), no shebang, and no tsx command in CI workflows, git hooks, or shell scripts. The root's dependency stays while any workspace package still uses tsx, and a package's dependency stays while a nested package under it does. When tsx stays, the warning names what still uses it. +Migration removes the `tsx` devDependency only when nothing uses tsx anymore: no kept script, no import of `tsx` or its subpaths such as `tsx/esm/api` (in source, build output, or a `package.json` `imports` alias), no shebang, no tool setting such as `--import=tsx` in `package.json` or a JSON or JavaScript config, and no tsx command in CI workflows, git hooks, or shell scripts. The root's dependency stays while any workspace package still uses tsx, and a package's dependency stays while a nested package under it does. When tsx stays, the warning names what still uses it. See [Coming from tsx](/guide/vpx#coming-from-tsx) for the other differences. diff --git a/packages/cli/src/migration/__tests__/tsx.spec.ts b/packages/cli/src/migration/__tests__/tsx.spec.ts index 143d033b45..eb16952cd8 100644 --- a/packages/cli/src/migration/__tests__/tsx.spec.ts +++ b/packages/cli/src/migration/__tests__/tsx.spec.ts @@ -138,6 +138,10 @@ describe('rewriteTsxCommand', () => { "import fs from 'node:fs';\nfs.readdirSync(__dirname);\n", ); fs.writeFileSync(path.join(dir, 'tools/plain.ts'), 'export {};\n'); + // tsx resolves `require('./helper.js')` to `helper.ts`; `vpx` does not. + fs.writeFileSync(path.join(dir, 'helper.ts'), 'exports.x = 1;\n'); + fs.writeFileSync(path.join(dir, 'require-js.cts'), "const { x } = require('./helper.js');\n"); + fs.writeFileSync(path.join(dir, 'require-bare.cts'), "const { x } = require('./helper');\n"); }); afterEach(() => { @@ -152,6 +156,7 @@ describe('rewriteTsxCommand', () => { ['concurrently "tsx dirname.ts"', '`dirname.ts` mixes import with CommonJS globals'], ['cd tools && tsx only.ts', '`only.ts` mixes import with CommonJS globals'], ['tsx exports.ts', '`exports.ts` mixes import with CommonJS globals'], + ['tsx require-js.cts', 'requires `./helper.js`, which only tsx resolves to `./helper.ts`'], ['pushd tools && tsx plain.ts', 'changes the directory'], ])('keeps %s', (command, reason) => { expect(rewriteTsxCommand(command, dir)).toEqual({ @@ -164,6 +169,7 @@ describe('rewriteTsxCommand', () => { ['tsx cjs.cts', 'vpx cjs.cts'], ['tsx plain.ts', 'vpx plain.ts'], ['tsx esm-idioms.ts', 'vpx esm-idioms.ts'], + ['tsx require-bare.cts', 'vpx require-bare.cts'], ['tsx --test plain.ts', 'vpx --test plain.ts'], ['cd tools && tsx plain.ts', 'cd tools && vpx plain.ts'], ])('rewrites %s', (command, expected) => { @@ -242,7 +248,7 @@ describe('migrateTsxToVpx', () => { }); it.each([ - ['a kept script', 'package.json', undefined, 'scripts in package.json still run tsx'], + ['a kept script', 'package.json', undefined, '`scripts` in package.json still uses tsx'], [ 'an import of the tsx API', 'src/load.ts', @@ -277,7 +283,7 @@ describe('migrateTsxToVpx', () => { 'a nested package', 'examples/demo/package.json', '{ "scripts": { "start": "tsx index.ts" } }\n', - 'scripts in examples/demo/package.json still run tsx', + '`scripts` in examples/demo/package.json still uses tsx', ], [ 'build output', @@ -291,6 +297,24 @@ describe('migrateTsxToVpx', () => { "import { spawn } from 'node:child_process';\nspawn('tsx', ['job.ts']);\n", 'src/run.ts still uses tsx', ], + [ + 'a shell pipe into tsx', + 'scripts/run.sh', + 'cat job.ts | tsx\n', + 'scripts/run.sh still uses tsx', + ], + [ + 'a JSON tool config', + '.mocharc.json', + '{ "require": "tsx", "spec": "test/**/*.test.ts" }\n', + '.mocharc.json still uses tsx', + ], + [ + 'a JavaScript tool config', + 'ava.config.js', + "export default { nodeArguments: ['--import=tsx'] };\n", + 'ava.config.js still uses tsx', + ], ])('keeps the tsx dependency used by %s', (_, file, content, user) => { write('package.json', { scripts: { @@ -347,10 +371,13 @@ describe('migrateTsxToVpx', () => { ]); }); - it('keeps the tsx dependency behind a package.json imports alias', () => { + it.each([ + ['imports', { '#loader': { node: 'tsx/esm/api' } }], + ['ava', { extensions: { ts: 'module' }, nodeArguments: ['--import=tsx'] }], + ])('keeps the tsx dependency used by the %s field of package.json', (field, value) => { write('package.json', { - imports: { '#loader': { node: 'tsx/esm/api' } }, scripts: { dev: 'tsx watch src/server.ts' }, + [field]: value, devDependencies: { tsx: '^4.20.0' }, }); const report = createMigrationReport(); @@ -358,7 +385,7 @@ describe('migrateTsxToVpx', () => { expect(migrateTsxToVpx(root, undefined, report).dependencyRemoved).toBe(false); expect(readJson('package.json').scripts.dev).toBe('vpx --watch src/server.ts'); expect(report.warnings).toEqual([ - 'Kept the tsx devDependency in package.json: imports in package.json still map to tsx.', + `Kept the tsx devDependency in package.json: \`${field}\` in package.json still uses tsx.`, ]); }); @@ -425,7 +452,7 @@ describe('migrateTsxToVpx', () => { expect(readJson('package.json').devDependencies).toEqual({ tsx: '^4.20.0' }); expect(report.warnings).toEqual([ expect.stringContaining('Kept tsx in packages/b/package.json scripts "calc"'), - 'Kept the tsx devDependency in package.json: scripts in packages/b/package.json still run tsx.', + 'Kept the tsx devDependency in package.json: `scripts` in packages/b/package.json still uses tsx.', ]); }); }); diff --git a/packages/cli/src/migration/migrator/tsx.ts b/packages/cli/src/migration/migrator/tsx.ts index a8f3dc6a2f..b836c2fe7c 100644 --- a/packages/cli/src/migration/migrator/tsx.ts +++ b/packages/cli/src/migration/migrator/tsx.ts @@ -3,6 +3,7 @@ import path from 'node:path'; import { styleText } from 'node:util'; import * as prompts from '@voidzero-dev/vite-plus-prompts'; +import { parse as parseJsonc } from 'jsonc-parser'; import { type LiteralCommandToken, parseLiteralCommands } from '../../../binding/index.js'; import { type WorkspacePackage } from '../../types/index.ts'; @@ -74,14 +75,45 @@ const TSX_ENV_SHEBANG = /^(#!\s*\S*\benv\s+(?:-S\s+)?)(tsx(?=[ \t\r\n]|$)[^\r\n] const SHEBANG_ENTRY = './shebang.ts'; // Any first line that runs tsx, including `#!/usr/local/bin/tsx`. const TSX_SHEBANG = /^#![^\n]*[\s/]tsx(?=\s|$)/; +// tsx loaded into Node.js by a flag, as in `--import=tsx` or `-r tsx/cjs`. +const TSX_LOADER_FLAG = + /(?:--import|--loader|--experimental-loader|--require|-r)[= \t]+['"]?tsx(?:\/(?:esm|cjs))?(?![\w./-])/; // In code: an import of tsx or a subpath such as `tsx/esm/api`, a string that starts a -// tsx command, or tsx as the program of a process call, as in `spawn('tsx', [...])`. -const TSX_CODE_REFERENCE = - /(?:\bfrom|\bimport|\brequire(?:\.resolve)?)\s*\(?\s*['"]tsx(?:\/[^'"]*)?['"]|['"`](?:npx |pnpm (?:exec )?|yarn |bunx )?tsx\s|\(\s*['"`]tsx['"`]\s*,/; -// Elsewhere (CI workflows, git hooks, shell scripts, JSON configs): tsx run with an -// argument on the same line, or loaded into Node.js. -const TSX_TEXT_REFERENCE = - /(?:^|[\s;&|("'=/`])tsx(?:\/(?:esm|cjs))?[ \t]+\S|(?:--import|--loader|--experimental-loader|--require|-r)[= \t]+['"]?tsx(?:\/(?:esm|cjs))?(?![\w./-])/m; +// tsx command, tsx as the program of a process call, as in `spawn('tsx', [...])`, or a +// loader flag, as in `nodeArguments: ['--import=tsx']`. +const TSX_CODE_REFERENCE = new RegExp( + [ + /(?:\bfrom|\bimport|\brequire(?:\.resolve)?)\s*\(?\s*['"]tsx(?:\/[^'"]*)?['"]/.source, + /['"`](?:npx |pnpm (?:exec )?|yarn |bunx )?tsx\s/.source, + /\(\s*['"`]tsx['"`]\s*,/.source, + TSX_LOADER_FLAG.source, + ].join('|'), +); +// In shell scripts, CI workflows, and git hooks: tsx in command position, with or +// without arguments, or a loader flag. +const TSX_TEXT_REFERENCE = new RegExp( + [ + /(?:^|[\s;&|("'=/`])tsx(?:\/(?:esm|cjs))?(?:[ \t]+\S|[ \t]*$)/.source, + TSX_LOADER_FLAG.source, + ].join('|'), + 'm', +); +// package.json fields that name tsx without running it: dependency declarations and prose. +const IGNORED_FIELDS = new Set([ + 'bundleDependencies', + 'bundledDependencies', + 'dependencies', + 'description', + 'devDependencies', + 'keywords', + 'name', + 'optionalDependencies', + 'overrides', + 'peerDependencies', + 'peerDependenciesMeta', + 'pnpm', + 'resolutions', +]); const CODE_EXTENSIONS: ReadonlySet = new Set(SCRIPT_EXTENSIONS); const REFERENCE_EXTENSIONS: ReadonlySet = new Set([ ...SCRIPT_EXTENSIONS, @@ -113,6 +145,14 @@ const COMMONJS_GLOBALS = [ ['__filename', /(? = { + js: ['ts', 'tsx'], + cjs: ['cts'], + mjs: ['mts'], + jsx: ['tsx'], +}; // Programs that run a command given as one quoted argument, as in // `concurrently "tsx watch a.ts"`. Elsewhere, a quoted string is data, as for `echo`. const COMMAND_LAUNCHERS = new Set([ @@ -124,8 +164,6 @@ const COMMAND_LAUNCHERS = new Set([ 'start-server-and-test', 'zsh', ]); -// A `package.json#imports` target in tsx, such as `"#tsx": "tsx/esm/api"`. -const TSX_IMPORT_TARGET = /"tsx(?:\/[^"]*)?"/; export interface TsxCommandRewrite { command: string; @@ -233,13 +271,14 @@ function removal(tokens: LiteralCommandToken[], index: number): Edit { } /** - * Why `vpx` would fail on a script that tsx runs, judged from its source. tsx compiles ES - * module syntax to CommonJS in a `.cts` file and in a package without `"type": "module"`. - * `vpx` rejects it in a `.cts` file and runs the other files as ES modules, as Node.js - * does, so `__dirname`, `__filename`, and `require` are not defined. + * Why `vpx` would fail on a script that tsx runs, judged from its source. tsx resolves + * `require('./a.js')` to `a.ts`, and compiles ES module syntax to CommonJS in a `.cts` + * file and in a package without `"type": "module"`. `vpx` resolves `require()` as + * Node.js does, rejects ES module syntax in a `.cts` file, and runs the other files as ES + * modules, so `__dirname`, `__filename`, and `require` are not defined. */ function scriptKeepReason(cwd: string | undefined, script: string): string | undefined { - if (cwd === undefined || !/\.(?:c?ts|tsx|jsx?)$/.test(script)) { + if (cwd === undefined || !SCRIPT_EXTENSIONS.some((extension) => script.endsWith(extension))) { return undefined; } const file = path.resolve(cwd, script); @@ -249,7 +288,16 @@ function scriptKeepReason(cwd: string | undefined, script: string): string | und } catch { return undefined; } - if (!ESM_SYNTAX.test(source)) { + for (const [, base, extension] of source.matchAll(RELATIVE_REQUIRE)) { + const resolve = (ext: string) => path.resolve(path.dirname(file), `${base}.${ext}`); + const typescript = fs.existsSync(resolve(extension)) + ? undefined + : TYPESCRIPT_EXTENSIONS[extension].find((ext) => fs.existsSync(resolve(ext))); + if (typescript) { + return `\`${script}\` requires \`${base}.${extension}\`, which only tsx resolves to \`${base}.${typescript}\`; write the TypeScript extension`; + } + } + if (!/\.(?:c?ts|tsx|jsx?)$/.test(script) || !ESM_SYNTAX.test(source)) { return undefined; } if (script.endsWith('.cts')) { @@ -549,35 +597,36 @@ function findTsxUse(dir: string): string | undefined { sourceTreeMatches( dir, (content, file) => { - if ( - LOCKFILES.has(path.basename(file)) || - !( - TSX_SHEBANG.test(content) || - (CODE_EXTENSIONS.has(path.extname(file)) ? TSX_CODE_REFERENCE : TSX_TEXT_REFERENCE).test( - content, - ) - ) - ) { + if (LOCKFILES.has(path.basename(file))) { return false; } - found = `${displayRelative(file)} still uses tsx`; - return true; + const extension = path.extname(file); + const uses = + TSX_SHEBANG.test(content) || + (extension === '.json' + ? jsonUsesTsx(parseJsonc(content)) + : (CODE_EXTENSIONS.has(extension) ? TSX_CODE_REFERENCE : TSX_TEXT_REFERENCE).test( + content, + )); + if (uses) { + found = `${displayRelative(file)} still uses tsx`; + } + return uses; }, { crossPackageBoundaries: true, extensions: REFERENCE_EXTENSIONS, includeExtensionless: true, includePackageReferences: true, + // Scripts, `imports` aliases, and tool settings such as ava's `nodeArguments`. matchesPackage: (packageDir, pkg) => { - const file = displayRelative(path.join(packageDir, 'package.json')); - if (packageCommands(pkg).some((c) => TSX_COMMAND.test(c))) { - found = `scripts in ${file} still run tsx`; - } else if ( - TSX_IMPORT_TARGET.test(JSON.stringify((pkg as { imports?: unknown }).imports ?? null)) - ) { - found = `imports in ${file} still map to tsx`; + const field = Object.entries(pkg).find( + ([key, value]) => !IGNORED_FIELDS.has(key) && jsonUsesTsx(value), + )?.[0]; + if (field !== undefined) { + found = `\`${field}\` in ${displayRelative(path.join(packageDir, 'package.json'))} still uses tsx`; } - return found !== undefined; + return field !== undefined; }, skipDirs: RETENTION_SCAN_SKIP_DIRS, }, @@ -585,6 +634,20 @@ function findTsxUse(dir: string): string | undefined { return found; } +/** + * Whether a JSON value holds a string that runs or loads tsx: a command such as + * `tsx a.ts`, a loader flag such as `--import=tsx`, or tsx and its subpaths. + */ +function jsonUsesTsx(value: unknown): boolean { + if (typeof value === 'string') { + return TSX_COMMAND.test(value) || value.startsWith('tsx/'); + } + if (Array.isArray(value)) { + return value.some(jsonUsesTsx); + } + return isPlainRecord(value) && Object.values(value).some(jsonUsesTsx); +} + /** * Move tsx scripts to `vpx`: rewrite package.json scripts, lint-staged tasks, and * `#!/usr/bin/env tsx` shebangs, then remove the tsx devDependency from each package diff --git a/rfcs/vpx-script-execution.md b/rfcs/vpx-script-execution.md index f465872d76..4ba314dd30 100644 --- a/rfcs/vpx-script-execution.md +++ b/rfcs/vpx-script-execution.md @@ -187,8 +187,8 @@ For a script, the local bin and the global shim resolve the same loader and Node - It reads commands with the same literal-command parser as the other script rewrites and finds the script the way `vpx` does, with the shared value-taking option lists. Commands it cannot read, and forms `vpx` cannot run (eval, `--check`, the REPL, `--test` without files, an entry without an extension), keep tsx with a warning. - It has no Node.js check of its own: `vpx` needs the `engines.node` range of `vite-plus` (`SUPPORTED_NODE_RANGE` in `script-hooks.ts`, which a unit test keeps equal), and migration already upgrades incompatible runtime pins to that range for Vite+ itself. -- The `tsx` devDependency goes only when no scripts, imports (including build output and `package.json#imports` aliases), shebangs, CI workflows, git hooks, or shell scripts use tsx anymore, including in nested packages that can resolve it. A package that lists tsx in `dependencies`, `optionalDependencies`, or `peerDependencies` keeps it, since `vpx` comes from a devDependency. -- It reads the scripts a command runs and keeps tsx when `vpx` would fail on them: a `.cts` file with `import` or `export`, and a file that mixes `import` with `__dirname`, `__filename`, `require()`, or `module.exports` in a package without `"type": "module"`. tsx compiles both to CommonJS; `vpx` rejects the first and runs the second as an ES module. +- The `tsx` devDependency goes only when no scripts, imports (including build output and `package.json#imports` aliases), shebangs, tool settings such as `--import=tsx`, CI workflows, git hooks, or shell scripts use tsx anymore, including in nested packages that can resolve it. A package that lists tsx in `dependencies`, `optionalDependencies`, or `peerDependencies` keeps it, since `vpx` comes from a devDependency. +- It reads the scripts a command runs and keeps tsx when `vpx` would fail on them: a `require()` of `./a.js` that only tsx resolves to `a.ts`, a `.cts` file with `import` or `export`, and a file that mixes `import` with `__dirname`, `__filename`, `require()`, or `module.exports` in a package without `"type": "module"`. tsx compiles the last two to CommonJS; `vpx` rejects the first of them and runs the second as an ES module. ## Native Side From 5d51c69a5c9b8c50f5887f16c2310cff55ad8d84 Mon Sep 17 00:00:00 2001 From: "MK (fengmk2)" Date: Thu, 22 Oct 2026 04:05:03 +0800 Subject: [PATCH 06/23] fix(migrate): rewrite only command arguments of launchers, keep tsx-only settings Address the fifth round of review feedback on the tsx migration: - Rewrite only the arguments a launcher runs: the value of `-c` for a shell and of `--exec`/`-x` for nodemon, so `sh -c 'echo "$0"' 'tsx a.ts'` keeps its data. - Keep tsx for a command that sets `TSX_TSCONFIG_PATH`, which vpx does not read, and for `yarn tsx`, `pnpm tsx`, or `bun tsx` when the package has its own `tsx` script. - Document that the script check reads only the named files and does not apply a tsconfig `module` of `node16` or `nodenext`. Co-Authored-By: Claude Opus 5.5 --- docs/guide/migrate-rules.md | 2 +- .../cli/src/migration/__tests__/tsx.spec.ts | 16 +++++ packages/cli/src/migration/migrator/tsx.ts | 63 ++++++++++++++----- rfcs/vpx-script-execution.md | 2 +- 4 files changed, 67 insertions(+), 16 deletions(-) diff --git a/docs/guide/migrate-rules.md b/docs/guide/migrate-rules.md index ccaa0821d1..f53fefcd5c 100644 --- a/docs/guide/migrate-rules.md +++ b/docs/guide/migrate-rules.md @@ -289,7 +289,7 @@ When a `package.json` script or lint-staged task runs [tsx](https://tsx.is), mig `tsx watch` becomes `vpx --watch`, never `vpx watch`, which runs the npm package `watch`. Node.js options and tsx watch options keep their order, and `--no-cache` is dropped. A quoted command is rewritten only for programs that run it, such as `concurrently`, `nodemon --exec`, `sh -c`, and `cross-env-shell`; a quoted argument of other programs, as in `echo "tsx a.ts"`, is data. `vpx` needs the same Node.js versions as Vite+ itself, and migration already upgrades incompatible runtime pins to them (see [Vitest 5](/guide/vitest-v5)). -Migration keeps tsx, with a warning, for a command `vpx` cannot run: `--eval`, `--print`, or `--check`, the REPL, `--test` without files, an entry without a file extension, and commands with pipes, variables, or subshells. It also reads the scripts a command runs and keeps tsx when `vpx` would fail on them: a `require()` of `./a.js` that only tsx resolves to `a.ts`, and, because tsx compiles ES module syntax to CommonJS, a `.cts` file with `import` or `export`, and a file that mixes `import` with `__dirname`, `__filename`, `require()`, or `module.exports` in a package without `"type": "module"`. A package that lists tsx in `dependencies`, `optionalDependencies`, or `peerDependencies` keeps it too, since `vpx` comes from the `vite-plus` devDependency. +Migration keeps tsx, with a warning, for a command `vpx` cannot run: `--eval`, `--print`, or `--check`, the REPL, `--test` without files, an entry without a file extension, commands that set `TSX_TSCONFIG_PATH` (pass `--tsconfig` to `vpx` instead), `yarn tsx` and the like when the package has its own `tsx` script, and commands with pipes, variables, or subshells. It also reads the scripts a command runs and keeps tsx when `vpx` would fail on them: a `require()` of `./a.js` that only tsx resolves to `a.ts`, and, because tsx compiles ES module syntax to CommonJS, a `.cts` file with `import` or `export`, and a file that mixes `import` with `__dirname`, `__filename`, `require()`, or `module.exports` in a package without `"type": "module"`. This check reads only the files a command names, not the modules they import, and does not apply a tsconfig `module` of `node16` or `nodenext`, under which `vpx` runs every `.ts` file as an ES module. A package that lists tsx in `dependencies`, `optionalDependencies`, or `peerDependencies` keeps it too, since `vpx` comes from the `vite-plus` devDependency. Migration removes the `tsx` devDependency only when nothing uses tsx anymore: no kept script, no import of `tsx` or its subpaths such as `tsx/esm/api` (in source, build output, or a `package.json` `imports` alias), no shebang, no tool setting such as `--import=tsx` in `package.json` or a JSON or JavaScript config, and no tsx command in CI workflows, git hooks, or shell scripts. The root's dependency stays while any workspace package still uses tsx, and a package's dependency stays while a nested package under it does. When tsx stays, the warning names what still uses it. diff --git a/packages/cli/src/migration/__tests__/tsx.spec.ts b/packages/cli/src/migration/__tests__/tsx.spec.ts index eb16952cd8..597a2d1c58 100644 --- a/packages/cli/src/migration/__tests__/tsx.spec.ts +++ b/packages/cli/src/migration/__tests__/tsx.spec.ts @@ -47,6 +47,8 @@ describe('rewriteTsxCommand', () => { ], ["nodemon --exec 'tsx src/a.ts'", "nodemon --exec 'vpx src/a.ts'"], ["sh -c 'tsx src/a.ts && vp build'", "sh -c 'vpx src/a.ts && vp build'"], + ["bash -lc 'tsx src/a.ts'", "bash -lc 'vpx src/a.ts'"], + ["nodemon --watch src --exec 'tsx src/a.ts'", "nodemon --watch src --exec 'vpx src/a.ts'"], ['cross-env-shell "tsx src/a.ts"', 'cross-env-shell "vpx src/a.ts"'], ])('rewrites %s', (command, expected) => { const result = rewriteTsxCommand(command); @@ -85,6 +87,10 @@ describe('rewriteTsxCommand', () => { ['pnpm dlx tsx ./a.ts', 'does not rewrite this use of tsx'], // A quoted argument of a program that does not run commands is data. ['echo "tsx scripts/build.ts"', 'does not rewrite this use of tsx'], + // Only the value of `-c` is a command; the next argument is the shell's `$0`. + [`sh -c 'echo "$0"' 'tsx a.ts'`, 'does not rewrite this use of tsx'], + ['TSX_TSCONFIG_PATH=cli.json tsx ./a.ts', 'TSX_TSCONFIG_PATH'], + ['TSX_TSCONFIG_PATH=cli.json node --import tsx ./a.ts', 'TSX_TSCONFIG_PATH'], ])('keeps %s', (command, reason) => { const result = rewriteTsxCommand(command); expect(result.command).toBe(command); @@ -181,6 +187,16 @@ describe('rewriteTsxCommand', () => { // `"type": "module"` already ran it as an ES module under tsx. expect(rewriteTsxCommand('tsx dirname.ts', dir).command).toBe('vpx dirname.ts'); }); + + it('keeps a package-manager shorthand that runs a script named tsx', () => { + fs.writeFileSync(path.join(dir, 'package.json'), '{ "scripts": { "tsx": "node run.js" } }\n'); + expect(rewriteTsxCommand('yarn tsx plain.ts', dir)).toEqual({ + command: 'yarn tsx plain.ts', + kept: ["`yarn tsx` runs the package's own `tsx` script"], + }); + // `exec` always runs the bin. + expect(rewriteTsxCommand('pnpm exec tsx plain.ts', dir).command).toBe('vpx plain.ts'); + }); }); }); diff --git a/packages/cli/src/migration/migrator/tsx.ts b/packages/cli/src/migration/migrator/tsx.ts index b836c2fe7c..c6b3e9283e 100644 --- a/packages/cli/src/migration/migrator/tsx.ts +++ b/packages/cli/src/migration/migrator/tsx.ts @@ -53,6 +53,8 @@ const KEEP_SHELL = 'the command uses shell syntax that vp migrate does not rewrite, such as pipes, variables, or subshells'; const KEEP_OTHER = 'vp migrate does not rewrite this use of tsx'; const KEEP_CWD = 'an earlier command changes the directory in a way vp migrate does not follow'; +const KEEP_TSX_TSCONFIG = + '`TSX_TSCONFIG_PATH` selects the tsconfig for tsx only; pass it to `vpx` as `--tsconfig`'; // Options that `vpx` does not run as tsx does. `-c` is Node.js' `--check`, but `vpx -c` // is `--shell-mode`. @@ -153,16 +155,19 @@ const TYPESCRIPT_EXTENSIONS: Record = { mjs: ['mts'], jsx: ['tsx'], }; -// Programs that run a command given as one quoted argument, as in -// `concurrently "tsx watch a.ts"`. Elsewhere, a quoted string is data, as for `echo`. -const COMMAND_LAUNCHERS = new Set([ - 'bash', - 'concurrently', - 'nodemon', - 'server-test', - 'sh', - 'start-server-and-test', - 'zsh', +// Programs that run commands given as quoted arguments, and which arguments those are, +// judged by the argument before: every one for `concurrently "tsx watch a.ts" "vp dev"`, +// only the value of `-c` for a shell. Elsewhere, a quoted string is data, as for `echo`. +const everyArgument = () => true; +const shellCommand = (previous: string) => /^-[a-z]*c$/.test(previous); +const COMMAND_LAUNCHERS = new Map boolean>([ + ['bash', shellCommand], + ['concurrently', everyArgument], + ['nodemon', (previous) => previous === '--exec' || previous === '-x'], + ['server-test', everyArgument], + ['sh', shellCommand], + ['start-server-and-test', everyArgument], + ['zsh', shellCommand], ]); export interface TsxCommandRewrite { @@ -244,15 +249,28 @@ function rewriteSegment(rewrite: Rewrite, tokens: LiteralCommandToken[]): void { if (first >= values.length) { return; } + // tsx reads TSX_TSCONFIG_PATH; `vpx` takes `--tsconfig` instead. + const tsconfigReason = values + .slice(0, first) + .some((value) => value.startsWith('TSX_TSCONFIG_PATH=')) + ? KEEP_TSX_TSCONFIG + : undefined; const start = first + getCommandStartIndex(values.slice(first)); + const launcher = COMMAND_LAUNCHERS.get(values[start]); if (values[start] === 'tsx') { - rewriteTsx(rewrite, tokens, first, start); + rewriteTsx(rewrite, tokens, first, start, tsconfigReason); } else if (values[start] === 'node') { - rewriteNodeLoader(rewrite, tokens, first, start); - } else if (shell || COMMAND_LAUNCHERS.has(values[start])) { + rewriteNodeLoader(rewrite, tokens, first, start, tsconfigReason); + } else if (shell) { for (const token of tokens.slice(start)) { rewriteQuotedCommand(rewrite, token); } + } else if (launcher) { + for (let index = start + 1; index < tokens.length; index++) { + if (launcher(values[index - 1])) { + rewriteQuotedCommand(rewrite, tokens[index]); + } + } } } @@ -360,7 +378,23 @@ function rewriteTsx( tokens: LiteralCommandToken[], first: number, start: number, + keepReason: string | undefined, ): void { + // `yarn tsx`, `pnpm tsx`, and `bun tsx` run the package's own `tsx` script before the bin. + if ( + start === first + 1 && + ['bun', 'pnpm', 'yarn'].includes(tokens[first].value) && + typeof rewrite.cwd === 'string' + ) { + const scripts = readNearestPackageJson(rewrite.cwd)?.scripts; + if (isPlainRecord(scripts) && scripts.tsx !== undefined) { + keepReason ??= `\`${tokens[first].value} tsx\` runs the package's own \`tsx\` script`; + } + } + if (keepReason) { + rewrite.kept.push(keepReason); + return; + } let index = start + 1; const watch = tokens[index]?.value === 'watch'; if (watch) { @@ -415,10 +449,11 @@ function rewriteNodeLoader( tokens: LiteralCommandToken[], first: number, start: number, + keepReason: string | undefined, ): void { const loaders: Edit[] = []; let entryIndex = tokens.length; - let optionReason: string | undefined; + let optionReason = keepReason; let test = false; for (let index = start + 1; index < tokens.length; index++) { const { value } = tokens[index]; diff --git a/rfcs/vpx-script-execution.md b/rfcs/vpx-script-execution.md index 4ba314dd30..2b4d7151ec 100644 --- a/rfcs/vpx-script-execution.md +++ b/rfcs/vpx-script-execution.md @@ -188,7 +188,7 @@ For a script, the local bin and the global shim resolve the same loader and Node - It reads commands with the same literal-command parser as the other script rewrites and finds the script the way `vpx` does, with the shared value-taking option lists. Commands it cannot read, and forms `vpx` cannot run (eval, `--check`, the REPL, `--test` without files, an entry without an extension), keep tsx with a warning. - It has no Node.js check of its own: `vpx` needs the `engines.node` range of `vite-plus` (`SUPPORTED_NODE_RANGE` in `script-hooks.ts`, which a unit test keeps equal), and migration already upgrades incompatible runtime pins to that range for Vite+ itself. - The `tsx` devDependency goes only when no scripts, imports (including build output and `package.json#imports` aliases), shebangs, tool settings such as `--import=tsx`, CI workflows, git hooks, or shell scripts use tsx anymore, including in nested packages that can resolve it. A package that lists tsx in `dependencies`, `optionalDependencies`, or `peerDependencies` keeps it, since `vpx` comes from a devDependency. -- It reads the scripts a command runs and keeps tsx when `vpx` would fail on them: a `require()` of `./a.js` that only tsx resolves to `a.ts`, a `.cts` file with `import` or `export`, and a file that mixes `import` with `__dirname`, `__filename`, `require()`, or `module.exports` in a package without `"type": "module"`. tsx compiles the last two to CommonJS; `vpx` rejects the first of them and runs the second as an ES module. +- It reads the scripts a command runs and keeps tsx when `vpx` would fail on them: a `require()` of `./a.js` that only tsx resolves to `a.ts`, a `.cts` file with `import` or `export`, and a file that mixes `import` with `__dirname`, `__filename`, `require()`, or `module.exports` in a package without `"type": "module"`. tsx compiles the last two to CommonJS; `vpx` rejects the first of them and runs the second as an ES module. The check reads only the named files, not their imports, and takes the module format from `"type"` alone, not from a tsconfig `module` of `node16` or `nodenext`; following imports and tsconfig `extends` would turn a guard against common breakage into module-graph analysis. ## Native Side From 59466493b98c24917098d5ca948596fee34339f0 Mon Sep 17 00:00:00 2001 From: "MK (fengmk2)" Date: Thu, 22 Oct 2026 04:22:45 +0800 Subject: [PATCH 07/23] fix(migrate): keep tsx for watched tests and launched TSX_TSCONFIG_PATH Address the sixth round of review feedback on the tsx migration: - Keep tsx for `--test` in watch mode (`tsx watch --test`, `--watch --test`), which `vpx --watch` rejects. - Keep tsx for commands a launcher runs when the launcher sets `TSX_TSCONFIG_PATH`, as in `cross-env-shell TSX_TSCONFIG_PATH=x "tsx a.ts"`. - Before removing tsx, match split loader arguments such as `['--import', 'tsx']` in JavaScript configs, and scan `.vue`, `.svelte`, and `.astro` files for imports. Co-Authored-By: Claude Opus 5.5 --- docs/guide/migrate-rules.md | 2 +- .../cli/src/migration/__tests__/tsx.spec.ts | 18 +++++ packages/cli/src/migration/migrator/tsx.ts | 68 ++++++++++++++----- rfcs/vpx-script-execution.md | 2 +- 4 files changed, 70 insertions(+), 20 deletions(-) diff --git a/docs/guide/migrate-rules.md b/docs/guide/migrate-rules.md index f53fefcd5c..7a0c816ee8 100644 --- a/docs/guide/migrate-rules.md +++ b/docs/guide/migrate-rules.md @@ -289,7 +289,7 @@ When a `package.json` script or lint-staged task runs [tsx](https://tsx.is), mig `tsx watch` becomes `vpx --watch`, never `vpx watch`, which runs the npm package `watch`. Node.js options and tsx watch options keep their order, and `--no-cache` is dropped. A quoted command is rewritten only for programs that run it, such as `concurrently`, `nodemon --exec`, `sh -c`, and `cross-env-shell`; a quoted argument of other programs, as in `echo "tsx a.ts"`, is data. `vpx` needs the same Node.js versions as Vite+ itself, and migration already upgrades incompatible runtime pins to them (see [Vitest 5](/guide/vitest-v5)). -Migration keeps tsx, with a warning, for a command `vpx` cannot run: `--eval`, `--print`, or `--check`, the REPL, `--test` without files, an entry without a file extension, commands that set `TSX_TSCONFIG_PATH` (pass `--tsconfig` to `vpx` instead), `yarn tsx` and the like when the package has its own `tsx` script, and commands with pipes, variables, or subshells. It also reads the scripts a command runs and keeps tsx when `vpx` would fail on them: a `require()` of `./a.js` that only tsx resolves to `a.ts`, and, because tsx compiles ES module syntax to CommonJS, a `.cts` file with `import` or `export`, and a file that mixes `import` with `__dirname`, `__filename`, `require()`, or `module.exports` in a package without `"type": "module"`. This check reads only the files a command names, not the modules they import, and does not apply a tsconfig `module` of `node16` or `nodenext`, under which `vpx` runs every `.ts` file as an ES module. A package that lists tsx in `dependencies`, `optionalDependencies`, or `peerDependencies` keeps it too, since `vpx` comes from the `vite-plus` devDependency. +Migration keeps tsx, with a warning, for a command `vpx` cannot run: `--eval`, `--print`, or `--check`, the REPL, `--test` without files or in watch mode, an entry without a file extension, commands that set `TSX_TSCONFIG_PATH` (pass `--tsconfig` to `vpx` instead), `yarn tsx` and the like when the package has its own `tsx` script, and commands with pipes, variables, or subshells. It also reads the scripts a command runs and keeps tsx when `vpx` would fail on them: a `require()` of `./a.js` that only tsx resolves to `a.ts`, and, because tsx compiles ES module syntax to CommonJS, a `.cts` file with `import` or `export`, and a file that mixes `import` with `__dirname`, `__filename`, `require()`, or `module.exports` in a package without `"type": "module"`. This check reads only the files a command names, not the modules they import, and does not apply a tsconfig `module` of `node16` or `nodenext`, under which `vpx` runs every `.ts` file as an ES module. A package that lists tsx in `dependencies`, `optionalDependencies`, or `peerDependencies` keeps it too, since `vpx` comes from the `vite-plus` devDependency. Migration removes the `tsx` devDependency only when nothing uses tsx anymore: no kept script, no import of `tsx` or its subpaths such as `tsx/esm/api` (in source, build output, or a `package.json` `imports` alias), no shebang, no tool setting such as `--import=tsx` in `package.json` or a JSON or JavaScript config, and no tsx command in CI workflows, git hooks, or shell scripts. The root's dependency stays while any workspace package still uses tsx, and a package's dependency stays while a nested package under it does. When tsx stays, the warning names what still uses it. diff --git a/packages/cli/src/migration/__tests__/tsx.spec.ts b/packages/cli/src/migration/__tests__/tsx.spec.ts index 597a2d1c58..fc23433617 100644 --- a/packages/cli/src/migration/__tests__/tsx.spec.ts +++ b/packages/cli/src/migration/__tests__/tsx.spec.ts @@ -91,6 +91,11 @@ describe('rewriteTsxCommand', () => { [`sh -c 'echo "$0"' 'tsx a.ts'`, 'does not rewrite this use of tsx'], ['TSX_TSCONFIG_PATH=cli.json tsx ./a.ts', 'TSX_TSCONFIG_PATH'], ['TSX_TSCONFIG_PATH=cli.json node --import tsx ./a.ts', 'TSX_TSCONFIG_PATH'], + ['cross-env-shell TSX_TSCONFIG_PATH=cli.json "tsx ./a.ts"', 'TSX_TSCONFIG_PATH'], + ['TSX_TSCONFIG_PATH=cli.json concurrently "tsx ./a.ts"', 'TSX_TSCONFIG_PATH'], + ['tsx watch --test ./a.test.ts', '`vpx --watch` does not run `--test`'], + ['tsx --watch --test ./a.test.ts', '`vpx --watch` does not run `--test`'], + ['node --import tsx --watch --test ./a.test.ts', '`vpx --watch` does not run `--test`'], ])('keeps %s', (command, reason) => { const result = rewriteTsxCommand(command); expect(result.command).toBe(command); @@ -236,6 +241,7 @@ describe('migrateTsxToVpx', () => { write('src/server.ts', 'export {};\n'); // File extensions are not uses of tsx. write('vite.config.ts', "export default { resolve: { extensions: ['ts', 'tsx'] } };\n"); + write('src/App.vue', '\n'); const report = createMigrationReport(); expect(detectTsxProject(root)).toBe(true); @@ -331,6 +337,18 @@ describe('migrateTsxToVpx', () => { "export default { nodeArguments: ['--import=tsx'] };\n", 'ava.config.js still uses tsx', ], + [ + 'a JavaScript tool config with split loader arguments', + 'ava.config.mjs', + "export default { nodeArguments: ['--import', 'tsx'] };\n", + 'ava.config.mjs still uses tsx', + ], + [ + 'a framework component', + 'src/pages/index.astro', + "---\nimport { tsImport } from 'tsx/esm/api';\n---\n", + 'src/pages/index.astro still uses tsx', + ], ])('keeps the tsx dependency used by %s', (_, file, content, user) => { write('package.json', { scripts: { diff --git a/packages/cli/src/migration/migrator/tsx.ts b/packages/cli/src/migration/migrator/tsx.ts index c6b3e9283e..ec5a33e2cd 100644 --- a/packages/cli/src/migration/migrator/tsx.ts +++ b/packages/cli/src/migration/migrator/tsx.ts @@ -55,6 +55,7 @@ const KEEP_OTHER = 'vp migrate does not rewrite this use of tsx'; const KEEP_CWD = 'an earlier command changes the directory in a way vp migrate does not follow'; const KEEP_TSX_TSCONFIG = '`TSX_TSCONFIG_PATH` selects the tsconfig for tsx only; pass it to `vpx` as `--tsconfig`'; +const KEEP_WATCH_TEST = '`vpx --watch` does not run `--test`'; // Options that `vpx` does not run as tsx does. `-c` is Node.js' `--check`, but `vpx -c` // is `--shell-mode`. @@ -82,13 +83,15 @@ const TSX_LOADER_FLAG = /(?:--import|--loader|--experimental-loader|--require|-r)[= \t]+['"]?tsx(?:\/(?:esm|cjs))?(?![\w./-])/; // In code: an import of tsx or a subpath such as `tsx/esm/api`, a string that starts a // tsx command, tsx as the program of a process call, as in `spawn('tsx', [...])`, or a -// loader flag, as in `nodeArguments: ['--import=tsx']`. +// loader flag, as in `nodeArguments: ['--import=tsx']` or `['--import', 'tsx']`. const TSX_CODE_REFERENCE = new RegExp( [ /(?:\bfrom|\bimport|\brequire(?:\.resolve)?)\s*\(?\s*['"]tsx(?:\/[^'"]*)?['"]/.source, /['"`](?:npx |pnpm (?:exec )?|yarn |bunx )?tsx\s/.source, /\(\s*['"`]tsx['"`]\s*,/.source, TSX_LOADER_FLAG.source, + /['"`](?:--import|--loader|--experimental-loader|--require|-r)['"`]\s*,\s*['"`]tsx(?:\/(?:esm|cjs))?['"`]/ + .source, ].join('|'), ); // In shell scripts, CI workflows, and git hooks: tsx in command position, with or @@ -116,9 +119,15 @@ const IGNORED_FIELDS = new Set([ 'pnpm', 'resolutions', ]); -const CODE_EXTENSIONS: ReadonlySet = new Set(SCRIPT_EXTENSIONS); -const REFERENCE_EXTENSIONS: ReadonlySet = new Set([ +// Script files and framework components whose code can import tsx. +const CODE_EXTENSIONS: ReadonlySet = new Set([ ...SCRIPT_EXTENSIONS, + '.astro', + '.svelte', + '.vue', +]); +const REFERENCE_EXTENSIONS: ReadonlySet = new Set([ + ...CODE_EXTENSIONS, '.json', '.yml', '.yaml', @@ -249,7 +258,8 @@ function rewriteSegment(rewrite: Rewrite, tokens: LiteralCommandToken[]): void { if (first >= values.length) { return; } - // tsx reads TSX_TSCONFIG_PATH; `vpx` takes `--tsconfig` instead. + // tsx reads TSX_TSCONFIG_PATH, also in the commands a launcher runs; `vpx` takes + // `--tsconfig` instead. const tsconfigReason = values .slice(0, first) .some((value) => value.startsWith('TSX_TSCONFIG_PATH=')) @@ -263,12 +273,12 @@ function rewriteSegment(rewrite: Rewrite, tokens: LiteralCommandToken[]): void { rewriteNodeLoader(rewrite, tokens, first, start, tsconfigReason); } else if (shell) { for (const token of tokens.slice(start)) { - rewriteQuotedCommand(rewrite, token); + rewriteQuotedCommand(rewrite, token, tsconfigReason); } } else if (launcher) { for (let index = start + 1; index < tokens.length; index++) { if (launcher(values[index - 1])) { - rewriteQuotedCommand(rewrite, tokens[index]); + rewriteQuotedCommand(rewrite, tokens[index], tsconfigReason); } } } @@ -405,6 +415,7 @@ function rewriteTsx( ]; let entryIndex = tokens.length; let test = false; + let nodeWatch = false; for (; index < tokens.length; index++) { const { value } = tokens[index]; if (value === '--' || !value.startsWith('-')) { @@ -423,6 +434,7 @@ function rewriteTsx( return; } test ||= name === '--test'; + nodeWatch ||= name === '--watch'; if ( !value.includes('=') && (NODE_OPTIONS_WITH_VALUE.has(name) || @@ -432,11 +444,14 @@ function rewriteTsx( index++; } } - const reason = runKeepReason( - rewrite.cwd, - test, - tokens.slice(entryIndex).map(({ value }) => value), - ); + const reason = + (watch || nodeWatch) && test + ? KEEP_WATCH_TEST + : runKeepReason( + rewrite.cwd, + test, + tokens.slice(entryIndex).map(({ value }) => value), + ); if (reason) { rewrite.kept.push(reason); return; @@ -455,6 +470,7 @@ function rewriteNodeLoader( let entryIndex = tokens.length; let optionReason = keepReason; let test = false; + let watch = false; for (let index = start + 1; index < tokens.length; index++) { const { value } = tokens[index]; if (value === '--' || !value.startsWith('-')) { @@ -475,6 +491,7 @@ function rewriteNodeLoader( } optionReason ??= KEEP_OPTIONS.get(name); test ||= name === '--test'; + watch ||= name === '--watch'; if (!inline && NODE_OPTIONS_WITH_VALUE.has(name)) { index++; } @@ -484,11 +501,13 @@ function rewriteNodeLoader( } const reason = optionReason ?? - runKeepReason( - rewrite.cwd, - test, - tokens.slice(entryIndex).map(({ value }) => value), - ); + (watch && test + ? KEEP_WATCH_TEST + : runKeepReason( + rewrite.cwd, + test, + tokens.slice(entryIndex).map(({ value }) => value), + )); if (reason) { rewrite.kept.push(reason); return; @@ -499,7 +518,15 @@ function rewriteNodeLoader( ); } -function rewriteQuotedCommand(rewrite: Rewrite, token: LiteralCommandToken): void { +/** + * Rewrite a command that a launcher runs, given as one quoted argument. `inherited` keeps + * tsx for it, as when the launcher passes on `TSX_TSCONFIG_PATH`. + */ +function rewriteQuotedCommand( + rewrite: Rewrite, + token: LiteralCommandToken, + inherited: string | undefined, +): void { const raw = rewrite.command.slice(token.start, token.end); const quote = raw[0]; if ((quote !== '"' && quote !== "'") || raw.length < 2 || !raw.endsWith(quote)) { @@ -507,7 +534,12 @@ function rewriteQuotedCommand(rewrite: Rewrite, token: LiteralCommandToken): voi } const inner = rewriteTsxCommand(token.value, rewrite.cwd); rewrite.kept.push(...inner.kept); - if (inner.command !== token.value && !inner.command.includes(quote)) { + if (inner.command === token.value) { + return; + } + if (inherited) { + rewrite.kept.push(inherited); + } else if (!inner.command.includes(quote)) { rewrite.edits.push({ start: token.start, end: token.end, diff --git a/rfcs/vpx-script-execution.md b/rfcs/vpx-script-execution.md index 2b4d7151ec..f4acc0c4d8 100644 --- a/rfcs/vpx-script-execution.md +++ b/rfcs/vpx-script-execution.md @@ -185,7 +185,7 @@ For a script, the local bin and the global shim resolve the same loader and Node `vp migrate` rewrites tsx commands to `vpx` as an optional migration, like Prettier and tsup: always on a fresh project, and with `--full` on one already using Vite+ (`packages/cli/src/migration/migrator/tsx.ts`; behavior in [Migration Rules](../docs/guide/migrate-rules.md#tsx-to-vpx)). -- It reads commands with the same literal-command parser as the other script rewrites and finds the script the way `vpx` does, with the shared value-taking option lists. Commands it cannot read, and forms `vpx` cannot run (eval, `--check`, the REPL, `--test` without files, an entry without an extension), keep tsx with a warning. +- It reads commands with the same literal-command parser as the other script rewrites and finds the script the way `vpx` does, with the shared value-taking option lists. Commands it cannot read, and forms `vpx` cannot run (eval, `--check`, the REPL, `--test` without files or in watch mode, an entry without an extension), keep tsx with a warning. - It has no Node.js check of its own: `vpx` needs the `engines.node` range of `vite-plus` (`SUPPORTED_NODE_RANGE` in `script-hooks.ts`, which a unit test keeps equal), and migration already upgrades incompatible runtime pins to that range for Vite+ itself. - The `tsx` devDependency goes only when no scripts, imports (including build output and `package.json#imports` aliases), shebangs, tool settings such as `--import=tsx`, CI workflows, git hooks, or shell scripts use tsx anymore, including in nested packages that can resolve it. A package that lists tsx in `dependencies`, `optionalDependencies`, or `peerDependencies` keeps it, since `vpx` comes from a devDependency. - It reads the scripts a command runs and keeps tsx when `vpx` would fail on them: a `require()` of `./a.js` that only tsx resolves to `a.ts`, a `.cts` file with `import` or `export`, and a file that mixes `import` with `__dirname`, `__filename`, `require()`, or `module.exports` in a package without `"type": "module"`. tsx compiles the last two to CommonJS; `vpx` rejects the first of them and runs the second as an ES module. The check reads only the named files, not their imports, and takes the module format from `"type"` alone, not from a tsconfig `module` of `node16` or `nodenext`; following imports and tsconfig `extends` would turn a guard against common breakage into module-graph analysis. From f799a45a0fa787f503feba4dd73bcd1bf2840fd9 Mon Sep 17 00:00:00 2001 From: "MK (fengmk2)" Date: Thu, 22 Oct 2026 04:35:51 +0800 Subject: [PATCH 08/23] fix(migrate): detect compact ES module syntax, follow TSX_TSCONFIG_PATH assignments Address the seventh round of review feedback on the tsx migration: - Detect compact ES module syntax such as `export{}` and `export*from`, and syntax after `;`. Stop reading identifiers like `importer` as `import`, and TypeScript's `import x = require()` and `export =` as ES modules, so a `.cts` file written that way migrates. - Keep tsx for later commands after a bare `TSX_TSCONFIG_PATH=...` segment, which can update an exported variable. `export TSX_TSCONFIG_PATH=...` already keeps tsx, since the command is not rewritten. Co-Authored-By: Claude Opus 5.5 --- .../cli/src/migration/__tests__/tsx.spec.ts | 32 +++++++++++++++---- packages/cli/src/migration/migrator/tsx.ts | 24 +++++++++----- 2 files changed, 41 insertions(+), 15 deletions(-) diff --git a/packages/cli/src/migration/__tests__/tsx.spec.ts b/packages/cli/src/migration/__tests__/tsx.spec.ts index fc23433617..36f67208ed 100644 --- a/packages/cli/src/migration/__tests__/tsx.spec.ts +++ b/packages/cli/src/migration/__tests__/tsx.spec.ts @@ -93,6 +93,8 @@ describe('rewriteTsxCommand', () => { ['TSX_TSCONFIG_PATH=cli.json node --import tsx ./a.ts', 'TSX_TSCONFIG_PATH'], ['cross-env-shell TSX_TSCONFIG_PATH=cli.json "tsx ./a.ts"', 'TSX_TSCONFIG_PATH'], ['TSX_TSCONFIG_PATH=cli.json concurrently "tsx ./a.ts"', 'TSX_TSCONFIG_PATH'], + ['TSX_TSCONFIG_PATH=cli.json; tsx ./a.ts', 'TSX_TSCONFIG_PATH'], + ['export TSX_TSCONFIG_PATH=cli.json && tsx ./a.ts', 'shell syntax'], ['tsx watch --test ./a.test.ts', '`vpx --watch` does not run `--test`'], ['tsx --watch --test ./a.test.ts', '`vpx --watch` does not run `--test`'], ['node --import tsx --watch --test ./a.test.ts', '`vpx --watch` does not run `--test`'], @@ -153,6 +155,13 @@ describe('rewriteTsxCommand', () => { fs.writeFileSync(path.join(dir, 'helper.ts'), 'exports.x = 1;\n'); fs.writeFileSync(path.join(dir, 'require-js.cts'), "const { x } = require('./helper.js');\n"); fs.writeFileSync(path.join(dir, 'require-bare.cts'), "const { x } = require('./helper');\n"); + // Compact ES module syntax, and CommonJS that only looks like it. + fs.writeFileSync(path.join(dir, 'compact.ts'), 'export{};console.log(__dirname);\n'); + fs.writeFileSync( + path.join(dir, 'import-equals.cts'), + "import fs = require('node:fs');\nexport = fs.readdirSync(__dirname);\n", + ); + fs.writeFileSync(path.join(dir, 'importer.ts'), 'importer.run(__dirname);\n'); }); afterEach(() => { @@ -162,11 +171,18 @@ describe('rewriteTsxCommand', () => { it.each([ ['tsx esm.cts', '`esm.cts` uses import or export'], ['node --import tsx ./esm.cts', '`./esm.cts` uses import or export'], - ['tsx watch dirname.ts', '`dirname.ts` mixes import with CommonJS globals'], - ['tsx --test plain.ts dirname.ts', '`dirname.ts` mixes import with CommonJS globals'], - ['concurrently "tsx dirname.ts"', '`dirname.ts` mixes import with CommonJS globals'], - ['cd tools && tsx only.ts', '`only.ts` mixes import with CommonJS globals'], - ['tsx exports.ts', '`exports.ts` mixes import with CommonJS globals'], + ['tsx watch dirname.ts', '`dirname.ts` mixes import or export with CommonJS globals'], + [ + 'tsx --test plain.ts dirname.ts', + '`dirname.ts` mixes import or export with CommonJS globals', + ], + [ + 'concurrently "tsx dirname.ts"', + '`dirname.ts` mixes import or export with CommonJS globals', + ], + ['cd tools && tsx only.ts', '`only.ts` mixes import or export with CommonJS globals'], + ['tsx exports.ts', '`exports.ts` mixes import or export with CommonJS globals'], + ['tsx compact.ts', '`compact.ts` mixes import or export with CommonJS globals'], ['tsx require-js.cts', 'requires `./helper.js`, which only tsx resolves to `./helper.ts`'], ['pushd tools && tsx plain.ts', 'changes the directory'], ])('keeps %s', (command, reason) => { @@ -181,6 +197,8 @@ describe('rewriteTsxCommand', () => { ['tsx plain.ts', 'vpx plain.ts'], ['tsx esm-idioms.ts', 'vpx esm-idioms.ts'], ['tsx require-bare.cts', 'vpx require-bare.cts'], + ['tsx import-equals.cts', 'vpx import-equals.cts'], + ['tsx importer.ts', 'vpx importer.ts'], ['tsx --test plain.ts', 'vpx --test plain.ts'], ['cd tools && tsx plain.ts', 'cd tools && vpx plain.ts'], ])('rewrites %s', (command, expected) => { @@ -461,10 +479,10 @@ describe('migrateTsxToVpx', () => { expect(read('scripts/release.ts')).toMatch(/^#!\/usr\/bin\/env tsx\n/); expect(report.warnings).toEqual([ expect.stringContaining( - 'Kept tsx in package.json scripts "build": `scripts/build.ts` mixes import with CommonJS globals', + 'Kept tsx in package.json scripts "build": `scripts/build.ts` mixes import or export with CommonJS globals', ), expect.stringContaining( - 'Kept tsx in the shebang of scripts/release.ts: `./release.ts` mixes import with CommonJS globals', + 'Kept tsx in the shebang of scripts/release.ts: `./release.ts` mixes import or export with CommonJS globals', ), ]); }); diff --git a/packages/cli/src/migration/migrator/tsx.ts b/packages/cli/src/migration/migrator/tsx.ts index ec5a33e2cd..5211dec440 100644 --- a/packages/cli/src/migration/migrator/tsx.ts +++ b/packages/cli/src/migration/migrator/tsx.ts @@ -148,8 +148,11 @@ const LOCKFILES = new Set([ 'yarn.lock', ]); -// ES module syntax, and uses of the CommonJS globals an ES module does not have. -const ESM_SYNTAX = /^\s*(?:import\s*[\w*{'"]|export\s)|\bimport\.meta\b/m; +// ES module syntax at the start of a statement, including compact forms such as +// `export{}` and `export*from`, and uses of the CommonJS globals an ES module does not +// have. TypeScript's `import x = require()` and `export =` stay CommonJS. +const ESM_SYNTAX = + /(?:^|;)\s*(?:import(?![\w$])\s*(?![\w$]+\s*=)[\w*{'"]|export(?![\w$])\s*[\w*{])|\bimport\.meta\b/m; const MODULE_EXPORTS = /(? value.startsWith('TSX_TSCONFIG_PATH=')); if (first >= values.length) { + // A bare assignment can update an exported variable that later commands inherit. + if (assignsTsconfig) { + rewrite.tsconfigReason = KEEP_TSX_TSCONFIG; + } return; } // tsx reads TSX_TSCONFIG_PATH, also in the commands a launcher runs; `vpx` takes // `--tsconfig` instead. - const tsconfigReason = values - .slice(0, first) - .some((value) => value.startsWith('TSX_TSCONFIG_PATH=')) - ? KEEP_TSX_TSCONFIG - : undefined; + const tsconfigReason = assignsTsconfig ? KEEP_TSX_TSCONFIG : rewrite.tsconfigReason; const start = first + getCommandStartIndex(values.slice(first)); const launcher = COMMAND_LAUNCHERS.get(values[start]); if (values[start] === 'tsx') { @@ -335,7 +343,7 @@ function scriptKeepReason(cwd: string | undefined, script: string): string | und usesCommonJsGlobals(source) && readNearestPackageJson(path.dirname(file))?.type !== 'module' ) { - return `\`${script}\` mixes import with CommonJS globals such as __dirname, require, or module.exports, and \`vpx\` runs it as an ES module; use import.meta.dirname, import.meta.filename, createRequire(), or export`; + return `\`${script}\` mixes import or export with CommonJS globals such as __dirname, require, or module.exports, and \`vpx\` runs it as an ES module; use import.meta.dirname, import.meta.filename, createRequire(), or export`; } return undefined; } From 8c8fcc44e4a99895238e8ae86a41813961902d9d Mon Sep 17 00:00:00 2001 From: "MK (fengmk2)" Date: Thu, 22 Oct 2026 04:48:47 +0800 Subject: [PATCH 09/23] fix(migrate): handle cd fallbacks, %VAR%, and Windows tsx bin paths Address the eighth round of review feedback on the tsx migration: - After a `cd`, treat the directory as unknown past `||`, since a fallback runs when an earlier command, maybe the `cd`, failed. - Keep tsx for commands with cmd.exe `%VAR%` expansions, as for `$VAR`. - Recognize `node_modules\.bin\tsx.cmd` and other Windows spellings of the tsx bin, so those commands keep tsx with a warning and keep the dependency. Co-Authored-By: Claude Opus 5.5 --- .../cli/src/migration/__tests__/tsx.spec.ts | 10 ++++++++ packages/cli/src/migration/migrator/tsx.ts | 23 +++++++++++++++---- 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/migration/__tests__/tsx.spec.ts b/packages/cli/src/migration/__tests__/tsx.spec.ts index 36f67208ed..afd701c82f 100644 --- a/packages/cli/src/migration/__tests__/tsx.spec.ts +++ b/packages/cli/src/migration/__tests__/tsx.spec.ts @@ -80,6 +80,8 @@ describe('rewriteTsxCommand', () => { ['tsx --watch_path=src ./a.ts', '--watch-path'], ['tsx ./a.ts | tee log', 'shell syntax'], ['tsx $ENTRY', 'shell syntax'], + ['tsx %ENTRY%.ts', 'shell syntax'], + ['.\\node_modules\\.bin\\tsx.cmd scripts/a.ts', 'shell syntax'], ['node --import tsx', 'REPL'], ['node --import tsx -e 1', '--eval or --print'], ['node --import tsx --watch-path src ./a.ts', '--watch-path'], @@ -185,6 +187,8 @@ describe('rewriteTsxCommand', () => { ['tsx compact.ts', '`compact.ts` mixes import or export with CommonJS globals'], ['tsx require-js.cts', 'requires `./helper.js`, which only tsx resolves to `./helper.ts`'], ['pushd tools && tsx plain.ts', 'changes the directory'], + // The fallback runs in the package directory, or after a later `cd`. + ['cd tools || tsx plain.ts', 'changes the directory'], ])('keeps %s', (command, reason) => { expect(rewriteTsxCommand(command, dir)).toEqual({ command, @@ -343,6 +347,12 @@ describe('migrateTsxToVpx', () => { 'cat job.ts | tsx\n', 'scripts/run.sh still uses tsx', ], + [ + 'a Windows batch file', + 'scripts/run.cmd', + '@node_modules\\.bin\\tsx.cmd scripts\\a.ts\r\n', + 'scripts/run.cmd still uses tsx', + ], [ 'a JSON tool config', '.mocharc.json', diff --git a/packages/cli/src/migration/migrator/tsx.ts b/packages/cli/src/migration/migrator/tsx.ts index 5211dec440..f90bf8e850 100644 --- a/packages/cli/src/migration/migrator/tsx.ts +++ b/packages/cli/src/migration/migrator/tsx.ts @@ -30,8 +30,10 @@ import { } from './shared.ts'; // tsx in command position: `tsx a.ts`, `npx tsx`, `--import tsx`, `--import=tsx`, -// `-r tsx/cjs`, or inside a quoted command. Not `.tsx` files or packages such as `tsx-foo`. -const TSX_COMMAND = /(?:^|[\s;&|("'=/`])tsx(?:\/(?:esm|cjs))?(?=$|[\s;&|)"'`])/; +// `-r tsx/cjs`, `node_modules\.bin\tsx.cmd`, or inside a quoted command. Not `.tsx` +// files or packages such as `tsx-foo`. +const TSX_COMMAND = + /(?:^|[\s;&|("'=/\\`])tsx(?:\.(?:cmd|exe|ps1))?(?:\/(?:esm|cjs))?(?=$|[\s;&|)"'`])/; // Node.js options that load tsx's hooks, as in `node --import tsx ./a.ts`. const LOADER_OPTIONS = new Set([ @@ -98,7 +100,7 @@ const TSX_CODE_REFERENCE = new RegExp( // without arguments, or a loader flag. const TSX_TEXT_REFERENCE = new RegExp( [ - /(?:^|[\s;&|("'=/`])tsx(?:\/(?:esm|cjs))?(?:[ \t]+\S|[ \t]*$)/.source, + /(?:^|[\s;&|("'=/\\`])tsx(?:\.(?:cmd|exe|ps1))?(?:\/(?:esm|cjs))?(?:[ \t]+\S|[ \t]*$)/.source, TSX_LOADER_FLAG.source, ].join('|'), 'm', @@ -217,16 +219,29 @@ export function rewriteTsxCommand(command: string, cwd?: string | null): TsxComm return { command, kept: [] }; } const segments = parseLiteralCommands(command, true); - if (!segments) { + // The parser rejects `$VAR`; cmd.exe expands `%VAR%` too. + if (!segments || /%\w+%/.test(command)) { return { command, kept: [KEEP_SHELL] }; } const rewrite: Rewrite = { command, cwd, edits: [], kept: [] }; + let changedDirectory = false; + let end = 0; for (const tokens of segments) { + // After `||`, a command runs because an earlier one failed, maybe a `cd`. + if ( + changedDirectory && + command.slice(end, tokens[0].start).includes('||') && + rewrite.cwd !== undefined + ) { + rewrite.cwd = null; + } + end = tokens[tokens.length - 1].end; const [name, ...args] = tokens.map(({ value }) => value); if (SHELL_STATE_COMMANDS.includes(name)) { // Follow `cd `; after another directory change, scripts cannot be found. if (name === 'cd' && args.length === 1 && !/^[-~]/.test(args[0])) { rewrite.cwd &&= path.resolve(rewrite.cwd, args[0]); + changedDirectory = true; } else if (!['export', 'set', 'unset'].includes(name) && rewrite.cwd !== undefined) { rewrite.cwd = null; } From 823675be04376b344da66e5bff041b837a734779 Mon Sep 17 00:00:00 2001 From: "MK (fengmk2)" Date: Thu, 22 Oct 2026 04:49:38 +0800 Subject: [PATCH 10/23] style(migrate): use Array#at for the last token Co-Authored-By: Claude Opus 5.5 --- packages/cli/src/migration/migrator/tsx.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/src/migration/migrator/tsx.ts b/packages/cli/src/migration/migrator/tsx.ts index f90bf8e850..5e40933188 100644 --- a/packages/cli/src/migration/migrator/tsx.ts +++ b/packages/cli/src/migration/migrator/tsx.ts @@ -235,7 +235,7 @@ export function rewriteTsxCommand(command: string, cwd?: string | null): TsxComm ) { rewrite.cwd = null; } - end = tokens[tokens.length - 1].end; + end = tokens.at(-1)!.end; const [name, ...args] = tokens.map(({ value }) => value); if (SHELL_STATE_COMMANDS.includes(name)) { // Follow `cd `; after another directory change, scripts cannot be found. From e95bb398c936f038a5dba7718d5ad36adeb14b9f Mon Sep 17 00:00:00 2001 From: "MK (fengmk2)" Date: Thu, 22 Oct 2026 05:01:14 +0800 Subject: [PATCH 11/23] fix(migrate): detect shebang-only tsx projects, scan JSONC configs Address the ninth round of review feedback on the tsx migration: - Offer the migration when a `#!/usr/bin/env tsx` shebang is the only tsx use it can rewrite, sharing the shebang rewrite with detection. - Scan `.jsonc` configs, such as `wrangler.jsonc`, before removing tsx. - Resolve `~/` entries against the home directory for the script check. - Ignore commented-out declarations, so a real `require()` beside `// const require = ...` still keeps tsx. Co-Authored-By: Claude Opus 5.5 --- docs/guide/migrate-rules.md | 2 +- .../cli/src/migration/__tests__/tsx.spec.ts | 36 ++++++++++ packages/cli/src/migration/migrator/tsx.ts | 72 +++++++++++++------ 3 files changed, 89 insertions(+), 21 deletions(-) diff --git a/docs/guide/migrate-rules.md b/docs/guide/migrate-rules.md index 7a0c816ee8..161c7acc4e 100644 --- a/docs/guide/migrate-rules.md +++ b/docs/guide/migrate-rules.md @@ -276,7 +276,7 @@ Unrelated `bunx` commands and other package-executor forms remain unchanged. ### tsx to `vpx` -When a `package.json` script or lint-staged task runs [tsx](https://tsx.is), migration offers to move it to [`vpx`](/guide/vpx), Vite+'s TypeScript script runner. It rewrites scripts and lint-staged tasks in `package.json` and JSON lint-staged files, and `#!/usr/bin/env tsx` shebangs in script files: +When a `package.json` script, a lint-staged task, or a `#!/usr/bin/env tsx` shebang runs [tsx](https://tsx.is), migration offers to move it to [`vpx`](/guide/vpx), Vite+'s TypeScript script runner. It rewrites scripts and lint-staged tasks in `package.json` and JSON lint-staged files, and `#!/usr/bin/env tsx` shebangs in script files: | Before | After | | ----------------------------------------------------- | ------------------------------------------ | diff --git a/packages/cli/src/migration/__tests__/tsx.spec.ts b/packages/cli/src/migration/__tests__/tsx.spec.ts index afd701c82f..2a41cdb00e 100644 --- a/packages/cli/src/migration/__tests__/tsx.spec.ts +++ b/packages/cli/src/migration/__tests__/tsx.spec.ts @@ -164,6 +164,10 @@ describe('rewriteTsxCommand', () => { "import fs = require('node:fs');\nexport = fs.readdirSync(__dirname);\n", ); fs.writeFileSync(path.join(dir, 'importer.ts'), 'importer.run(__dirname);\n'); + fs.writeFileSync( + path.join(dir, 'commented.ts'), + "import fs from 'node:fs';\n// const require = createRequire(import.meta.url);\nrequire('./x');\n", + ); }); afterEach(() => { @@ -185,6 +189,7 @@ describe('rewriteTsxCommand', () => { ['cd tools && tsx only.ts', '`only.ts` mixes import or export with CommonJS globals'], ['tsx exports.ts', '`exports.ts` mixes import or export with CommonJS globals'], ['tsx compact.ts', '`compact.ts` mixes import or export with CommonJS globals'], + ['tsx commented.ts', '`commented.ts` mixes import or export with CommonJS globals'], ['tsx require-js.cts', 'requires `./helper.js`, which only tsx resolves to `./helper.ts`'], ['pushd tools && tsx plain.ts', 'changes the directory'], // The fallback runs in the package directory, or after a later `cd`. @@ -215,6 +220,17 @@ describe('rewriteTsxCommand', () => { expect(rewriteTsxCommand('tsx dirname.ts', dir).command).toBe('vpx dirname.ts'); }); + it('checks a script under the home directory', () => { + vi.spyOn(os, 'homedir').mockReturnValue(dir); + try { + expect(rewriteTsxCommand('tsx ~/dirname.ts', path.join(dir, 'tools')).kept).toEqual([ + expect.stringContaining('mixes import or export with CommonJS globals'), + ]); + } finally { + vi.restoreAllMocks(); + } + }); + it('keeps a package-manager shorthand that runs a script named tsx', () => { fs.writeFileSync(path.join(dir, 'package.json'), '{ "scripts": { "tsx": "node run.js" } }\n'); expect(rewriteTsxCommand('yarn tsx plain.ts', dir)).toEqual({ @@ -359,6 +375,12 @@ describe('migrateTsxToVpx', () => { '{ "require": "tsx", "spec": "test/**/*.test.ts" }\n', '.mocharc.json still uses tsx', ], + [ + 'a JSONC tool config', + 'wrangler.jsonc', + '{\n // Build step\n "build": { "command": "tsx scripts/build.ts" },\n}\n', + 'wrangler.jsonc still uses tsx', + ], [ 'a JavaScript tool config', 'ava.config.js', @@ -451,6 +473,20 @@ describe('migrateTsxToVpx', () => { ]); }); + it('detects a project that runs tsx only through shebangs', () => { + write('package.json', { + type: 'module', + scripts: { release: './scripts/release.ts' }, + devDependencies: { tsx: '^4.20.0' }, + }); + write('scripts/release.ts', '#!/usr/bin/env tsx\nexport {};\n'); + + expect(detectTsxProject(root)).toBe(true); + expect(migrateTsxToVpx(root)).toEqual({ changed: true, dependencyRemoved: true }); + expect(read('scripts/release.ts')).toBe('#!/usr/bin/env vpx\nexport {};\n'); + expect(detectTsxProject(root)).toBe(false); + }); + it('detects tsx tasks in a lint-staged JSON file', () => { write('package.json', { scripts: { prepare: 'husky' } }); write('.lintstagedrc.json', { '*.ts': 'tsx scripts/check.ts' }); diff --git a/packages/cli/src/migration/migrator/tsx.ts b/packages/cli/src/migration/migrator/tsx.ts index 5e40933188..4f1abd6a97 100644 --- a/packages/cli/src/migration/migrator/tsx.ts +++ b/packages/cli/src/migration/migrator/tsx.ts @@ -1,4 +1,5 @@ import fs from 'node:fs'; +import os from 'node:os'; import path from 'node:path'; import { styleText } from 'node:util'; @@ -131,6 +132,7 @@ const CODE_EXTENSIONS: ReadonlySet = new Set([ const REFERENCE_EXTENSIONS: ReadonlySet = new Set([ ...CODE_EXTENSIONS, '.json', + '.jsonc', '.yml', '.yaml', '.sh', @@ -332,7 +334,9 @@ function scriptKeepReason(cwd: string | undefined, script: string): string | und if (cwd === undefined || !SCRIPT_EXTENSIONS.some((extension) => script.endsWith(extension))) { return undefined; } - const file = path.resolve(cwd, script); + const file = script.startsWith('~/') + ? path.join(os.homedir(), script.slice(2)) + : path.resolve(cwd, script); let source: string; try { source = fs.readFileSync(file, 'utf8'); @@ -368,11 +372,13 @@ function scriptKeepReason(cwd: string | undefined, script: string): string | und * declare itself, as in `const require = createRequire(import.meta.url)`. */ function usesCommonJsGlobals(source: string): boolean { + // A declaration in a comment does not count. + const code = source.replaceAll(/\/\*[\s\S]*?\*\/|\/\/[^\n]*/g, ''); return ( MODULE_EXPORTS.test(source) || COMMONJS_GLOBALS.some( ([name, use]) => - use.test(source) && !new RegExp(`\\b(?:const|let|var|function)\\s+${name}\\b`).test(source), + use.test(source) && !new RegExp(`\\b(?:const|let|var|function)\\s+${name}\\b`).test(code), ) ); } @@ -608,7 +614,10 @@ function packageDirs(rootDir: string, packages?: readonly WorkspacePackage[]): s return [rootDir, ...(packages ?? []).map((pkg) => path.join(rootDir, pkg.path))]; } -/** Whether a script or lint-staged task runs tsx in a form `vpx` replaces. */ +/** + * Whether a script, lint-staged task, or `#!/usr/bin/env tsx` shebang runs tsx in a form + * `vpx` replaces. + */ export function detectTsxProject(rootDir: string, packages?: readonly WorkspacePackage[]): boolean { return packageDirs(rootDir, packages).some((dir) => { const pkg = readPackageJsonIfExists(path.join(dir, 'package.json')); @@ -619,7 +628,13 @@ export function detectTsxProject(rootDir: string, packages?: readonly WorkspaceP ...packageCommands(pkg), ...lintStagedJsonFiles(dir).flatMap((file) => recordCommands(readJsonFile(file))), ]; - return commands.some((command) => rewriteTsxCommand(command, dir).command !== command); + return ( + commands.some((command) => rewriteTsxCommand(command, dir).command !== command) || + sourceTreeMatches(dir, (content, file) => { + const result = rewriteTsxShebang(content, file); + return result !== undefined && 'content' in result; + }) + ); }); } @@ -651,27 +666,44 @@ function rewriteCommandRecord( return edited; } +/** + * The `vpx` version of a script file's tsx shebang, or why it keeps tsx; undefined when + * the file has no `#!/usr/bin/env tsx` shebang. + */ +function rewriteTsxShebang( + content: string, + file: string, +): { content: string } | { reason: string } | undefined { + const match = TSX_ENV_SHEBANG.exec(content); + if (!match) { + return undefined; + } + const [line, prefix, command] = match; + // The kernel runs the shebang's command with the file appended. + const result = rewriteTsxCommand(`${command.trimEnd()} ${SHEBANG_ENTRY}`); + const reason = result.kept[0] ?? scriptKeepReason(path.dirname(file), `./${path.basename(file)}`); + if (reason) { + return { reason }; + } + const vpxCommand = result.command.slice(0, -` ${SHEBANG_ENTRY}`.length); + return { content: `${prefix}${vpxCommand}${content.slice(line.length)}` }; +} + /** Point `#!/usr/bin/env tsx` shebangs of a package's script files at `vpx`. */ function rewriteTsxShebangs(dir: string, report?: MigrationReport): boolean { let changed = false; // The callback never reports a match, so the walk visits every file. sourceTreeMatches(dir, (content, file) => { - const match = TSX_ENV_SHEBANG.exec(content); - if (!match) { - return false; - } - const [line, prefix, command] = match; - // The kernel runs the shebang's command with the file appended. - const result = rewriteTsxCommand(`${command.trimEnd()} ${SHEBANG_ENTRY}`); - const reason = - result.kept[0] ?? scriptKeepReason(path.dirname(file), `./${path.basename(file)}`); - if (reason) { - warnMigration(`Kept tsx in the shebang of ${displayRelative(file)}: ${reason}.`, report); - return false; + const result = rewriteTsxShebang(content, file); + if (result && 'reason' in result) { + warnMigration( + `Kept tsx in the shebang of ${displayRelative(file)}: ${result.reason}.`, + report, + ); + } else if (result) { + fs.writeFileSync(file, result.content); + changed = true; } - const vpxCommand = result.command.slice(0, -` ${SHEBANG_ENTRY}`.length); - fs.writeFileSync(file, `${prefix}${vpxCommand}${content.slice(line.length)}`); - changed = true; return false; }); return changed; @@ -693,7 +725,7 @@ function findTsxUse(dir: string): string | undefined { const extension = path.extname(file); const uses = TSX_SHEBANG.test(content) || - (extension === '.json' + (extension === '.json' || extension === '.jsonc' ? jsonUsesTsx(parseJsonc(content)) : (CODE_EXTENSIONS.has(extension) ? TSX_CODE_REFERENCE : TSX_TEXT_REFERENCE).test( content, From fc3977fd1a8b1f26d297f15de6be7e6f42a53078 Mon Sep 17 00:00:00 2001 From: "MK (fengmk2)" Date: Thu, 22 Oct 2026 05:20:25 +0800 Subject: [PATCH 12/23] fix(migrate): refine the tsx script checks and use scan Address the tenth round of review feedback on the tsx migration: - Keep tsx for a command whose `NODE_OPTIONS` loads tsx, whose hooks would compete with those of vpx. - Check the files a glob in a command matches, as in `tsx --test tests/*.test.ts`. - Count `exports['x']` and `module['exports']` as CommonJS uses, and only module-level declarations of `require` and the like as covering them. - Before removing tsx, match `spawn('tsx')` without arguments and `{ command: 'tsx' }` in code configs. Co-Authored-By: Claude Opus 5.5 --- docs/guide/migrate-rules.md | 2 +- .../cli/src/migration/__tests__/tsx.spec.ts | 43 +++++++++ packages/cli/src/migration/migrator/tsx.ts | 89 ++++++++++++------- 3 files changed, 102 insertions(+), 32 deletions(-) diff --git a/docs/guide/migrate-rules.md b/docs/guide/migrate-rules.md index 161c7acc4e..d63230f6b7 100644 --- a/docs/guide/migrate-rules.md +++ b/docs/guide/migrate-rules.md @@ -289,7 +289,7 @@ When a `package.json` script, a lint-staged task, or a `#!/usr/bin/env tsx` sheb `tsx watch` becomes `vpx --watch`, never `vpx watch`, which runs the npm package `watch`. Node.js options and tsx watch options keep their order, and `--no-cache` is dropped. A quoted command is rewritten only for programs that run it, such as `concurrently`, `nodemon --exec`, `sh -c`, and `cross-env-shell`; a quoted argument of other programs, as in `echo "tsx a.ts"`, is data. `vpx` needs the same Node.js versions as Vite+ itself, and migration already upgrades incompatible runtime pins to them (see [Vitest 5](/guide/vitest-v5)). -Migration keeps tsx, with a warning, for a command `vpx` cannot run: `--eval`, `--print`, or `--check`, the REPL, `--test` without files or in watch mode, an entry without a file extension, commands that set `TSX_TSCONFIG_PATH` (pass `--tsconfig` to `vpx` instead), `yarn tsx` and the like when the package has its own `tsx` script, and commands with pipes, variables, or subshells. It also reads the scripts a command runs and keeps tsx when `vpx` would fail on them: a `require()` of `./a.js` that only tsx resolves to `a.ts`, and, because tsx compiles ES module syntax to CommonJS, a `.cts` file with `import` or `export`, and a file that mixes `import` with `__dirname`, `__filename`, `require()`, or `module.exports` in a package without `"type": "module"`. This check reads only the files a command names, not the modules they import, and does not apply a tsconfig `module` of `node16` or `nodenext`, under which `vpx` runs every `.ts` file as an ES module. A package that lists tsx in `dependencies`, `optionalDependencies`, or `peerDependencies` keeps it too, since `vpx` comes from the `vite-plus` devDependency. +Migration keeps tsx, with a warning, for a command `vpx` cannot run: `--eval`, `--print`, or `--check`, the REPL, `--test` without files or in watch mode, an entry without a file extension, commands that set `TSX_TSCONFIG_PATH` (pass `--tsconfig` to `vpx` instead) or load tsx through `NODE_OPTIONS`, `yarn tsx` and the like when the package has its own `tsx` script, and commands with pipes, variables, or subshells. It also reads the scripts a command runs and keeps tsx when `vpx` would fail on them: a `require()` of `./a.js` that only tsx resolves to `a.ts`, and, because tsx compiles ES module syntax to CommonJS, a `.cts` file with `import` or `export`, and a file that mixes `import` with `__dirname`, `__filename`, `require()`, or `module.exports` in a package without `"type": "module"`. This check reads only the files a command names, not the modules they import, and does not apply a tsconfig `module` of `node16` or `nodenext`, under which `vpx` runs every `.ts` file as an ES module. A package that lists tsx in `dependencies`, `optionalDependencies`, or `peerDependencies` keeps it too, since `vpx` comes from the `vite-plus` devDependency. Migration removes the `tsx` devDependency only when nothing uses tsx anymore: no kept script, no import of `tsx` or its subpaths such as `tsx/esm/api` (in source, build output, or a `package.json` `imports` alias), no shebang, no tool setting such as `--import=tsx` in `package.json` or a JSON or JavaScript config, and no tsx command in CI workflows, git hooks, or shell scripts. The root's dependency stays while any workspace package still uses tsx, and a package's dependency stays while a nested package under it does. When tsx stays, the warning names what still uses it. diff --git a/packages/cli/src/migration/__tests__/tsx.spec.ts b/packages/cli/src/migration/__tests__/tsx.spec.ts index 2a41cdb00e..2c6f725761 100644 --- a/packages/cli/src/migration/__tests__/tsx.spec.ts +++ b/packages/cli/src/migration/__tests__/tsx.spec.ts @@ -96,6 +96,7 @@ describe('rewriteTsxCommand', () => { ['cross-env-shell TSX_TSCONFIG_PATH=cli.json "tsx ./a.ts"', 'TSX_TSCONFIG_PATH'], ['TSX_TSCONFIG_PATH=cli.json concurrently "tsx ./a.ts"', 'TSX_TSCONFIG_PATH'], ['TSX_TSCONFIG_PATH=cli.json; tsx ./a.ts', 'TSX_TSCONFIG_PATH'], + ['NODE_OPTIONS=--import=tsx tsx ./a.ts', 'NODE_OPTIONS'], ['export TSX_TSCONFIG_PATH=cli.json && tsx ./a.ts', 'shell syntax'], ['tsx watch --test ./a.test.ts', '`vpx --watch` does not run `--test`'], ['tsx --watch --test ./a.test.ts', '`vpx --watch` does not run `--test`'], @@ -168,6 +169,29 @@ describe('rewriteTsxCommand', () => { path.join(dir, 'commented.ts'), "import fs from 'node:fs';\n// const require = createRequire(import.meta.url);\nrequire('./x');\n", ); + fs.writeFileSync( + path.join(dir, 'bracket.ts'), + "import fs from 'node:fs';\nexports['result'] = fs.statSync('.');\n", + ); + // The declaration covers only the function, not the module-level `require`. + fs.writeFileSync( + path.join(dir, 'inner.ts'), + [ + "import { createRequire } from 'node:module';", + 'function load() {', + ' const require = createRequire(import.meta.url);', + " return require('./x');", + '}', + "require('./setup');", + '', + ].join('\n'), + ); + fs.mkdirSync(path.join(dir, 'tests')); + fs.writeFileSync(path.join(dir, 'tests/ok.test.ts'), 'export {};\n'); + fs.writeFileSync( + path.join(dir, 'tests/dirname.test.ts'), + "import fs from 'node:fs';\nfs.readdirSync(__dirname);\n", + ); }); afterEach(() => { @@ -190,6 +214,12 @@ describe('rewriteTsxCommand', () => { ['tsx exports.ts', '`exports.ts` mixes import or export with CommonJS globals'], ['tsx compact.ts', '`compact.ts` mixes import or export with CommonJS globals'], ['tsx commented.ts', '`commented.ts` mixes import or export with CommonJS globals'], + ['tsx bracket.ts', '`bracket.ts` mixes import or export with CommonJS globals'], + ['tsx inner.ts', '`inner.ts` mixes import or export with CommonJS globals'], + [ + 'tsx --test tests/*.test.ts', + '`tests/dirname.test.ts` mixes import or export with CommonJS globals', + ], ['tsx require-js.cts', 'requires `./helper.js`, which only tsx resolves to `./helper.ts`'], ['pushd tools && tsx plain.ts', 'changes the directory'], // The fallback runs in the package directory, or after a later `cd`. @@ -209,6 +239,7 @@ describe('rewriteTsxCommand', () => { ['tsx import-equals.cts', 'vpx import-equals.cts'], ['tsx importer.ts', 'vpx importer.ts'], ['tsx --test plain.ts', 'vpx --test plain.ts'], + ['tsx --test tests/ok.*.ts', 'vpx --test tests/ok.*.ts'], ['cd tools && tsx plain.ts', 'cd tools && vpx plain.ts'], ])('rewrites %s', (command, expected) => { expect(rewriteTsxCommand(command, dir)).toEqual({ command: expected, kept: [] }); @@ -357,6 +388,18 @@ describe('migrateTsxToVpx', () => { "import { spawn } from 'node:child_process';\nspawn('tsx', ['job.ts']);\n", 'src/run.ts still uses tsx', ], + [ + 'a process call without arguments', + 'src/repl.ts', + "import { spawn } from 'node:child_process';\nspawn('tsx');\n", + 'src/repl.ts still uses tsx', + ], + [ + 'a tool config that names tsx as its command', + 'tasks.config.ts', + "export default { command: 'tsx', args: ['job.ts'] };\n", + 'tasks.config.ts still uses tsx', + ], [ 'a shell pipe into tsx', 'scripts/run.sh', diff --git a/packages/cli/src/migration/migrator/tsx.ts b/packages/cli/src/migration/migrator/tsx.ts index 4f1abd6a97..6a7ffc8a70 100644 --- a/packages/cli/src/migration/migrator/tsx.ts +++ b/packages/cli/src/migration/migrator/tsx.ts @@ -5,6 +5,7 @@ import { styleText } from 'node:util'; import * as prompts from '@voidzero-dev/vite-plus-prompts'; import { parse as parseJsonc } from 'jsonc-parser'; +import { globSync } from 'tinyglobby'; import { type LiteralCommandToken, parseLiteralCommands } from '../../../binding/index.js'; import { type WorkspacePackage } from '../../types/index.ts'; @@ -58,6 +59,8 @@ const KEEP_OTHER = 'vp migrate does not rewrite this use of tsx'; const KEEP_CWD = 'an earlier command changes the directory in a way vp migrate does not follow'; const KEEP_TSX_TSCONFIG = '`TSX_TSCONFIG_PATH` selects the tsconfig for tsx only; pass it to `vpx` as `--tsconfig`'; +const KEEP_NODE_OPTIONS = + '`NODE_OPTIONS` loads tsx, whose hooks would compete with those of `vpx`; remove it first'; const KEEP_WATCH_TEST = '`vpx --watch` does not run `--test`'; // Options that `vpx` does not run as tsx does. `-c` is Node.js' `--check`, but `vpx -c` @@ -85,13 +88,15 @@ const TSX_SHEBANG = /^#![^\n]*[\s/]tsx(?=\s|$)/; const TSX_LOADER_FLAG = /(?:--import|--loader|--experimental-loader|--require|-r)[= \t]+['"]?tsx(?:\/(?:esm|cjs))?(?![\w./-])/; // In code: an import of tsx or a subpath such as `tsx/esm/api`, a string that starts a -// tsx command, tsx as the program of a process call, as in `spawn('tsx', [...])`, or a -// loader flag, as in `nodeArguments: ['--import=tsx']` or `['--import', 'tsx']`. +// tsx command, tsx as the program of a process call or config, as in `spawn('tsx')` or +// `{ command: 'tsx' }`, or a loader flag, as in `nodeArguments: ['--import=tsx']` or +// `['--import', 'tsx']`. const TSX_CODE_REFERENCE = new RegExp( [ /(?:\bfrom|\bimport|\brequire(?:\.resolve)?)\s*\(?\s*['"]tsx(?:\/[^'"]*)?['"]/.source, /['"`](?:npx |pnpm (?:exec )?|yarn |bunx )?tsx\s/.source, - /\(\s*['"`]tsx['"`]\s*,/.source, + /\(\s*['"`]tsx['"`]\s*[,)]/.source, + /\b(?:command|cmd|executable|program|runtimeExecutable)\s*:\s*['"`]tsx['"`]/.source, TSX_LOADER_FLAG.source, /['"`](?:--import|--loader|--experimental-loader|--require|-r)['"`]\s*,\s*['"`]tsx(?:\/(?:esm|cjs))?['"`]/ .source, @@ -157,14 +162,18 @@ const LOCKFILES = new Set([ // have. TypeScript's `import x = require()` and `export =` stay CommonJS. const ESM_SYNTAX = /(?:^|;)\s*(?:import(?![\w$])\s*(?![\w$]+\s*=)[\w*{'"]|export(?![\w$])\s*[\w*{])|\bimport\.meta\b/m; -const MODULE_EXPORTS = /(? = { js: ['ts', 'tsx'], cjs: ['cts'], @@ -202,8 +211,8 @@ interface Rewrite { command: string; /** The directory the next segment runs in; null once a command moved it out of sight. */ cwd?: string | null; - /** Set once a segment assigns TSX_TSCONFIG_PATH, which later commands can inherit. */ - tsconfigReason?: string; + /** Set once a segment assigns tsx settings, which later commands can inherit. */ + envReason?: string; edits: Edit[]; kept: string[]; } @@ -277,38 +286,50 @@ function rewriteSegment(rewrite: Rewrite, tokens: LiteralCommandToken[]): void { first++; skipAssignments(); } - const assignsTsconfig = values - .slice(0, first) - .some((value) => value.startsWith('TSX_TSCONFIG_PATH=')); + // tsx reads TSX_TSCONFIG_PATH, where `vpx` takes `--tsconfig`, and tsx hooks loaded + // through NODE_OPTIONS would compete with those of `vpx`. Both reach the commands a + // launcher runs. + const envReason = envKeepReason(values.slice(0, first)); if (first >= values.length) { // A bare assignment can update an exported variable that later commands inherit. - if (assignsTsconfig) { - rewrite.tsconfigReason = KEEP_TSX_TSCONFIG; - } + rewrite.envReason ??= envReason; return; } - // tsx reads TSX_TSCONFIG_PATH, also in the commands a launcher runs; `vpx` takes - // `--tsconfig` instead. - const tsconfigReason = assignsTsconfig ? KEEP_TSX_TSCONFIG : rewrite.tsconfigReason; + const inheritedReason = envReason ?? rewrite.envReason; const start = first + getCommandStartIndex(values.slice(first)); const launcher = COMMAND_LAUNCHERS.get(values[start]); if (values[start] === 'tsx') { - rewriteTsx(rewrite, tokens, first, start, tsconfigReason); + rewriteTsx(rewrite, tokens, first, start, inheritedReason); } else if (values[start] === 'node') { - rewriteNodeLoader(rewrite, tokens, first, start, tsconfigReason); + rewriteNodeLoader(rewrite, tokens, first, start, inheritedReason); } else if (shell) { for (const token of tokens.slice(start)) { - rewriteQuotedCommand(rewrite, token, tsconfigReason); + rewriteQuotedCommand(rewrite, token, inheritedReason); } } else if (launcher) { for (let index = start + 1; index < tokens.length; index++) { if (launcher(values[index - 1])) { - rewriteQuotedCommand(rewrite, tokens[index], tsconfigReason); + rewriteQuotedCommand(rewrite, tokens[index], inheritedReason); } } } } +/** Why environment assignments keep tsx for the command they apply to, or undefined. */ +function envKeepReason(assignments: readonly string[]): string | undefined { + if (assignments.some((value) => value.startsWith('TSX_TSCONFIG_PATH='))) { + return KEEP_TSX_TSCONFIG; + } + if ( + assignments.some( + (value) => value.startsWith('NODE_OPTIONS=') && TSX_COMMAND.test(value.slice(13)), + ) + ) { + return KEEP_NODE_OPTIONS; + } + return undefined; +} + /** * An option's name without `=value`. Node.js reads `_` as `-` in long option names, as * in `--watch_path`, and so does `vpx`. @@ -368,18 +389,17 @@ function scriptKeepReason(cwd: string | undefined, script: string): string | und } /** - * Whether `source` uses `module.exports`, `exports`, or a CommonJS global it does not - * declare itself, as in `const require = createRequire(import.meta.url)`. + * Whether `source` uses a CommonJS global it does not declare at module level, as in + * `const require = createRequire(import.meta.url)`. A declaration inside a function, + * which an unindented line does not start, covers only that function, and one in a + * comment covers nothing. */ function usesCommonJsGlobals(source: string): boolean { - // A declaration in a comment does not count. const code = source.replaceAll(/\/\*[\s\S]*?\*\/|\/\/[^\n]*/g, ''); - return ( - MODULE_EXPORTS.test(source) || - COMMONJS_GLOBALS.some( - ([name, use]) => - use.test(source) && !new RegExp(`\\b(?:const|let|var|function)\\s+${name}\\b`).test(code), - ) + return COMMONJS_GLOBALS.some( + ([name, use]) => + use.test(source) && + !new RegExp(`^(?:export\\s+)?(?:const|let|var|function)\\s+${name}\\b`, 'm').test(code), ); } @@ -404,9 +424,16 @@ function runKeepReason( return KEEP_CWD; } for (const script of test ? positionals.filter((value) => !value.startsWith('-')) : [entry]) { - const reason = scriptKeepReason(cwd, script); - if (reason) { - return reason; + // The shell, or Node.js for a quoted `--test` glob, runs the files a glob matches. + const files = + cwd !== undefined && /[*?[{]/.test(script) + ? globSync(script, { cwd, ignore: ['**/node_modules/**'] }).slice(0, MAX_GLOB_FILES) + : [script]; + for (const file of files) { + const reason = scriptKeepReason(cwd, file); + if (reason) { + return reason; + } } } return undefined; From 69138cafb0eb9373cf580a85f26be7181cf3066a Mon Sep 17 00:00:00 2001 From: "MK (fengmk2)" Date: Thu, 22 Oct 2026 05:31:28 +0800 Subject: [PATCH 13/23] fix(migrate): keep tsx for large globs, scan TOML and argv-array launches Address the eleventh round of review feedback on the tsx migration: - Keep tsx when a glob in a command matches more files than the script check reads, instead of checking only the first ones. - Find ES module syntax after a comment, as in `/* banner */ import ...`. - Before removing tsx, scan `.toml` configs such as `netlify.toml`, and match argv-array launches such as `Bun.spawn(['tsx', 'job.ts'])`. Co-Authored-By: Claude Opus 5.5 --- .../cli/src/migration/__tests__/tsx.spec.ts | 27 ++++++++++++++++ packages/cli/src/migration/migrator/tsx.ts | 31 ++++++++++++------- 2 files changed, 47 insertions(+), 11 deletions(-) diff --git a/packages/cli/src/migration/__tests__/tsx.spec.ts b/packages/cli/src/migration/__tests__/tsx.spec.ts index 2c6f725761..f569cea43b 100644 --- a/packages/cli/src/migration/__tests__/tsx.spec.ts +++ b/packages/cli/src/migration/__tests__/tsx.spec.ts @@ -192,6 +192,10 @@ describe('rewriteTsxCommand', () => { path.join(dir, 'tests/dirname.test.ts'), "import fs from 'node:fs';\nfs.readdirSync(__dirname);\n", ); + fs.writeFileSync( + path.join(dir, 'banner.ts'), + "/* banner */ import fs from 'node:fs'; fs.readdirSync(__dirname);\n", + ); }); afterEach(() => { @@ -215,6 +219,7 @@ describe('rewriteTsxCommand', () => { ['tsx compact.ts', '`compact.ts` mixes import or export with CommonJS globals'], ['tsx commented.ts', '`commented.ts` mixes import or export with CommonJS globals'], ['tsx bracket.ts', '`bracket.ts` mixes import or export with CommonJS globals'], + ['tsx banner.ts', '`banner.ts` mixes import or export with CommonJS globals'], ['tsx inner.ts', '`inner.ts` mixes import or export with CommonJS globals'], [ 'tsx --test tests/*.test.ts', @@ -251,6 +256,16 @@ describe('rewriteTsxCommand', () => { expect(rewriteTsxCommand('tsx dirname.ts', dir).command).toBe('vpx dirname.ts'); }); + it('keeps a glob with more files than it checks', () => { + fs.mkdirSync(path.join(dir, 'many')); + for (let index = 0; index <= 200; index++) { + fs.writeFileSync(path.join(dir, `many/${index}.test.ts`), 'export {};\n'); + } + expect(rewriteTsxCommand('tsx --test many/*.test.ts', dir).kept).toEqual([ + '`many/*.test.ts` matches more than 200 files, too many for vp migrate to check', + ]); + }); + it('checks a script under the home directory', () => { vi.spyOn(os, 'homedir').mockReturnValue(dir); try { @@ -394,6 +409,18 @@ describe('migrateTsxToVpx', () => { "import { spawn } from 'node:child_process';\nspawn('tsx');\n", 'src/repl.ts still uses tsx', ], + [ + 'a process call with an argv array', + 'src/job.ts', + "Bun.spawn(['tsx', 'job.ts']);\n", + 'src/job.ts still uses tsx', + ], + [ + 'a TOML config', + 'netlify.toml', + '[build]\ncommand = "tsx scripts/build.ts"\n', + 'netlify.toml still uses tsx', + ], [ 'a tool config that names tsx as its command', 'tasks.config.ts', diff --git a/packages/cli/src/migration/migrator/tsx.ts b/packages/cli/src/migration/migrator/tsx.ts index 6a7ffc8a70..679ae56d56 100644 --- a/packages/cli/src/migration/migrator/tsx.ts +++ b/packages/cli/src/migration/migrator/tsx.ts @@ -88,14 +88,14 @@ const TSX_SHEBANG = /^#![^\n]*[\s/]tsx(?=\s|$)/; const TSX_LOADER_FLAG = /(?:--import|--loader|--experimental-loader|--require|-r)[= \t]+['"]?tsx(?:\/(?:esm|cjs))?(?![\w./-])/; // In code: an import of tsx or a subpath such as `tsx/esm/api`, a string that starts a -// tsx command, tsx as the program of a process call or config, as in `spawn('tsx')` or -// `{ command: 'tsx' }`, or a loader flag, as in `nodeArguments: ['--import=tsx']` or -// `['--import', 'tsx']`. +// tsx command, tsx as the program of a process call or config, as in `spawn('tsx')`, +// `Bun.spawn(['tsx', ...])`, or `{ command: 'tsx' }`, or a loader flag, as in +// `nodeArguments: ['--import=tsx']` or `['--import', 'tsx']`. const TSX_CODE_REFERENCE = new RegExp( [ /(?:\bfrom|\bimport|\brequire(?:\.resolve)?)\s*\(?\s*['"]tsx(?:\/[^'"]*)?['"]/.source, /['"`](?:npx |pnpm (?:exec )?|yarn |bunx )?tsx\s/.source, - /\(\s*['"`]tsx['"`]\s*[,)]/.source, + /\(\s*\[?\s*['"`]tsx['"`]\s*[,)\]]/.source, /\b(?:command|cmd|executable|program|runtimeExecutable)\s*:\s*['"`]tsx['"`]/.source, TSX_LOADER_FLAG.source, /['"`](?:--import|--loader|--experimental-loader|--require|-r)['"`]\s*,\s*['"`]tsx(?:\/(?:esm|cjs))?['"`]/ @@ -138,6 +138,7 @@ const REFERENCE_EXTENSIONS: ReadonlySet = new Set([ ...CODE_EXTENSIONS, '.json', '.jsonc', + '.toml', '.yml', '.yaml', '.sh', @@ -172,7 +173,7 @@ const COMMONJS_GLOBALS = [ ] as const; // A relative `require()` of a JavaScript path, which tsx also resolves to TypeScript. const RELATIVE_REQUIRE = /(? = { js: ['ts', 'tsx'], @@ -373,14 +374,16 @@ function scriptKeepReason(cwd: string | undefined, script: string): string | und return `\`${script}\` requires \`${base}.${extension}\`, which only tsx resolves to \`${base}.${typescript}\`; write the TypeScript extension`; } } - if (!/\.(?:c?ts|tsx|jsx?)$/.test(script) || !ESM_SYNTAX.test(source)) { + // A comment can start a line before ES module syntax, as in `/* banner */ import ...`. + const code = stripComments(source); + if (!/\.(?:c?ts|tsx|jsx?)$/.test(script) || !(ESM_SYNTAX.test(source) || ESM_SYNTAX.test(code))) { return undefined; } if (script.endsWith('.cts')) { return `\`${script}\` uses import or export, which \`vpx\` does not compile to CommonJS; rename it to .mts`; } if ( - usesCommonJsGlobals(source) && + usesCommonJsGlobals(source, code) && readNearestPackageJson(path.dirname(file))?.type !== 'module' ) { return `\`${script}\` mixes import or export with CommonJS globals such as __dirname, require, or module.exports, and \`vpx\` runs it as an ES module; use import.meta.dirname, import.meta.filename, createRequire(), or export`; @@ -388,14 +391,17 @@ function scriptKeepReason(cwd: string | undefined, script: string): string | und return undefined; } +function stripComments(source: string): string { + return source.replaceAll(/\/\*[\s\S]*?\*\/|\/\/[^\n]*/g, ''); +} + /** * Whether `source` uses a CommonJS global it does not declare at module level, as in * `const require = createRequire(import.meta.url)`. A declaration inside a function, * which an unindented line does not start, covers only that function, and one in a - * comment covers nothing. + * comment (absent from `code`) covers nothing. */ -function usesCommonJsGlobals(source: string): boolean { - const code = source.replaceAll(/\/\*[\s\S]*?\*\/|\/\/[^\n]*/g, ''); +function usesCommonJsGlobals(source: string, code: string): boolean { return COMMONJS_GLOBALS.some( ([name, use]) => use.test(source) && @@ -427,8 +433,11 @@ function runKeepReason( // The shell, or Node.js for a quoted `--test` glob, runs the files a glob matches. const files = cwd !== undefined && /[*?[{]/.test(script) - ? globSync(script, { cwd, ignore: ['**/node_modules/**'] }).slice(0, MAX_GLOB_FILES) + ? globSync(script, { cwd, ignore: ['**/node_modules/**'] }) : [script]; + if (files.length > MAX_GLOB_FILES) { + return `\`${script}\` matches more than ${MAX_GLOB_FILES} files, too many for vp migrate to check`; + } for (const file of files) { const reason = scriptKeepReason(cwd, file); if (reason) { From 139f20714fdc1ea94a956eb3a5833f7f31f7c13a Mon Sep 17 00:00:00 2001 From: "MK (fengmk2)" Date: Thu, 22 Oct 2026 05:46:24 +0800 Subject: [PATCH 14/23] fix(migrate): keep tsx for unreadable entries and unresolvable requires Address the twelfth round of review feedback on the tsx migration: - Keep tsx for an entry that does not exist yet, as when an earlier command generates it, instead of treating it as compatible. - Keep tsx for a `require()` of a name Node.js cannot resolve, which tsx can map through tsconfig `paths` and vpx cannot. This needs no tsconfig resolution: dependencies are installed when the step runs. - Count `require.main` and other `require` properties as CommonJS uses. - Before removing tsx, match a quoted executable such as `"tsx" job.ts`. Co-Authored-By: Claude Opus 5.5 --- docs/guide/migrate-rules.md | 2 +- .../cli/src/migration/__tests__/tsx.spec.ts | 32 ++++++++++++++++++- packages/cli/src/migration/migrator/tsx.ts | 24 +++++++++++--- rfcs/vpx-script-execution.md | 2 +- 4 files changed, 53 insertions(+), 7 deletions(-) diff --git a/docs/guide/migrate-rules.md b/docs/guide/migrate-rules.md index d63230f6b7..27af5b2bc2 100644 --- a/docs/guide/migrate-rules.md +++ b/docs/guide/migrate-rules.md @@ -289,7 +289,7 @@ When a `package.json` script, a lint-staged task, or a `#!/usr/bin/env tsx` sheb `tsx watch` becomes `vpx --watch`, never `vpx watch`, which runs the npm package `watch`. Node.js options and tsx watch options keep their order, and `--no-cache` is dropped. A quoted command is rewritten only for programs that run it, such as `concurrently`, `nodemon --exec`, `sh -c`, and `cross-env-shell`; a quoted argument of other programs, as in `echo "tsx a.ts"`, is data. `vpx` needs the same Node.js versions as Vite+ itself, and migration already upgrades incompatible runtime pins to them (see [Vitest 5](/guide/vitest-v5)). -Migration keeps tsx, with a warning, for a command `vpx` cannot run: `--eval`, `--print`, or `--check`, the REPL, `--test` without files or in watch mode, an entry without a file extension, commands that set `TSX_TSCONFIG_PATH` (pass `--tsconfig` to `vpx` instead) or load tsx through `NODE_OPTIONS`, `yarn tsx` and the like when the package has its own `tsx` script, and commands with pipes, variables, or subshells. It also reads the scripts a command runs and keeps tsx when `vpx` would fail on them: a `require()` of `./a.js` that only tsx resolves to `a.ts`, and, because tsx compiles ES module syntax to CommonJS, a `.cts` file with `import` or `export`, and a file that mixes `import` with `__dirname`, `__filename`, `require()`, or `module.exports` in a package without `"type": "module"`. This check reads only the files a command names, not the modules they import, and does not apply a tsconfig `module` of `node16` or `nodenext`, under which `vpx` runs every `.ts` file as an ES module. A package that lists tsx in `dependencies`, `optionalDependencies`, or `peerDependencies` keeps it too, since `vpx` comes from the `vite-plus` devDependency. +Migration keeps tsx, with a warning, for a command `vpx` cannot run: `--eval`, `--print`, or `--check`, the REPL, `--test` without files or in watch mode, an entry without a file extension, commands that set `TSX_TSCONFIG_PATH` (pass `--tsconfig` to `vpx` instead) or load tsx through `NODE_OPTIONS`, `yarn tsx` and the like when the package has its own `tsx` script, and commands with pipes, variables, or subshells. It also reads the scripts a command runs, and keeps tsx when one does not exist yet, as when an earlier command generates it, and when `vpx` would fail on one: a `require()` of `./a.js` that only tsx resolves to `a.ts`, a `require()` of a name Node.js cannot resolve, which tsx can map through tsconfig `paths`, and, because tsx compiles ES module syntax to CommonJS, a `.cts` file with `import` or `export`, and a file that mixes `import` with `__dirname`, `__filename`, `require()`, or `module.exports` in a package without `"type": "module"`. This check reads only the files a command names, not the modules they import, and does not apply a tsconfig `module` of `node16` or `nodenext`, under which `vpx` runs every `.ts` file as an ES module. A package that lists tsx in `dependencies`, `optionalDependencies`, or `peerDependencies` keeps it too, since `vpx` comes from the `vite-plus` devDependency. Migration removes the `tsx` devDependency only when nothing uses tsx anymore: no kept script, no import of `tsx` or its subpaths such as `tsx/esm/api` (in source, build output, or a `package.json` `imports` alias), no shebang, no tool setting such as `--import=tsx` in `package.json` or a JSON or JavaScript config, and no tsx command in CI workflows, git hooks, or shell scripts. The root's dependency stays while any workspace package still uses tsx, and a package's dependency stays while a nested package under it does. When tsx stays, the warning names what still uses it. diff --git a/packages/cli/src/migration/__tests__/tsx.spec.ts b/packages/cli/src/migration/__tests__/tsx.spec.ts index f569cea43b..a666aa32f3 100644 --- a/packages/cli/src/migration/__tests__/tsx.spec.ts +++ b/packages/cli/src/migration/__tests__/tsx.spec.ts @@ -196,6 +196,13 @@ describe('rewriteTsxCommand', () => { path.join(dir, 'banner.ts'), "/* banner */ import fs from 'node:fs'; fs.readdirSync(__dirname);\n", ); + fs.writeFileSync( + path.join(dir, 'cli-guard.ts'), + "import fs from 'node:fs';\nif (require.main === module) fs.readdirSync('.');\n", + ); + // tsx maps `@app/job` through tsconfig `paths`; Node.js does not. + fs.writeFileSync(path.join(dir, 'alias.cts'), "const job = require('@app/job');\n"); + fs.writeFileSync(path.join(dir, 'builtin.cts'), "const fs = require('node:fs');\n"); }); afterEach(() => { @@ -220,6 +227,9 @@ describe('rewriteTsxCommand', () => { ['tsx commented.ts', '`commented.ts` mixes import or export with CommonJS globals'], ['tsx bracket.ts', '`bracket.ts` mixes import or export with CommonJS globals'], ['tsx banner.ts', '`banner.ts` mixes import or export with CommonJS globals'], + ['tsx cli-guard.ts', '`cli-guard.ts` mixes import or export with CommonJS globals'], + ['tsx alias.cts', 'requires `@app/job`, which Node.js cannot resolve'], + ['generate && tsx generated.ts', '`generated.ts` does not exist yet'], ['tsx inner.ts', '`inner.ts` mixes import or export with CommonJS globals'], [ 'tsx --test tests/*.test.ts', @@ -241,6 +251,7 @@ describe('rewriteTsxCommand', () => { ['tsx plain.ts', 'vpx plain.ts'], ['tsx esm-idioms.ts', 'vpx esm-idioms.ts'], ['tsx require-bare.cts', 'vpx require-bare.cts'], + ['tsx builtin.cts', 'vpx builtin.cts'], ['tsx import-equals.cts', 'vpx import-equals.cts'], ['tsx importer.ts', 'vpx importer.ts'], ['tsx --test plain.ts', 'vpx --test plain.ts'], @@ -306,6 +317,16 @@ describe('migrateTsxToVpx', () => { beforeEach(() => { root = fs.mkdtempSync(path.join(os.tmpdir(), 'vp-test-tsx-')); vi.spyOn(process, 'cwd').mockReturnValue(root); + // The scripts the commands below run; migration reads them before rewriting. + for (const file of [ + 'src/server.ts', + 'scripts/seed.ts', + 'scripts/check.ts', + 'scripts/docs.ts', + 'packages/a/gen.ts', + ]) { + write(file, 'export {};\n'); + } }); afterEach(() => { @@ -433,6 +454,12 @@ describe('migrateTsxToVpx', () => { 'cat job.ts | tsx\n', 'scripts/run.sh still uses tsx', ], + [ + 'a quoted tsx in a shell script', + 'scripts/job.sh', + '"tsx" scripts/job.ts\n', + 'scripts/job.sh still uses tsx', + ], [ 'a Windows batch file', 'scripts/run.cmd', @@ -585,7 +612,10 @@ describe('migrateTsxToVpx', () => { it('keeps tsx for a script that would lose CommonJS globals under vpx', () => { write('package.json', { scripts: { build: 'tsx scripts/build.ts' } }); write('scripts/build.ts', "import fs from 'node:fs';\nfs.readdirSync(__dirname);\n"); - write('scripts/release.ts', '#!/usr/bin/env tsx\nimport fs from "node:fs";\nrequire("x");\n'); + write( + 'scripts/release.ts', + '#!/usr/bin/env tsx\nimport fs from "node:fs";\nrequire("node:path");\n', + ); const report = createMigrationReport(); expect(detectTsxProject(root)).toBe(false); diff --git a/packages/cli/src/migration/migrator/tsx.ts b/packages/cli/src/migration/migrator/tsx.ts index 679ae56d56..b94c9de1ec 100644 --- a/packages/cli/src/migration/migrator/tsx.ts +++ b/packages/cli/src/migration/migrator/tsx.ts @@ -1,4 +1,5 @@ import fs from 'node:fs'; +import { createRequire } from 'node:module'; import os from 'node:os'; import path from 'node:path'; import { styleText } from 'node:util'; @@ -106,7 +107,8 @@ const TSX_CODE_REFERENCE = new RegExp( // without arguments, or a loader flag. const TSX_TEXT_REFERENCE = new RegExp( [ - /(?:^|[\s;&|("'=/\\`])tsx(?:\.(?:cmd|exe|ps1))?(?:\/(?:esm|cjs))?(?:[ \t]+\S|[ \t]*$)/.source, + /(?:^|[\s;&|("'=/\\`])tsx(?:\.(?:cmd|exe|ps1))?(?:\/(?:esm|cjs))?['"]?(?:[ \t]+\S|[ \t]*$)/ + .source, TSX_LOADER_FLAG.source, ].join('|'), 'm', @@ -166,13 +168,16 @@ const ESM_SYNTAX = const COMMONJS_GLOBALS = [ ['__dirname', /(? = { @@ -362,8 +367,11 @@ function scriptKeepReason(cwd: string | undefined, script: string): string | und let source: string; try { source = fs.readFileSync(file, 'utf8'); - } catch { - return undefined; + } catch (error) { + // A file that an earlier command generates cannot be checked yet. + return (error as NodeJS.ErrnoException).code === 'ENOENT' + ? `\`${script}\` does not exist yet, so vp migrate cannot check it` + : undefined; } for (const [, base, extension] of source.matchAll(RELATIVE_REQUIRE)) { const resolve = (ext: string) => path.resolve(path.dirname(file), `${base}.${ext}`); @@ -374,6 +382,14 @@ function scriptKeepReason(cwd: string | undefined, script: string): string | und return `\`${script}\` requires \`${base}.${extension}\`, which only tsx resolves to \`${base}.${typescript}\`; write the TypeScript extension`; } } + // tsx applies tsconfig `paths` to `require()`; `vpx` resolves it as Node.js does. + for (const [, specifier] of source.matchAll(BARE_REQUIRE)) { + try { + createRequire(file).resolve(specifier); + } catch { + return `\`${script}\` requires \`${specifier}\`, which Node.js cannot resolve; tsx can map it through tsconfig \`paths\``; + } + } // A comment can start a line before ES module syntax, as in `/* banner */ import ...`. const code = stripComments(source); if (!/\.(?:c?ts|tsx|jsx?)$/.test(script) || !(ESM_SYNTAX.test(source) || ESM_SYNTAX.test(code))) { diff --git a/rfcs/vpx-script-execution.md b/rfcs/vpx-script-execution.md index f4acc0c4d8..255b7414cd 100644 --- a/rfcs/vpx-script-execution.md +++ b/rfcs/vpx-script-execution.md @@ -188,7 +188,7 @@ For a script, the local bin and the global shim resolve the same loader and Node - It reads commands with the same literal-command parser as the other script rewrites and finds the script the way `vpx` does, with the shared value-taking option lists. Commands it cannot read, and forms `vpx` cannot run (eval, `--check`, the REPL, `--test` without files or in watch mode, an entry without an extension), keep tsx with a warning. - It has no Node.js check of its own: `vpx` needs the `engines.node` range of `vite-plus` (`SUPPORTED_NODE_RANGE` in `script-hooks.ts`, which a unit test keeps equal), and migration already upgrades incompatible runtime pins to that range for Vite+ itself. - The `tsx` devDependency goes only when no scripts, imports (including build output and `package.json#imports` aliases), shebangs, tool settings such as `--import=tsx`, CI workflows, git hooks, or shell scripts use tsx anymore, including in nested packages that can resolve it. A package that lists tsx in `dependencies`, `optionalDependencies`, or `peerDependencies` keeps it, since `vpx` comes from a devDependency. -- It reads the scripts a command runs and keeps tsx when `vpx` would fail on them: a `require()` of `./a.js` that only tsx resolves to `a.ts`, a `.cts` file with `import` or `export`, and a file that mixes `import` with `__dirname`, `__filename`, `require()`, or `module.exports` in a package without `"type": "module"`. tsx compiles the last two to CommonJS; `vpx` rejects the first of them and runs the second as an ES module. The check reads only the named files, not their imports, and takes the module format from `"type"` alone, not from a tsconfig `module` of `node16` or `nodenext`; following imports and tsconfig `extends` would turn a guard against common breakage into module-graph analysis. +- It reads the scripts a command runs and keeps tsx when one does not exist yet, as when an earlier command generates it, and when `vpx` would fail on one: a `require()` of `./a.js` that only tsx resolves to `a.ts`, a `require()` of a name Node.js cannot resolve (tsx applies tsconfig `paths` to `require()`), a `.cts` file with `import` or `export`, and a file that mixes `import` with `__dirname`, `__filename`, `require()`, or `module.exports` in a package without `"type": "module"`. tsx compiles the last two to CommonJS; `vpx` rejects the first of them and runs the second as an ES module. The check reads only the named files, not their imports, and takes the module format from `"type"` alone, not from a tsconfig `module` of `node16` or `nodenext`; following imports and tsconfig `extends` would turn a guard against common breakage into module-graph analysis. ## Native Side From a7999c76e1c8b13f6f6bd8e4f2e21f596c4370a1 Mon Sep 17 00:00:00 2001 From: "MK (fengmk2)" Date: Thu, 22 Oct 2026 07:01:37 +0800 Subject: [PATCH 15/23] fix(migrate): count more module uses, skip concurrently option values Address the thirteenth round of review feedback on the tsx migration: - Count any `module.` property, such as `module.filename`, as a CommonJS use, and treat `import module from 'node:module'` as a declaration. - Skip the values of concurrently options such as `--names` and `--prefix`, which are metadata rather than commands. - Before removing tsx, match template-literal imports such as ``import(`tsx/esm/api`)``. Co-Authored-By: Claude Opus 5.5 --- .../cli/src/migration/__tests__/tsx.spec.ts | 25 +++++++++++ packages/cli/src/migration/migrator/tsx.ts | 45 ++++++++++++++----- 2 files changed, 60 insertions(+), 10 deletions(-) diff --git a/packages/cli/src/migration/__tests__/tsx.spec.ts b/packages/cli/src/migration/__tests__/tsx.spec.ts index a666aa32f3..a8a1fc624d 100644 --- a/packages/cli/src/migration/__tests__/tsx.spec.ts +++ b/packages/cli/src/migration/__tests__/tsx.spec.ts @@ -45,6 +45,10 @@ describe('rewriteTsxCommand', () => { 'concurrently "tsx watch src/api.ts" "vp dev"', 'concurrently "vpx --watch src/api.ts" "vp dev"', ], + [ + 'concurrently --names "api,web" "tsx watch src/api.ts" "vp dev"', + 'concurrently --names "api,web" "vpx --watch src/api.ts" "vp dev"', + ], ["nodemon --exec 'tsx src/a.ts'", "nodemon --exec 'vpx src/a.ts'"], ["sh -c 'tsx src/a.ts && vp build'", "sh -c 'vpx src/a.ts && vp build'"], ["bash -lc 'tsx src/a.ts'", "bash -lc 'vpx src/a.ts'"], @@ -89,6 +93,8 @@ describe('rewriteTsxCommand', () => { ['pnpm dlx tsx ./a.ts', 'does not rewrite this use of tsx'], // A quoted argument of a program that does not run commands is data. ['echo "tsx scripts/build.ts"', 'does not rewrite this use of tsx'], + // So is the value of a concurrently option such as `--names`. + ['concurrently --names "tsx a.ts" "vp dev"', 'does not rewrite this use of tsx'], // Only the value of `-c` is a command; the next argument is the shell's `$0`. [`sh -c 'echo "$0"' 'tsx a.ts'`, 'does not rewrite this use of tsx'], ['TSX_TSCONFIG_PATH=cli.json tsx ./a.ts', 'TSX_TSCONFIG_PATH'], @@ -203,6 +209,14 @@ describe('rewriteTsxCommand', () => { // tsx maps `@app/job` through tsconfig `paths`; Node.js does not. fs.writeFileSync(path.join(dir, 'alias.cts'), "const job = require('@app/job');\n"); fs.writeFileSync(path.join(dir, 'builtin.cts'), "const fs = require('node:fs');\n"); + fs.writeFileSync( + path.join(dir, 'module-filename.ts'), + "import fs from 'node:fs';\nconsole.log(module.filename);\n", + ); + fs.writeFileSync( + path.join(dir, 'node-module.ts'), + "import module from 'node:module';\nconst require = module.createRequire(import.meta.url);\nrequire('node:fs');\n", + ); }); afterEach(() => { @@ -228,6 +242,10 @@ describe('rewriteTsxCommand', () => { ['tsx bracket.ts', '`bracket.ts` mixes import or export with CommonJS globals'], ['tsx banner.ts', '`banner.ts` mixes import or export with CommonJS globals'], ['tsx cli-guard.ts', '`cli-guard.ts` mixes import or export with CommonJS globals'], + [ + 'tsx module-filename.ts', + '`module-filename.ts` mixes import or export with CommonJS globals', + ], ['tsx alias.cts', 'requires `@app/job`, which Node.js cannot resolve'], ['generate && tsx generated.ts', '`generated.ts` does not exist yet'], ['tsx inner.ts', '`inner.ts` mixes import or export with CommonJS globals'], @@ -252,6 +270,7 @@ describe('rewriteTsxCommand', () => { ['tsx esm-idioms.ts', 'vpx esm-idioms.ts'], ['tsx require-bare.cts', 'vpx require-bare.cts'], ['tsx builtin.cts', 'vpx builtin.cts'], + ['tsx node-module.ts', 'vpx node-module.ts'], ['tsx import-equals.cts', 'vpx import-equals.cts'], ['tsx importer.ts', 'vpx importer.ts'], ['tsx --test plain.ts', 'vpx --test plain.ts'], @@ -382,6 +401,12 @@ describe('migrateTsxToVpx', () => { "import { tsImport } from 'tsx/esm/api';\n", 'src/load.ts still uses tsx', ], + [ + 'a template-literal import of the tsx API', + 'src/lazy.ts', + 'const { tsImport } = await import(`tsx/esm/api`);\n', + 'src/lazy.ts still uses tsx', + ], [ 'a CI workflow', '.github/workflows/ci.yml', diff --git a/packages/cli/src/migration/migrator/tsx.ts b/packages/cli/src/migration/migrator/tsx.ts index b94c9de1ec..b45e4b7ad3 100644 --- a/packages/cli/src/migration/migrator/tsx.ts +++ b/packages/cli/src/migration/migrator/tsx.ts @@ -94,7 +94,7 @@ const TSX_LOADER_FLAG = // `nodeArguments: ['--import=tsx']` or `['--import', 'tsx']`. const TSX_CODE_REFERENCE = new RegExp( [ - /(?:\bfrom|\bimport|\brequire(?:\.resolve)?)\s*\(?\s*['"]tsx(?:\/[^'"]*)?['"]/.source, + /(?:\bfrom|\bimport|\brequire(?:\.resolve)?)\s*\(?\s*['"`]tsx(?:\/[^'"`]*)?['"`]/.source, /['"`](?:npx |pnpm (?:exec )?|yarn |bunx )?tsx\s/.source, /\(\s*\[?\s*['"`]tsx['"`]\s*[,)\]]/.source, /\b(?:command|cmd|executable|program|runtimeExecutable)\s*:\s*['"`]tsx['"`]/.source, @@ -172,7 +172,8 @@ const COMMONJS_GLOBALS = [ ['require', /(? = { jsx: ['tsx'], }; // Programs that run commands given as quoted arguments, and which arguments those are, -// judged by the argument before: every one for `concurrently "tsx watch a.ts" "vp dev"`, -// only the value of `-c` for a shell. Elsewhere, a quoted string is data, as for `echo`. +// judged by the argument before: every one for `concurrently "tsx watch a.ts" "vp dev"` +// except option values such as `--names`, only the value of `-c` for a shell. Elsewhere, +// a quoted string is data, as for `echo`. +const CONCURRENTLY_VALUE_OPTIONS = new Set([ + '-c', + '--default-input-target', + '-k', + '-l', + '-n', + '--name-separator', + '--names', + '-p', + '--prefix', + '--prefix-colors', + '--prefix-length', + '--restart-after', + '--restart-tries', + '-s', + '--success', + '-t', + '--timestamp-format', +]); const everyArgument = () => true; const shellCommand = (previous: string) => /^-[a-z]*c$/.test(previous); const COMMAND_LAUNCHERS = new Map boolean>([ ['bash', shellCommand], - ['concurrently', everyArgument], + ['concurrently', (previous) => !CONCURRENTLY_VALUE_OPTIONS.has(previous)], ['nodemon', (previous) => previous === '--exec' || previous === '-x'], ['server-test', everyArgument], ['sh', shellCommand], @@ -412,16 +433,20 @@ function stripComments(source: string): string { } /** - * Whether `source` uses a CommonJS global it does not declare at module level, as in - * `const require = createRequire(import.meta.url)`. A declaration inside a function, - * which an unindented line does not start, covers only that function, and one in a - * comment (absent from `code`) covers nothing. + * Whether `source` uses a CommonJS global it does not declare or import at module level, + * as in `const require = createRequire(import.meta.url)` or `import module from + * 'node:module'`. A declaration inside a function, which an unindented line does not + * start, covers only that function, and one in a comment (absent from `code`) covers + * nothing. */ function usesCommonJsGlobals(source: string, code: string): boolean { return COMMONJS_GLOBALS.some( ([name, use]) => use.test(source) && - !new RegExp(`^(?:export\\s+)?(?:const|let|var|function)\\s+${name}\\b`, 'm').test(code), + !new RegExp( + `^(?:(?:export\\s+)?(?:const|let|var|function|class)\\s+${name}\\b|import\\s+(?:\\*\\s+as\\s+)?${name}\\b)`, + 'm', + ).test(code), ); } From ecd57a6f3daeb18d60d35b9a03a82974cfbf51a1 Mon Sep 17 00:00:00 2001 From: "MK (fengmk2)" Date: Thu, 22 Oct 2026 07:14:01 +0800 Subject: [PATCH 16/23] fix(migrate): drop redundant tsx loader flags, find more loader references Address the fourteenth round of review feedback on the tsx migration: - Drop a tsx loader flag passed to tsx itself, as in `tsx --import tsx a.ts`, which would otherwise register a second loader under vpx. - Before removing tsx, match Mocha's dashless `node-option` values such as `import=tsx` and `import.meta.resolve('tsx/esm')`. Co-Authored-By: Claude Opus 5.5 --- .../cli/src/migration/__tests__/tsx.spec.ts | 21 ++++++++++++++++++ packages/cli/src/migration/migrator/tsx.ts | 22 +++++++++++++++---- 2 files changed, 39 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/migration/__tests__/tsx.spec.ts b/packages/cli/src/migration/__tests__/tsx.spec.ts index a8a1fc624d..00299e7097 100644 --- a/packages/cli/src/migration/__tests__/tsx.spec.ts +++ b/packages/cli/src/migration/__tests__/tsx.spec.ts @@ -27,6 +27,9 @@ describe('rewriteTsxCommand', () => { ['tsx --env-file=.env --inspect ./a.ts', 'vpx --env-file=.env --inspect ./a.ts'], ['tsx --env-file .env ./a.ts arg', 'vpx --env-file .env ./a.ts arg'], ['tsx --test ./a.test.ts', 'vpx --test ./a.test.ts'], + // tsx loading itself again is redundant, and would compete with vpx. + ['tsx --import tsx ./a.ts', 'vpx ./a.ts'], + ['tsx -r tsx/cjs --import ./setup.ts ./a.cts', 'vpx --import ./setup.ts ./a.cts'], ["tsx 'my script.ts'", "vpx 'my script.ts'"], ['npx tsx ./a.ts', 'vpx ./a.ts'], ['pnpm exec tsx ./a.ts', 'vpx ./a.ts'], @@ -407,6 +410,24 @@ describe('migrateTsxToVpx', () => { 'const { tsImport } = await import(`tsx/esm/api`);\n', 'src/lazy.ts still uses tsx', ], + [ + 'a resolved tsx subpath', + 'src/register.ts', + "const loader = import.meta.resolve('tsx/esm');\n", + 'src/register.ts still uses tsx', + ], + [ + 'a Mocha YAML config', + '.mocharc.yml', + 'node-option:\n - import=tsx\n', + '.mocharc.yml still uses tsx', + ], + [ + 'a Mocha JavaScript config', + '.mocharc.cjs', + "module.exports = { 'node-option': ['import=tsx'] };\n", + '.mocharc.cjs still uses tsx', + ], [ 'a CI workflow', '.github/workflows/ci.yml', diff --git a/packages/cli/src/migration/migrator/tsx.ts b/packages/cli/src/migration/migrator/tsx.ts index b45e4b7ad3..5b3fbdc37f 100644 --- a/packages/cli/src/migration/migrator/tsx.ts +++ b/packages/cli/src/migration/migrator/tsx.ts @@ -85,16 +85,18 @@ const TSX_ENV_SHEBANG = /^(#!\s*\S*\benv\s+(?:-S\s+)?)(tsx(?=[ \t\r\n]|$)[^\r\n] const SHEBANG_ENTRY = './shebang.ts'; // Any first line that runs tsx, including `#!/usr/local/bin/tsx`. const TSX_SHEBANG = /^#![^\n]*[\s/]tsx(?=\s|$)/; -// tsx loaded into Node.js by a flag, as in `--import=tsx` or `-r tsx/cjs`. +// tsx loaded into Node.js by a flag, as in `--import=tsx` or `-r tsx/cjs`, or by Mocha's +// dashless `node-option` value `import=tsx`. const TSX_LOADER_FLAG = - /(?:--import|--loader|--experimental-loader|--require|-r)[= \t]+['"]?tsx(?:\/(?:esm|cjs))?(?![\w./-])/; + /(?:(?:--import|--loader|--experimental-loader|--require|-r)[= \t]+|(? Date: Thu, 22 Oct 2026 07:28:27 +0800 Subject: [PATCH 17/23] fix(migrate): keep tsx for decorators vpx cannot compile and unmatched globs Address the fifteenth round of review feedback on the tsx migration: - Keep tsx for an entry with standard decorators, unless the nearest tsconfig (following `extends`) enables `experimentalDecorators`, and for `accessor` fields: tsx lowers both, and vpx does not compile them yet. - Keep tsx for a glob that matches no files yet, as for a missing entry. - Record cmd.exe `set "TSX_TSCONFIG_PATH=..."` for later commands. - Inspect template-literal `require()` paths, and match imports of tsx in extensionless JavaScript files before removing it. Co-Authored-By: Claude Opus 5.5 --- docs/guide/migrate-rules.md | 2 +- .../cli/src/migration/__tests__/tsx.spec.ts | 30 +++++ packages/cli/src/migration/migrator/tsx.ts | 111 ++++++++++++++++-- 3 files changed, 131 insertions(+), 12 deletions(-) diff --git a/docs/guide/migrate-rules.md b/docs/guide/migrate-rules.md index 27af5b2bc2..2ca1f24ff5 100644 --- a/docs/guide/migrate-rules.md +++ b/docs/guide/migrate-rules.md @@ -289,7 +289,7 @@ When a `package.json` script, a lint-staged task, or a `#!/usr/bin/env tsx` sheb `tsx watch` becomes `vpx --watch`, never `vpx watch`, which runs the npm package `watch`. Node.js options and tsx watch options keep their order, and `--no-cache` is dropped. A quoted command is rewritten only for programs that run it, such as `concurrently`, `nodemon --exec`, `sh -c`, and `cross-env-shell`; a quoted argument of other programs, as in `echo "tsx a.ts"`, is data. `vpx` needs the same Node.js versions as Vite+ itself, and migration already upgrades incompatible runtime pins to them (see [Vitest 5](/guide/vitest-v5)). -Migration keeps tsx, with a warning, for a command `vpx` cannot run: `--eval`, `--print`, or `--check`, the REPL, `--test` without files or in watch mode, an entry without a file extension, commands that set `TSX_TSCONFIG_PATH` (pass `--tsconfig` to `vpx` instead) or load tsx through `NODE_OPTIONS`, `yarn tsx` and the like when the package has its own `tsx` script, and commands with pipes, variables, or subshells. It also reads the scripts a command runs, and keeps tsx when one does not exist yet, as when an earlier command generates it, and when `vpx` would fail on one: a `require()` of `./a.js` that only tsx resolves to `a.ts`, a `require()` of a name Node.js cannot resolve, which tsx can map through tsconfig `paths`, and, because tsx compiles ES module syntax to CommonJS, a `.cts` file with `import` or `export`, and a file that mixes `import` with `__dirname`, `__filename`, `require()`, or `module.exports` in a package without `"type": "module"`. This check reads only the files a command names, not the modules they import, and does not apply a tsconfig `module` of `node16` or `nodenext`, under which `vpx` runs every `.ts` file as an ES module. A package that lists tsx in `dependencies`, `optionalDependencies`, or `peerDependencies` keeps it too, since `vpx` comes from the `vite-plus` devDependency. +Migration keeps tsx, with a warning, for a command `vpx` cannot run: `--eval`, `--print`, or `--check`, the REPL, `--test` without files or in watch mode, an entry without a file extension, commands that set `TSX_TSCONFIG_PATH` (pass `--tsconfig` to `vpx` instead) or load tsx through `NODE_OPTIONS`, `yarn tsx` and the like when the package has its own `tsx` script, and commands with pipes, variables, or subshells. It also reads the scripts a command runs, and keeps tsx when one does not exist yet, as when an earlier command generates it, and when `vpx` would fail on one: standard decorators (without `experimentalDecorators`) or `accessor` fields, which `vpx` does not compile yet, a `require()` of `./a.js` that only tsx resolves to `a.ts`, a `require()` of a name Node.js cannot resolve, which tsx can map through tsconfig `paths`, and, because tsx compiles ES module syntax to CommonJS, a `.cts` file with `import` or `export`, and a file that mixes `import` with `__dirname`, `__filename`, `require()`, or `module.exports` in a package without `"type": "module"`. This check reads only the files a command names, not the modules they import, and does not apply a tsconfig `module` of `node16` or `nodenext`, under which `vpx` runs every `.ts` file as an ES module. A package that lists tsx in `dependencies`, `optionalDependencies`, or `peerDependencies` keeps it too, since `vpx` comes from the `vite-plus` devDependency. Migration removes the `tsx` devDependency only when nothing uses tsx anymore: no kept script, no import of `tsx` or its subpaths such as `tsx/esm/api` (in source, build output, or a `package.json` `imports` alias), no shebang, no tool setting such as `--import=tsx` in `package.json` or a JSON or JavaScript config, and no tsx command in CI workflows, git hooks, or shell scripts. The root's dependency stays while any workspace package still uses tsx, and a package's dependency stays while a nested package under it does. When tsx stays, the warning names what still uses it. diff --git a/packages/cli/src/migration/__tests__/tsx.spec.ts b/packages/cli/src/migration/__tests__/tsx.spec.ts index 00299e7097..6bf7f91c64 100644 --- a/packages/cli/src/migration/__tests__/tsx.spec.ts +++ b/packages/cli/src/migration/__tests__/tsx.spec.ts @@ -105,6 +105,7 @@ describe('rewriteTsxCommand', () => { ['cross-env-shell TSX_TSCONFIG_PATH=cli.json "tsx ./a.ts"', 'TSX_TSCONFIG_PATH'], ['TSX_TSCONFIG_PATH=cli.json concurrently "tsx ./a.ts"', 'TSX_TSCONFIG_PATH'], ['TSX_TSCONFIG_PATH=cli.json; tsx ./a.ts', 'TSX_TSCONFIG_PATH'], + ['set "TSX_TSCONFIG_PATH=cli.json" && tsx ./a.ts', 'TSX_TSCONFIG_PATH'], ['NODE_OPTIONS=--import=tsx tsx ./a.ts', 'NODE_OPTIONS'], ['export TSX_TSCONFIG_PATH=cli.json && tsx ./a.ts', 'shell syntax'], ['tsx watch --test ./a.test.ts', '`vpx --watch` does not run `--test`'], @@ -220,6 +221,24 @@ describe('rewriteTsxCommand', () => { path.join(dir, 'node-module.ts'), "import module from 'node:module';\nconst require = module.createRequire(import.meta.url);\nrequire('node:fs');\n", ); + fs.writeFileSync( + path.join(dir, 'template-require.cts'), + 'const { x } = require(`./helper.js`);\n', + ); + // Syntax `vpx` does not compile yet, unless a tsconfig enables legacy decorators. + const decorated = '@sealed\nclass Job {}\nfunction sealed(target: unknown) {}\n'; + fs.writeFileSync(path.join(dir, 'decorated.ts'), decorated); + fs.writeFileSync(path.join(dir, 'accessor.ts'), 'class Job {\n accessor count = 0;\n}\n'); + fs.mkdirSync(path.join(dir, 'legacy')); + fs.writeFileSync( + path.join(dir, 'legacy/tsconfig.json'), + '{\n // Inherits experimentalDecorators.\n "extends": "../base.json"\n}\n', + ); + fs.writeFileSync( + path.join(dir, 'base.json'), + '{ "compilerOptions": { "experimentalDecorators": true } }\n', + ); + fs.writeFileSync(path.join(dir, 'legacy/decorated.ts'), decorated); }); afterEach(() => { @@ -250,6 +269,10 @@ describe('rewriteTsxCommand', () => { '`module-filename.ts` mixes import or export with CommonJS globals', ], ['tsx alias.cts', 'requires `@app/job`, which Node.js cannot resolve'], + ['tsx template-require.cts', 'requires `./helper.js`, which only tsx resolves'], + ['tsx decorated.ts', '`decorated.ts` uses standard decorators'], + ['tsx accessor.ts', '`accessor.ts` declares an `accessor` field'], + ['generate && tsx --test generated/*.test.ts', '`generated/*.test.ts` matches no files yet'], ['generate && tsx generated.ts', '`generated.ts` does not exist yet'], ['tsx inner.ts', '`inner.ts` mixes import or export with CommonJS globals'], [ @@ -274,6 +297,7 @@ describe('rewriteTsxCommand', () => { ['tsx require-bare.cts', 'vpx require-bare.cts'], ['tsx builtin.cts', 'vpx builtin.cts'], ['tsx node-module.ts', 'vpx node-module.ts'], + ['tsx legacy/decorated.ts', 'vpx legacy/decorated.ts'], ['tsx import-equals.cts', 'vpx import-equals.cts'], ['tsx importer.ts', 'vpx importer.ts'], ['tsx --test plain.ts', 'vpx --test plain.ts'], @@ -452,6 +476,12 @@ describe('migrateTsxToVpx', () => { '#!/usr/bin/env tsx\nexport {};\n', 'bin/release still uses tsx', ], + [ + 'an extensionless JavaScript file', + 'bin/cli', + "import { tsImport } from 'tsx/esm/api';\n", + 'bin/cli still uses tsx', + ], [ 'a nested package', 'examples/demo/package.json', diff --git a/packages/cli/src/migration/migrator/tsx.ts b/packages/cli/src/migration/migrator/tsx.ts index 5b3fbdc37f..a2b3261f7a 100644 --- a/packages/cli/src/migration/migrator/tsx.ts +++ b/packages/cli/src/migration/migrator/tsx.ts @@ -178,9 +178,15 @@ const COMMONJS_GLOBALS = [ ['module', /(? = { @@ -282,7 +288,10 @@ export function rewriteTsxCommand(command: string, cwd?: string | null): TsxComm if (name === 'cd' && args.length === 1 && !/^[-~]/.test(args[0])) { rewrite.cwd &&= path.resolve(rewrite.cwd, args[0]); changedDirectory = true; - } else if (!['export', 'set', 'unset'].includes(name) && rewrite.cwd !== undefined) { + } else if (name === 'set') { + // cmd.exe's `set "TSX_TSCONFIG_PATH=x"` reaches later commands. + rewrite.envReason ??= envKeepReason(args); + } else if (!['export', 'unset'].includes(name) && rewrite.cwd !== undefined) { rewrite.cwd = null; } continue; @@ -415,6 +424,12 @@ function scriptKeepReason(cwd: string | undefined, script: string): string | und } // A comment can start a line before ES module syntax, as in `/* banner */ import ...`. const code = stripComments(source); + if (ACCESSOR_FIELD.test(code)) { + return `\`${script}\` declares an \`accessor\` field, which \`vpx\` does not compile yet`; + } + if (DECORATOR.test(code) && !experimentalDecorators(file)) { + return `\`${script}\` uses standard decorators, which \`vpx\` does not compile yet; it supports decorators with \`experimentalDecorators\``; + } if (!/\.(?:c?ts|tsx|jsx?)$/.test(script) || !(ESM_SYNTAX.test(source) || ESM_SYNTAX.test(code))) { return undefined; } @@ -434,6 +449,68 @@ function stripComments(source: string): string { return source.replaceAll(/\/\*[\s\S]*?\*\/|\/\/[^\n]*/g, ''); } +/** + * Whether the tsconfig nearest to `file`, which `vpx` applies, enables + * `experimentalDecorators`, directly or through `extends`. + */ +function experimentalDecorators(file: string): boolean { + for (let dir = path.dirname(file); ; dir = path.dirname(dir)) { + const tsconfig = path.join(dir, 'tsconfig.json'); + if (fs.existsSync(tsconfig)) { + return tsconfigOption(tsconfig, 'experimentalDecorators', new Set()) === true; + } + if (path.dirname(dir) === dir) { + return false; + } + } +} + +/** A `compilerOptions` value of a tsconfig, following `extends` (later entries win). */ +function tsconfigOption(tsconfig: string, name: string, seen: Set): unknown { + if (seen.has(tsconfig)) { + return undefined; + } + seen.add(tsconfig); + let config: unknown; + try { + config = parseJsonc(fs.readFileSync(tsconfig, 'utf8')); + } catch { + return undefined; + } + if (!isPlainRecord(config)) { + return undefined; + } + if (isPlainRecord(config.compilerOptions) && config.compilerOptions[name] !== undefined) { + return config.compilerOptions[name]; + } + const parents = Array.isArray(config.extends) ? config.extends : [config.extends]; + for (const parent of parents.toReversed()) { + if (typeof parent !== 'string') { + continue; + } + const resolved = resolveTsconfig(tsconfig, parent); + const value = resolved === undefined ? undefined : tsconfigOption(resolved, name, seen); + if (value !== undefined) { + return value; + } + } + return undefined; +} + +function resolveTsconfig(from: string, specifier: string): string | undefined { + if (specifier.startsWith('.') || path.isAbsolute(specifier)) { + const file = path.resolve(path.dirname(from), specifier); + return [file, `${file}.json`].find((candidate) => fs.existsSync(candidate)); + } + const require = createRequire(from); + for (const candidate of [specifier, `${specifier}/tsconfig.json`]) { + try { + return require.resolve(candidate); + } catch {} + } + return undefined; +} + /** * Whether `source` uses a CommonJS global it does not declare or import at module level, * as in `const require = createRequire(import.meta.url)` or `import module from @@ -481,6 +558,10 @@ function runKeepReason( if (files.length > MAX_GLOB_FILES) { return `\`${script}\` matches more than ${MAX_GLOB_FILES} files, too many for vp migrate to check`; } + // The files can come from an earlier command, as for a missing entry. + if (!files.length) { + return `\`${script}\` matches no files yet, so vp migrate cannot check them`; + } for (const file of files) { const reason = scriptKeepReason(cwd, file); if (reason) { @@ -813,14 +894,7 @@ function findTsxUse(dir: string): string | undefined { if (LOCKFILES.has(path.basename(file))) { return false; } - const extension = path.extname(file); - const uses = - TSX_SHEBANG.test(content) || - (extension === '.json' || extension === '.jsonc' - ? jsonUsesTsx(parseJsonc(content)) - : (CODE_EXTENSIONS.has(extension) ? TSX_CODE_REFERENCE : TSX_TEXT_REFERENCE).test( - content, - )); + const uses = fileUsesTsx(content, path.extname(file)); if (uses) { found = `${displayRelative(file)} still uses tsx`; } @@ -847,6 +921,21 @@ function findTsxUse(dir: string): string | undefined { return found; } +/** Whether a file with `extension` runs, imports, or loads tsx. */ +function fileUsesTsx(content: string, extension: string): boolean { + if (TSX_SHEBANG.test(content)) { + return true; + } + if (extension === '.json' || extension === '.jsonc') { + return jsonUsesTsx(parseJsonc(content)); + } + if (CODE_EXTENSIONS.has(extension)) { + return TSX_CODE_REFERENCE.test(content); + } + // An extensionless file can be JavaScript, such as an executable, as well as shell. + return TSX_TEXT_REFERENCE.test(content) || (extension === '' && TSX_CODE_REFERENCE.test(content)); +} + /** * Whether a JSON value holds a string that runs or loads tsx: a command such as * `tsx a.ts`, a loader flag such as `--import=tsx`, or tsx and its subpaths. From 9a3451f0f969ea5443649023b2ca9adbb42260b2 Mon Sep 17 00:00:00 2001 From: "MK (fengmk2)" Date: Thu, 22 Oct 2026 07:53:50 +0800 Subject: [PATCH 18/23] fix(migrate): check preloads, env files, and --tsconfig before rewriting tsx Address the sixteenth round of review feedback on the tsx migration: - Read one set of options for `tsx` and `node --import tsx`, and check what they name besides the scripts: local `--require`/`--import` preloads, the variables of `--env-file` files (`NODE_OPTIONS` or `TSX_TSCONFIG_PATH`), and the `--tsconfig` the decorator check applies instead of the nearest. - Find decorators and `accessor` fields inside a line, follow cmd.exe's `chdir` like `cd`, and match environment variable names and the tsx bin in any case, as Windows does. - Before removing tsx, match a variable assigned `'tsx'` in code configs. Co-Authored-By: Claude Opus 5.5 --- .../cli/src/migration/__tests__/tsx.spec.ts | 32 ++ packages/cli/src/migration/migrator/tsx.ts | 280 +++++++++++------- 2 files changed, 203 insertions(+), 109 deletions(-) diff --git a/packages/cli/src/migration/__tests__/tsx.spec.ts b/packages/cli/src/migration/__tests__/tsx.spec.ts index 6bf7f91c64..5dc3620abb 100644 --- a/packages/cli/src/migration/__tests__/tsx.spec.ts +++ b/packages/cli/src/migration/__tests__/tsx.spec.ts @@ -107,6 +107,10 @@ describe('rewriteTsxCommand', () => { ['TSX_TSCONFIG_PATH=cli.json; tsx ./a.ts', 'TSX_TSCONFIG_PATH'], ['set "TSX_TSCONFIG_PATH=cli.json" && tsx ./a.ts', 'TSX_TSCONFIG_PATH'], ['NODE_OPTIONS=--import=tsx tsx ./a.ts', 'NODE_OPTIONS'], + // Windows reads environment variable names in any case. + ['cross-env node_options=--import=tsx tsx ./a.ts', 'NODE_OPTIONS'], + // Windows also finds the tsx bin as `TSX`. + ['TSX ./a.ts', 'does not rewrite this use of tsx'], ['export TSX_TSCONFIG_PATH=cli.json && tsx ./a.ts', 'shell syntax'], ['tsx watch --test ./a.test.ts', '`vpx --watch` does not run `--test`'], ['tsx --watch --test ./a.test.ts', '`vpx --watch` does not run `--test`'], @@ -239,6 +243,14 @@ describe('rewriteTsxCommand', () => { '{ "compilerOptions": { "experimentalDecorators": true } }\n', ); fs.writeFileSync(path.join(dir, 'legacy/decorated.ts'), decorated); + fs.writeFileSync(path.join(dir, 'standard.json'), '{}\n'); + fs.writeFileSync( + path.join(dir, 'inline-decorator.ts'), + 'class Job { @logged run() {} }\nfunction logged() {}\n', + ); + fs.writeFileSync(path.join(dir, 'inline-accessor.ts'), 'class Job { accessor count = 0 }\n'); + // An env file that loads tsx, which would compete with vpx. + fs.writeFileSync(path.join(dir, '.env.tsx'), 'NODE_OPTIONS="--import=tsx"\n'); }); afterEach(() => { @@ -271,6 +283,19 @@ describe('rewriteTsxCommand', () => { ['tsx alias.cts', 'requires `@app/job`, which Node.js cannot resolve'], ['tsx template-require.cts', 'requires `./helper.js`, which only tsx resolves'], ['tsx decorated.ts', '`decorated.ts` uses standard decorators'], + // `--tsconfig` replaces the nearest tsconfig, which enables legacy decorators. + [ + 'tsx --tsconfig standard.json legacy/decorated.ts', + '`legacy/decorated.ts` uses standard decorators', + ], + ['tsx inline-decorator.ts', '`inline-decorator.ts` uses standard decorators'], + ['tsx inline-accessor.ts', '`inline-accessor.ts` declares an `accessor` field'], + [ + 'node --require tsx/cjs --require ./require-js.cts ./builtin.cts', + '`./require-js.cts` requires `./helper.js`', + ], + ['tsx --env-file=.env.tsx plain.ts', '`.env.tsx`: `NODE_OPTIONS` loads tsx'], + ['chdir tools && tsx only.ts', '`only.ts` mixes import or export with CommonJS globals'], ['tsx accessor.ts', '`accessor.ts` declares an `accessor` field'], ['generate && tsx --test generated/*.test.ts', '`generated/*.test.ts` matches no files yet'], ['generate && tsx generated.ts', '`generated.ts` does not exist yet'], @@ -298,6 +323,7 @@ describe('rewriteTsxCommand', () => { ['tsx builtin.cts', 'vpx builtin.cts'], ['tsx node-module.ts', 'vpx node-module.ts'], ['tsx legacy/decorated.ts', 'vpx legacy/decorated.ts'], + ['tsx --tsconfig base.json decorated.ts', 'vpx --tsconfig base.json decorated.ts'], ['tsx import-equals.cts', 'vpx import-equals.cts'], ['tsx importer.ts', 'vpx importer.ts'], ['tsx --test plain.ts', 'vpx --test plain.ts'], @@ -524,6 +550,12 @@ describe('migrateTsxToVpx', () => { "export default { command: 'tsx', args: ['job.ts'] };\n", 'tasks.config.ts still uses tsx', ], + [ + 'a tool config that names tsx through a variable', + 'tasks.config.mjs', + "const command = 'tsx';\nexport default { command, args: ['job.ts'] };\n", + 'tasks.config.mjs still uses tsx', + ], [ 'a shell pipe into tsx', 'scripts/run.sh', diff --git a/packages/cli/src/migration/migrator/tsx.ts b/packages/cli/src/migration/migrator/tsx.ts index a2b3261f7a..422d152964 100644 --- a/packages/cli/src/migration/migrator/tsx.ts +++ b/packages/cli/src/migration/migrator/tsx.ts @@ -33,10 +33,10 @@ import { } from './shared.ts'; // tsx in command position: `tsx a.ts`, `npx tsx`, `--import tsx`, `--import=tsx`, -// `-r tsx/cjs`, `node_modules\.bin\tsx.cmd`, or inside a quoted command. Not `.tsx` -// files or packages such as `tsx-foo`. +// `-r tsx/cjs`, `node_modules\.bin\tsx.cmd`, or inside a quoted command, in any case, as +// Windows finds `TSX`. Not `.tsx` files or packages such as `tsx-foo`. const TSX_COMMAND = - /(?:^|[\s;&|("'=/\\`])tsx(?:\.(?:cmd|exe|ps1))?(?:\/(?:esm|cjs))?(?=$|[\s;&|)"'`])/; + /(?:^|[\s;&|("'=/\\`])tsx(?:\.(?:cmd|exe|ps1))?(?:\/(?:esm|cjs))?(?=$|[\s;&|)"'`])/i; // Node.js options that load tsx's hooks, as in `node --import tsx ./a.ts`. const LOADER_OPTIONS = new Set([ @@ -91,8 +91,8 @@ const TSX_LOADER_FLAG = /(?:(?:--import|--loader|--experimental-loader|--require|-r)[= \t]+|(? = { @@ -283,9 +285,10 @@ export function rewriteTsxCommand(command: string, cwd?: string | null): TsxComm } end = tokens.at(-1)!.end; const [name, ...args] = tokens.map(({ value }) => value); - if (SHELL_STATE_COMMANDS.includes(name)) { + // cmd.exe's `chdir` is `cd`. + if (SHELL_STATE_COMMANDS.includes(name) || name === 'chdir') { // Follow `cd `; after another directory change, scripts cannot be found. - if (name === 'cd' && args.length === 1 && !/^[-~]/.test(args[0])) { + if ((name === 'cd' || name === 'chdir') && args.length === 1 && !/^[-~]/.test(args[0])) { rewrite.cwd &&= path.resolve(rewrite.cwd, args[0]); changedDirectory = true; } else if (name === 'set') { @@ -353,21 +356,44 @@ function rewriteSegment(rewrite: Rewrite, tokens: LiteralCommandToken[]): void { } } -/** Why environment assignments keep tsx for the command they apply to, or undefined. */ +/** + * Why environment assignments keep tsx for the command they apply to, or undefined. + * Names match in any case, as on Windows. + */ function envKeepReason(assignments: readonly string[]): string | undefined { - if (assignments.some((value) => value.startsWith('TSX_TSCONFIG_PATH='))) { + if (assignments.some((value) => /^TSX_TSCONFIG_PATH=/i.test(value))) { return KEEP_TSX_TSCONFIG; } if ( - assignments.some( - (value) => value.startsWith('NODE_OPTIONS=') && TSX_COMMAND.test(value.slice(13)), - ) + assignments.some((value) => /^NODE_OPTIONS=/i.test(value) && TSX_COMMAND.test(value.slice(13))) ) { return KEEP_NODE_OPTIONS; } return undefined; } +/** Why the variables an `--env-file` sets keep tsx, or undefined. */ +function envFileKeepReason(cwd: string | undefined, envFile: string): string | undefined { + if (cwd === undefined) { + return undefined; + } + let content: string; + try { + content = fs.readFileSync(path.resolve(cwd, envFile), 'utf8'); + } catch { + return undefined; + } + const reason = envKeepReason( + content.split(/\r?\n/).map((line) => + line + .trim() + .replace(/^export\s+/, '') + .replace(/=\s*(['"])(.*)\1\s*$/, '=$2'), + ), + ); + return reason && `\`${envFile}\`: ${reason}`; +} + /** * An option's name without `=value`. Node.js reads `_` as `-` in long option names, as * in `--watch_path`, and so does `vpx`. @@ -389,7 +415,11 @@ function removal(tokens: LiteralCommandToken[], index: number): Edit { * Node.js does, rejects ES module syntax in a `.cts` file, and runs the other files as ES * modules, so `__dirname`, `__filename`, and `require` are not defined. */ -function scriptKeepReason(cwd: string | undefined, script: string): string | undefined { +function scriptKeepReason( + cwd: string | undefined, + script: string, + tsconfig?: string, +): string | undefined { if (cwd === undefined || !SCRIPT_EXTENSIONS.some((extension) => script.endsWith(extension))) { return undefined; } @@ -427,7 +457,10 @@ function scriptKeepReason(cwd: string | undefined, script: string): string | und if (ACCESSOR_FIELD.test(code)) { return `\`${script}\` declares an \`accessor\` field, which \`vpx\` does not compile yet`; } - if (DECORATOR.test(code) && !experimentalDecorators(file)) { + if ( + DECORATOR.test(code) && + !experimentalDecorators(file, tsconfig === undefined ? undefined : path.resolve(cwd, tsconfig)) + ) { return `\`${script}\` uses standard decorators, which \`vpx\` does not compile yet; it supports decorators with \`experimentalDecorators\``; } if (!/\.(?:c?ts|tsx|jsx?)$/.test(script) || !(ESM_SYNTAX.test(source) || ESM_SYNTAX.test(code))) { @@ -450,10 +483,13 @@ function stripComments(source: string): string { } /** - * Whether the tsconfig nearest to `file`, which `vpx` applies, enables - * `experimentalDecorators`, directly or through `extends`. + * Whether the tsconfig `vpx` applies to `file`, the one `--tsconfig` names or else the + * nearest, enables `experimentalDecorators`, directly or through `extends`. */ -function experimentalDecorators(file: string): boolean { +function experimentalDecorators(file: string, tsconfig: string | undefined): boolean { + if (tsconfig !== undefined) { + return tsconfigOption(tsconfig, 'experimentalDecorators', new Set()) === true; + } for (let dir = path.dirname(file); ; dir = path.dirname(dir)) { const tsconfig = path.join(dir, 'tsconfig.json'); if (fs.existsSync(tsconfig)) { @@ -529,6 +565,16 @@ function usesCommonJsGlobals(source: string, code: string): boolean { ); } +/** What a run's options name besides its scripts. */ +interface RunOptions { + /** `--tsconfig`, which replaces the nearest tsconfig. */ + tsconfig?: string; + /** Local `--require` and `--import` modules, which load before the scripts. */ + preloads: string[]; + /** `--env-file` files, whose variables the run gets. */ + envFiles: string[]; +} + /** * Why `vpx` cannot run what tsx or `node --import tsx` runs: `positionals` are the entry * and its arguments, or with `--test`, the test files. @@ -537,6 +583,7 @@ function runKeepReason( cwd: string | null | undefined, test: boolean, positionals: readonly string[], + options: RunOptions, ): string | undefined { const [entry] = positionals; if (entry === undefined) { @@ -549,7 +596,14 @@ function runKeepReason( if (cwd === null) { return KEEP_CWD; } - for (const script of test ? positionals.filter((value) => !value.startsWith('-')) : [entry]) { + for (const envFile of options.envFiles) { + const reason = envFileKeepReason(cwd, envFile); + if (reason) { + return reason; + } + } + const scripts = test ? positionals.filter((value) => !value.startsWith('-')) : [entry]; + for (const script of [...options.preloads, ...scripts]) { // The shell, or Node.js for a quoted `--test` glob, runs the files a glob matches. const files = cwd !== undefined && /[*?[{]/.test(script) @@ -563,7 +617,7 @@ function runKeepReason( return `\`${script}\` matches no files yet, so vp migrate cannot check them`; } for (const file of files) { - const reason = scriptKeepReason(cwd, file); + const reason = scriptKeepReason(cwd, file, options.tsconfig); if (reason) { return reason; } @@ -599,65 +653,16 @@ function rewriteTsx( if (watch) { index++; } - const edits: Edit[] = [ - { start: tokens[first].start, end: tokens[index - 1].end, text: watch ? 'vpx --watch' : 'vpx' }, - ]; - let entryIndex = tokens.length; - let test = false; - let nodeWatch = false; - for (; index < tokens.length; index++) { - const { value } = tokens[index]; - if (value === '--' || !value.startsWith('-')) { - entryIndex = value === '--' ? index + 1 : index; - break; - } - const name = optionName(value); - if (name === '--no-cache') { - // `vpx` has no transform cache, and Node.js would reject the flag. - edits.push(removal(tokens, index)); - continue; - } - const inline = value.includes('='); - if (LOADER_OPTIONS.has(name)) { - const loader = inline ? value.slice(value.indexOf('=') + 1) : tokens[index + 1]?.value; - // tsx loading itself again would compete with the hooks of `vpx`. - if (loader !== undefined && TSX_LOADER.test(loader)) { - edits.push({ ...removal(tokens, index), end: tokens[inline ? index : index + 1].end }); - } - if (!inline) { - index++; - } - continue; - } - const keep = KEEP_OPTIONS.get(name); - if (keep) { - rewrite.kept.push(keep); - return; - } - test ||= name === '--test'; - nodeWatch ||= name === '--watch'; - if ( - !inline && - (NODE_OPTIONS_WITH_VALUE.has(name) || - WATCH_OPTIONS_WITH_VALUE.has(name) || - name === '--tsconfig') - ) { - index++; - } - } - const reason = - (watch || nodeWatch) && test - ? KEEP_WATCH_TEST - : runKeepReason( - rewrite.cwd, - test, - tokens.slice(entryIndex).map(({ value }) => value), - ); + const scanned = scanOptions(tokens, index, true); + const reason = runReason(rewrite.cwd, tokens, scanned, watch); if (reason) { rewrite.kept.push(reason); return; } - rewrite.edits.push(...edits); + rewrite.edits.push( + { start: tokens[first].start, end: tokens[index - 1].end, text: watch ? 'vpx --watch' : 'vpx' }, + ...scanned.removals, + ); } function rewriteNodeLoader( @@ -667,55 +672,112 @@ function rewriteNodeLoader( start: number, keepReason: string | undefined, ): void { - const loaders: Edit[] = []; - let entryIndex = tokens.length; - let optionReason = keepReason; - let test = false; - let watch = false; - for (let index = start + 1; index < tokens.length; index++) { + const scanned = scanOptions(tokens, start + 1, false); + if (!scanned.loaders) { + return; + } + const reason = keepReason ?? runReason(rewrite.cwd, tokens, scanned, false); + if (reason) { + rewrite.kept.push(reason); + return; + } + rewrite.edits.push( + { start: tokens[first].start, end: tokens[start].end, text: 'vpx' }, + ...scanned.removals, + ); +} + +/** What the options of `tsx` or `node` before the entry say about a run. */ +interface ScannedOptions extends RunOptions { + /** Where the entry, or with `--test` the test files, start. */ + entryIndex: number; + /** Removals of tsx loader flags, and for tsx, of `--no-cache`. */ + removals: Edit[]; + loaders: number; + keepReason?: string; + test: boolean; + watch: boolean; +} + +/** Read the options of `tsx` (`tsx` true) or `node` from `tokens[index]` up to the entry. */ +function scanOptions(tokens: LiteralCommandToken[], index: number, tsx: boolean): ScannedOptions { + const scanned: ScannedOptions = { + entryIndex: tokens.length, + removals: [], + loaders: 0, + test: false, + watch: false, + preloads: [], + envFiles: [], + }; + for (; index < tokens.length; index++) { const { value } = tokens[index]; if (value === '--' || !value.startsWith('-')) { - entryIndex = value === '--' ? index + 1 : index; + scanned.entryIndex = value === '--' ? index + 1 : index; break; } const name = optionName(value); const inline = value.includes('='); + const optionValue = inline ? value.slice(value.indexOf('=') + 1) : tokens[index + 1]?.value; + if (tsx && name === '--no-cache') { + // `vpx` has no transform cache, and Node.js would reject the flag. + scanned.removals.push(removal(tokens, index)); + continue; + } if (LOADER_OPTIONS.has(name)) { - const loader = inline ? value.slice(value.indexOf('=') + 1) : tokens[index + 1]?.value; - if (loader !== undefined && TSX_LOADER.test(loader)) { - loaders.push({ ...removal(tokens, index), end: tokens[inline ? index : index + 1].end }); + if (optionValue !== undefined && TSX_LOADER.test(optionValue)) { + // A tsx loader would compete with the hooks of `vpx`. + scanned.removals.push({ + ...removal(tokens, index), + end: tokens[inline ? index : index + 1].end, + }); + scanned.loaders++; + } else if (optionValue?.startsWith('.')) { + scanned.preloads.push(optionValue); } if (!inline) { index++; } continue; } - optionReason ??= KEEP_OPTIONS.get(name); - test ||= name === '--test'; - watch ||= name === '--watch'; - if (!inline && NODE_OPTIONS_WITH_VALUE.has(name)) { + scanned.keepReason ??= KEEP_OPTIONS.get(name); + scanned.test ||= name === '--test'; + scanned.watch ||= name === '--watch'; + if (tsx && name === '--tsconfig') { + scanned.tsconfig = optionValue; + } + if ((name === '--env-file' || name === '--env-file-if-exists') && optionValue !== undefined) { + scanned.envFiles.push(optionValue); + } + if ( + !inline && + (NODE_OPTIONS_WITH_VALUE.has(name) || + (tsx && (WATCH_OPTIONS_WITH_VALUE.has(name) || name === '--tsconfig'))) + ) { index++; } } - if (!loaders.length) { - return; + return scanned; +} + +/** Why `vpx` cannot run what the scanned options and the scripts after them run. */ +function runReason( + cwd: string | null | undefined, + tokens: LiteralCommandToken[], + scanned: ScannedOptions, + watch: boolean, +): string | undefined { + if (scanned.keepReason) { + return scanned.keepReason; } - const reason = - optionReason ?? - (watch && test - ? KEEP_WATCH_TEST - : runKeepReason( - rewrite.cwd, - test, - tokens.slice(entryIndex).map(({ value }) => value), - )); - if (reason) { - rewrite.kept.push(reason); - return; + if ((watch || scanned.watch) && scanned.test) { + return KEEP_WATCH_TEST; } - rewrite.edits.push( - { start: tokens[first].start, end: tokens[start].end, text: 'vpx' }, - ...loaders, + return runKeepReason( + cwd, + scanned.test, + tokens.slice(scanned.entryIndex).map(({ value }) => value), + scanned, ); } From 0daf722028038efe3c6de9a8ce196e067f7f5ed8 Mon Sep 17 00:00:00 2001 From: "MK (fengmk2)" Date: Thu, 22 Oct 2026 09:27:00 +0800 Subject: [PATCH 19/23] refactor(migrate): simplify the tsx checks, fix false retention Act on the design review of the tsx migration and the seventeenth Codex review: - Cut checks that approximated what vpx reports itself when the script runs: standard decorators and `accessor` fields with tsconfig `extends` resolution, resolving bare `require()` calls, glob expansion, following `cd`, reading `--env-file` files and cmd.exe `set`, the `yarn tsx` script shadow rule, the concurrently option table, and two process-API retention heuristics. A glob or a script after a shell state change is now rewritten without the file check. The docs list these as limits. - Rewrite `pnpm dlx tsx` to `vpx`, as the design specified. - Count a bare `"tsx"` only in scripts and lint-staged tasks, so `moduleFileExtensions: ["ts", "tsx"]` and similar lists no longer keep the dependency, and skip caches and coverage in the retention scan. - Report `NODE_OPTIONS` for `NODE_OPTIONS='--import tsx' node a.ts`, read CommonJS uses from comment-free code without letting a `//` in a string hide the rest of a line, and keep tsx for TypeScript's `export =`. Co-Authored-By: Claude Opus 5.5 --- docs/guide/migrate-rules.md | 18 +- .../cli/src/migration/__tests__/tsx.spec.ts | 124 ++---- packages/cli/src/migration/migrator/shared.ts | 2 +- packages/cli/src/migration/migrator/tsx.ts | 376 +++++------------- rfcs/vpx-script-execution.md | 2 +- 5 files changed, 143 insertions(+), 379 deletions(-) diff --git a/docs/guide/migrate-rules.md b/docs/guide/migrate-rules.md index 2ca1f24ff5..503c2f401e 100644 --- a/docs/guide/migrate-rules.md +++ b/docs/guide/migrate-rules.md @@ -278,18 +278,18 @@ Unrelated `bunx` commands and other package-executor forms remain unchanged. When a `package.json` script, a lint-staged task, or a `#!/usr/bin/env tsx` shebang runs [tsx](https://tsx.is), migration offers to move it to [`vpx`](/guide/vpx), Vite+'s TypeScript script runner. It rewrites scripts and lint-staged tasks in `package.json` and JSON lint-staged files, and `#!/usr/bin/env tsx` shebangs in script files: -| Before | After | -| ----------------------------------------------------- | ------------------------------------------ | -| `tsx src/seed.ts --rows 10` | `vpx src/seed.ts --rows 10` | -| `tsx watch --include "*.json" src/a.ts` | `vpx --watch --include "*.json" src/a.ts` | -| `npx tsx a.ts`, `pnpm exec tsx a.ts`, `bunx tsx a.ts` | `vpx a.ts` | -| `node --import tsx ./a.ts` | `vpx ./a.ts` | -| `cross-env NODE_ENV=dev tsx a.ts` | `cross-env NODE_ENV=dev vpx a.ts` | -| `concurrently "tsx watch a.ts" "vp dev"` | `concurrently "vpx --watch a.ts" "vp dev"` | +| Before | After | +| -------------------------------------------------------------------------- | ------------------------------------------ | +| `tsx src/seed.ts --rows 10` | `vpx src/seed.ts --rows 10` | +| `tsx watch --include "*.json" src/a.ts` | `vpx --watch --include "*.json" src/a.ts` | +| `npx tsx a.ts`, `pnpm exec tsx a.ts`, `pnpm dlx tsx a.ts`, `bunx tsx a.ts` | `vpx a.ts` | +| `node --import tsx ./a.ts` | `vpx ./a.ts` | +| `cross-env NODE_ENV=dev tsx a.ts` | `cross-env NODE_ENV=dev vpx a.ts` | +| `concurrently "tsx watch a.ts" "vp dev"` | `concurrently "vpx --watch a.ts" "vp dev"` | `tsx watch` becomes `vpx --watch`, never `vpx watch`, which runs the npm package `watch`. Node.js options and tsx watch options keep their order, and `--no-cache` is dropped. A quoted command is rewritten only for programs that run it, such as `concurrently`, `nodemon --exec`, `sh -c`, and `cross-env-shell`; a quoted argument of other programs, as in `echo "tsx a.ts"`, is data. `vpx` needs the same Node.js versions as Vite+ itself, and migration already upgrades incompatible runtime pins to them (see [Vitest 5](/guide/vitest-v5)). -Migration keeps tsx, with a warning, for a command `vpx` cannot run: `--eval`, `--print`, or `--check`, the REPL, `--test` without files or in watch mode, an entry without a file extension, commands that set `TSX_TSCONFIG_PATH` (pass `--tsconfig` to `vpx` instead) or load tsx through `NODE_OPTIONS`, `yarn tsx` and the like when the package has its own `tsx` script, and commands with pipes, variables, or subshells. It also reads the scripts a command runs, and keeps tsx when one does not exist yet, as when an earlier command generates it, and when `vpx` would fail on one: standard decorators (without `experimentalDecorators`) or `accessor` fields, which `vpx` does not compile yet, a `require()` of `./a.js` that only tsx resolves to `a.ts`, a `require()` of a name Node.js cannot resolve, which tsx can map through tsconfig `paths`, and, because tsx compiles ES module syntax to CommonJS, a `.cts` file with `import` or `export`, and a file that mixes `import` with `__dirname`, `__filename`, `require()`, or `module.exports` in a package without `"type": "module"`. This check reads only the files a command names, not the modules they import, and does not apply a tsconfig `module` of `node16` or `nodenext`, under which `vpx` runs every `.ts` file as an ES module. A package that lists tsx in `dependencies`, `optionalDependencies`, or `peerDependencies` keeps it too, since `vpx` comes from the `vite-plus` devDependency. +Migration keeps tsx, with a warning, for a command `vpx` cannot run: `--eval`, `--print`, or `--check`, the REPL, `--test` without files or in watch mode, an entry without a file extension, commands that set `TSX_TSCONFIG_PATH` (pass `--tsconfig` to `vpx` instead) or load tsx through `NODE_OPTIONS`, and commands with pipes, variables, or subshells. It also reads the scripts a command runs, including local `--require` and `--import` modules, and keeps tsx when one does not exist yet, as when an earlier command generates it, and when `vpx` would fail on one: a `require()` of `./a.js` that only tsx resolves to `a.ts`, and, because tsx compiles ES module syntax to CommonJS, a `.cts` file with `import` or `export`, and a file that mixes `import` with `__dirname`, `__filename`, `require()`, or `module.exports` in a package without `"type": "module"`. This check skips globs and scripts after a `cd` or another shell state change, and reads only the files a command names, not the modules they import. It does not look for what else `vpx` handles differently, such as standard decorators, tsconfig `paths` in `require()`, or a tsconfig `module` of `node16` or `nodenext`; `vpx` reports those when the script runs. A package that lists tsx in `dependencies`, `optionalDependencies`, or `peerDependencies` keeps it too, since `vpx` comes from the `vite-plus` devDependency. Migration removes the `tsx` devDependency only when nothing uses tsx anymore: no kept script, no import of `tsx` or its subpaths such as `tsx/esm/api` (in source, build output, or a `package.json` `imports` alias), no shebang, no tool setting such as `--import=tsx` in `package.json` or a JSON or JavaScript config, and no tsx command in CI workflows, git hooks, or shell scripts. The root's dependency stays while any workspace package still uses tsx, and a package's dependency stays while a nested package under it does. When tsx stays, the warning names what still uses it. diff --git a/packages/cli/src/migration/__tests__/tsx.spec.ts b/packages/cli/src/migration/__tests__/tsx.spec.ts index 5dc3620abb..e3fc86fe48 100644 --- a/packages/cli/src/migration/__tests__/tsx.spec.ts +++ b/packages/cli/src/migration/__tests__/tsx.spec.ts @@ -33,6 +33,7 @@ describe('rewriteTsxCommand', () => { ["tsx 'my script.ts'", "vpx 'my script.ts'"], ['npx tsx ./a.ts', 'vpx ./a.ts'], ['pnpm exec tsx ./a.ts', 'vpx ./a.ts'], + ['pnpm dlx tsx ./a.ts', 'vpx ./a.ts'], ['pnpm tsx ./a.ts', 'vpx ./a.ts'], ['yarn tsx watch ./a.ts', 'vpx --watch ./a.ts'], ['bunx tsx ./a.ts', 'vpx ./a.ts'], @@ -92,20 +93,15 @@ describe('rewriteTsxCommand', () => { ['node --import tsx', 'REPL'], ['node --import tsx -e 1', '--eval or --print'], ['node --import tsx --watch-path src ./a.ts', '--watch-path'], - ["NODE_OPTIONS='--import tsx' node ./a.ts", 'does not rewrite this use of tsx'], - ['pnpm dlx tsx ./a.ts', 'does not rewrite this use of tsx'], + ["NODE_OPTIONS='--import tsx' node ./a.ts", '`NODE_OPTIONS` loads tsx'], // A quoted argument of a program that does not run commands is data. ['echo "tsx scripts/build.ts"', 'does not rewrite this use of tsx'], - // So is the value of a concurrently option such as `--names`. - ['concurrently --names "tsx a.ts" "vp dev"', 'does not rewrite this use of tsx'], // Only the value of `-c` is a command; the next argument is the shell's `$0`. [`sh -c 'echo "$0"' 'tsx a.ts'`, 'does not rewrite this use of tsx'], ['TSX_TSCONFIG_PATH=cli.json tsx ./a.ts', 'TSX_TSCONFIG_PATH'], ['TSX_TSCONFIG_PATH=cli.json node --import tsx ./a.ts', 'TSX_TSCONFIG_PATH'], ['cross-env-shell TSX_TSCONFIG_PATH=cli.json "tsx ./a.ts"', 'TSX_TSCONFIG_PATH'], ['TSX_TSCONFIG_PATH=cli.json concurrently "tsx ./a.ts"', 'TSX_TSCONFIG_PATH'], - ['TSX_TSCONFIG_PATH=cli.json; tsx ./a.ts', 'TSX_TSCONFIG_PATH'], - ['set "TSX_TSCONFIG_PATH=cli.json" && tsx ./a.ts', 'TSX_TSCONFIG_PATH'], ['NODE_OPTIONS=--import=tsx tsx ./a.ts', 'NODE_OPTIONS'], // Windows reads environment variable names in any case. ['cross-env node_options=--import=tsx tsx ./a.ts', 'NODE_OPTIONS'], @@ -161,7 +157,7 @@ describe('rewriteTsxCommand', () => { '', ].join('\n'), ); - // Only `cd tools` finds this script. + // `cd tools` stops the checks; this script would keep tsx otherwise. fs.mkdirSync(path.join(dir, 'tools')); fs.writeFileSync( path.join(dir, 'tools/only.ts'), @@ -183,6 +179,20 @@ describe('rewriteTsxCommand', () => { path.join(dir, 'commented.ts'), "import fs from 'node:fs';\n// const require = createRequire(import.meta.url);\nrequire('./x');\n", ); + // A CommonJS global named only in a comment is not a use. + fs.writeFileSync( + path.join(dir, 'comment-only.ts'), + "import fs from 'node:fs';\n// no require() or __dirname here\nfs.readdirSync('.');\n", + ); + // A `//` in a string does not start a comment that hides the rest of the line. + fs.writeFileSync( + path.join(dir, 'string-url.ts'), + "import fs from 'node:fs'; const url = 'http://x'; fs.readdirSync(__dirname);\n", + ); + fs.writeFileSync( + path.join(dir, 'export-assign.ts'), + "import fs from 'node:fs';\nexport = fs;\n", + ); fs.writeFileSync( path.join(dir, 'bracket.ts'), "import fs from 'node:fs';\nexports['result'] = fs.statSync('.');\n", @@ -202,10 +212,6 @@ describe('rewriteTsxCommand', () => { ); fs.mkdirSync(path.join(dir, 'tests')); fs.writeFileSync(path.join(dir, 'tests/ok.test.ts'), 'export {};\n'); - fs.writeFileSync( - path.join(dir, 'tests/dirname.test.ts'), - "import fs from 'node:fs';\nfs.readdirSync(__dirname);\n", - ); fs.writeFileSync( path.join(dir, 'banner.ts'), "/* banner */ import fs from 'node:fs'; fs.readdirSync(__dirname);\n", @@ -214,8 +220,6 @@ describe('rewriteTsxCommand', () => { path.join(dir, 'cli-guard.ts'), "import fs from 'node:fs';\nif (require.main === module) fs.readdirSync('.');\n", ); - // tsx maps `@app/job` through tsconfig `paths`; Node.js does not. - fs.writeFileSync(path.join(dir, 'alias.cts'), "const job = require('@app/job');\n"); fs.writeFileSync(path.join(dir, 'builtin.cts'), "const fs = require('node:fs');\n"); fs.writeFileSync( path.join(dir, 'module-filename.ts'), @@ -229,28 +233,6 @@ describe('rewriteTsxCommand', () => { path.join(dir, 'template-require.cts'), 'const { x } = require(`./helper.js`);\n', ); - // Syntax `vpx` does not compile yet, unless a tsconfig enables legacy decorators. - const decorated = '@sealed\nclass Job {}\nfunction sealed(target: unknown) {}\n'; - fs.writeFileSync(path.join(dir, 'decorated.ts'), decorated); - fs.writeFileSync(path.join(dir, 'accessor.ts'), 'class Job {\n accessor count = 0;\n}\n'); - fs.mkdirSync(path.join(dir, 'legacy')); - fs.writeFileSync( - path.join(dir, 'legacy/tsconfig.json'), - '{\n // Inherits experimentalDecorators.\n "extends": "../base.json"\n}\n', - ); - fs.writeFileSync( - path.join(dir, 'base.json'), - '{ "compilerOptions": { "experimentalDecorators": true } }\n', - ); - fs.writeFileSync(path.join(dir, 'legacy/decorated.ts'), decorated); - fs.writeFileSync(path.join(dir, 'standard.json'), '{}\n'); - fs.writeFileSync( - path.join(dir, 'inline-decorator.ts'), - 'class Job { @logged run() {} }\nfunction logged() {}\n', - ); - fs.writeFileSync(path.join(dir, 'inline-accessor.ts'), 'class Job { accessor count = 0 }\n'); - // An env file that loads tsx, which would compete with vpx. - fs.writeFileSync(path.join(dir, '.env.tsx'), 'NODE_OPTIONS="--import=tsx"\n'); }); afterEach(() => { @@ -269,45 +251,26 @@ describe('rewriteTsxCommand', () => { 'concurrently "tsx dirname.ts"', '`dirname.ts` mixes import or export with CommonJS globals', ], - ['cd tools && tsx only.ts', '`only.ts` mixes import or export with CommonJS globals'], ['tsx exports.ts', '`exports.ts` mixes import or export with CommonJS globals'], ['tsx compact.ts', '`compact.ts` mixes import or export with CommonJS globals'], ['tsx commented.ts', '`commented.ts` mixes import or export with CommonJS globals'], ['tsx bracket.ts', '`bracket.ts` mixes import or export with CommonJS globals'], + ['tsx string-url.ts', '`string-url.ts` mixes import or export with CommonJS globals'], + ['tsx export-assign.ts', '`export-assign.ts` mixes import or export with CommonJS globals'], ['tsx banner.ts', '`banner.ts` mixes import or export with CommonJS globals'], ['tsx cli-guard.ts', '`cli-guard.ts` mixes import or export with CommonJS globals'], [ 'tsx module-filename.ts', '`module-filename.ts` mixes import or export with CommonJS globals', ], - ['tsx alias.cts', 'requires `@app/job`, which Node.js cannot resolve'], ['tsx template-require.cts', 'requires `./helper.js`, which only tsx resolves'], - ['tsx decorated.ts', '`decorated.ts` uses standard decorators'], - // `--tsconfig` replaces the nearest tsconfig, which enables legacy decorators. - [ - 'tsx --tsconfig standard.json legacy/decorated.ts', - '`legacy/decorated.ts` uses standard decorators', - ], - ['tsx inline-decorator.ts', '`inline-decorator.ts` uses standard decorators'], - ['tsx inline-accessor.ts', '`inline-accessor.ts` declares an `accessor` field'], [ 'node --require tsx/cjs --require ./require-js.cts ./builtin.cts', '`./require-js.cts` requires `./helper.js`', ], - ['tsx --env-file=.env.tsx plain.ts', '`.env.tsx`: `NODE_OPTIONS` loads tsx'], - ['chdir tools && tsx only.ts', '`only.ts` mixes import or export with CommonJS globals'], - ['tsx accessor.ts', '`accessor.ts` declares an `accessor` field'], - ['generate && tsx --test generated/*.test.ts', '`generated/*.test.ts` matches no files yet'], ['generate && tsx generated.ts', '`generated.ts` does not exist yet'], ['tsx inner.ts', '`inner.ts` mixes import or export with CommonJS globals'], - [ - 'tsx --test tests/*.test.ts', - '`tests/dirname.test.ts` mixes import or export with CommonJS globals', - ], ['tsx require-js.cts', 'requires `./helper.js`, which only tsx resolves to `./helper.ts`'], - ['pushd tools && tsx plain.ts', 'changes the directory'], - // The fallback runs in the package directory, or after a later `cd`. - ['cd tools || tsx plain.ts', 'changes the directory'], ])('keeps %s', (command, reason) => { expect(rewriteTsxCommand(command, dir)).toEqual({ command, @@ -319,16 +282,16 @@ describe('rewriteTsxCommand', () => { ['tsx cjs.cts', 'vpx cjs.cts'], ['tsx plain.ts', 'vpx plain.ts'], ['tsx esm-idioms.ts', 'vpx esm-idioms.ts'], + ['tsx comment-only.ts', 'vpx comment-only.ts'], ['tsx require-bare.cts', 'vpx require-bare.cts'], ['tsx builtin.cts', 'vpx builtin.cts'], ['tsx node-module.ts', 'vpx node-module.ts'], - ['tsx legacy/decorated.ts', 'vpx legacy/decorated.ts'], - ['tsx --tsconfig base.json decorated.ts', 'vpx --tsconfig base.json decorated.ts'], ['tsx import-equals.cts', 'vpx import-equals.cts'], ['tsx importer.ts', 'vpx importer.ts'], ['tsx --test plain.ts', 'vpx --test plain.ts'], - ['tsx --test tests/ok.*.ts', 'vpx --test tests/ok.*.ts'], - ['cd tools && tsx plain.ts', 'cd tools && vpx plain.ts'], + // A glob, and a script after a shell state change, are not checked. + ['tsx --test tests/*.test.ts', 'vpx --test tests/*.test.ts'], + ['cd tools && tsx only.ts', 'cd tools && vpx only.ts'], ])('rewrites %s', (command, expected) => { expect(rewriteTsxCommand(command, dir)).toEqual({ command: expected, kept: [] }); }); @@ -339,16 +302,6 @@ describe('rewriteTsxCommand', () => { expect(rewriteTsxCommand('tsx dirname.ts', dir).command).toBe('vpx dirname.ts'); }); - it('keeps a glob with more files than it checks', () => { - fs.mkdirSync(path.join(dir, 'many')); - for (let index = 0; index <= 200; index++) { - fs.writeFileSync(path.join(dir, `many/${index}.test.ts`), 'export {};\n'); - } - expect(rewriteTsxCommand('tsx --test many/*.test.ts', dir).kept).toEqual([ - '`many/*.test.ts` matches more than 200 files, too many for vp migrate to check', - ]); - }); - it('checks a script under the home directory', () => { vi.spyOn(os, 'homedir').mockReturnValue(dir); try { @@ -359,16 +312,6 @@ describe('rewriteTsxCommand', () => { vi.restoreAllMocks(); } }); - - it('keeps a package-manager shorthand that runs a script named tsx', () => { - fs.writeFileSync(path.join(dir, 'package.json'), '{ "scripts": { "tsx": "node run.js" } }\n'); - expect(rewriteTsxCommand('yarn tsx plain.ts', dir)).toEqual({ - command: 'yarn tsx plain.ts', - kept: ["`yarn tsx` runs the package's own `tsx` script"], - }); - // `exec` always runs the bin. - expect(rewriteTsxCommand('pnpm exec tsx plain.ts', dir).command).toBe('vpx plain.ts'); - }); }); }); @@ -411,6 +354,8 @@ describe('migrateTsxToVpx', () => { type: 'module', scripts: { dev: 'tsx watch src/server.ts', seed: 'npx tsx scripts/seed.ts' }, 'lint-staged': { '*.ts': ['tsx scripts/check.ts'] }, + // A file extension, not a use of tsx. + jest: { moduleFileExtensions: ['ts', 'tsx'] }, devDependencies: { tsx: '^4.20.0', vite: '^7.0.0' }, }); write('.lintstagedrc.json', { '*.md': 'tsx scripts/docs.ts' }); @@ -419,6 +364,10 @@ describe('migrateTsxToVpx', () => { // File extensions are not uses of tsx. write('vite.config.ts', "export default { resolve: { extensions: ['ts', 'tsx'] } };\n"); write('src/App.vue', '\n'); + write('.eslintrc.json', { settings: { 'import/resolver': { node: { extensions: ['tsx'] } } } }); + // Caches and reports are not scanned. + write('.next/cache/config.json', { command: 'tsx scripts/old.ts' }); + write('coverage/report.json', { command: 'tsx scripts/old.ts' }); const report = createMigrationReport(); expect(detectTsxProject(root)).toBe(true); @@ -431,6 +380,7 @@ describe('migrateTsxToVpx', () => { type: 'module', scripts: { dev: 'vpx --watch src/server.ts', seed: 'vpx scripts/seed.ts' }, 'lint-staged': { '*.ts': ['vpx scripts/check.ts'] }, + jest: { moduleFileExtensions: ['ts', 'tsx'] }, devDependencies: { vite: '^7.0.0' }, }); expect(readJson('.lintstagedrc.json')).toEqual({ '*.md': 'vpx scripts/docs.ts' }); @@ -544,18 +494,6 @@ describe('migrateTsxToVpx', () => { '[build]\ncommand = "tsx scripts/build.ts"\n', 'netlify.toml still uses tsx', ], - [ - 'a tool config that names tsx as its command', - 'tasks.config.ts', - "export default { command: 'tsx', args: ['job.ts'] };\n", - 'tasks.config.ts still uses tsx', - ], - [ - 'a tool config that names tsx through a variable', - 'tasks.config.mjs', - "const command = 'tsx';\nexport default { command, args: ['job.ts'] };\n", - 'tasks.config.mjs still uses tsx', - ], [ 'a shell pipe into tsx', 'scripts/run.sh', @@ -577,7 +515,7 @@ describe('migrateTsxToVpx', () => { [ 'a JSON tool config', '.mocharc.json', - '{ "require": "tsx", "spec": "test/**/*.test.ts" }\n', + '{ "require": "tsx/cjs", "spec": "test/**/*.test.ts" }\n', '.mocharc.json still uses tsx', ], [ diff --git a/packages/cli/src/migration/migrator/shared.ts b/packages/cli/src/migration/migrator/shared.ts index c126ab41ed..8624bd871d 100644 --- a/packages/cli/src/migration/migrator/shared.ts +++ b/packages/cli/src/migration/migrator/shared.ts @@ -30,7 +30,7 @@ export function getCommandStartIndex(argv: readonly string[]): number { return 0; } let start = 1; - if (argv[start] === 'exec' || argv[start] === 'x') { + if (argv[start] === 'exec' || argv[start] === 'x' || argv[start] === 'dlx') { start++; } if (argv[start] === '--') { diff --git a/packages/cli/src/migration/migrator/tsx.ts b/packages/cli/src/migration/migrator/tsx.ts index 422d152964..3cb8a981ec 100644 --- a/packages/cli/src/migration/migrator/tsx.ts +++ b/packages/cli/src/migration/migrator/tsx.ts @@ -1,12 +1,10 @@ import fs from 'node:fs'; -import { createRequire } from 'node:module'; import os from 'node:os'; import path from 'node:path'; import { styleText } from 'node:util'; import * as prompts from '@voidzero-dev/vite-plus-prompts'; import { parse as parseJsonc } from 'jsonc-parser'; -import { globSync } from 'tinyglobby'; import { type LiteralCommandToken, parseLiteralCommands } from '../../../binding/index.js'; import { type WorkspacePackage } from '../../types/index.ts'; @@ -57,7 +55,6 @@ const KEEP_WATCH_PATH = '`--watch-path` has no `vpx` equivalent; use `--include` const KEEP_SHELL = 'the command uses shell syntax that vp migrate does not rewrite, such as pipes, variables, or subshells'; const KEEP_OTHER = 'vp migrate does not rewrite this use of tsx'; -const KEEP_CWD = 'an earlier command changes the directory in a way vp migrate does not follow'; const KEEP_TSX_TSCONFIG = '`TSX_TSCONFIG_PATH` selects the tsconfig for tsx only; pass it to `vpx` as `--tsconfig`'; const KEEP_NODE_OPTIONS = @@ -90,17 +87,15 @@ const TSX_SHEBANG = /^#![^\n]*[\s/]tsx(?=\s|$)/; const TSX_LOADER_FLAG = /(?:(?:--import|--loader|--experimental-loader|--require|-r)[= \t]+|(? = new Set([ + ...RETENTION_SCAN_SKIP_DIRS, + '.cache', + '.next', + '.nuxt', + '.svelte-kit', + '.turbo', + '.vite', + 'coverage', +]); // ES module syntax at the start of a statement, including compact forms such as // `export{}` and `export*from`, and uses of the CommonJS globals an ES module does not @@ -178,19 +194,11 @@ const COMMONJS_GLOBALS = [ // `module.exports`, `module.filename`, `module['exports']`. ['module', /(? = { js: ['ts', 'tsx'], cjs: ['cts'], @@ -198,37 +206,14 @@ const TYPESCRIPT_EXTENSIONS: Record = { jsx: ['tsx'], }; // Programs that run commands given as quoted arguments, and which arguments those are, -// judged by the argument before: every one for `concurrently "tsx watch a.ts" "vp dev"` -// except option values such as `--names`, only the value of `-c` for a shell. Elsewhere, -// a quoted string is data, as for `echo`. -const CONCURRENTLY_VALUE_OPTIONS = new Set([ - '-c', - '--default-input-target', - '-k', - '-l', - '-n', - '--name-separator', - '--names', - '-p', - '--prefix', - '--prefix-colors', - '--prefix-length', - '--restart-after', - '--restart-tries', - '-s', - '--success', - '-t', - '--timestamp-format', -]); -const everyArgument = () => true; +// judged by the argument before: every one for `concurrently "tsx watch a.ts" "vp dev"`, +// only the value of `-c` for a shell. Elsewhere, a quoted string is data, as for `echo`. const shellCommand = (previous: string) => /^-[a-z]*c$/.test(previous); const COMMAND_LAUNCHERS = new Map boolean>([ ['bash', shellCommand], - ['concurrently', (previous) => !CONCURRENTLY_VALUE_OPTIONS.has(previous)], + ['concurrently', () => true], ['nodemon', (previous) => previous === '--exec' || previous === '-x'], - ['server-test', everyArgument], ['sh', shellCommand], - ['start-server-and-test', everyArgument], ['zsh', shellCommand], ]); @@ -246,10 +231,8 @@ interface Edit { interface Rewrite { command: string; - /** The directory the next segment runs in; null once a command moved it out of sight. */ - cwd?: string | null; - /** Set once a segment assigns tsx settings, which later commands can inherit. */ - envReason?: string; + /** The directory the next segment runs in, or undefined when it is not known. */ + cwd?: string; edits: Edit[]; kept: string[]; } @@ -262,7 +245,7 @@ interface Rewrite { * stays as it is, with the reason in `kept`. With `cwd`, the directory the command runs * in, the scripts it runs are checked for syntax `vpx` runs differently. */ -export function rewriteTsxCommand(command: string, cwd?: string | null): TsxCommandRewrite { +export function rewriteTsxCommand(command: string, cwd?: string): TsxCommandRewrite { if (!TSX_COMMAND.test(command)) { return { command, kept: [] }; } @@ -272,31 +255,11 @@ export function rewriteTsxCommand(command: string, cwd?: string | null): TsxComm return { command, kept: [KEEP_SHELL] }; } const rewrite: Rewrite = { command, cwd, edits: [], kept: [] }; - let changedDirectory = false; - let end = 0; for (const tokens of segments) { - // After `||`, a command runs because an earlier one failed, maybe a `cd`. - if ( - changedDirectory && - command.slice(end, tokens[0].start).includes('||') && - rewrite.cwd !== undefined - ) { - rewrite.cwd = null; - } - end = tokens.at(-1)!.end; - const [name, ...args] = tokens.map(({ value }) => value); - // cmd.exe's `chdir` is `cd`. + const name = tokens[0].value; + // After `cd` or another shell state change, scripts are no longer checked. if (SHELL_STATE_COMMANDS.includes(name) || name === 'chdir') { - // Follow `cd `; after another directory change, scripts cannot be found. - if ((name === 'cd' || name === 'chdir') && args.length === 1 && !/^[-~]/.test(args[0])) { - rewrite.cwd &&= path.resolve(rewrite.cwd, args[0]); - changedDirectory = true; - } else if (name === 'set') { - // cmd.exe's `set "TSX_TSCONFIG_PATH=x"` reaches later commands. - rewrite.envReason ??= envKeepReason(args); - } else if (!['export', 'unset'].includes(name) && rewrite.cwd !== undefined) { - rewrite.cwd = null; - } + rewrite.cwd = undefined; continue; } rewriteSegment(rewrite, tokens); @@ -327,30 +290,27 @@ function rewriteSegment(rewrite: Rewrite, tokens: LiteralCommandToken[]): void { first++; skipAssignments(); } + if (first >= values.length) { + return; + } // tsx reads TSX_TSCONFIG_PATH, where `vpx` takes `--tsconfig`, and tsx hooks loaded // through NODE_OPTIONS would compete with those of `vpx`. Both reach the commands a // launcher runs. const envReason = envKeepReason(values.slice(0, first)); - if (first >= values.length) { - // A bare assignment can update an exported variable that later commands inherit. - rewrite.envReason ??= envReason; - return; - } - const inheritedReason = envReason ?? rewrite.envReason; const start = first + getCommandStartIndex(values.slice(first)); const launcher = COMMAND_LAUNCHERS.get(values[start]); if (values[start] === 'tsx') { - rewriteTsx(rewrite, tokens, first, start, inheritedReason); + rewriteTsx(rewrite, tokens, first, start, envReason); } else if (values[start] === 'node') { - rewriteNodeLoader(rewrite, tokens, first, start, inheritedReason); + rewriteNodeLoader(rewrite, tokens, first, start, envReason); } else if (shell) { for (const token of tokens.slice(start)) { - rewriteQuotedCommand(rewrite, token, inheritedReason); + rewriteQuotedCommand(rewrite, token, envReason); } } else if (launcher) { for (let index = start + 1; index < tokens.length; index++) { if (launcher(values[index - 1])) { - rewriteQuotedCommand(rewrite, tokens[index], inheritedReason); + rewriteQuotedCommand(rewrite, tokens[index], envReason); } } } @@ -372,28 +332,6 @@ function envKeepReason(assignments: readonly string[]): string | undefined { return undefined; } -/** Why the variables an `--env-file` sets keep tsx, or undefined. */ -function envFileKeepReason(cwd: string | undefined, envFile: string): string | undefined { - if (cwd === undefined) { - return undefined; - } - let content: string; - try { - content = fs.readFileSync(path.resolve(cwd, envFile), 'utf8'); - } catch { - return undefined; - } - const reason = envKeepReason( - content.split(/\r?\n/).map((line) => - line - .trim() - .replace(/^export\s+/, '') - .replace(/=\s*(['"])(.*)\1\s*$/, '=$2'), - ), - ); - return reason && `\`${envFile}\`: ${reason}`; -} - /** * An option's name without `=value`. Node.js reads `_` as `-` in long option names, as * in `--watch_path`, and so does `vpx`. @@ -415,12 +353,13 @@ function removal(tokens: LiteralCommandToken[], index: number): Edit { * Node.js does, rejects ES module syntax in a `.cts` file, and runs the other files as ES * modules, so `__dirname`, `__filename`, and `require` are not defined. */ -function scriptKeepReason( - cwd: string | undefined, - script: string, - tsconfig?: string, -): string | undefined { - if (cwd === undefined || !SCRIPT_EXTENSIONS.some((extension) => script.endsWith(extension))) { +function scriptKeepReason(cwd: string | undefined, script: string): string | undefined { + // A glob is not checked: its files can differ when the command runs. + if ( + cwd === undefined || + /[*?[{]/.test(script) || + !SCRIPT_EXTENSIONS.some((extension) => script.endsWith(extension)) + ) { return undefined; } const file = script.startsWith('~/') @@ -444,25 +383,8 @@ function scriptKeepReason( return `\`${script}\` requires \`${base}.${extension}\`, which only tsx resolves to \`${base}.${typescript}\`; write the TypeScript extension`; } } - // tsx applies tsconfig `paths` to `require()`; `vpx` resolves it as Node.js does. - for (const [, specifier] of source.matchAll(BARE_REQUIRE)) { - try { - createRequire(file).resolve(specifier); - } catch { - return `\`${script}\` requires \`${specifier}\`, which Node.js cannot resolve; tsx can map it through tsconfig \`paths\``; - } - } // A comment can start a line before ES module syntax, as in `/* banner */ import ...`. const code = stripComments(source); - if (ACCESSOR_FIELD.test(code)) { - return `\`${script}\` declares an \`accessor\` field, which \`vpx\` does not compile yet`; - } - if ( - DECORATOR.test(code) && - !experimentalDecorators(file, tsconfig === undefined ? undefined : path.resolve(cwd, tsconfig)) - ) { - return `\`${script}\` uses standard decorators, which \`vpx\` does not compile yet; it supports decorators with \`experimentalDecorators\``; - } if (!/\.(?:c?ts|tsx|jsx?)$/.test(script) || !(ESM_SYNTAX.test(source) || ESM_SYNTAX.test(code))) { return undefined; } @@ -470,7 +392,7 @@ function scriptKeepReason( return `\`${script}\` uses import or export, which \`vpx\` does not compile to CommonJS; rename it to .mts`; } if ( - usesCommonJsGlobals(source, code) && + (usesCommonJsGlobals(code) || EXPORT_ASSIGNMENT.test(code)) && readNearestPackageJson(path.dirname(file))?.type !== 'module' ) { return `\`${script}\` mixes import or export with CommonJS globals such as __dirname, require, or module.exports, and \`vpx\` runs it as an ES module; use import.meta.dirname, import.meta.filename, createRequire(), or export`; @@ -478,86 +400,24 @@ function scriptKeepReason( return undefined; } +/** `source` without comments; a string that only looks like one, as in `'http://x'`, stays. */ function stripComments(source: string): string { - return source.replaceAll(/\/\*[\s\S]*?\*\/|\/\/[^\n]*/g, ''); -} - -/** - * Whether the tsconfig `vpx` applies to `file`, the one `--tsconfig` names or else the - * nearest, enables `experimentalDecorators`, directly or through `extends`. - */ -function experimentalDecorators(file: string, tsconfig: string | undefined): boolean { - if (tsconfig !== undefined) { - return tsconfigOption(tsconfig, 'experimentalDecorators', new Set()) === true; - } - for (let dir = path.dirname(file); ; dir = path.dirname(dir)) { - const tsconfig = path.join(dir, 'tsconfig.json'); - if (fs.existsSync(tsconfig)) { - return tsconfigOption(tsconfig, 'experimentalDecorators', new Set()) === true; - } - if (path.dirname(dir) === dir) { - return false; - } - } -} - -/** A `compilerOptions` value of a tsconfig, following `extends` (later entries win). */ -function tsconfigOption(tsconfig: string, name: string, seen: Set): unknown { - if (seen.has(tsconfig)) { - return undefined; - } - seen.add(tsconfig); - let config: unknown; - try { - config = parseJsonc(fs.readFileSync(tsconfig, 'utf8')); - } catch { - return undefined; - } - if (!isPlainRecord(config)) { - return undefined; - } - if (isPlainRecord(config.compilerOptions) && config.compilerOptions[name] !== undefined) { - return config.compilerOptions[name]; - } - const parents = Array.isArray(config.extends) ? config.extends : [config.extends]; - for (const parent of parents.toReversed()) { - if (typeof parent !== 'string') { - continue; - } - const resolved = resolveTsconfig(tsconfig, parent); - const value = resolved === undefined ? undefined : tsconfigOption(resolved, name, seen); - if (value !== undefined) { - return value; - } - } - return undefined; -} - -function resolveTsconfig(from: string, specifier: string): string | undefined { - if (specifier.startsWith('.') || path.isAbsolute(specifier)) { - const file = path.resolve(path.dirname(from), specifier); - return [file, `${file}.json`].find((candidate) => fs.existsSync(candidate)); - } - const require = createRequire(from); - for (const candidate of [specifier, `${specifier}/tsconfig.json`]) { - try { - return require.resolve(candidate); - } catch {} - } - return undefined; + return source.replaceAll( + /("(?:\\.|[^"\\\n])*"|'(?:\\.|[^'\\\n])*'|`(?:\\.|[^`\\])*`)|\/\*[\s\S]*?\*\/|\/\/[^\n]*/g, + (_, string: string | undefined) => string ?? '', + ); } /** - * Whether `source` uses a CommonJS global it does not declare or import at module level, - * as in `const require = createRequire(import.meta.url)` or `import module from - * 'node:module'`. A declaration inside a function, which an unindented line does not - * start, covers only that function, and one in a comment (absent from `code`) covers - * nothing. + * Whether `code`, without comments, uses a CommonJS global it does not declare or import + * at module level, as in `const require = createRequire(import.meta.url)` or `import + * module from 'node:module'`. A declaration inside a function, which an unindented line + * does not start, covers only that function. */ -function usesCommonJsGlobals(source: string, code: string): boolean { +function usesCommonJsGlobals(code: string): boolean { return COMMONJS_GLOBALS.some( ([name, use]) => - use.test(source) && + use.test(code) && !new RegExp( `^(?:(?:export\\s+)?(?:const|let|var|function|class)\\s+${name}\\b|import\\s+(?:\\*\\s+as\\s+)?${name}\\b)`, 'm', @@ -565,25 +425,16 @@ function usesCommonJsGlobals(source: string, code: string): boolean { ); } -/** What a run's options name besides its scripts. */ -interface RunOptions { - /** `--tsconfig`, which replaces the nearest tsconfig. */ - tsconfig?: string; - /** Local `--require` and `--import` modules, which load before the scripts. */ - preloads: string[]; - /** `--env-file` files, whose variables the run gets. */ - envFiles: string[]; -} - /** * Why `vpx` cannot run what tsx or `node --import tsx` runs: `positionals` are the entry - * and its arguments, or with `--test`, the test files. + * and its arguments, or with `--test`, the test files; `preloads` are local `--require` + * and `--import` modules, which load first. */ function runKeepReason( - cwd: string | null | undefined, + cwd: string | undefined, test: boolean, positionals: readonly string[], - options: RunOptions, + preloads: readonly string[], ): string | undefined { const [entry] = positionals; if (entry === undefined) { @@ -593,36 +444,13 @@ function runKeepReason( if (!test && !SCRIPT_EXTENSIONS.some((extension) => entry.endsWith(extension))) { return `\`${entry}\` has no file extension, which \`vpx\` needs to run it as a script`; } - if (cwd === null) { - return KEEP_CWD; - } - for (const envFile of options.envFiles) { - const reason = envFileKeepReason(cwd, envFile); + const scripts = test ? positionals.filter((value) => !value.startsWith('-')) : [entry]; + for (const script of [...preloads, ...scripts]) { + const reason = scriptKeepReason(cwd, script); if (reason) { return reason; } } - const scripts = test ? positionals.filter((value) => !value.startsWith('-')) : [entry]; - for (const script of [...options.preloads, ...scripts]) { - // The shell, or Node.js for a quoted `--test` glob, runs the files a glob matches. - const files = - cwd !== undefined && /[*?[{]/.test(script) - ? globSync(script, { cwd, ignore: ['**/node_modules/**'] }) - : [script]; - if (files.length > MAX_GLOB_FILES) { - return `\`${script}\` matches more than ${MAX_GLOB_FILES} files, too many for vp migrate to check`; - } - // The files can come from an earlier command, as for a missing entry. - if (!files.length) { - return `\`${script}\` matches no files yet, so vp migrate cannot check them`; - } - for (const file of files) { - const reason = scriptKeepReason(cwd, file, options.tsconfig); - if (reason) { - return reason; - } - } - } return undefined; } @@ -633,17 +461,6 @@ function rewriteTsx( start: number, keepReason: string | undefined, ): void { - // `yarn tsx`, `pnpm tsx`, and `bun tsx` run the package's own `tsx` script before the bin. - if ( - start === first + 1 && - ['bun', 'pnpm', 'yarn'].includes(tokens[first].value) && - typeof rewrite.cwd === 'string' - ) { - const scripts = readNearestPackageJson(rewrite.cwd)?.scripts; - if (isPlainRecord(scripts) && scripts.tsx !== undefined) { - keepReason ??= `\`${tokens[first].value} tsx\` runs the package's own \`tsx\` script`; - } - } if (keepReason) { rewrite.kept.push(keepReason); return; @@ -674,6 +491,10 @@ function rewriteNodeLoader( ): void { const scanned = scanOptions(tokens, start + 1, false); if (!scanned.loaders) { + // `NODE_OPTIONS='--import tsx' node a.ts` loads tsx without a flag of its own. + if (keepReason === KEEP_NODE_OPTIONS) { + rewrite.kept.push(keepReason); + } return; } const reason = keepReason ?? runReason(rewrite.cwd, tokens, scanned, false); @@ -688,12 +509,14 @@ function rewriteNodeLoader( } /** What the options of `tsx` or `node` before the entry say about a run. */ -interface ScannedOptions extends RunOptions { +interface ScannedOptions { /** Where the entry, or with `--test` the test files, start. */ entryIndex: number; /** Removals of tsx loader flags, and for tsx, of `--no-cache`. */ removals: Edit[]; loaders: number; + /** Local `--require` and `--import` modules, which load before the scripts. */ + preloads: string[]; keepReason?: string; test: boolean; watch: boolean; @@ -705,10 +528,9 @@ function scanOptions(tokens: LiteralCommandToken[], index: number, tsx: boolean) entryIndex: tokens.length, removals: [], loaders: 0, + preloads: [], test: false, watch: false, - preloads: [], - envFiles: [], }; for (; index < tokens.length; index++) { const { value } = tokens[index]; @@ -718,22 +540,22 @@ function scanOptions(tokens: LiteralCommandToken[], index: number, tsx: boolean) } const name = optionName(value); const inline = value.includes('='); - const optionValue = inline ? value.slice(value.indexOf('=') + 1) : tokens[index + 1]?.value; if (tsx && name === '--no-cache') { // `vpx` has no transform cache, and Node.js would reject the flag. scanned.removals.push(removal(tokens, index)); continue; } if (LOADER_OPTIONS.has(name)) { - if (optionValue !== undefined && TSX_LOADER.test(optionValue)) { + const loader = inline ? value.slice(value.indexOf('=') + 1) : tokens[index + 1]?.value; + if (loader !== undefined && TSX_LOADER.test(loader)) { // A tsx loader would compete with the hooks of `vpx`. scanned.removals.push({ ...removal(tokens, index), end: tokens[inline ? index : index + 1].end, }); scanned.loaders++; - } else if (optionValue?.startsWith('.')) { - scanned.preloads.push(optionValue); + } else if (loader?.startsWith('.')) { + scanned.preloads.push(loader); } if (!inline) { index++; @@ -743,12 +565,6 @@ function scanOptions(tokens: LiteralCommandToken[], index: number, tsx: boolean) scanned.keepReason ??= KEEP_OPTIONS.get(name); scanned.test ||= name === '--test'; scanned.watch ||= name === '--watch'; - if (tsx && name === '--tsconfig') { - scanned.tsconfig = optionValue; - } - if ((name === '--env-file' || name === '--env-file-if-exists') && optionValue !== undefined) { - scanned.envFiles.push(optionValue); - } if ( !inline && (NODE_OPTIONS_WITH_VALUE.has(name) || @@ -762,7 +578,7 @@ function scanOptions(tokens: LiteralCommandToken[], index: number, tsx: boolean) /** Why `vpx` cannot run what the scanned options and the scripts after them run. */ function runReason( - cwd: string | null | undefined, + cwd: string | undefined, tokens: LiteralCommandToken[], scanned: ScannedOptions, watch: boolean, @@ -777,7 +593,7 @@ function runReason( cwd, scanned.test, tokens.slice(scanned.entryIndex).map(({ value }) => value), - scanned, + scanned.preloads, ); } @@ -970,14 +786,16 @@ function findTsxUse(dir: string): string | undefined { // Scripts, `imports` aliases, and tool settings such as ava's `nodeArguments`. matchesPackage: (packageDir, pkg) => { const field = Object.entries(pkg).find( - ([key, value]) => !IGNORED_FIELDS.has(key) && jsonUsesTsx(value), + ([key, value]) => + !IGNORED_FIELDS.has(key) && + jsonUsesTsx(value, key === 'scripts' || key === 'lint-staged'), )?.[0]; if (field !== undefined) { found = `\`${field}\` in ${displayRelative(path.join(packageDir, 'package.json'))} still uses tsx`; } return field !== undefined; }, - skipDirs: RETENTION_SCAN_SKIP_DIRS, + skipDirs: USE_SCAN_SKIP_DIRS, }, ); return found; @@ -989,7 +807,7 @@ function fileUsesTsx(content: string, extension: string): boolean { return true; } if (extension === '.json' || extension === '.jsonc') { - return jsonUsesTsx(parseJsonc(content)); + return jsonUsesTsx(parseJsonc(content), false); } if (CODE_EXTENSIONS.has(extension)) { return TSX_CODE_REFERENCE.test(content); @@ -999,17 +817,25 @@ function fileUsesTsx(content: string, extension: string): boolean { } /** - * Whether a JSON value holds a string that runs or loads tsx: a command such as - * `tsx a.ts`, a loader flag such as `--import=tsx`, or tsx and its subpaths. + * Whether a JSON value holds a string that runs or loads tsx: a tsx command with an + * argument, a loader flag such as `--import=tsx` or `["--import", "tsx"]`, or a subpath + * such as `tsx/cjs`. A bare `tsx` counts only in `commands` (scripts and lint-staged + * tasks); elsewhere it is often a file extension, as in `moduleFileExtensions`. */ -function jsonUsesTsx(value: unknown): boolean { +function jsonUsesTsx(value: unknown, commands: boolean): boolean { if (typeof value === 'string') { - return TSX_COMMAND.test(value) || value.startsWith('tsx/'); + return (commands ? TSX_COMMAND : TSX_CONFIG_VALUE).test(value); } if (Array.isArray(value)) { - return value.some(jsonUsesTsx); + return value.some( + (item, index) => + jsonUsesTsx(item, commands) || + (typeof item === 'string' && + LOADER_OPTIONS.has(item) && + TSX_LOADER.test(String(value[index + 1]))), + ); } - return isPlainRecord(value) && Object.values(value).some(jsonUsesTsx); + return isPlainRecord(value) && Object.values(value).some((item) => jsonUsesTsx(item, commands)); } /** diff --git a/rfcs/vpx-script-execution.md b/rfcs/vpx-script-execution.md index 255b7414cd..33471ef022 100644 --- a/rfcs/vpx-script-execution.md +++ b/rfcs/vpx-script-execution.md @@ -188,7 +188,7 @@ For a script, the local bin and the global shim resolve the same loader and Node - It reads commands with the same literal-command parser as the other script rewrites and finds the script the way `vpx` does, with the shared value-taking option lists. Commands it cannot read, and forms `vpx` cannot run (eval, `--check`, the REPL, `--test` without files or in watch mode, an entry without an extension), keep tsx with a warning. - It has no Node.js check of its own: `vpx` needs the `engines.node` range of `vite-plus` (`SUPPORTED_NODE_RANGE` in `script-hooks.ts`, which a unit test keeps equal), and migration already upgrades incompatible runtime pins to that range for Vite+ itself. - The `tsx` devDependency goes only when no scripts, imports (including build output and `package.json#imports` aliases), shebangs, tool settings such as `--import=tsx`, CI workflows, git hooks, or shell scripts use tsx anymore, including in nested packages that can resolve it. A package that lists tsx in `dependencies`, `optionalDependencies`, or `peerDependencies` keeps it, since `vpx` comes from a devDependency. -- It reads the scripts a command runs and keeps tsx when one does not exist yet, as when an earlier command generates it, and when `vpx` would fail on one: a `require()` of `./a.js` that only tsx resolves to `a.ts`, a `require()` of a name Node.js cannot resolve (tsx applies tsconfig `paths` to `require()`), a `.cts` file with `import` or `export`, and a file that mixes `import` with `__dirname`, `__filename`, `require()`, or `module.exports` in a package without `"type": "module"`. tsx compiles the last two to CommonJS; `vpx` rejects the first of them and runs the second as an ES module. The check reads only the named files, not their imports, and takes the module format from `"type"` alone, not from a tsconfig `module` of `node16` or `nodenext`; following imports and tsconfig `extends` would turn a guard against common breakage into module-graph analysis. +- It reads the scripts a command runs, and its local preloads, and keeps tsx when one does not exist yet, as when an earlier command generates it, and when `vpx` would fail on one: a `require()` of `./a.js` that only tsx resolves to `a.ts`, a `.cts` file with `import` or `export`, and a file that mixes `import` with `__dirname`, `__filename`, `require()`, or `module.exports` in a package without `"type": "module"`. tsx compiles the last two to CommonJS; `vpx` rejects the first of them and runs the second as an ES module. These checks are exact and local. The migration skips globs and scripts after a shell state change, and does not look for differences that need module-graph or tsconfig analysis (imports of the named files, standard decorators, tsconfig `paths` in `require()`, a tsconfig `module` of `node16` or `nodenext`); `vpx` reports those when the script runs. ## Native Side From e6d9b400c9ec2f3a45186a1fb9bd5d25d28eeb3d Mon Sep 17 00:00:00 2001 From: "MK (fengmk2)" Date: Thu, 22 Oct 2026 10:57:50 +0800 Subject: [PATCH 20/23] fix(migrate): find annotated tsx imports, bare imports aliases, npx --yes Address the eighteenth round of review feedback on the tsx migration: - Match imports with a comment before the specifier, as in `import(/* @vite-ignore */ 'tsx/esm/api')`. - Count a bare `tsx` in `package.json#imports`, as in `"#loader": "tsx"`. - Skip `-y`/`--yes` after `npx` when locating the command. Co-Authored-By: Claude Opus 5.5 --- .../cli/src/migration/__tests__/tsx.spec.ts | 9 +++++++++ packages/cli/src/migration/migrator/shared.ts | 4 ++++ packages/cli/src/migration/migrator/tsx.ts | 19 ++++++++++--------- 3 files changed, 23 insertions(+), 9 deletions(-) diff --git a/packages/cli/src/migration/__tests__/tsx.spec.ts b/packages/cli/src/migration/__tests__/tsx.spec.ts index e3fc86fe48..f038de4996 100644 --- a/packages/cli/src/migration/__tests__/tsx.spec.ts +++ b/packages/cli/src/migration/__tests__/tsx.spec.ts @@ -32,6 +32,8 @@ describe('rewriteTsxCommand', () => { ['tsx -r tsx/cjs --import ./setup.ts ./a.cts', 'vpx --import ./setup.ts ./a.cts'], ["tsx 'my script.ts'", "vpx 'my script.ts'"], ['npx tsx ./a.ts', 'vpx ./a.ts'], + ['npx --yes tsx ./a.ts', 'vpx ./a.ts'], + ['npx -y tsx ./a.ts', 'vpx ./a.ts'], ['pnpm exec tsx ./a.ts', 'vpx ./a.ts'], ['pnpm dlx tsx ./a.ts', 'vpx ./a.ts'], ['pnpm tsx ./a.ts', 'vpx ./a.ts'], @@ -410,6 +412,12 @@ describe('migrateTsxToVpx', () => { 'const { tsImport } = await import(`tsx/esm/api`);\n', 'src/lazy.ts still uses tsx', ], + [ + 'an annotated dynamic import of the tsx API', + 'src/ignored.ts', + "const api = await import(/* @vite-ignore */ 'tsx/esm/api');\n", + 'src/ignored.ts still uses tsx', + ], [ 'a resolved tsx subpath', 'src/register.ts', @@ -600,6 +608,7 @@ describe('migrateTsxToVpx', () => { it.each([ ['imports', { '#loader': { node: 'tsx/esm/api' } }], + ['imports', { '#loader': 'tsx' }], ['ava', { extensions: { ts: 'module' }, nodeArguments: ['--import=tsx'] }], ])('keeps the tsx dependency used by the %s field of package.json', (field, value) => { write('package.json', { diff --git a/packages/cli/src/migration/migrator/shared.ts b/packages/cli/src/migration/migrator/shared.ts index 8624bd871d..03473942b6 100644 --- a/packages/cli/src/migration/migrator/shared.ts +++ b/packages/cli/src/migration/migrator/shared.ts @@ -33,6 +33,10 @@ export function getCommandStartIndex(argv: readonly string[]): number { if (argv[start] === 'exec' || argv[start] === 'x' || argv[start] === 'dlx') { start++; } + // `npx --yes tsx`: skip the install prompt flag. + if (argv[start] === '-y' || argv[start] === '--yes') { + start++; + } if (argv[start] === '--') { start++; } diff --git a/packages/cli/src/migration/migrator/tsx.ts b/packages/cli/src/migration/migrator/tsx.ts index 3cb8a981ec..1fa7c2ad42 100644 --- a/packages/cli/src/migration/migrator/tsx.ts +++ b/packages/cli/src/migration/migrator/tsx.ts @@ -86,13 +86,13 @@ const TSX_SHEBANG = /^#![^\n]*[\s/]tsx(?=\s|$)/; // dashless `node-option` value `import=tsx`. const TSX_LOADER_FLAG = /(?:(?:--import|--loader|--experimental-loader|--require|-r)[= \t]+|(? !IGNORED_FIELDS.has(key) && - jsonUsesTsx(value, key === 'scripts' || key === 'lint-staged'), + jsonUsesTsx(value, ['imports', 'lint-staged', 'scripts'].includes(key)), )?.[0]; if (field !== undefined) { found = `\`${field}\` in ${displayRelative(path.join(packageDir, 'package.json'))} still uses tsx`; @@ -819,12 +819,13 @@ function fileUsesTsx(content: string, extension: string): boolean { /** * Whether a JSON value holds a string that runs or loads tsx: a tsx command with an * argument, a loader flag such as `--import=tsx` or `["--import", "tsx"]`, or a subpath - * such as `tsx/cjs`. A bare `tsx` counts only in `commands` (scripts and lint-staged - * tasks); elsewhere it is often a file extension, as in `moduleFileExtensions`. + * such as `tsx/cjs`. A bare `tsx` counts only in `commands` (scripts, lint-staged tasks, + * and `imports` aliases); elsewhere it is often a file extension, as in + * `moduleFileExtensions`. */ function jsonUsesTsx(value: unknown, commands: boolean): boolean { if (typeof value === 'string') { - return (commands ? TSX_COMMAND : TSX_CONFIG_VALUE).test(value); + return (commands && TSX_COMMAND.test(value)) || TSX_CONFIG_VALUE.test(value); } if (Array.isArray(value)) { return value.some( From 464cc7fbd30344a5a96b8d4cb7ed37e1f4d9145e Mon Sep 17 00:00:00 2001 From: "MK (fengmk2)" Date: Thu, 22 Oct 2026 11:29:04 +0800 Subject: [PATCH 21/23] fix(migrate): check tsx preloads given as absolute paths Co-Authored-By: Claude Opus 5.5 --- packages/cli/src/migration/__tests__/tsx.spec.ts | 8 ++++++++ packages/cli/src/migration/migrator/tsx.ts | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/migration/__tests__/tsx.spec.ts b/packages/cli/src/migration/__tests__/tsx.spec.ts index f038de4996..069d7b8e12 100644 --- a/packages/cli/src/migration/__tests__/tsx.spec.ts +++ b/packages/cli/src/migration/__tests__/tsx.spec.ts @@ -280,6 +280,14 @@ describe('rewriteTsxCommand', () => { }); }); + it('checks a preload given as an absolute path', () => { + const command = `tsx --require '${path.join(dir, 'esm.cts')}' ./builtin.cts`; + expect(rewriteTsxCommand(command, dir)).toEqual({ + command, + kept: [expect.stringContaining('esm.cts` uses import or export')], + }); + }); + it.each([ ['tsx cjs.cts', 'vpx cjs.cts'], ['tsx plain.ts', 'vpx plain.ts'], diff --git a/packages/cli/src/migration/migrator/tsx.ts b/packages/cli/src/migration/migrator/tsx.ts index 1fa7c2ad42..064ea5dc04 100644 --- a/packages/cli/src/migration/migrator/tsx.ts +++ b/packages/cli/src/migration/migrator/tsx.ts @@ -554,7 +554,7 @@ function scanOptions(tokens: LiteralCommandToken[], index: number, tsx: boolean) end: tokens[inline ? index : index + 1].end, }); scanned.loaders++; - } else if (loader?.startsWith('.')) { + } else if (loader !== undefined && (loader.startsWith('.') || path.isAbsolute(loader))) { scanned.preloads.push(loader); } if (!inline) { From f7b188dedf22989cdf46376210d9d17a08cd6b07 Mon Sep 17 00:00:00 2001 From: "MK (fengmk2)" Date: Thu, 22 Oct 2026 11:56:53 +0800 Subject: [PATCH 22/23] test(migrate): keep the absolute preload test free of Windows temp paths Co-Authored-By: Claude Opus 5.5 --- packages/cli/src/migration/__tests__/tsx.spec.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/migration/__tests__/tsx.spec.ts b/packages/cli/src/migration/__tests__/tsx.spec.ts index 069d7b8e12..d7d4a2396d 100644 --- a/packages/cli/src/migration/__tests__/tsx.spec.ts +++ b/packages/cli/src/migration/__tests__/tsx.spec.ts @@ -281,10 +281,12 @@ describe('rewriteTsxCommand', () => { }); it('checks a preload given as an absolute path', () => { - const command = `tsx --require '${path.join(dir, 'esm.cts')}' ./builtin.cts`; + // A root-level path is absolute on Windows too, unlike a temporary directory with + // an 8.3 name such as `RUNNER~1`, which reads as shell syntax. + const command = 'tsx --require /vp-missing-preload/setup.cts ./builtin.cts'; expect(rewriteTsxCommand(command, dir)).toEqual({ command, - kept: [expect.stringContaining('esm.cts` uses import or export')], + kept: [expect.stringContaining('`/vp-missing-preload/setup.cts` does not exist yet')], }); }); From ecac02afbdfee3098d314d0afa8384d3d0115d65 Mon Sep 17 00:00:00 2001 From: "MK (fengmk2)" Date: Fri, 23 Oct 2026 00:00:30 +0900 Subject: [PATCH 23/23] fix(migrate): keep tsx for bare lint-staged tasks and loader settings Co-Authored-By: Claude Opus 5.5 --- .../cli/src/migration/__tests__/tsx.spec.ts | 24 ++++++++++++++ packages/cli/src/migration/migrator/tsx.ts | 31 +++++++++++++++---- 2 files changed, 49 insertions(+), 6 deletions(-) diff --git a/packages/cli/src/migration/__tests__/tsx.spec.ts b/packages/cli/src/migration/__tests__/tsx.spec.ts index d7d4a2396d..e66c8f88be 100644 --- a/packages/cli/src/migration/__tests__/tsx.spec.ts +++ b/packages/cli/src/migration/__tests__/tsx.spec.ts @@ -536,6 +536,30 @@ describe('migrateTsxToVpx', () => { '{ "require": "tsx/cjs", "spec": "test/**/*.test.ts" }\n', '.mocharc.json still uses tsx', ], + [ + 'a bare loader in a JSON tool config', + '.mocharc.json', + '{ "import": "tsx" }\n', + '.mocharc.json still uses tsx', + ], + [ + 'a bare loader list in a JSON tool config', + '.mocharc.json', + '{ "require": ["tsx"] }\n', + '.mocharc.json still uses tsx', + ], + [ + 'a bare lint-staged task', + '.lintstagedrc.json', + '{ "*.ts": "tsx" }\n', + '.lintstagedrc.json still uses tsx', + ], + [ + 'a bare lint-staged task in an extensionless config', + '.lintstagedrc', + '{"*.ts":"tsx"}\n', + '.lintstagedrc still uses tsx', + ], [ 'a JSONC tool config', 'wrangler.jsonc', diff --git a/packages/cli/src/migration/migrator/tsx.ts b/packages/cli/src/migration/migrator/tsx.ts index 064ea5dc04..67f1865830 100644 --- a/packages/cli/src/migration/migrator/tsx.ts +++ b/packages/cli/src/migration/migrator/tsx.ts @@ -45,6 +45,8 @@ const LOADER_OPTIONS = new Set([ '-r', ]); const TSX_LOADER = /^tsx(?:\/(?:esm|cjs))?$/; +// Tool settings that name modules to preload, as in Mocha's `"import": "tsx"`. +const LOADER_SETTINGS = new Set(['import', 'loader', 'require']); const KEEP_EVAL = '`vpx` does not run TypeScript passed to --eval or --print'; const KEEP_CHECK = '`vpx` does not transform TypeScript for -c or --check'; @@ -772,7 +774,7 @@ function findTsxUse(dir: string): string | undefined { if (LOCKFILES.has(path.basename(file))) { return false; } - const uses = fileUsesTsx(content, path.extname(file)); + const uses = fileUsesTsx(content, file); if (uses) { found = `${displayRelative(file)} still uses tsx`; } @@ -801,11 +803,20 @@ function findTsxUse(dir: string): string | undefined { return found; } -/** Whether a file with `extension` runs, imports, or loads tsx. */ -function fileUsesTsx(content: string, extension: string): boolean { +/** Whether `file` runs, imports, or loads tsx. */ +function fileUsesTsx(content: string, file: string): boolean { if (TSX_SHEBANG.test(content)) { return true; } + // lint-staged runs a bare `tsx` task too, with the staged files as its arguments. + const name = path.basename(file); + if ( + LINT_STAGED_JSON_CONFIG_FILES.some((config) => config === name) && + jsonUsesTsx(parseJsonc(content), true) + ) { + return true; + } + const extension = path.extname(file); if (extension === '.json' || extension === '.jsonc') { return jsonUsesTsx(parseJsonc(content), false); } @@ -820,8 +831,8 @@ function fileUsesTsx(content: string, extension: string): boolean { * Whether a JSON value holds a string that runs or loads tsx: a tsx command with an * argument, a loader flag such as `--import=tsx` or `["--import", "tsx"]`, or a subpath * such as `tsx/cjs`. A bare `tsx` counts only in `commands` (scripts, lint-staged tasks, - * and `imports` aliases); elsewhere it is often a file extension, as in - * `moduleFileExtensions`. + * and `imports` aliases) and as a loader setting such as Mocha's `"import": "tsx"`; + * elsewhere it is often a file extension, as in `moduleFileExtensions`. */ function jsonUsesTsx(value: unknown, commands: boolean): boolean { if (typeof value === 'string') { @@ -836,7 +847,15 @@ function jsonUsesTsx(value: unknown, commands: boolean): boolean { TSX_LOADER.test(String(value[index + 1]))), ); } - return isPlainRecord(value) && Object.values(value).some((item) => jsonUsesTsx(item, commands)); + return ( + isPlainRecord(value) && + Object.entries(value).some( + ([key, item]) => + jsonUsesTsx(item, commands) || + (LOADER_SETTINGS.has(key) && + [item].flat().some((loader) => TSX_LOADER.test(String(loader)))), + ) + ); } /**