diff --git a/.github/workflows/e2e-test.yml b/.github/workflows/e2e-test.yml index 9939bf9711..28b1fdbe6d 100644 --- a/.github/workflows/e2e-test.yml +++ b/.github/workflows/e2e-test.yml @@ -292,8 +292,8 @@ jobs: - name: bun-vite-template node-version: 24 command: | - vp run build - vp run test + vp fmt + vp run validate exclude: # frm-stack uses Docker (testcontainers) which doesn't work the same way on Windows - os: windows-latest diff --git a/packages/cli/package.json b/packages/cli/package.json index d6c6e7f229..ef4164a464 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -325,6 +325,7 @@ "@voidzero-dev/vite-plus-test": "workspace:*", "cac": "catalog:", "cross-spawn": "catalog:", + "jsonc-parser": "catalog:", "oxfmt": "catalog:", "oxlint": "catalog:", "oxlint-tsgolint": "catalog:", @@ -342,7 +343,6 @@ "detect-indent": "catalog:", "detect-newline": "catalog:", "glob": "catalog:", - "jsonc-parser": "catalog:", "lint-staged": "catalog:", "minimatch": "catalog:", "mri": "catalog:", diff --git a/packages/cli/snap-tests-global/migration-tsconfig-esmoduleinterop/package.json b/packages/cli/snap-tests-global/migration-tsconfig-esmoduleinterop/package.json new file mode 100644 index 0000000000..4f2f2293c1 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-tsconfig-esmoduleinterop/package.json @@ -0,0 +1,5 @@ +{ + "devDependencies": { + "vite": "latest" + } +} diff --git a/packages/cli/snap-tests-global/migration-tsconfig-esmoduleinterop/snap.txt b/packages/cli/snap-tests-global/migration-tsconfig-esmoduleinterop/snap.txt new file mode 100644 index 0000000000..f1a747174e --- /dev/null +++ b/packages/cli/snap-tests-global/migration-tsconfig-esmoduleinterop/snap.txt @@ -0,0 +1,46 @@ +> vp migrate --no-interactive # should remove esModuleInterop: false from tsconfig.json +VITE+ - The Unified Toolchain for the Web + +◇ Migrated . to Vite+ +• Node pnpm +• 3 config updates applied +! Warnings: + - Removed `"esModuleInterop": false` from tsconfig.json — this option has been deprecated. See https://github.com/oxc-project/tsgolint/issues/351, https://github.com/microsoft/TypeScript/issues/62529 + +> cat tsconfig.json # verify esModuleInterop: false is removed +{ + "compilerOptions": { + "target": "ES2023", + "module": "ESNext", + "allowSyntheticDefaultImports": true, + "strict": true + } +} + +> cat vite.config.ts # check vite.config.ts +import { defineConfig } from 'vite-plus'; + +export default defineConfig({ + staged: { + "*": "vp check --fix" + }, + lint: {"options":{"typeAware":true,"typeCheck":true}}, +}); + +> cat package.json # check package.json +{ + "devDependencies": { + "vite": "npm:@voidzero-dev/vite-plus-core@latest", + "vite-plus": "latest" + }, + "pnpm": { + "overrides": { + "vite": "npm:@voidzero-dev/vite-plus-core@latest", + "vitest": "npm:@voidzero-dev/vite-plus-test@latest" + } + }, + "packageManager": "pnpm@", + "scripts": { + "prepare": "vp config" + } +} diff --git a/packages/cli/snap-tests-global/migration-tsconfig-esmoduleinterop/steps.json b/packages/cli/snap-tests-global/migration-tsconfig-esmoduleinterop/steps.json new file mode 100644 index 0000000000..678c97eac6 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-tsconfig-esmoduleinterop/steps.json @@ -0,0 +1,8 @@ +{ + "commands": [ + "vp migrate --no-interactive # should remove esModuleInterop: false from tsconfig.json", + "cat tsconfig.json # verify esModuleInterop: false is removed", + "cat vite.config.ts # check vite.config.ts", + "cat package.json # check package.json" + ] +} diff --git a/packages/cli/snap-tests-global/migration-tsconfig-esmoduleinterop/tsconfig.json b/packages/cli/snap-tests-global/migration-tsconfig-esmoduleinterop/tsconfig.json new file mode 100644 index 0000000000..ad5b7a1f68 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-tsconfig-esmoduleinterop/tsconfig.json @@ -0,0 +1,9 @@ +{ + "compilerOptions": { + "target": "ES2023", + "module": "ESNext", + "esModuleInterop": false, + "allowSyntheticDefaultImports": true, + "strict": true + } +} diff --git a/packages/cli/src/migration/migrator.ts b/packages/cli/src/migration/migrator.ts index d3b56c7597..f6da63af49 100644 --- a/packages/cli/src/migration/migrator.ts +++ b/packages/cli/src/migration/migrator.ts @@ -28,7 +28,11 @@ import { editJsonFile, isJsonFile, readJsonFile } from '../utils/json.js'; import { detectPackageMetadata } from '../utils/package.js'; import { displayRelative, rulesDir } from '../utils/path.js'; import { getSpinner } from '../utils/prompts.js'; -import { hasBaseUrlInTsconfig } from '../utils/tsconfig.js'; +import { + findTsconfigFiles, + hasBaseUrlInTsconfig, + removeEsModuleInteropFalseFromFile, +} from '../utils/tsconfig.js'; import { editYamlFile, scalarString, type YamlDocument } from '../utils/yaml.js'; import { PRETTIER_CONFIG_FILES, @@ -643,6 +647,28 @@ function rewritePrettierLintStagedConfigFiles(projectPath: string, report?: Migr rewriteToolLintStagedConfigFiles(projectPath, rewritePrettier, 'prettier', report); } +function cleanupDeprecatedTsconfigOptions( + projectPath: string, + silent = false, + report?: MigrationReport, +): void { + const files = findTsconfigFiles(projectPath); + for (const filePath of files) { + if (removeEsModuleInteropFalseFromFile(filePath)) { + if (report) { + report.removedConfigCount++; + } + if (!silent) { + prompts.log.success(`✔ Removed esModuleInterop: false from ${displayRelative(filePath)}`); + } + warnMigration( + `Removed \`"esModuleInterop": false\` from ${displayRelative(filePath)} — this option has been deprecated. See https://github.com/oxc-project/tsgolint/issues/351, https://github.com/microsoft/TypeScript/issues/62529`, + report, + ); + } + } +} + /** * Rewrite standalone project to add vite-plus dependencies * @param projectPath - The path to the project @@ -724,6 +750,7 @@ export function rewriteStandaloneProject( if (!skipStagedMigration) { rewriteLintStagedConfigFile(projectPath, report); } + cleanupDeprecatedTsconfigOptions(projectPath, silent, report); mergeViteConfigFiles(projectPath, silent, report); injectLintTypeCheckDefaults(projectPath, silent, report); injectFmtDefaults(projectPath, silent, report); @@ -772,6 +799,7 @@ export function rewriteMonorepo( if (!skipStagedMigration) { rewriteLintStagedConfigFile(workspaceInfo.rootDir, report); } + cleanupDeprecatedTsconfigOptions(workspaceInfo.rootDir, silent, report); mergeViteConfigFiles(workspaceInfo.rootDir, silent, report); injectLintTypeCheckDefaults(workspaceInfo.rootDir, silent, report); injectFmtDefaults(workspaceInfo.rootDir, silent, report); @@ -793,6 +821,7 @@ export function rewriteMonorepoProject( silent = false, report?: MigrationReport, ): void { + cleanupDeprecatedTsconfigOptions(projectPath, silent, report); mergeViteConfigFiles(projectPath, silent, report); mergeTsdownConfigFile(projectPath, silent, report); diff --git a/packages/cli/src/utils/__tests__/tsconfig.spec.ts b/packages/cli/src/utils/__tests__/tsconfig.spec.ts new file mode 100644 index 0000000000..743dcb276c --- /dev/null +++ b/packages/cli/src/utils/__tests__/tsconfig.spec.ts @@ -0,0 +1,205 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { findTsconfigFiles, removeEsModuleInteropFalseFromFile } from '../tsconfig.js'; + +describe('findTsconfigFiles', () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tsconfig-test-')); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('finds all tsconfig variants', () => { + fs.writeFileSync(path.join(tmpDir, 'tsconfig.json'), '{}'); + fs.writeFileSync(path.join(tmpDir, 'tsconfig.app.json'), '{}'); + fs.writeFileSync(path.join(tmpDir, 'tsconfig.node.json'), '{}'); + fs.writeFileSync(path.join(tmpDir, 'tsconfig.build.json'), '{}'); + fs.writeFileSync(path.join(tmpDir, 'other.json'), '{}'); + fs.writeFileSync(path.join(tmpDir, 'package.json'), '{}'); + + const files = findTsconfigFiles(tmpDir); + const expected = [ + path.join(tmpDir, 'tsconfig.app.json'), + path.join(tmpDir, 'tsconfig.build.json'), + path.join(tmpDir, 'tsconfig.json'), + path.join(tmpDir, 'tsconfig.node.json'), + ]; + expect(new Set(files)).toEqual(new Set(expected)); + expect(files).toHaveLength(4); + }); + + it('returns empty array for non-existent directory', () => { + expect(findTsconfigFiles('/non-existent-dir-12345')).toEqual([]); + }); + + it('returns empty array when no tsconfig files exist', () => { + fs.writeFileSync(path.join(tmpDir, 'package.json'), '{}'); + expect(findTsconfigFiles(tmpDir)).toEqual([]); + }); +}); + +describe('removeEsModuleInteropFalseFromFile', () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tsconfig-test-')); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + function writeAndRemove(filePath: string, content: string): string { + fs.writeFileSync(filePath, content); + const result = removeEsModuleInteropFalseFromFile(filePath); + expect(result).toBe(true); + return fs.readFileSync(filePath, 'utf-8'); + } + + it('removes esModuleInterop: false (middle property)', () => { + const filePath = path.join(tmpDir, 'tsconfig.json'); + expect( + writeAndRemove( + filePath, + `{ + "compilerOptions": { + "target": "ES2023", + "esModuleInterop": false, + "strict": true + } +}`, + ), + ).toMatchInlineSnapshot(` + "{ + "compilerOptions": { + "target": "ES2023", + "strict": true + } + }" + `); + }); + + it('preserves comments in JSONC', () => { + const filePath = path.join(tmpDir, 'tsconfig.json'); + expect( + writeAndRemove( + filePath, + `{ + // This is a comment + "compilerOptions": { + "target": "ES2023", + "esModuleInterop": false, + /* block comment */ + "strict": true + } +}`, + ), + ).toMatchInlineSnapshot(` + "{ + // This is a comment + "compilerOptions": { + "target": "ES2023", + /* block comment */ + "strict": true + } + }" + `); + }); + + it('handles esModuleInterop: false as last property', () => { + const filePath = path.join(tmpDir, 'tsconfig.json'); + expect( + writeAndRemove( + filePath, + `{ + "compilerOptions": { + "target": "ES2023", + "esModuleInterop": false + } +}`, + ), + ).toMatchInlineSnapshot(` + "{ + "compilerOptions": { + "target": "ES2023" + } + }" + `); + }); + + it('handles inline block comment next to esModuleInterop: false', () => { + const filePath = path.join(tmpDir, 'tsconfig.json'); + expect( + writeAndRemove( + filePath, + `{ + "compilerOptions": { + "target": "ES2023", + "esModuleInterop": false /* reason */, + "strict": true + } +}`, + ), + ).toMatchInlineSnapshot(` + "{ + "compilerOptions": { + "target": "ES2023" /* reason */, + "strict": true + } + }" + `); + }); + + it('handles compact single-line JSON', () => { + const filePath = path.join(tmpDir, 'tsconfig.json'); + expect( + writeAndRemove(filePath, '{"compilerOptions":{"esModuleInterop": false, "strict": true}}'), + ).toMatchInlineSnapshot(`"{"compilerOptions":{"strict": true}}"`); + }); + + it('handles compact single-line JSONC with spaces', () => { + const filePath = path.join(tmpDir, 'tsconfig.json'); + expect( + writeAndRemove( + filePath, + '{ "compilerOptions": { "esModuleInterop": false, "strict": true } }', + ), + ).toMatchInlineSnapshot(`"{ "compilerOptions": {"strict": true } }"`); + }); + + it('leaves esModuleInterop: true untouched', () => { + const filePath = path.join(tmpDir, 'tsconfig.json'); + const original = JSON.stringify({ compilerOptions: { esModuleInterop: true } }, null, 2); + fs.writeFileSync(filePath, original); + + const result = removeEsModuleInteropFalseFromFile(filePath); + expect(result).toBe(false); + expect(fs.readFileSync(filePath, 'utf-8')).toBe(original); + }); + + it('returns false for non-existent file', () => { + expect(removeEsModuleInteropFalseFromFile('/non-existent-file.json')).toBe(false); + }); + + it('returns false when no compilerOptions', () => { + const filePath = path.join(tmpDir, 'tsconfig.json'); + fs.writeFileSync(filePath, '{}'); + + expect(removeEsModuleInteropFalseFromFile(filePath)).toBe(false); + }); + + it('returns false when esModuleInterop is not present', () => { + const filePath = path.join(tmpDir, 'tsconfig.json'); + fs.writeFileSync(filePath, JSON.stringify({ compilerOptions: { strict: true } }, null, 2)); + + expect(removeEsModuleInteropFalseFromFile(filePath)).toBe(false); + }); +}); diff --git a/packages/cli/src/utils/tsconfig.ts b/packages/cli/src/utils/tsconfig.ts index f540b8ad97..8d26868be9 100644 --- a/packages/cli/src/utils/tsconfig.ts +++ b/packages/cli/src/utils/tsconfig.ts @@ -1,6 +1,8 @@ import fs from 'node:fs'; import path from 'node:path'; +import { applyEdits, modify, parse as parseJsonc } from 'jsonc-parser'; + /** * Check if tsconfig.json has compilerOptions.baseUrl set. * oxlint's TypeScript checker (tsgolint) does not support baseUrl, @@ -16,3 +18,45 @@ export function hasBaseUrlInTsconfig(projectPath: string): boolean { return false; } } + +const TSCONFIG_FILE_RE = /^tsconfig(\.[\w-]+)?\.json$/i; + +export function findTsconfigFiles(projectPath: string): string[] { + try { + const entries = fs.readdirSync(projectPath); + return entries + .filter((name) => TSCONFIG_FILE_RE.test(name)) + .map((name) => path.join(projectPath, name)); + } catch { + return []; + } +} + +// jsonc-parser is in dependencies (not devDependencies) so it's available at +// runtime for tsc-compiled code (init-config.ts imports this file). +// TODO: move back to devDependencies once the bundle refactoring lands +// https://github.com/voidzero-dev/vite-plus/issues/744 +export function removeEsModuleInteropFalseFromFile(filePath: string): boolean { + let text: string; + try { + text = fs.readFileSync(filePath, 'utf-8'); + } catch { + return false; + } + + const parsed = parseJsonc(text) as { + compilerOptions?: { esModuleInterop?: boolean }; + } | null; + if (parsed?.compilerOptions?.esModuleInterop !== false) { + return false; + } + + const edits = modify(text, ['compilerOptions', 'esModuleInterop'], undefined, {}); + if (edits.length === 0) { + return false; + } + + const newText = applyEdits(text, edits); + fs.writeFileSync(filePath, newText); + return true; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 35d2e84d98..6f30c695f8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -343,6 +343,9 @@ importers: cross-spawn: specifier: 'catalog:' version: 7.0.6 + jsonc-parser: + specifier: 'catalog:' + version: 3.3.1 oxfmt: specifier: 'catalog:' version: 0.42.0 @@ -389,9 +392,6 @@ importers: glob: specifier: 'catalog:' version: 13.0.0 - jsonc-parser: - specifier: 'catalog:' - version: 3.3.1 lint-staged: specifier: 'catalog:' version: 16.4.0 diff --git a/rfcs/migration-command.md b/rfcs/migration-command.md index 64d86fe6fc..f4d9e71dab 100644 --- a/rfcs/migration-command.md +++ b/rfcs/migration-command.md @@ -38,6 +38,7 @@ When transitioning to Vite+, projects typically use standalone tools like vite, - ✅ **Configuration files**: - .oxlintrc → vite.config.ts (lint section) - .oxfmtrc → vite.config.ts (format section) +- ✅ **tsconfig.json cleanup**: Removes deprecated `esModuleInterop: false` (causes oxlint tsgolint errors) **What this command optionally migrates** (prompted): @@ -52,7 +53,6 @@ When transitioning to Vite+, projects typically use standalone tools like vite, **What this command does NOT migrate**: - ❌ Package.json scripts → vite-task.json (different feature) -- ❌ TypeScript configuration changes - ❌ Build tool changes (webpack/rollup → vite) These are **consolidation migrations**, not **feature migrations**. @@ -605,6 +605,21 @@ When a Prettier configuration file (`.prettierrc*`, `prettier.config.*`, or `"pr - Failure is non-blocking — warns and continues with the rest of migration - Re-runnable: if user declines initially, running `vp migrate` again offers prettier migration +## tsconfig.json Cleanup + +During migration, `vp migrate` scans all `tsconfig*.json` files in the project directory (non-recursive) and removes deprecated options that would cause lint errors. + +**Currently removed options**: + +- `"esModuleInterop": false` — This option has been removed by typescript. When present, `vp lint --type-aware` fails with: `Option 'esModuleInterop=false' has been removed.` + +**Behavior**: + +- Only `esModuleInterop: false` is removed — `true` is left alone +- Uses `jsonc-parser` for JSONC-aware editing that preserves comments and formatting +- Scans all `tsconfig*.json` variants (e.g., `tsconfig.json`, `tsconfig.app.json`, `tsconfig.node.json`) +- Runs automatically as part of the config rewrite phase — no user prompt needed + ## References ### Code Transformation