Skip to content

Commit a313322

Browse files
committed
fix(migrate): auto-remove esModuleInterop: false from tsconfig.json
Closes #1145 When `vp migrate` runs, it now scans all tsconfig*.json files and removes `"esModuleInterop": false` which has been deprecated by oxlint's tsgolint and causes lint errors with type-aware checking. Uses jsonc-parser for JSONC-aware editing that correctly handles inline comments, compact formatting, and trailing commas. Moved jsonc-parser to dependencies temporarily until bundle refactoring lands (#744).
1 parent 46a68bb commit a313322

11 files changed

Lines changed: 322 additions & 18 deletions

File tree

.github/workflows/e2e-test.yml

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -292,8 +292,7 @@ jobs:
292292
- name: bun-vite-template
293293
node-version: 24
294294
command: |
295-
vp run build
296-
vp run test
295+
vp run validate
297296
exclude:
298297
# frm-stack uses Docker (testcontainers) which doesn't work the same way on Windows
299298
- os: windows-latest

packages/cli/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -325,6 +325,7 @@
325325
"@voidzero-dev/vite-plus-test": "workspace:*",
326326
"cac": "catalog:",
327327
"cross-spawn": "catalog:",
328+
"jsonc-parser": "catalog:",
328329
"oxfmt": "catalog:",
329330
"oxlint": "catalog:",
330331
"oxlint-tsgolint": "catalog:",
@@ -342,7 +343,6 @@
342343
"detect-indent": "catalog:",
343344
"detect-newline": "catalog:",
344345
"glob": "catalog:",
345-
"jsonc-parser": "catalog:",
346346
"lint-staged": "catalog:",
347347
"minimatch": "catalog:",
348348
"mri": "catalog:",
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
{
2+
"devDependencies": {
3+
"vite": "latest"
4+
}
5+
}
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
> vp migrate --no-interactive # should remove esModuleInterop: false from tsconfig.json
2+
VITE+ - The Unified Toolchain for the Web
3+
4+
◇ Migrated . to Vite+<repeat>
5+
• Node <semver> pnpm <semver>
6+
• 3 config updates applied
7+
! Warnings:
8+
- Removed `"esModuleInterop": false` from tsconfig.json — this option has been deprecated and causes oxlint type-aware lint errors. See https://github.com/oxc-project/tsgolint/issues/351
9+
10+
> cat tsconfig.json # verify esModuleInterop: false is removed
11+
{
12+
"compilerOptions": {
13+
"target": "ES2023",
14+
"module": "ESNext",
15+
"allowSyntheticDefaultImports": true,
16+
"strict": true
17+
}
18+
}
19+
20+
> cat vite.config.ts # check vite.config.ts
21+
import { defineConfig } from 'vite-plus';
22+
23+
export default defineConfig({
24+
staged: {
25+
"*": "vp check --fix"
26+
},
27+
lint: {"options":{"typeAware":true,"typeCheck":true}},
28+
});
29+
30+
> cat package.json # check package.json
31+
{
32+
"devDependencies": {
33+
"vite": "npm:@voidzero-dev/vite-plus-core@latest",
34+
"vite-plus": "latest"
35+
},
36+
"pnpm": {
37+
"overrides": {
38+
"vite": "npm:@voidzero-dev/vite-plus-core@latest",
39+
"vitest": "npm:@voidzero-dev/vite-plus-test@latest"
40+
}
41+
},
42+
"packageManager": "pnpm@<semver>",
43+
"scripts": {
44+
"prepare": "vp config"
45+
}
46+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
{
2+
"commands": [
3+
"vp migrate --no-interactive # should remove esModuleInterop: false from tsconfig.json",
4+
"cat tsconfig.json # verify esModuleInterop: false is removed",
5+
"cat vite.config.ts # check vite.config.ts",
6+
"cat package.json # check package.json"
7+
]
8+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
{
2+
"compilerOptions": {
3+
"target": "ES2023",
4+
"module": "ESNext",
5+
"esModuleInterop": false,
6+
"allowSyntheticDefaultImports": true,
7+
"strict": true
8+
}
9+
}

packages/cli/src/migration/migrator.ts

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,11 @@ import { editJsonFile, isJsonFile, readJsonFile } from '../utils/json.js';
2828
import { detectPackageMetadata } from '../utils/package.js';
2929
import { displayRelative, rulesDir } from '../utils/path.js';
3030
import { getSpinner } from '../utils/prompts.js';
31-
import { hasBaseUrlInTsconfig } from '../utils/tsconfig.js';
31+
import {
32+
findTsconfigFiles,
33+
hasBaseUrlInTsconfig,
34+
removeEsModuleInteropFalseFromFile,
35+
} from '../utils/tsconfig.js';
3236
import { editYamlFile, scalarString, type YamlDocument } from '../utils/yaml.js';
3337
import {
3438
PRETTIER_CONFIG_FILES,
@@ -643,6 +647,32 @@ function rewritePrettierLintStagedConfigFiles(projectPath: string, report?: Migr
643647
rewriteToolLintStagedConfigFiles(projectPath, rewritePrettier, 'prettier', report);
644648
}
645649

650+
/**
651+
* Remove deprecated tsconfig options (e.g. esModuleInterop: false) from all
652+
* tsconfig*.json files in the given directory.
653+
*/
654+
function cleanupDeprecatedTsconfigOptions(
655+
projectPath: string,
656+
silent = false,
657+
report?: MigrationReport,
658+
): void {
659+
const files = findTsconfigFiles(projectPath);
660+
for (const filePath of files) {
661+
if (removeEsModuleInteropFalseFromFile(filePath)) {
662+
if (report) {
663+
report.removedConfigCount++;
664+
}
665+
if (!silent) {
666+
prompts.log.success(`✔ Removed esModuleInterop: false from ${displayRelative(filePath)}`);
667+
}
668+
warnMigration(
669+
`Removed \`"esModuleInterop": false\` from ${displayRelative(filePath)} — this option has been deprecated and causes oxlint type-aware lint errors. See https://github.com/oxc-project/tsgolint/issues/351`,
670+
report,
671+
);
672+
}
673+
}
674+
}
675+
646676
/**
647677
* Rewrite standalone project to add vite-plus dependencies
648678
* @param projectPath - The path to the project
@@ -724,6 +754,7 @@ export function rewriteStandaloneProject(
724754
if (!skipStagedMigration) {
725755
rewriteLintStagedConfigFile(projectPath, report);
726756
}
757+
cleanupDeprecatedTsconfigOptions(projectPath, silent, report);
727758
mergeViteConfigFiles(projectPath, silent, report);
728759
injectLintTypeCheckDefaults(projectPath, silent, report);
729760
mergeTsdownConfigFile(projectPath, silent, report);
@@ -771,6 +802,7 @@ export function rewriteMonorepo(
771802
if (!skipStagedMigration) {
772803
rewriteLintStagedConfigFile(workspaceInfo.rootDir, report);
773804
}
805+
cleanupDeprecatedTsconfigOptions(workspaceInfo.rootDir, silent, report);
774806
mergeViteConfigFiles(workspaceInfo.rootDir, silent, report);
775807
injectLintTypeCheckDefaults(workspaceInfo.rootDir, silent, report);
776808
mergeTsdownConfigFile(workspaceInfo.rootDir, silent, report);
@@ -791,6 +823,7 @@ export function rewriteMonorepoProject(
791823
silent = false,
792824
report?: MigrationReport,
793825
): void {
826+
cleanupDeprecatedTsconfigOptions(projectPath, silent, report);
794827
mergeViteConfigFiles(projectPath, silent, report);
795828
mergeTsdownConfigFile(projectPath, silent, report);
796829

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
import fs from 'node:fs';
2+
import os from 'node:os';
3+
import path from 'node:path';
4+
5+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
6+
7+
import { findTsconfigFiles, removeEsModuleInteropFalseFromFile } from '../tsconfig.js';
8+
9+
describe('findTsconfigFiles', () => {
10+
let tmpDir: string;
11+
12+
beforeEach(() => {
13+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tsconfig-test-'));
14+
});
15+
16+
afterEach(() => {
17+
fs.rmSync(tmpDir, { recursive: true, force: true });
18+
});
19+
20+
it('finds all tsconfig variants', () => {
21+
fs.writeFileSync(path.join(tmpDir, 'tsconfig.json'), '{}');
22+
fs.writeFileSync(path.join(tmpDir, 'tsconfig.app.json'), '{}');
23+
fs.writeFileSync(path.join(tmpDir, 'tsconfig.node.json'), '{}');
24+
fs.writeFileSync(path.join(tmpDir, 'tsconfig.build.json'), '{}');
25+
fs.writeFileSync(path.join(tmpDir, 'other.json'), '{}');
26+
fs.writeFileSync(path.join(tmpDir, 'package.json'), '{}');
27+
28+
const files = findTsconfigFiles(tmpDir);
29+
const expected = [
30+
path.join(tmpDir, 'tsconfig.app.json'),
31+
path.join(tmpDir, 'tsconfig.build.json'),
32+
path.join(tmpDir, 'tsconfig.json'),
33+
path.join(tmpDir, 'tsconfig.node.json'),
34+
];
35+
expect(new Set(files)).toEqual(new Set(expected));
36+
expect(files).toHaveLength(4);
37+
});
38+
39+
it('returns empty array for non-existent directory', () => {
40+
expect(findTsconfigFiles('/non-existent-dir-12345')).toEqual([]);
41+
});
42+
43+
it('returns empty array when no tsconfig files exist', () => {
44+
fs.writeFileSync(path.join(tmpDir, 'package.json'), '{}');
45+
expect(findTsconfigFiles(tmpDir)).toEqual([]);
46+
});
47+
});
48+
49+
describe('removeEsModuleInteropFalseFromFile', () => {
50+
let tmpDir: string;
51+
52+
beforeEach(() => {
53+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tsconfig-test-'));
54+
});
55+
56+
afterEach(() => {
57+
fs.rmSync(tmpDir, { recursive: true, force: true });
58+
});
59+
60+
it('removes esModuleInterop: false', () => {
61+
const filePath = path.join(tmpDir, 'tsconfig.json');
62+
fs.writeFileSync(
63+
filePath,
64+
JSON.stringify(
65+
{
66+
compilerOptions: {
67+
target: 'ES2023',
68+
esModuleInterop: false,
69+
strict: true,
70+
},
71+
},
72+
null,
73+
2,
74+
),
75+
);
76+
77+
const result = removeEsModuleInteropFalseFromFile(filePath);
78+
expect(result).toBe(true);
79+
80+
const content = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
81+
expect(content.compilerOptions).not.toHaveProperty('esModuleInterop');
82+
expect(content.compilerOptions.target).toBe('ES2023');
83+
expect(content.compilerOptions.strict).toBe(true);
84+
});
85+
86+
it('preserves comments in JSONC', () => {
87+
const filePath = path.join(tmpDir, 'tsconfig.json');
88+
const content = `{
89+
// This is a comment
90+
"compilerOptions": {
91+
"target": "ES2023",
92+
"esModuleInterop": false,
93+
/* block comment */
94+
"strict": true
95+
}
96+
}
97+
`;
98+
fs.writeFileSync(filePath, content);
99+
100+
const result = removeEsModuleInteropFalseFromFile(filePath);
101+
expect(result).toBe(true);
102+
103+
const newContent = fs.readFileSync(filePath, 'utf-8');
104+
expect(newContent).toContain('// This is a comment');
105+
expect(newContent).toContain('/* block comment */');
106+
expect(newContent).not.toContain('esModuleInterop');
107+
expect(newContent).toContain('"strict": true');
108+
});
109+
110+
it('handles esModuleInterop: false as last property (trailing comma on previous line is valid JSONC)', () => {
111+
const filePath = path.join(tmpDir, 'tsconfig.json');
112+
const content = `{
113+
"compilerOptions": {
114+
"target": "ES2023",
115+
"esModuleInterop": false
116+
}
117+
}
118+
`;
119+
fs.writeFileSync(filePath, content);
120+
121+
const result = removeEsModuleInteropFalseFromFile(filePath);
122+
expect(result).toBe(true);
123+
124+
const newContent = fs.readFileSync(filePath, 'utf-8');
125+
expect(newContent).not.toContain('esModuleInterop');
126+
expect(newContent).toContain('"target": "ES2023"');
127+
});
128+
129+
it('leaves esModuleInterop: true untouched', () => {
130+
const filePath = path.join(tmpDir, 'tsconfig.json');
131+
const original = JSON.stringify({ compilerOptions: { esModuleInterop: true } }, null, 2);
132+
fs.writeFileSync(filePath, original);
133+
134+
const result = removeEsModuleInteropFalseFromFile(filePath);
135+
expect(result).toBe(false);
136+
expect(fs.readFileSync(filePath, 'utf-8')).toBe(original);
137+
});
138+
139+
it('returns false for non-existent file', () => {
140+
expect(removeEsModuleInteropFalseFromFile('/non-existent-file.json')).toBe(false);
141+
});
142+
143+
it('returns false when no compilerOptions', () => {
144+
const filePath = path.join(tmpDir, 'tsconfig.json');
145+
fs.writeFileSync(filePath, '{}');
146+
147+
expect(removeEsModuleInteropFalseFromFile(filePath)).toBe(false);
148+
});
149+
150+
it('returns false when esModuleInterop is not present', () => {
151+
const filePath = path.join(tmpDir, 'tsconfig.json');
152+
fs.writeFileSync(filePath, JSON.stringify({ compilerOptions: { strict: true } }, null, 2));
153+
154+
expect(removeEsModuleInteropFalseFromFile(filePath)).toBe(false);
155+
});
156+
});

packages/cli/src/utils/tsconfig.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import fs from 'node:fs';
22
import path from 'node:path';
33

4+
import { applyEdits, modify, parse as parseJsonc } from 'jsonc-parser';
5+
46
/**
57
* Check if tsconfig.json has compilerOptions.baseUrl set.
68
* oxlint's TypeScript checker (tsgolint) does not support baseUrl,
@@ -16,3 +18,44 @@ export function hasBaseUrlInTsconfig(projectPath: string): boolean {
1618
return false;
1719
}
1820
}
21+
22+
const TSCONFIG_FILE_RE = /^tsconfig(\.[\w-]+)?\.json$/i;
23+
24+
export function findTsconfigFiles(projectPath: string): string[] {
25+
try {
26+
const entries = fs.readdirSync(projectPath);
27+
return entries
28+
.filter((name) => TSCONFIG_FILE_RE.test(name))
29+
.map((name) => path.join(projectPath, name));
30+
} catch {
31+
return [];
32+
}
33+
}
34+
35+
// jsonc-parser is in dependencies (not devDependencies) so it's available at
36+
// runtime for tsc-compiled code (init-config.ts imports this file).
37+
// TODO: move back to devDependencies once the bundle refactoring lands (#744).
38+
export function removeEsModuleInteropFalseFromFile(filePath: string): boolean {
39+
let text: string;
40+
try {
41+
text = fs.readFileSync(filePath, 'utf-8');
42+
} catch {
43+
return false;
44+
}
45+
46+
const parsed = parseJsonc(text) as {
47+
compilerOptions?: { esModuleInterop?: boolean };
48+
} | null;
49+
if (parsed?.compilerOptions?.esModuleInterop !== false) {
50+
return false;
51+
}
52+
53+
const edits = modify(text, ['compilerOptions', 'esModuleInterop'], undefined, {});
54+
if (edits.length === 0) {
55+
return false;
56+
}
57+
58+
const newText = applyEdits(text, edits);
59+
fs.writeFileSync(filePath, newText);
60+
return true;
61+
}

0 commit comments

Comments
 (0)