From e107c184f11207e9bdbf8f845d3a903edd3e127f Mon Sep 17 00:00:00 2001 From: MK Date: Sat, 17 Oct 2026 14:56:19 +0800 Subject: [PATCH 01/24] fix(migrate): resolve Oxlint JSON extends --- .../migration_oxlint_extends/.gitignore | 1 + .../migration_oxlint_extends/.oxlintrc.json | 14 + .../migration_oxlint_extends/example.test.js | 2 + .../migration_oxlint_extends/index.js | 5 + .../migration_oxlint_extends/package.json | 10 + .../migration_oxlint_extends/shared/base.json | 11 + .../shared/nested/first.json | 22 ++ .../shared/override-plugin.mjs | 17 ++ .../shared/plugin.mjs | 17 ++ .../shared/second.jsonc | 5 + .../migration_oxlint_extends/snapshots.toml | 17 ++ .../snapshots/migration_oxlint_extends.md | 213 +++++++++++++ .../migration_oxlint_extends/tsconfig.json | 14 + .../migration_oxlint_extends/verify-lint.mjs | 33 ++ docs/guide/migrate-rules.md | 8 + .../__tests__/oxlint-extends.spec.ts | 288 ++++++++++++++++++ .../cli/src/migration/migrator/catalog.ts | 4 +- .../src/migration/migrator/orchestrators.ts | 30 +- .../cli/src/migration/migrator/vite-config.ts | 268 +++++++++++++--- 19 files changed, 938 insertions(+), 41 deletions(-) create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends/.gitignore create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends/.oxlintrc.json create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends/example.test.js create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends/index.js create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends/package.json create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends/shared/base.json create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends/shared/nested/first.json create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends/shared/override-plugin.mjs create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends/shared/plugin.mjs create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends/shared/second.jsonc create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends/snapshots.toml create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends/snapshots/migration_oxlint_extends.md create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends/tsconfig.json create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends/verify-lint.mjs create mode 100644 packages/cli/src/migration/__tests__/oxlint-extends.spec.ts diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends/.gitignore b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends/.gitignore new file mode 100644 index 0000000000..c2658d7d1b --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends/.gitignore @@ -0,0 +1 @@ +node_modules/ diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends/.oxlintrc.json b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends/.oxlintrc.json new file mode 100644 index 0000000000..741ac15bbd --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends/.oxlintrc.json @@ -0,0 +1,14 @@ +{ + "extends": [ + "./shared/nested/first.json", + "./shared/second.jsonc" + ], + "rules": { + "no-alert": "off", + "local/no-foo": "error" + }, + "ignorePatterns": [ + "shared/**", + "verify-lint.mjs" + ] +} diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends/example.test.js b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends/example.test.js new file mode 100644 index 0000000000..f4faff2fd2 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends/example.test.js @@ -0,0 +1,2 @@ +debugger; +export const bar = 1; diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends/index.js b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends/index.js new file mode 100644 index 0000000000..4074824586 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends/index.js @@ -0,0 +1,5 @@ +debugger; +console.log("test"); +alert("allowed"); +export const foo = 1; +export const bar = 1; diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends/package.json b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends/package.json new file mode 100644 index 0000000000..55f399dac4 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends/package.json @@ -0,0 +1,10 @@ +{ + "name": "migration-oxlint-extends", + "private": true, + "type": "module", + "devDependencies": { + "oxlint": "1.88.0", + "typescript": "6.0.3", + "@types/node": "24.10.3" + } +} diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends/shared/base.json b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends/shared/base.json new file mode 100644 index 0000000000..da4b481d02 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends/shared/base.json @@ -0,0 +1,11 @@ +{ + "options": { + "typeAware": false, + "typeCheck": false + }, + "rules": { + "no-debugger": "error", + "no-console": "error", + "no-alert": "error" + } +} diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends/shared/nested/first.json b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends/shared/nested/first.json new file mode 100644 index 0000000000..68d8867e1e --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends/shared/nested/first.json @@ -0,0 +1,22 @@ +{ + "extends": [ + "../base.json" + ], + "overrides": [ + { + "files": [ + "**/*.test.js" + ], + "rules": { + "no-debugger": "warn", + "override/no-bar": "error" + }, + "jsPlugins": [ + { + "name": "override", + "specifier": "../override-plugin.mjs" + } + ] + } + ] +} diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends/shared/override-plugin.mjs b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends/shared/override-plugin.mjs new file mode 100644 index 0000000000..96d244c985 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends/shared/override-plugin.mjs @@ -0,0 +1,17 @@ +export default { + meta: { name: 'override' }, + rules: { + 'no-bar': { + meta: { schema: [] }, + create(context) { + return { + Identifier(node) { + if (node.name === 'bar') { + context.report({ node, message: 'Do not use bar.' }); + } + }, + }; + }, + }, + }, +}; diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends/shared/plugin.mjs b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends/shared/plugin.mjs new file mode 100644 index 0000000000..a332c05333 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends/shared/plugin.mjs @@ -0,0 +1,17 @@ +export default { + meta: { name: 'local' }, + rules: { + 'no-foo': { + meta: { schema: [] }, + create(context) { + return { + Identifier(node) { + if (node.name === 'foo') { + context.report({ node, message: 'Do not use foo.' }); + } + }, + }; + }, + }, + }, +}; diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends/shared/second.jsonc b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends/shared/second.jsonc new file mode 100644 index 0000000000..6134e8bd2b --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends/shared/second.jsonc @@ -0,0 +1,5 @@ +{ + // Later entries take priority. + "rules": { "no-console": "warn" }, + "jsPlugins": ["./plugin.mjs"] +} diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends/snapshots.toml new file mode 100644 index 0000000000..bb0eeaea7a --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends/snapshots.toml @@ -0,0 +1,17 @@ +[[case]] +name = "migration_oxlint_extends" +vp = "local" +local-registry = true +unset-env = ["VP_SKIP_INSTALL"] +steps = [ + { argv = ["node", "verify-lint.mjs", "--before"], comment = "Original inherited errors, warnings, and overrides" }, + { argv = ["vp", "migrate", "--no-interactive", "--no-agent", "--no-editor", "--no-hooks"] }, + { argv = ["vpt", "print-file", "vite.config.ts"], comment = "Path extends become config objects; relative plugins move to the root lint config" }, + { argv = ["vpt", "stat-file", ".oxlintrc.json", "--assert-not", "file"] }, + { argv = ["vpt", "print-file", "shared/second.jsonc"], comment = "Shared JSON configs are retained" }, + { argv = ["vp", "exec", "tsc", "--noEmit"], comment = "Generated config types accept the resolved inheritance" }, + { argv = ["node", "verify-lint.mjs"], comment = "Plain vp lint preserves inherited severity and override matching" }, + { argv = ["vp", "migrate", "--no-interactive", "--no-agent", "--no-editor", "--no-hooks"] }, + { argv = ["vpt", "print-file", "vite.config.ts"], comment = "A second migration leaves the config unchanged" }, + { argv = ["node", "verify-lint.mjs"], comment = "Inherited behavior survives the second migration" }, +] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends/snapshots/migration_oxlint_extends.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends/snapshots/migration_oxlint_extends.md new file mode 100644 index 0000000000..a96dd2d273 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends/snapshots/migration_oxlint_extends.md @@ -0,0 +1,213 @@ +# migration_oxlint_extends + +## `node verify-lint.mjs --before` + +Original inherited errors, warnings, and overrides + +``` +vp lint -c .oxlintrc.json: 3 errors and 2 warnings +example.test.js: error override(no-bar) +example.test.js: warning eslint(no-debugger) +index.js: error eslint(no-debugger) +index.js: error local(no-foo) +index.js: warning eslint(no-console) +``` + +## `vp migrate --no-interactive --no-agent --no-editor --no-hooks` + +``` +VITE+ - The Unified Toolchain for the Web + +● Formatting code... +● Code formatted +◇ Migrated . to Vite+ +• Node pnpm +✓ Dependencies installed in +• 2 config updates applied +``` + +## `vpt print-file vite.config.ts` + +Path extends become config objects; relative plugins move to the root lint config + +``` +import { defineConfig } from "vite-plus"; + +export default defineConfig({ + fmt: {}, + lint: { + extends: [ + { + extends: [ + { + options: { + typeAware: false, + typeCheck: false, + }, + rules: { + "no-debugger": "error", + "no-console": "error", + "no-alert": "error", + }, + }, + ], + overrides: [ + { + files: ["**/*.test.js"], + rules: { + "no-debugger": "warn", + "override/no-bar": "error", + }, + }, + ], + }, + { + rules: { + "no-console": "warn", + }, + }, + ], + rules: { + "no-alert": "off", + "local/no-foo": "error", + "vite-plus/prefer-vite-plus-imports": "error", + }, + ignorePatterns: ["shared/**", "verify-lint.mjs"], + jsPlugins: [ + { + name: "override", + specifier: "./shared/override-plugin.mjs", + }, + "./shared/plugin.mjs", + { + name: "vite-plus", + specifier: "vite-plus/oxlint-plugin", + }, + ], + options: {}, + }, +}); +``` + +## `vpt stat-file .oxlintrc.json --assert-not file` + +``` +.oxlintrc.json: missing +``` + +## `vpt print-file shared/second.jsonc` + +Shared JSON configs are retained + +``` +{ + // Later entries take priority. + "rules": { "no-console": "warn" }, + "jsPlugins": ["./plugin.mjs"], +} +``` + +## `vp exec tsc --noEmit` + +Generated config types accept the resolved inheritance + +``` +``` + +## `node verify-lint.mjs` + +Plain vp lint preserves inherited severity and override matching + +``` +vp lint: 3 errors and 2 warnings +example.test.js: error override(no-bar) +example.test.js: warning eslint(no-debugger) +index.js: error eslint(no-debugger) +index.js: error local(no-foo) +index.js: warning eslint(no-console) +``` + +## `vp migrate --no-interactive --no-agent --no-editor --no-hooks` + +``` +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. +This project is already using Vite+! Happy coding! +``` + +## `vpt print-file vite.config.ts` + +A second migration leaves the config unchanged + +``` +import { defineConfig } from "vite-plus"; + +export default defineConfig({ + fmt: {}, + lint: { + extends: [ + { + extends: [ + { + options: { + typeAware: false, + typeCheck: false, + }, + rules: { + "no-debugger": "error", + "no-console": "error", + "no-alert": "error", + }, + }, + ], + overrides: [ + { + files: ["**/*.test.js"], + rules: { + "no-debugger": "warn", + "override/no-bar": "error", + }, + }, + ], + }, + { + rules: { + "no-console": "warn", + }, + }, + ], + rules: { + "no-alert": "off", + "local/no-foo": "error", + "vite-plus/prefer-vite-plus-imports": "error", + }, + ignorePatterns: ["shared/**", "verify-lint.mjs"], + jsPlugins: [ + { + name: "override", + specifier: "./shared/override-plugin.mjs", + }, + "./shared/plugin.mjs", + { + name: "vite-plus", + specifier: "vite-plus/oxlint-plugin", + }, + ], + options: {}, + }, +}); +``` + +## `node verify-lint.mjs` + +Inherited behavior survives the second migration + +``` +vp lint: 3 errors and 2 warnings +example.test.js: error override(no-bar) +example.test.js: warning eslint(no-debugger) +index.js: error eslint(no-debugger) +index.js: error local(no-foo) +index.js: warning eslint(no-console) +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends/tsconfig.json b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends/tsconfig.json new file mode 100644 index 0000000000..fa17f555fe --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "NodeNext", + "noEmit": true, + "skipLibCheck": true, + "types": [ + "node" + ] + }, + "include": [ + "vite.config.ts" + ] +} diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends/verify-lint.mjs b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends/verify-lint.mjs new file mode 100644 index 0000000000..1bf31e2984 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends/verify-lint.mjs @@ -0,0 +1,33 @@ +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import { stripVTControlCharacters } from 'node:util'; + +// Execute the same JS entry point as the local vp binary on every platform. +const cli = fileURLToPath(import.meta.resolve('vite-plus/bin')); +const args = process.argv.includes('--before') ? ['lint', '-c', '.oxlintrc.json'] : ['lint']; +function lint(extraArgs = []) { + const result = spawnSync(process.execPath, [cli, ...args, ...extraArgs], { + encoding: 'utf8', timeout: 30000, + }); + assert.ifError(result.error); + assert.equal(result.status, 1, result.stdout + result.stderr); + return result; +} + +// Exercise plain `vp lint` after migration, without selecting a config or adding flags. +const plain = lint(); +assert.match(stripVTControlCharacters(plain.stdout + plain.stderr), /Found 2 warnings and 3 errors/); + +// Compare each rule's severity and file without depending on worker completion order. +const { diagnostics } = JSON.parse(lint(['--format', 'json']).stdout); +const actual = diagnostics.map(({ filename, code, severity }) => `${filename}: ${severity} ${code}`).sort(); +assert.deepEqual(actual, [ + 'example.test.js: error override(no-bar)', + 'example.test.js: warning eslint(no-debugger)', + 'index.js: error eslint(no-debugger)', + 'index.js: error local(no-foo)', + 'index.js: warning eslint(no-console)', +]); +console.log(`vp ${args.join(' ')}: 3 errors and 2 warnings`); +console.log(actual.join('\n')); diff --git a/docs/guide/migrate-rules.md b/docs/guide/migrate-rules.md index 83a5a95dab..f5e8cde22e 100644 --- a/docs/guide/migrate-rules.md +++ b/docs/guide/migrate-rules.md @@ -23,6 +23,14 @@ On a project that already depends on `vite-plus`, `vp migrate` performs an upgra 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. +## Oxlint JSON Inheritance + +When `.oxlintrc.json` or `.oxlintrc.jsonc` uses `extends`, `vp migrate` resolves each file path relative to the config that declares it. It copies the inherited configs into `lint.extends` as objects, including nested inheritance. Oxlint applies the existing merge order, rule severities, and overrides. Plain `vp lint` uses the migrated rules without a `-c` flag. + +Shared JSON and JSONC files are retained. The generated config contains a copy of their settings; later edits to the shared files do not update that copy. Relative JS-plugin declarations move to `lint.jsPlugins`, with their paths rebased to the Vite config directory. Explicit inherited `typeAware` and `typeCheck` settings take precedence over migration defaults. + +Missing files, invalid JSON, inheritance cycles, and package-local plugins that resolve differently from the Vite config directory produce a warning and leave the original config in place. An existing `lint` block also requires a manual merge. The root JSON config is removed only after a successful migration. `vp lint` continues to reject string paths in `lint.extends`; path resolution happens only during migration. + ## Pack Configuration `vp migrate` updates static `pack` objects in `vite.config.*` and exported objects in `tsdown.config.*` for [tsdown 0.23](https://github.com/rolldown/tsdown/releases/tag/v0.23.0). This also runs on existing Vite+ projects without `--full`, including workspace packages. Arrays and direct objects returned by `defineConfig` callbacks are supported. JSON tsdown configs receive the same updates after they merge into `vite.config.ts`. diff --git a/packages/cli/src/migration/__tests__/oxlint-extends.spec.ts b/packages/cli/src/migration/__tests__/oxlint-extends.spec.ts new file mode 100644 index 0000000000..49820830c4 --- /dev/null +++ b/packages/cli/src/migration/__tests__/oxlint-extends.spec.ts @@ -0,0 +1,288 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { PackageManager } from '../../types/index.ts'; +import { + injectLintTypeCheckDefaults, + mergeViteConfigFiles, + resolveOxlintConfigExtends, + rewriteMonorepo, +} from '../migrator.ts'; +import { createMigrationReport } from '../report.ts'; + +describe('Oxlint JSON extends migration', () => { + let project: string; + + function write(filename: string, content: string | object) { + const filenamePath = path.join(project, filename); + fs.mkdirSync(path.dirname(filenamePath), { recursive: true }); + fs.writeFileSync(filenamePath, typeof content === 'string' ? content : JSON.stringify(content)); + return filenamePath; + } + + beforeEach(() => { + project = fs.mkdtempSync(path.join(os.tmpdir(), 'vp-oxlint-extends-')); + write('package.json', { name: 'extends-test', type: 'module' }); + write('vite.config.mjs', 'export default {};'); + }); + + afterEach(() => { + fs.rmSync(project, { recursive: true, force: true }); + }); + + it('resolves nested JSONC paths relative to each declaring file and retains inheritance order', async () => { + const base = write( + 'shared/base.jsonc', + `{ + // Inherited options must not be replaced by migration defaults. + "$schema": "./schema.json", + "options": { "typeAware": false, "typeCheck": false }, + "rules": { "no-debugger": "error" }, + }`, + ); + write('shared/nested/first.json', { + extends: ['../base.jsonc'], + rules: { 'no-console': 'warn' }, + overrides: [{ files: ['**/*.test.js'], rules: { 'no-debugger': 'off' } }], + }); + write('second.json', { rules: { 'no-console': 'error' } }); + write('.oxlintrc.json', { + extends: ['./shared/nested/first.json', './second.json'], + rules: { 'no-alert': 'off' }, + }); + + const report = createMigrationReport(); + mergeViteConfigFiles(project, true, report); + const configPath = path.join(project, 'vite.config.mjs'); + const { default: config } = await import(pathToFileURL(configPath).href); + expect(config.lint).toMatchObject({ + extends: [ + { + extends: [ + { options: { typeAware: false, typeCheck: false }, rules: { 'no-debugger': 'error' } }, + ], + rules: { 'no-console': 'warn' }, + overrides: [{ files: ['**/*.test.js'], rules: { 'no-debugger': 'off' } }], + }, + { rules: { 'no-console': 'error' } }, + ], + options: {}, + rules: { 'no-alert': 'off' }, + }); + expect(JSON.stringify(config)).not.toContain('$schema'); + expect(fs.existsSync(path.join(project, '.oxlintrc.json'))).toBe(false); + expect(fs.readFileSync(base, 'utf8')).toContain('// Inherited options'); + expect(report.mergedConfigCount).toBe(1); + expect(report.warnings).toEqual([]); + + const migrated = fs.readFileSync(configPath, 'utf8'); + mergeViteConfigFiles(project, true, report); + injectLintTypeCheckDefaults(project, true, report); + expect(fs.readFileSync(configPath, 'utf8')).toBe(migrated); + expect(report.mergedConfigCount).toBe(1); + }); + + it('allows a shared ancestor to occur in separate branches', () => { + write('base.json', { rules: { 'no-debugger': 'error' } }); + write('a.json', { extends: ['./base.json'] }); + write('b.json', { extends: ['./base.json'] }); + const root = write('.oxlintrc.json', { extends: ['./a.json', './b.json'] }); + expect(resolveOxlintConfigExtends(root).extends).toEqual([ + { extends: [{ rules: { 'no-debugger': 'error' } }] }, + { extends: [{ rules: { 'no-debugger': 'error' } }] }, + ]); + }); + + it.each([false, true])( + 'resolves workspace inheritance before configs are removed (extends root: %s)', + async (extendsRoot) => { + write('package.json', { name: 'root', private: true, workspaces: ['packages/*'] }); + for (const name of ['a', 'b']) { + write(`packages/${name}/package.json`, { name }); + write(`packages/${name}/vite.config.mjs`, 'export default {};'); + } + write('packages/a/.oxlintrc.json', { rules: { 'no-debugger': 'error' } }); + write('packages/b/.oxlintrc.json', { + extends: [extendsRoot ? '../../.oxlintrc.json' : '../a/.oxlintrc.json'], + }); + write('.oxlintrc.json', { + extends: [extendsRoot ? './packages/a/.oxlintrc.json' : './packages/b/.oxlintrc.json'], + }); + const report = createMigrationReport(); + + rewriteMonorepo( + { + rootDir: project, + isMonorepo: true, + monorepoScope: '', + workspacePatterns: ['packages/*'], + parentDirs: ['packages'], + packageManager: PackageManager.npm, + packageManagerVersion: '11.0.0', + downloadPackageManager: { + name: PackageManager.npm, + installDir: project, + binPrefix: project, + packageName: 'npm', + version: '11.0.0', + }, + packages: [ + { name: 'a', path: 'packages/a' }, + { name: 'b', path: 'packages/b' }, + ], + }, + true, + true, + report, + ); + + const { default: root } = await import( + pathToFileURL(path.join(project, 'vite.config.mjs')).href + ); + const { default: sibling } = await import( + pathToFileURL(path.join(project, 'packages/b/vite.config.mjs')).href + ); + const inheritedRoot = extendsRoot ? root.lint.extends[0] : root.lint.extends[0].extends[0]; + const inheritedSibling = extendsRoot + ? sibling.lint.extends[0].extends[0] + : sibling.lint.extends[0]; + expect(inheritedRoot.rules['no-debugger']).toBe('error'); + expect(inheritedSibling.rules['no-debugger']).toBe('error'); + for (const dir of ['', 'packages/a', 'packages/b']) { + expect(fs.existsSync(path.join(project, dir, '.oxlintrc.json'))).toBe(false); + } + expect(report.warnings).toEqual([]); + }, + ); + + it('retains a bare plugin when it resolves to the same package from both directories', () => { + write('node_modules/eslint-plugin-test/package.json', { + name: 'eslint-plugin-test', + main: 'index.js', + }); + write('node_modules/eslint-plugin-test/index.js', 'module.exports = {};'); + write('shared/base.json', { + jsPlugins: ['eslint-plugin-test'], + rules: { 'test/custom': 'error' }, + }); + const configPath = write('.oxlintrc.json', { extends: ['./shared/base.json'] }); + + expect(resolveOxlintConfigExtends(configPath).extends?.[0]).toEqual({ + jsPlugins: ['eslint-plugin-test'], + rules: { 'test/custom': 'error' }, + }); + }); + + it('preserves configs whose inherited package-local plugin would resolve differently', () => { + write('shared/node_modules/eslint-plugin-test/package.json', { + name: 'eslint-plugin-test', + main: 'index.js', + }); + write('shared/node_modules/eslint-plugin-test/index.js', 'module.exports = {};'); + write('shared/base.json', { jsPlugins: ['eslint-plugin-test'] }); + const source = write('.oxlintrc.json', { extends: ['./shared/base.json'] }); + const original = fs.readFileSync(source, 'utf8'); + const report = createMigrationReport(); + + mergeViteConfigFiles(project, true, report); + + expect(fs.readFileSync(source, 'utf8')).toBe(original); + expect(report.mergedConfigCount).toBe(0); + expect(report.warnings[0]).toContain('resolves differently'); + }); + + it('does not override an inherited opt-out of the default import rule', async () => { + write('base.json', { rules: { 'vite-plus/prefer-vite-plus-imports': 'off' } }); + write('.oxlintrc.json', { extends: ['./base.json'] }); + + mergeViteConfigFiles(project, true); + + const { default: config } = await import( + pathToFileURL(path.join(project, 'vite.config.mjs')).href + ); + expect(config.lint.rules['vite-plus/prefer-vite-plus-imports']).toBe('off'); + }); + + it('rebases inherited JS plugins, including overrides, without embedding machine paths', async () => { + write('shared/base.json', { + jsPlugins: [{ name: 'import', specifier: './plugin.mjs' }], + overrides: [{ files: ['**/*.js'], jsPlugins: ['../override.mjs'] }], + }); + write('.oxlintrc.json', { + extends: ['./shared/base.json'], + rules: { 'import/custom-rule': 'error' }, + }); + + mergeViteConfigFiles(project, true); + + const configPath = path.join(project, 'vite.config.mjs'); + expect(fs.readFileSync(configPath, 'utf8')).not.toContain(project); + const { default: config } = await import(pathToFileURL(configPath).href); + expect(config.lint.jsPlugins).toContainEqual({ + name: 'import', + specifier: './shared/plugin.mjs', + }); + expect(config.lint.jsPlugins).toContain('./override.mjs'); + expect(config.lint.extends[0].jsPlugins).toBeUndefined(); + expect(config.lint.extends[0].overrides[0].jsPlugins).toBeUndefined(); + expect(config.lint.rules['import/custom-rule']).toBe('error'); + }); + + it.each([ + ['missing file', { extends: ['./missing.json'] }], + ['cycle', { extends: ['./.oxlintrc.json'] }], + ['invalid extends', { extends: './base.json' }], + ['object extends in JSON', { extends: [{}] }], + ['invalid JSON', '{ "extends": ["./base.json"], broken }'], + ['non-object JSON', '[]'], + ])('preserves the source and avoids default lint config on %s', (_name, content) => { + const source = write('.oxlintrc.json', content); + const original = fs.readFileSync(source, 'utf8'); + const report = createMigrationReport(); + + mergeViteConfigFiles(project, true, report); + injectLintTypeCheckDefaults(project, true, report); + + expect(fs.readFileSync(source, 'utf8')).toBe(original); + expect(fs.readFileSync(path.join(project, 'vite.config.mjs'), 'utf8')).toBe( + 'export default {};', + ); + expect(report.warnings).toHaveLength(1); + expect(report.warnings[0]).toContain('vp lint -c .oxlintrc.json'); + expect(report.mergedConfigCount).toBe(0); + }); + + it('preserves the source when the Vite config cannot be merged', () => { + write('base.json', { rules: { 'no-debugger': 'error' } }); + const source = write('.oxlintrc.json', { extends: ['./base.json'] }); + const original = fs.readFileSync(source, 'utf8'); + write('vite.config.mjs', "export { default } from './another-config.mjs';"); + const report = createMigrationReport(); + + mergeViteConfigFiles(project, true, report); + + expect(fs.readFileSync(source, 'utf8')).toBe(original); + expect(report.mergedConfigCount).toBe(0); + expect(report.warnings[0]).toContain('Failed to merge'); + expect(fs.readdirSync(project).some((file) => file.startsWith('.vite-plus-lint-'))).toBe(false); + }); + + it('preserves an inheritance tree when a lint config already exists', () => { + write('base.json', { rules: { 'no-debugger': 'error' } }); + const source = write('.oxlintrc.json', { extends: ['./base.json'] }); + const original = fs.readFileSync(source, 'utf8'); + const viteConfig = 'export default { lint: { rules: { "no-console": "warn" } } };'; + write('vite.config.mjs', viteConfig); + const report = createMigrationReport(); + + mergeViteConfigFiles(project, true, report); + + expect(fs.readFileSync(source, 'utf8')).toBe(original); + expect(fs.readFileSync(path.join(project, 'vite.config.mjs'), 'utf8')).toBe(viteConfig); + expect(report.warnings[0]).toContain('already has a lint config'); + }); +}); diff --git a/packages/cli/src/migration/migrator/catalog.ts b/packages/cli/src/migration/migrator/catalog.ts index 34a30c24da..ba83fe19c9 100644 --- a/packages/cli/src/migration/migrator/catalog.ts +++ b/packages/cli/src/migration/migrator/catalog.ts @@ -1,6 +1,7 @@ import fs from 'node:fs'; import path from 'node:path'; +import { type OxlintConfig } from 'oxlint'; import { coerce, isGreaterThanOrEqual } from 'verkit'; import { Scalar, YAMLMap, YAMLSeq } from 'yaml'; @@ -1151,6 +1152,7 @@ export function rewriteRootWorkspacePackageJson( // catalogs require Yarn >= 4.10.0; pnpm/bun monorepos always manage a catalog. // When false, the root `vite-plus` edge and the direct `vite` edge are concrete. supportCatalog = true, + preparedOxlintConfigs?: Map, ): void { const packageJsonPath = path.join(projectPath, 'package.json'); if (!fs.existsSync(packageJsonPath)) { @@ -1291,7 +1293,7 @@ export function rewriteRootWorkspacePackageJson( undefined, undefined, catalogDependencyResolver, - packages ? { rootDir: projectPath, packages } : undefined, + packages ? { rootDir: projectPath, packages, oxlintConfigs: preparedOxlintConfigs } : undefined, true, supportCatalog, ); diff --git a/packages/cli/src/migration/migrator/orchestrators.ts b/packages/cli/src/migration/migrator/orchestrators.ts index a551ba2f79..e9afca871a 100644 --- a/packages/cli/src/migration/migrator/orchestrators.ts +++ b/packages/cli/src/migration/migrator/orchestrators.ts @@ -32,6 +32,7 @@ import { migratePnpmSettingsToWorkspaceYaml, migrateTaskCacheConfigInViteConfig, pnpmSupportsWorkspaceSettings, + prepareOxlintConfigs, supportsCatalog, projectListsRequiredVitestPeer, projectUsesVitestDirectly, @@ -399,6 +400,14 @@ export function rewriteMonorepo( workspaceInfo.downloadPackageManager.version, true, ); + const workspaceContext = { + rootDir: workspaceInfo.rootDir, + packages: workspaceInfo.packages, + oxlintConfigs: prepareOxlintConfigs([ + workspaceInfo.rootDir, + ...workspaceInfo.packages.map((pkg) => path.join(workspaceInfo.rootDir, pkg.path)), + ]), + }; // rewrite root workspace if (workspaceInfo.packageManager === PackageManager.yarn) { rewriteYarnrcYml( @@ -427,6 +436,7 @@ export function rewriteMonorepo( workspaceShouldAllowBrowserBuilds, workspaceUsesVitest, supportCatalog, + workspaceContext.oxlintConfigs, ); if (workspaceInfo.packageManager === PackageManager.pnpm) { rewritePnpmWorkspaceYaml( @@ -452,10 +462,6 @@ export function rewriteMonorepo( // rewrite packages — pass workspace context so the per-package // sanitizer can see hoisted deps that live elsewhere in the // workspace, not just this sub-package's own `package.json`. - const workspaceContext = { - rootDir: workspaceInfo.rootDir, - packages: workspaceInfo.packages, - }; // Yarn `node-modules` + an isolating `nmHoistingLimits` would give each // vite-plus-receiving workspace its own physical `vitest` copy, splitting the // runner across two `@vitest/runner` instances. `rewriteMonorepoProject` detects @@ -481,7 +487,14 @@ export function rewriteMonorepo( } cleanupDeprecatedTsconfigOptions(workspaceInfo.rootDir, silent, report); rewriteTsconfigTypes(workspaceInfo.rootDir, silent, report); - mergeViteConfigFiles(workspaceInfo.rootDir, silent, report, workspaceInfo.packages); + mergeViteConfigFiles( + workspaceInfo.rootDir, + silent, + report, + workspaceInfo.packages, + workspaceInfo.rootDir, + workspaceContext.oxlintConfigs, + ); injectLintTypeCheckDefaults(workspaceInfo.rootDir, silent, report); injectFmtDefaults(workspaceInfo.rootDir, silent, report); mergeTsdownConfigFile(workspaceInfo.rootDir, silent, report); @@ -517,7 +530,11 @@ export function rewriteMonorepoProject( silent = false, report?: MigrationReport, catalogDependencyResolver?: CatalogDependencyResolver, - workspaceContext?: { rootDir: string; packages: WorkspacePackage[] }, + workspaceContext?: { + rootDir: string; + packages: WorkspacePackage[]; + oxlintConfigs?: ReturnType; + }, deferLazyPluginWrapping = false, // Whether this workspace's edges use catalog references. Yarn catalogs require // Yarn >= 4.10.0; pnpm/bun monorepos always manage a catalog. Defaults to true @@ -536,6 +553,7 @@ export function rewriteMonorepoProject( report, workspaceContext?.packages, workspaceContext?.rootDir, + workspaceContext?.oxlintConfigs, ); mergeTsdownConfigFile(projectPath, silent, report); diff --git a/packages/cli/src/migration/migrator/vite-config.ts b/packages/cli/src/migration/migrator/vite-config.ts index a96e182b64..406e408104 100644 --- a/packages/cli/src/migration/migrator/vite-config.ts +++ b/packages/cli/src/migration/migrator/vite-config.ts @@ -2,11 +2,13 @@ import fs from 'node:fs'; import path from 'node:path'; import * as prompts from '@voidzero-dev/vite-plus-prompts'; +import { parse as parseJsonc, type ParseError } from 'jsonc-parser'; import { braceExpand } from 'minimatch'; import { type OxlintConfig } from 'oxlint'; import { hasConfigKey, + ResolverFactory, mergeJsonConfig, mergeTsdownConfig, migrateTaskCacheConfig, @@ -17,11 +19,12 @@ import { import { createDefaultVitePlusLintConfig, ensureVitePlusImportRuleDefaults, + PREFER_VITE_PLUS_IMPORTS_RULE, } from '../../oxlint-plugin-config.ts'; import { type WorkspacePackage } from '../../types/index.ts'; import { BASEURL_TSCONFIG_WARNING, VITE_PLUS_NAME } from '../../utils/constants.ts'; import { documentationUrl } from '../../utils/documentation.ts'; -import { editJsonFile, isJsonFile, readJsonFile, writeJsonFile } from '../../utils/json.ts'; +import { editJsonFile, isJsonFile, readJsonFile } from '../../utils/json.ts'; import { displayRelative } from '../../utils/path.ts'; import { hasBaseUrlInTsconfig } from '../../utils/tsconfig.ts'; import { detectConfigs, type ConfigFiles } from '../detector.ts'; @@ -48,6 +51,117 @@ const SVELTE_RUNE_GLOBALS = { $host: 'readonly', } satisfies Record; +/** Resolve JSON inheritance once, during migration. Keep the tree so Oxlint + * still controls rule aliases, category precedence, plugins, and overrides. + */ +export function resolveOxlintConfigExtends(configPath: string): OxlintConfig { + const rootDir = path.dirname(path.resolve(configPath)); + const inheritedJsPlugins: NonNullable = []; + let pluginResolver: ResolverFactory | undefined; + + function visit(filename: string, stack: string[]): OxlintConfig { + const canonicalPath = fs.realpathSync(filename); + if (stack.includes(canonicalPath)) { + throw new Error(`Circular Oxlint extends: ${[...stack, canonicalPath].join(' -> ')}`); + } + const errors: ParseError[] = []; + const config = parseJsonc(fs.readFileSync(filename, 'utf8'), errors, { + allowTrailingComma: true, + }) as Record; + if (errors.length > 0 || !config || typeof config !== 'object' || Array.isArray(config)) { + throw new Error(`Invalid Oxlint JSON config: ${filename}`); + } + delete config.$schema; + if (config.extends !== undefined) { + if ( + !Array.isArray(config.extends) || + config.extends.some((entry) => typeof entry !== 'string') + ) { + throw new Error(`Expected an array of file paths in ${filename}: extends`); + } + config.extends = config.extends.map((entry: string) => + visit(path.resolve(path.dirname(filename), entry), [...stack, canonicalPath]), + ); + } + if (stack.length > 0) { + const lintConfig = config as OxlintConfig; + for (const layer of [lintConfig, ...(lintConfig.overrides ?? [])]) { + if (!layer.jsPlugins) { + continue; + } + layer.jsPlugins = layer.jsPlugins.filter((entry) => { + const specifier = typeof entry === 'string' ? entry : entry.specifier; + if (!/^\.{1,2}(?:[/\\]|$)/.test(specifier)) { + // An object extends has no origin directory. Do not silently load a + // different version of a package-local plugin after moving it. + if (!path.isAbsolute(specifier) && path.dirname(filename) !== rootDir) { + pluginResolver ??= new ResolverFactory({ + conditionNames: ['module-sync', 'node', 'import'], + }); + const original = pluginResolver.sync(path.dirname(filename), specifier); + const migrated = pluginResolver.sync(rootDir, specifier); + if (original.path && original.path !== migrated.path) { + throw new Error( + `JS plugin "${specifier}" in ${filename} resolves differently from the Vite config directory; migrate this plugin manually`, + ); + } + } + return true; + } + // Oxlint loads JS plugins globally, even those declared in overrides. + // Hoist relative paths to the root, where they remain supported. Keep + // them relative: absolute paths change string plugins' inferred names. + const relative = path + .relative(rootDir, path.resolve(path.dirname(filename), specifier)) + .split(path.sep) + .join('/'); + if (path.isAbsolute(relative)) { + throw new Error(`Cannot rebase JS plugin "${specifier}" in ${filename} across drives`); + } + const rebased = relative.startsWith('../') ? relative : `./${relative}`; + inheritedJsPlugins.push( + typeof entry === 'string' ? rebased : { ...entry, specifier: rebased }, + ); + return false; + }); + if (layer.jsPlugins.length === 0) { + delete layer.jsPlugins; + } + } + } + return config; + } + + const config = visit(configPath, []); + if (inheritedJsPlugins.length > 0) { + config.jsPlugins = [...(config.jsPlugins ?? []), ...inheritedJsPlugins]; + } + return config; +} + +function oxlintConfigLayers(config: OxlintConfig): OxlintConfig[] { + return [...(config.extends ?? []).flatMap(oxlintConfigLayers), config]; +} + +// Resolve every workspace entry before merging any of them: a root or sibling +// .oxlintrc.json can itself be an extends target and be deleted by its migration. +export function prepareOxlintConfigs(projectPaths: string[]): Map { + const configs = new Map(); + for (const projectPath of projectPaths) { + const filename = detectConfigs(projectPath).oxlintConfig; + if (!filename) { + continue; + } + const configPath = path.join(projectPath, filename); + try { + configs.set(configPath, resolveOxlintConfigExtends(configPath)); + } catch (error) { + configs.set(configPath, error instanceof Error ? error : new Error(String(error))); + } + } + return configs; +} + // Add Svelte's built-in rune globals to migrated Svelte overrides. // https://github.com/oxc-project/oxc/issues/20191 export function ensureSvelteRuneGlobals(config: OxlintConfig): void { @@ -251,6 +365,7 @@ export function mergeViteConfigFiles( // (correct for the top-level standalone/monorepo callers, where // projectPath IS the workspace root). workspaceRoot?: string, + preparedOxlintConfigs?: Map, ): void { const configs = detectConfigs(projectPath); if (!configs.oxfmtConfig && !configs.oxlintConfig) { @@ -258,47 +373,112 @@ export function mergeViteConfigFiles( } const viteConfig = ensureViteConfig(projectPath, configs, silent, report); if (configs.oxlintConfig) { - // Inject options.typeAware and options.typeCheck defaults before merging - const fullOxlintPath = path.join(projectPath, configs.oxlintConfig); - const oxlintJson = readJsonFile(fullOxlintPath, true) as OxlintConfig; - if (!oxlintJson.options) { - oxlintJson.options = {}; + mergeOxlintConfigFile( + projectPath, + viteConfig, + configs.oxlintConfig, + silent, + report, + packages, + workspaceRoot, + preparedOxlintConfigs, + ); + } + if (configs.oxfmtConfig) { + // merge oxfmt config into vite.config.ts + mergeAndRemoveJsonConfig(projectPath, viteConfig, configs.oxfmtConfig, 'fmt', silent, report); + } +} + +function mergeOxlintConfigFile( + projectPath: string, + viteConfig: string, + oxlintConfig: string, + silent: boolean, + report?: MigrationReport, + packages?: WorkspacePackage[], + workspaceRoot?: string, + preparedOxlintConfigs?: Map, +): void { + // Inject options.typeAware and options.typeCheck defaults before merging + const fullOxlintPath = path.join(projectPath, oxlintConfig); + let oxlintJson: OxlintConfig; + try { + const prepared = preparedOxlintConfigs?.get(fullOxlintPath); + if (prepared instanceof Error) { + throw prepared; } - // Skip typeAware/typeCheck when tsconfig.json has baseUrl (unsupported by tsgolint) - if (!hasBaseUrlInTsconfig(projectPath)) { - if (oxlintJson.options.typeAware === undefined) { - oxlintJson.options.typeAware = true; - } - if (oxlintJson.options.typeCheck === undefined) { - oxlintJson.options.typeCheck = true; - } - } else { - warnMigration(BASEURL_TSCONFIG_WARNING, report); + oxlintJson = prepared ?? resolveOxlintConfigExtends(fullOxlintPath); + } catch (error) { + warnMigration( + `Could not migrate ${displayRelative(fullOxlintPath)}: ${String(error)}. The original config was preserved; use vp lint -c ${oxlintConfig} until it is migrated manually.`, + report, + ); + return; + } + // Never discard an inheritance tree when a lint block already exists. + if (oxlintJson.extends?.length && hasConfigKey(path.join(projectPath, viteConfig), 'lint')) { + warnMigration( + `Preserved ${displayRelative(fullOxlintPath)} because ${viteConfig} already has a lint config. Merge its inherited rules manually.`, + report, + ); + return; + } + const layers = oxlintConfigLayers(oxlintJson); + const inheritedOptions: NonNullable = {}; + for (const layer of layers) { + Object.assign(inheritedOptions, layer.options); + } + if (!oxlintJson.options) { + oxlintJson.options = {}; + } + // Skip typeAware/typeCheck when tsconfig.json has baseUrl (unsupported by tsgolint) + if (!hasBaseUrlInTsconfig(projectPath)) { + if (inheritedOptions.typeAware === undefined) { + oxlintJson.options.typeAware = true; + } + if (inheritedOptions.typeCheck === undefined) { + oxlintJson.options.typeCheck = true; } - // Drop references to plugins / jsPlugins / rules that won't resolve - // at lint time (e.g. `@oxlint/migrate` translating `@unocss/eslint-config` - // → `eslint-plugin-unocss` even when that package isn't installed). - // Resolve workspace package paths against `workspaceRoot` when the - // caller is processing a sub-package — otherwise the sanitizer would - // mistakenly look for `subPath/` and miss the - // hoisted deps it's supposed to see. + } else { + warnMigration(BASEURL_TSCONFIG_WARNING, report); + } + // Drop references to plugins / jsPlugins / rules that won't resolve + // at lint time (e.g. `@oxlint/migrate` translating `@unocss/eslint-config` + // → `eslint-plugin-unocss` even when that package isn't installed). + // Resolve workspace package paths against `workspaceRoot` when the + // caller is processing a sub-package — otherwise the sanitizer would + // mistakenly look for `subPath/` and miss the + // hoisted deps it's supposed to see. + // A pre-existing inheritance tree can declare JS plugins in another layer, + // including local plugins whose namespace is only known at runtime. The + // ESLint migration sanitizer cannot validate these rules independently. + if (!oxlintJson.extends?.length) { sanitizeMigratedOxlintConfig( oxlintJson, collectInstalledPackageNames(workspaceRoot ?? projectPath, packages), report, ); - ensureSvelteRuneGlobals(oxlintJson); - const normalizedOxlintConfig = ensureVitePlusImportRuleDefaults(oxlintJson); - // writeJsonFile preserves the user file's existing indent/newline (and adds a - // trailing newline) instead of forcing 2-space + no EOL. - writeJsonFile(fullOxlintPath, normalizedOxlintConfig as Record); - // merge oxlint config into vite.config.ts - mergeAndRemoveJsonConfig(projectPath, viteConfig, configs.oxlintConfig, 'lint', silent, report); } - if (configs.oxfmtConfig) { - // merge oxfmt config into vite.config.ts - mergeAndRemoveJsonConfig(projectPath, viteConfig, configs.oxfmtConfig, 'fmt', silent, report); + for (const layer of layers) { + ensureSvelteRuneGlobals(layer); + } + const inheritedImportRule = layers.findLast( + (layer) => layer.rules?.[PREFER_VITE_PLUS_IMPORTS_RULE] !== undefined, + )?.rules?.[PREFER_VITE_PLUS_IMPORTS_RULE]; + const normalizedOxlintConfig = ensureVitePlusImportRuleDefaults(oxlintJson); + if (inheritedImportRule !== undefined) { + normalizedOxlintConfig.rules![PREFER_VITE_PLUS_IMPORTS_RULE] = inheritedImportRule; } + mergeAndRemoveJsonConfig( + projectPath, + viteConfig, + oxlintConfig, + 'lint', + silent, + report, + JSON.stringify(normalizedOxlintConfig, null, 2), + ); } /** @@ -311,6 +491,11 @@ export function injectLintTypeCheckDefaults( silent = false, report?: MigrationReport, ): void { + // An unresolved JSON inheritance tree needs manual attention. Do not mask it + // with a new lint block, which would also prevent a later migration retry. + if (detectConfigs(projectPath).oxlintConfig) { + return; + } if (hasBaseUrlInTsconfig(projectPath)) { warnMigration(BASEURL_TSCONFIG_WARNING, report); return; @@ -406,6 +591,7 @@ function mergeAndRemoveJsonConfig( configKey: string, silent = false, report?: MigrationReport, + configContent?: string, ): void { const fullViteConfigPath = path.join(projectPath, viteConfigPath); const fullJsonConfigPath = path.join(projectPath, jsonConfigPath); @@ -424,7 +610,21 @@ function mergeAndRemoveJsonConfig( } return; } - const result = mergeJsonConfig(fullViteConfigPath, fullJsonConfigPath, configKey); + // Only delete the original JSON after a successful merge. Resolved lint + // configs contain object extends, which JSON Oxlint configs do not accept. + let result; + if (configContent === undefined) { + result = mergeJsonConfig(fullViteConfigPath, fullJsonConfigPath, configKey); + } else { + const tempDir = fs.mkdtempSync(path.join(projectPath, '.vite-plus-lint-')); + try { + const tempConfig = path.join(tempDir, 'config.json'); + fs.writeFileSync(tempConfig, configContent); + result = mergeJsonConfig(fullViteConfigPath, tempConfig, configKey); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + } if (result.updated) { fs.writeFileSync(fullViteConfigPath, result.content); fs.unlinkSync(fullJsonConfigPath); From 0f1cf02e2b5349e6711b06b1b4e57dfa4ea02da0 Mon Sep 17 00:00:00 2001 From: MK Date: Sat, 17 Oct 2026 15:43:23 +0800 Subject: [PATCH 02/24] test(migrate): snapshot lint output directly --- .../migration_oxlint_extends/.oxlintrc.json | 2 +- .../migration_oxlint_extends/snapshots.toml | 8 +- .../snapshots/migration_oxlint_extends.md | 167 +++++++++++++++--- .../migration_oxlint_extends/verify-lint.mjs | 33 ---- 4 files changed, 148 insertions(+), 62 deletions(-) delete mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends/verify-lint.mjs diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends/.oxlintrc.json b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends/.oxlintrc.json index 741ac15bbd..d35c8e9638 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends/.oxlintrc.json +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends/.oxlintrc.json @@ -9,6 +9,6 @@ }, "ignorePatterns": [ "shared/**", - "verify-lint.mjs" + "vite.config.ts" ] } diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends/snapshots.toml index bb0eeaea7a..9d030a40f6 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends/snapshots.toml @@ -3,15 +3,17 @@ name = "migration_oxlint_extends" vp = "local" local-registry = true unset-env = ["VP_SKIP_INSTALL"] +# Use one lint thread so diagnostic order is stable across the three runs. +# Ignore the generated Vite config so each run checks the same source files. steps = [ - { argv = ["node", "verify-lint.mjs", "--before"], comment = "Original inherited errors, warnings, and overrides" }, + { argv = ["vp", "lint", "--threads=1", "-c", ".oxlintrc.json"], comment = "Original inherited errors, warnings, and overrides", continue-on-failure = true }, { argv = ["vp", "migrate", "--no-interactive", "--no-agent", "--no-editor", "--no-hooks"] }, { argv = ["vpt", "print-file", "vite.config.ts"], comment = "Path extends become config objects; relative plugins move to the root lint config" }, { argv = ["vpt", "stat-file", ".oxlintrc.json", "--assert-not", "file"] }, { argv = ["vpt", "print-file", "shared/second.jsonc"], comment = "Shared JSON configs are retained" }, { argv = ["vp", "exec", "tsc", "--noEmit"], comment = "Generated config types accept the resolved inheritance" }, - { argv = ["node", "verify-lint.mjs"], comment = "Plain vp lint preserves inherited severity and override matching" }, + { argv = ["vp", "lint", "--threads=1"], comment = "The diagnostics and exit code match the original config without a config flag", continue-on-failure = true }, { argv = ["vp", "migrate", "--no-interactive", "--no-agent", "--no-editor", "--no-hooks"] }, { argv = ["vpt", "print-file", "vite.config.ts"], comment = "A second migration leaves the config unchanged" }, - { argv = ["node", "verify-lint.mjs"], comment = "Inherited behavior survives the second migration" }, + { argv = ["vp", "lint", "--threads=1"], comment = "The diagnostics and exit code remain unchanged after the second migration", continue-on-failure = true }, ] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends/snapshots/migration_oxlint_extends.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends/snapshots/migration_oxlint_extends.md index a96dd2d273..a126796033 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends/snapshots/migration_oxlint_extends.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends/snapshots/migration_oxlint_extends.md @@ -1,16 +1,55 @@ # migration_oxlint_extends -## `node verify-lint.mjs --before` +## `vp lint --threads=1 -c .oxlintrc.json` Original inherited errors, warnings, and overrides +**Exit code:** 1 + ``` -vp lint -c .oxlintrc.json: 3 errors and 2 warnings -example.test.js: error override(no-bar) -example.test.js: warning eslint(no-debugger) -index.js: error eslint(no-debugger) -index.js: error local(no-foo) -index.js: warning eslint(no-console) + + ⚠ eslint(no-debugger): `debugger` statement is not allowed + ╭─[example.test.js:1:1] + 1 │ debugger; + · ───────── + 2 │ export const bar = 1; + ╰──── + help: Remove the debugger statement + + × override(no-bar): Do not use bar. + ╭─[example.test.js:2:14] + 1 │ debugger; + 2 │ export const bar = 1; + · ─── + ╰──── + + × eslint(no-debugger): `debugger` statement is not allowed + ╭─[index.js:1:1] + 1 │ debugger; + · ───────── + 2 │ console.log("test"); + ╰──── + help: Remove the debugger statement + + ⚠ eslint(no-console): Unexpected console statement. + ╭─[index.js:2:1] + 1 │ debugger; + 2 │ console.log("test"); + · ─────────── + 3 │ alert("allowed"); + ╰──── + help: Delete this console statement. + + × local(no-foo): Do not use foo. + ╭─[index.js:4:14] + 3 │ alert("allowed"); + 4 │ export const foo = 1; + · ─── + 5 │ export const bar = 1; + ╰──── + +Found 2 warnings and 3 errors. +Finished in on 2 files with rules using threads. ``` ## `vp migrate --no-interactive --no-agent --no-editor --no-hooks` @@ -72,7 +111,7 @@ export default defineConfig({ "local/no-foo": "error", "vite-plus/prefer-vite-plus-imports": "error", }, - ignorePatterns: ["shared/**", "verify-lint.mjs"], + ignorePatterns: ["shared/**", "vite.config.ts"], jsPlugins: [ { name: "override", @@ -114,17 +153,56 @@ Generated config types accept the resolved inheritance ``` ``` -## `node verify-lint.mjs` +## `vp lint --threads=1` + +The diagnostics and exit code match the original config without a config flag -Plain vp lint preserves inherited severity and override matching +**Exit code:** 1 ``` -vp lint: 3 errors and 2 warnings -example.test.js: error override(no-bar) -example.test.js: warning eslint(no-debugger) -index.js: error eslint(no-debugger) -index.js: error local(no-foo) -index.js: warning eslint(no-console) + + ⚠ eslint(no-debugger): `debugger` statement is not allowed + ╭─[example.test.js:1:1] + 1 │ debugger; + · ───────── + 2 │ export const bar = 1; + ╰──── + help: Remove the debugger statement + + × override(no-bar): Do not use bar. + ╭─[example.test.js:2:14] + 1 │ debugger; + 2 │ export const bar = 1; + · ─── + ╰──── + + × eslint(no-debugger): `debugger` statement is not allowed + ╭─[index.js:1:1] + 1 │ debugger; + · ───────── + 2 │ console.log("test"); + ╰──── + help: Remove the debugger statement + + ⚠ eslint(no-console): Unexpected console statement. + ╭─[index.js:2:1] + 1 │ debugger; + 2 │ console.log("test"); + · ─────────── + 3 │ alert("allowed"); + ╰──── + help: Delete this console statement. + + × local(no-foo): Do not use foo. + ╭─[index.js:4:14] + 3 │ alert("allowed"); + 4 │ export const foo = 1; + · ─── + 5 │ export const bar = 1; + ╰──── + +Found 2 warnings and 3 errors. +Finished in on 2 files with rules using threads. ``` ## `vp migrate --no-interactive --no-agent --no-editor --no-hooks` @@ -182,7 +260,7 @@ export default defineConfig({ "local/no-foo": "error", "vite-plus/prefer-vite-plus-imports": "error", }, - ignorePatterns: ["shared/**", "verify-lint.mjs"], + ignorePatterns: ["shared/**", "vite.config.ts"], jsPlugins: [ { name: "override", @@ -199,15 +277,54 @@ export default defineConfig({ }); ``` -## `node verify-lint.mjs` +## `vp lint --threads=1` -Inherited behavior survives the second migration +The diagnostics and exit code remain unchanged after the second migration + +**Exit code:** 1 ``` -vp lint: 3 errors and 2 warnings -example.test.js: error override(no-bar) -example.test.js: warning eslint(no-debugger) -index.js: error eslint(no-debugger) -index.js: error local(no-foo) -index.js: warning eslint(no-console) + + ⚠ eslint(no-debugger): `debugger` statement is not allowed + ╭─[example.test.js:1:1] + 1 │ debugger; + · ───────── + 2 │ export const bar = 1; + ╰──── + help: Remove the debugger statement + + × override(no-bar): Do not use bar. + ╭─[example.test.js:2:14] + 1 │ debugger; + 2 │ export const bar = 1; + · ─── + ╰──── + + × eslint(no-debugger): `debugger` statement is not allowed + ╭─[index.js:1:1] + 1 │ debugger; + · ───────── + 2 │ console.log("test"); + ╰──── + help: Remove the debugger statement + + ⚠ eslint(no-console): Unexpected console statement. + ╭─[index.js:2:1] + 1 │ debugger; + 2 │ console.log("test"); + · ─────────── + 3 │ alert("allowed"); + ╰──── + help: Delete this console statement. + + × local(no-foo): Do not use foo. + ╭─[index.js:4:14] + 3 │ alert("allowed"); + 4 │ export const foo = 1; + · ─── + 5 │ export const bar = 1; + ╰──── + +Found 2 warnings and 3 errors. +Finished in on 2 files with rules using threads. ``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends/verify-lint.mjs b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends/verify-lint.mjs deleted file mode 100644 index 1bf31e2984..0000000000 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends/verify-lint.mjs +++ /dev/null @@ -1,33 +0,0 @@ -import assert from 'node:assert/strict'; -import { spawnSync } from 'node:child_process'; -import { fileURLToPath } from 'node:url'; -import { stripVTControlCharacters } from 'node:util'; - -// Execute the same JS entry point as the local vp binary on every platform. -const cli = fileURLToPath(import.meta.resolve('vite-plus/bin')); -const args = process.argv.includes('--before') ? ['lint', '-c', '.oxlintrc.json'] : ['lint']; -function lint(extraArgs = []) { - const result = spawnSync(process.execPath, [cli, ...args, ...extraArgs], { - encoding: 'utf8', timeout: 30000, - }); - assert.ifError(result.error); - assert.equal(result.status, 1, result.stdout + result.stderr); - return result; -} - -// Exercise plain `vp lint` after migration, without selecting a config or adding flags. -const plain = lint(); -assert.match(stripVTControlCharacters(plain.stdout + plain.stderr), /Found 2 warnings and 3 errors/); - -// Compare each rule's severity and file without depending on worker completion order. -const { diagnostics } = JSON.parse(lint(['--format', 'json']).stdout); -const actual = diagnostics.map(({ filename, code, severity }) => `${filename}: ${severity} ${code}`).sort(); -assert.deepEqual(actual, [ - 'example.test.js: error override(no-bar)', - 'example.test.js: warning eslint(no-debugger)', - 'index.js: error eslint(no-debugger)', - 'index.js: error local(no-foo)', - 'index.js: warning eslint(no-console)', -]); -console.log(`vp ${args.join(' ')}: 3 errors and 2 warnings`); -console.log(actual.join('\n')); From ba6d2a2fe63a0bf055adef7b1444d280279725e1 Mon Sep 17 00:00:00 2001 From: MK Date: Sun, 18 Oct 2026 00:54:57 +0800 Subject: [PATCH 03/24] fix(migrate): import shared Oxlint configs --- .../migration_oxlint_extends/snapshots.toml | 7 +- .../snapshots/migration_oxlint_extends.md | 216 ++++++++------ docs/guide/migrate-rules.md | 8 +- .../__tests__/oxlint-extends.spec.ts | 204 +++++++++++--- packages/cli/src/migration/migrator.ts | 1 + packages/cli/src/migration/migrator/README.md | 1 + .../cli/src/migration/migrator/catalog.ts | 4 +- .../src/migration/migrator/oxlint-config.ts | 263 ++++++++++++++++++ packages/cli/src/migration/migrator/shared.ts | 15 + .../cli/src/migration/migrator/vite-config.ts | 198 +++++-------- 10 files changed, 668 insertions(+), 249 deletions(-) create mode 100644 packages/cli/src/migration/migrator/oxlint-config.ts diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends/snapshots.toml index 9d030a40f6..16495721f2 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends/snapshots.toml @@ -8,7 +8,10 @@ unset-env = ["VP_SKIP_INSTALL"] steps = [ { argv = ["vp", "lint", "--threads=1", "-c", ".oxlintrc.json"], comment = "Original inherited errors, warnings, and overrides", continue-on-failure = true }, { argv = ["vp", "migrate", "--no-interactive", "--no-agent", "--no-editor", "--no-hooks"] }, - { argv = ["vpt", "print-file", "vite.config.ts"], comment = "Path extends become config objects; relative plugins move to the root lint config" }, + { argv = ["vpt", "print-file", "vite.config.ts"], comment = "Path extends become imports of shared TypeScript configs" }, + { argv = ["vpt", "print-file", "shared/nested/first.ts"], comment = "Nested imports preserve overrides and their plugin declarations" }, + { argv = ["vpt", "print-file", "shared/base.ts"] }, + { argv = ["vpt", "print-file", "shared/second.ts"], comment = "Relative plugin paths resolve from the shared module" }, { argv = ["vpt", "stat-file", ".oxlintrc.json", "--assert-not", "file"] }, { argv = ["vpt", "print-file", "shared/second.jsonc"], comment = "Shared JSON configs are retained" }, { argv = ["vp", "exec", "tsc", "--noEmit"], comment = "Generated config types accept the resolved inheritance" }, @@ -16,4 +19,6 @@ steps = [ { argv = ["vp", "migrate", "--no-interactive", "--no-agent", "--no-editor", "--no-hooks"] }, { argv = ["vpt", "print-file", "vite.config.ts"], comment = "A second migration leaves the config unchanged" }, { argv = ["vp", "lint", "--threads=1"], comment = "The diagnostics and exit code remain unchanged after the second migration", continue-on-failure = true }, + { argv = ["vpt", "replace-file-content", "shared/base.ts", '"no-debugger": "error"', '"no-debugger": "warn"'], comment = "Edits to the shared TS config propagate through its imports" }, + { argv = ["vp", "lint", "--threads=1"], comment = "The edited base rule now warns in index.js; the test override remains a warning", continue-on-failure = true }, ] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends/snapshots/migration_oxlint_extends.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends/snapshots/migration_oxlint_extends.md index a126796033..b92a3f84b0 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends/snapshots/migration_oxlint_extends.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends/snapshots/migration_oxlint_extends.md @@ -67,67 +67,92 @@ VITE+ - The Unified Toolchain for the Web ## `vpt print-file vite.config.ts` -Path extends become config objects; relative plugins move to the root lint config +Path extends become imports of shared TypeScript configs ``` +import oxlintConfig1 from "./shared/nested/first.js"; +import oxlintConfig2 from "./shared/second.js"; import { defineConfig } from "vite-plus"; export default defineConfig({ fmt: {}, lint: { - extends: [ - { - extends: [ - { - options: { - typeAware: false, - typeCheck: false, - }, - rules: { - "no-debugger": "error", - "no-console": "error", - "no-alert": "error", - }, - }, - ], - overrides: [ - { - files: ["**/*.test.js"], - rules: { - "no-debugger": "warn", - "override/no-bar": "error", - }, - }, - ], - }, - { - rules: { - "no-console": "warn", - }, - }, - ], + extends: [oxlintConfig1, oxlintConfig2], rules: { "no-alert": "off", "local/no-foo": "error", "vite-plus/prefer-vite-plus-imports": "error", }, ignorePatterns: ["shared/**", "vite.config.ts"], - jsPlugins: [ - { - name: "override", - specifier: "./shared/override-plugin.mjs", - }, - "./shared/plugin.mjs", - { - name: "vite-plus", - specifier: "vite-plus/oxlint-plugin", - }, - ], options: {}, + jsPlugins: [{ name: "vite-plus", specifier: "vite-plus/oxlint-plugin" }], }, }); ``` +## `vpt print-file shared/nested/first.ts` + +Nested imports preserve overrides and their plugin declarations + +``` +import { fileURLToPath } from "node:url"; +import type { OxlintConfig } from "vite-plus/lint"; +import oxlintConfig1 from "../base.js"; + +export default { + extends: [oxlintConfig1], + overrides: [ + { + files: ["**/*.test.js"], + rules: { + "no-debugger": "warn", + "override/no-bar": "error", + }, + jsPlugins: [ + { + name: "override", + specifier: fileURLToPath(new URL("../override-plugin.mjs", import.meta.url)), + }, + ], + }, + ], +} satisfies OxlintConfig; +``` + +## `vpt print-file shared/base.ts` + +``` +import type { OxlintConfig } from "vite-plus/lint"; + +export default { + options: { + typeAware: false, + typeCheck: false, + }, + rules: { + "no-debugger": "error", + "no-console": "error", + "no-alert": "error", + }, +} satisfies OxlintConfig; +``` + +## `vpt print-file shared/second.ts` + +Relative plugin paths resolve from the shared module + +``` +import { fileURLToPath } from "node:url"; +import type { OxlintConfig } from "vite-plus/lint"; + +export default { + rules: { + "no-console": "warn", + }, + jsPlugins: [fileURLToPath(new URL("./plugin.mjs", import.meta.url))], +} satisfies OxlintConfig; +``` + ## `vpt stat-file .oxlintrc.json --assert-not file` ``` @@ -219,60 +244,22 @@ This project is already using Vite+! Happy coding! A second migration leaves the config unchanged ``` +import oxlintConfig1 from "./shared/nested/first.js"; +import oxlintConfig2 from "./shared/second.js"; import { defineConfig } from "vite-plus"; export default defineConfig({ fmt: {}, lint: { - extends: [ - { - extends: [ - { - options: { - typeAware: false, - typeCheck: false, - }, - rules: { - "no-debugger": "error", - "no-console": "error", - "no-alert": "error", - }, - }, - ], - overrides: [ - { - files: ["**/*.test.js"], - rules: { - "no-debugger": "warn", - "override/no-bar": "error", - }, - }, - ], - }, - { - rules: { - "no-console": "warn", - }, - }, - ], + extends: [oxlintConfig1, oxlintConfig2], rules: { "no-alert": "off", "local/no-foo": "error", "vite-plus/prefer-vite-plus-imports": "error", }, ignorePatterns: ["shared/**", "vite.config.ts"], - jsPlugins: [ - { - name: "override", - specifier: "./shared/override-plugin.mjs", - }, - "./shared/plugin.mjs", - { - name: "vite-plus", - specifier: "vite-plus/oxlint-plugin", - }, - ], options: {}, + jsPlugins: [{ name: "vite-plus", specifier: "vite-plus/oxlint-plugin" }], }, }); ``` @@ -328,3 +315,62 @@ The diagnostics and exit code remain unchanged after the second migration Found 2 warnings and 3 errors. Finished in on 2 files with rules using threads. ``` + +## `vpt replace-file-content shared/base.ts '"no-debugger": "error"' '"no-debugger": "warn"'` + +Edits to the shared TS config propagate through its imports + +``` +``` + +## `vp lint --threads=1` + +The edited base rule now warns in index.js; the test override remains a warning + +**Exit code:** 1 + +``` + + ⚠ eslint(no-debugger): `debugger` statement is not allowed + ╭─[example.test.js:1:1] + 1 │ debugger; + · ───────── + 2 │ export const bar = 1; + ╰──── + help: Remove the debugger statement + + × override(no-bar): Do not use bar. + ╭─[example.test.js:2:14] + 1 │ debugger; + 2 │ export const bar = 1; + · ─── + ╰──── + + ⚠ eslint(no-debugger): `debugger` statement is not allowed + ╭─[index.js:1:1] + 1 │ debugger; + · ───────── + 2 │ console.log("test"); + ╰──── + help: Remove the debugger statement + + ⚠ eslint(no-console): Unexpected console statement. + ╭─[index.js:2:1] + 1 │ debugger; + 2 │ console.log("test"); + · ─────────── + 3 │ alert("allowed"); + ╰──── + help: Delete this console statement. + + × local(no-foo): Do not use foo. + ╭─[index.js:4:14] + 3 │ alert("allowed"); + 4 │ export const foo = 1; + · ─── + 5 │ export const bar = 1; + ╰──── + +Found 3 warnings and 2 errors. +Finished in on 2 files with rules using threads. +``` diff --git a/docs/guide/migrate-rules.md b/docs/guide/migrate-rules.md index f5e8cde22e..03538fc8b8 100644 --- a/docs/guide/migrate-rules.md +++ b/docs/guide/migrate-rules.md @@ -25,11 +25,13 @@ When a default upgrade skips setup actions that would apply, it prints a hint to ## Oxlint JSON Inheritance -When `.oxlintrc.json` or `.oxlintrc.jsonc` uses `extends`, `vp migrate` resolves each file path relative to the config that declares it. It copies the inherited configs into `lint.extends` as objects, including nested inheritance. Oxlint applies the existing merge order, rule severities, and overrides. Plain `vp lint` uses the migrated rules without a `-c` flag. +When `.oxlintrc.json` or `.oxlintrc.jsonc` uses `extends`, `vp migrate` resolves each file path relative to the config that declares it. It creates shared TypeScript configs and imports them through `lint.extends`, including nested inheritance. Oxlint applies the existing merge order, rule severities, and overrides. Plain `vp lint` uses the migrated rules without a `-c` flag. -Shared JSON and JSONC files are retained. The generated config contains a copy of their settings; later edits to the shared files do not update that copy. Relative JS-plugin declarations move to `lint.jsPlugins`, with their paths rebased to the Vite config directory. Explicit inherited `typeAware` and `typeCheck` settings take precedence over migration defaults. +Shared configs become sibling `.ts` files, or `.mts` files outside ESM packages. Generated TypeScript configs use `.js` or `.mjs` import specifiers, which Vite and TypeScript resolve to the generated source files without changing compiler settings. Workspace consumers reuse the same generated modules. Edit these TypeScript files to update shared settings. Original shared JSON and JSONC files remain available to other consumers; edits to those originals do not update the migrated configs. -Missing files, invalid JSON, inheritance cycles, and package-local plugins that resolve differently from the Vite config directory produce a warning and leave the original config in place. An existing `lint` block also requires a manual merge. The root JSON config is removed only after a successful migration. `vp lint` continues to reject string paths in `lint.extends`; path resolution happens only during migration. +JS-plugin declarations stay in their original config or override. Relative plugin paths become absolute paths at runtime through `fileURLToPath(new URL(..., import.meta.url))`. Explicit inherited `typeAware` and `typeCheck` settings take precedence over migration defaults. + +Missing files, invalid JSON, inheritance cycles, conflicting output files, and package-local plugins that resolve differently from the Vite config directory produce a warning and leave the original config in place. Configs outside the project or workspace, configs inside `node_modules`, CommonJS Vite configs, and an existing `lint` block require a manual merge. The root JSON config is removed only after a successful migration. `vp lint` continues to reject string paths in `lint.extends`; path resolution happens only during migration. ## Pack Configuration diff --git a/packages/cli/src/migration/__tests__/oxlint-extends.spec.ts b/packages/cli/src/migration/__tests__/oxlint-extends.spec.ts index 49820830c4..7bf03036d7 100644 --- a/packages/cli/src/migration/__tests__/oxlint-extends.spec.ts +++ b/packages/cli/src/migration/__tests__/oxlint-extends.spec.ts @@ -1,9 +1,9 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; -import { pathToFileURL } from 'node:url'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { loadConfigFromFile } from 'vite'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { PackageManager } from '../../types/index.ts'; import { @@ -24,8 +24,18 @@ describe('Oxlint JSON extends migration', () => { return filenamePath; } + async function loadConfig(directory = project) { + const result = await loadConfigFromFile( + { command: 'build', mode: 'test' }, + path.join(directory, 'vite.config.mjs'), + directory, + 'silent', + ); + return result!.config; + } + beforeEach(() => { - project = fs.mkdtempSync(path.join(os.tmpdir(), 'vp-oxlint-extends-')); + project = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'vp-oxlint-extends-'))); write('package.json', { name: 'extends-test', type: 'module' }); write('vite.config.mjs', 'export default {};'); }); @@ -58,7 +68,7 @@ describe('Oxlint JSON extends migration', () => { const report = createMigrationReport(); mergeViteConfigFiles(project, true, report); const configPath = path.join(project, 'vite.config.mjs'); - const { default: config } = await import(pathToFileURL(configPath).href); + const config = await loadConfig(); expect(config.lint).toMatchObject({ extends: [ { @@ -86,17 +96,130 @@ describe('Oxlint JSON extends migration', () => { expect(report.mergedConfigCount).toBe(1); }); + it('keeps shared module edits live through nested imports', async () => { + write('base.json', { rules: { 'no-debugger': 'warn' } }); + write('first.json', { extends: ['./base.json'] }); + write('second.json', { extends: ['./base.json'] }); + write('.oxlintrc.json', { extends: ['./first.json', './second.json'] }); + mergeViteConfigFiles(project, true); + + const modulePath = path.join(project, 'base.ts'); + expect(fs.readFileSync(path.join(project, 'first.ts'), 'utf8')).toContain('./base.js'); + expect(fs.readFileSync(path.join(project, 'second.ts'), 'utf8')).toContain('./base.js'); + fs.writeFileSync(modulePath, fs.readFileSync(modulePath, 'utf8').replace('"warn"', '"error"')); + const config = await loadConfig(); + for (const branch of config.lint!.extends!) { + expect(branch.extends![0].rules!['no-debugger']).toBe('error'); + } + }); + + it.each(['base.ts', 'base.js'])('preserves configs when %s already exists', (filename) => { + write('base.json', { rules: { 'no-debugger': 'warn' } }); + const original = 'export default { rules: { "no-debugger": "off" } };'; + write(filename, original); + write('.oxlintrc.json', { extends: ['./base.json'] }); + const report = createMigrationReport(); + mergeViteConfigFiles(project, true, report); + expect(fs.readFileSync(path.join(project, filename), 'utf8')).toBe(original); + expect(fs.existsSync(path.join(project, '.oxlintrc.json'))).toBe(true); + expect(fs.readFileSync(path.join(project, 'vite.config.mjs'), 'utf8')).toBe( + 'export default {};', + ); + expect(report.warnings[0]).toContain('already exists'); + }); + + it('does not create partial modules when two JSON sources map to the same TS file', () => { + write('base.json', { rules: { 'no-debugger': 'warn' } }); + write('base.jsonc', { rules: { 'no-console': 'warn' } }); + write('.oxlintrc.json', { extends: ['./base.json', './base.jsonc'] }); + const report = createMigrationReport(); + mergeViteConfigFiles(project, true, report); + expect(report.warnings[0]).toContain('Multiple Oxlint configs'); + expect(fs.existsSync(path.join(project, 'base.ts'))).toBe(false); + expect(fs.existsSync(path.join(project, '.oxlintrc.json'))).toBe(true); + }); + + it('avoids import binding conflicts and preserves existing config content', async () => { + write( + 'vite.config.mjs', + 'const oxlintConfig1 = "original"; export default { custom: oxlintConfig1 };', + ); + write('base.json', { rules: { 'no-debugger': 'warn' } }); + write('.oxlintrc.json', { extends: ['./base.json'] }); + mergeViteConfigFiles(project, true); + expect(fs.readFileSync(path.join(project, 'vite.config.mjs'), 'utf8')).toContain( + 'import oxlintConfig2', + ); + expect(await loadConfig()).toHaveProperty('custom', 'original'); + }); + + it('generates ESM .mts modules in a project without type: module', async () => { + write('package.json', { name: 'commonjs-project' }); + write('base.json', { jsPlugins: ['./plugin.mjs'], rules: { 'no-debugger': 'warn' } }); + write('.oxlintrc.json', { extends: ['./base.json'] }); + mergeViteConfigFiles(project, true); + expect(fs.existsSync(path.join(project, 'base.mts'))).toBe(true); + expect(fs.readFileSync(path.join(project, 'vite.config.mjs'), 'utf8')).toContain('./base.mts'); + expect((await loadConfig()).lint!.extends![0].jsPlugins).toEqual([ + path.join(project, 'plugin.mjs'), + ]); + }); + it('allows a shared ancestor to occur in separate branches', () => { write('base.json', { rules: { 'no-debugger': 'error' } }); write('a.json', { extends: ['./base.json'] }); write('b.json', { extends: ['./base.json'] }); const root = write('.oxlintrc.json', { extends: ['./a.json', './b.json'] }); - expect(resolveOxlintConfigExtends(root).extends).toEqual([ + expect(resolveOxlintConfigExtends(root).config.extends).toEqual([ { extends: [{ rules: { 'no-debugger': 'error' } }] }, { extends: [{ rules: { 'no-debugger': 'error' } }] }, ]); }); + it('encodes URL characters in relative plugin paths', async () => { + write('base.json', { jsPlugins: ['./plugin #1%.mjs'] }); + write('.oxlintrc.json', { extends: ['./base.json'] }); + mergeViteConfigFiles(project, true); + expect((await loadConfig()).lint!.extends![0].jsPlugins).toEqual([ + path.join(project, 'plugin #1%.mjs'), + ]); + }); + + it('preserves external shared configs without writing outside the project', () => { + write('external/base.json', { rules: { 'no-debugger': 'warn' } }); + write('app/vite.config.mjs', 'export default {};'); + write('app/.oxlintrc.json', { extends: ['../external/base.json'] }); + const report = createMigrationReport(); + mergeViteConfigFiles(path.join(project, 'app'), true, report); + expect(report.warnings[0]).toContain('outside the project'); + expect(fs.existsSync(path.join(project, 'external/base.ts'))).toBe(false); + expect(fs.existsSync(path.join(project, 'app/.oxlintrc.json'))).toBe(true); + }); + + it('removes newly created modules if writing a module fails', () => { + write('base.json', { rules: { 'no-debugger': 'warn' } }); + write('.oxlintrc.json', { extends: ['./base.json'] }); + const report = createMigrationReport(); + const writeFile = fs.writeFileSync; + const spy = vi.spyOn(fs, 'writeFileSync').mockImplementation((file, data, options) => { + if (typeof file === 'number') { + throw new Error('module write failed'); + } + return writeFile(file, data, options); + }); + try { + mergeViteConfigFiles(project, true, report); + } finally { + spy.mockRestore(); + } + expect(report.warnings[0]).toContain('module write failed'); + expect(fs.existsSync(path.join(project, 'base.ts'))).toBe(false); + expect(fs.existsSync(path.join(project, '.oxlintrc.json'))).toBe(true); + expect(fs.readFileSync(path.join(project, 'vite.config.mjs'), 'utf8')).toBe( + 'export default {};', + ); + }); + it.each([false, true])( 'resolves workspace inheritance before configs are removed (extends root: %s)', async (extendsRoot) => { @@ -140,18 +263,16 @@ describe('Oxlint JSON extends migration', () => { report, ); - const { default: root } = await import( - pathToFileURL(path.join(project, 'vite.config.mjs')).href - ); - const { default: sibling } = await import( - pathToFileURL(path.join(project, 'packages/b/vite.config.mjs')).href - ); - const inheritedRoot = extendsRoot ? root.lint.extends[0] : root.lint.extends[0].extends[0]; + const root = await loadConfig(); + const sibling = await loadConfig(path.join(project, 'packages/b')); + const inheritedRoot = extendsRoot + ? root.lint!.extends![0] + : root.lint!.extends![0].extends![0]; const inheritedSibling = extendsRoot - ? sibling.lint.extends[0].extends[0] - : sibling.lint.extends[0]; - expect(inheritedRoot.rules['no-debugger']).toBe('error'); - expect(inheritedSibling.rules['no-debugger']).toBe('error'); + ? sibling.lint!.extends![0].extends![0] + : sibling.lint!.extends![0]; + expect(inheritedRoot.rules!['no-debugger']).toBe('error'); + expect(inheritedSibling.rules!['no-debugger']).toBe('error'); for (const dir of ['', 'packages/a', 'packages/b']) { expect(fs.existsSync(path.join(project, dir, '.oxlintrc.json'))).toBe(false); } @@ -171,7 +292,7 @@ describe('Oxlint JSON extends migration', () => { }); const configPath = write('.oxlintrc.json', { extends: ['./shared/base.json'] }); - expect(resolveOxlintConfigExtends(configPath).extends?.[0]).toEqual({ + expect(resolveOxlintConfigExtends(configPath).config.extends?.[0]).toEqual({ jsPlugins: ['eslint-plugin-test'], rules: { 'test/custom': 'error' }, }); @@ -201,13 +322,12 @@ describe('Oxlint JSON extends migration', () => { mergeViteConfigFiles(project, true); - const { default: config } = await import( - pathToFileURL(path.join(project, 'vite.config.mjs')).href - ); - expect(config.lint.rules['vite-plus/prefer-vite-plus-imports']).toBe('off'); + const config = await loadConfig(); + expect(config.lint!.rules!['vite-plus/prefer-vite-plus-imports']).toBeUndefined(); + expect(config.lint!.extends![0].rules!['vite-plus/prefer-vite-plus-imports']).toBe('off'); }); - it('rebases inherited JS plugins, including overrides, without embedding machine paths', async () => { + it('keeps inherited JS plugins and overrides in their modules with portable absolute paths', async () => { write('shared/base.json', { jsPlugins: [{ name: 'import', specifier: './plugin.mjs' }], overrides: [{ files: ['**/*.js'], jsPlugins: ['../override.mjs'] }], @@ -221,15 +341,23 @@ describe('Oxlint JSON extends migration', () => { const configPath = path.join(project, 'vite.config.mjs'); expect(fs.readFileSync(configPath, 'utf8')).not.toContain(project); - const { default: config } = await import(pathToFileURL(configPath).href); - expect(config.lint.jsPlugins).toContainEqual({ - name: 'import', - specifier: './shared/plugin.mjs', - }); - expect(config.lint.jsPlugins).toContain('./override.mjs'); - expect(config.lint.extends[0].jsPlugins).toBeUndefined(); - expect(config.lint.extends[0].overrides[0].jsPlugins).toBeUndefined(); - expect(config.lint.rules['import/custom-rule']).toBe('error'); + const config = await loadConfig(); + expect(config.lint!.extends![0].jsPlugins).toEqual([ + { + name: 'import', + specifier: path.join(project, 'shared/plugin.mjs'), + }, + ]); + expect(config.lint!.extends![0].overrides![0].jsPlugins).toEqual([ + path.join(project, 'override.mjs'), + ]); + expect(config.lint!.jsPlugins).toEqual([ + { name: 'vite-plus', specifier: 'vite-plus/oxlint-plugin' }, + ]); + const module = fs.readFileSync(path.join(project, 'shared/base.ts'), 'utf8'); + expect(module).toContain('import.meta.url'); + expect(module).not.toContain(project); + expect(config.lint!.rules!['import/custom-rule']).toBe('error'); }); it.each([ @@ -267,7 +395,8 @@ describe('Oxlint JSON extends migration', () => { expect(fs.readFileSync(source, 'utf8')).toBe(original); expect(report.mergedConfigCount).toBe(0); - expect(report.warnings[0]).toContain('Failed to merge'); + expect(report.warnings[0]).toContain('ESM default export'); + expect(fs.existsSync(path.join(project, 'base.ts'))).toBe(false); expect(fs.readdirSync(project).some((file) => file.startsWith('.vite-plus-lint-'))).toBe(false); }); @@ -285,4 +414,15 @@ describe('Oxlint JSON extends migration', () => { expect(fs.readFileSync(path.join(project, 'vite.config.mjs'), 'utf8')).toBe(viteConfig); expect(report.warnings[0]).toContain('already has a lint config'); }); + + it('creates no shared modules when the config merger does not support the export shape', () => { + write('base.json', { rules: { 'no-debugger': 'warn' } }); + write('.oxlintrc.json', { extends: ['./base.json'] }); + write('vite.config.mjs', 'const makeConfig = () => ({}); export default makeConfig();'); + const report = createMigrationReport(); + mergeViteConfigFiles(project, true, report); + expect(report.warnings[0]).toContain('Failed to merge'); + expect(fs.existsSync(path.join(project, 'base.ts'))).toBe(false); + expect(fs.existsSync(path.join(project, '.oxlintrc.json'))).toBe(true); + }); }); diff --git a/packages/cli/src/migration/migrator.ts b/packages/cli/src/migration/migrator.ts index 1c745797e0..4433d1d157 100644 --- a/packages/cli/src/migration/migrator.ts +++ b/packages/cli/src/migration/migrator.ts @@ -12,6 +12,7 @@ export * from './migrator/source-scan.ts'; export * from './migrator/vite-plus-bootstrap.ts'; export * from './migrator/package-json.ts'; export * from './migrator/vite-config.ts'; +export * from './migrator/oxlint-config.ts'; export * from './migrator/git-hooks.ts'; export * from './migrator/setup.ts'; export * from './migrator/core-finalization.ts'; diff --git a/packages/cli/src/migration/migrator/README.md b/packages/cli/src/migration/migrator/README.md index 4e824f020a..b93087cfde 100644 --- a/packages/cli/src/migration/migrator/README.md +++ b/packages/cli/src/migration/migrator/README.md @@ -30,6 +30,7 @@ Pick the file by what a function _does_, not by where it happens to be called. | `yarn.ts` | `.yarnrc.yml`, Yarn PnP detection, workspace-hoisting fix, webdriverio detection. | | `source-scan.ts` | Scanning a project's source tree for signals (browser-mode, opt-in providers, `@nuxt/test-utils`, retained upstream-vitest references). | | `vite-config.ts` | `vite.config.ts` merging, default-config injection, staged-config merge, lazy-plugin wrapping, import rewriting (`rewriteAllImports`), migrated-oxlint-config sanitization, lint-staged removal. | +| `oxlint-config.ts` | Oxlint JSON inheritance graphs and shared TypeScript config generation. | | `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. | diff --git a/packages/cli/src/migration/migrator/catalog.ts b/packages/cli/src/migration/migrator/catalog.ts index ba83fe19c9..7b6af8e975 100644 --- a/packages/cli/src/migration/migrator/catalog.ts +++ b/packages/cli/src/migration/migrator/catalog.ts @@ -1,7 +1,6 @@ import fs from 'node:fs'; import path from 'node:path'; -import { type OxlintConfig } from 'oxlint'; import { coerce, isGreaterThanOrEqual } from 'verkit'; import { Scalar, YAMLMap, YAMLSeq } from 'yaml'; @@ -33,6 +32,7 @@ import { workspaceUsesWebdriverioProvider, } from '../migrator.ts'; import { + type OxlintConfigFile, LEGACY_WRAPPER_FALLBACK_VERSIONS, PROVIDER_OVERRIDE_DROP_NAMES, REMOVE_PACKAGES, @@ -1152,7 +1152,7 @@ export function rewriteRootWorkspacePackageJson( // catalogs require Yarn >= 4.10.0; pnpm/bun monorepos always manage a catalog. // When false, the root `vite-plus` edge and the direct `vite` edge are concrete. supportCatalog = true, - preparedOxlintConfigs?: Map, + preparedOxlintConfigs?: Map, ): void { const packageJsonPath = path.join(projectPath, 'package.json'); if (!fs.existsSync(packageJsonPath)) { diff --git a/packages/cli/src/migration/migrator/oxlint-config.ts b/packages/cli/src/migration/migrator/oxlint-config.ts new file mode 100644 index 0000000000..1c6e5f651d --- /dev/null +++ b/packages/cli/src/migration/migrator/oxlint-config.ts @@ -0,0 +1,263 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +import type { Program } from '@oxc-project/types'; +import { parse as parseJsonc, type ParseError } from 'jsonc-parser'; +import type { OxlintConfig } from 'oxlint'; + +import { analyzeMigrationSource, ResolverFactory } from '../../../binding/index.js'; +import { readJsonFile } from '../../utils/json.ts'; +import { detectConfigs } from '../detector.ts'; +import { ensureSvelteRuneGlobals } from '../migrator.ts'; +import type { OxlintConfigFile, OxlintModulePlan } from './shared.ts'; + +/** Read the inheritance graph before migration removes any workspace configs. */ +export function resolveOxlintConfigExtends( + configPath: string, + cache = new Map(), + stack: string[] = [], +): OxlintConfigFile { + const filename = fs.realpathSync(configPath); + if (stack.includes(filename)) { + throw new Error(`Circular Oxlint extends: ${[...stack, filename].join(' -> ')}`); + } + const cached = cache.get(filename); + if (cached) { + return cached; + } + const errors: ParseError[] = []; + const config = parseJsonc(fs.readFileSync(filename, 'utf8'), errors, { + allowTrailingComma: true, + }) as Record; + if (errors.length > 0 || !config || typeof config !== 'object' || Array.isArray(config)) { + throw new Error(`Invalid Oxlint JSON config: ${filename}`); + } + delete config.$schema; + const inherited = config.extends; + if ( + inherited !== undefined && + (!Array.isArray(inherited) || inherited.some((entry) => typeof entry !== 'string')) + ) { + throw new Error(`Expected an array of file paths in ${filename}: extends`); + } + const children = ((inherited ?? []) as string[]).map((entry) => + resolveOxlintConfigExtends(path.resolve(path.dirname(filename), entry), cache, [ + ...stack, + filename, + ]), + ); + if (inherited !== undefined) { + config.extends = children.map((child) => child.config); + } + const file: OxlintConfigFile = { filename, config, extends: children }; + cache.set(filename, file); + return file; +} + +export function prepareOxlintConfigs( + projectPaths: string[], +): Map { + const configs = new Map(); + const cache = new Map(); + for (const projectPath of projectPaths) { + const filename = detectConfigs(projectPath).oxlintConfig; + if (!filename) { + continue; + } + const configPath = path.join(projectPath, filename); + try { + configs.set(configPath, resolveOxlintConfigExtends(configPath, cache)); + } catch (error) { + configs.set(configPath, error instanceof Error ? error : new Error(String(error))); + } + } + return configs; +} + +function moduleExtension(filename: string): '.ts' | '.mts' { + let directory = path.dirname(filename); + for (;;) { + const packageJson = path.join(directory, 'package.json'); + if (fs.existsSync(packageJson)) { + return readJsonFile(packageJson).type === 'module' ? '.ts' : '.mts'; + } + const parent = path.dirname(directory); + if (parent === directory) { + return '.mts'; + } + directory = parent; + } +} + +function relativeImport(from: string, to: string): string { + const relative = path.relative(path.dirname(from), to).split(path.sep).join('/'); + if (path.isAbsolute(relative)) { + throw new Error(`Cannot import an Oxlint config across drives: ${to}`); + } + // Vite and TypeScript resolve .js/.mjs imports to .ts/.mts source files. + // This also works with NodeNext without changing allowImportingTsExtensions. + const specifier = /\.[cm]?ts$/.test(from) + ? relative.replace(/\.mts$/, '.mjs').replace(/\.ts$/, '.js') + : relative; + return specifier.startsWith('../') ? specifier : `./${specifier}`; +} + +function printConfig( + config: OxlintConfig, + inheritedNames: string[], + pluginExpression?: (specifier: string) => string, +): string { + const printPlugin = (entry: NonNullable[number]): string => { + const specifier = typeof entry === 'string' ? entry : entry.specifier; + const expression = pluginExpression?.(specifier) ?? JSON.stringify(specifier); + return typeof entry === 'string' + ? expression + : `{ "name": ${JSON.stringify(entry.name)}, "specifier": ${expression} }`; + }; + return `{ +${Object.entries(config) + .map(([key, value]) => { + let code = JSON.stringify(value, null, 2); + if (key === 'extends') { + code = `[${inheritedNames.join(', ')}]`; + } else if (key === 'jsPlugins' && Array.isArray(config.jsPlugins)) { + code = `[${config.jsPlugins.map(printPlugin).join(', ')}]`; + } else if (key === 'overrides' && config.overrides) { + code = `[${config.overrides.map((override) => printConfig(override, [], pluginExpression)).join(',\n')}]`; + } + return ` ${JSON.stringify(key)}: ${code.replaceAll('\n', '\n ')}`; + }) + .join(',\n')} +}`; +} + +/** Generate portable shared modules, leaving their plugins in their original layers. */ +export function prepareOxlintConfigModules( + root: OxlintConfigFile, + config: OxlintConfig, + viteConfigPath: string, + workspaceRoot: string, +): OxlintModulePlan { + viteConfigPath = fs.realpathSync(viteConfigPath); + const source = fs.readFileSync(viteConfigPath, 'utf8'); + const names = new Set(); + const { ast } = JSON.parse(analyzeMigrationSource(viteConfigPath, source), (key, value) => { + if (key === 'name' && typeof value === 'string') { + names.add(value); + } + return value; + }) as { ast: { node: Program } }; + if ( + /\.c[jt]s$/.test(viteConfigPath) || + !ast.node.body.some((node) => node.type === 'ExportDefaultDeclaration') + ) { + throw new Error('Shared Oxlint configs require a Vite config with an ESM default export'); + } + const modules: OxlintModulePlan['modules'] = new Map(); + const boundary = fs.realpathSync(workspaceRoot); + let resolver: ResolverFactory | undefined; + + const validated = new Set(); + function validate(file: OxlintConfigFile): void { + if (validated.has(file)) { + return; + } + validated.add(file); + const relative = path.relative(boundary, file.filename); + if ( + path.isAbsolute(relative) || + relative === '..' || + relative.startsWith(`..${path.sep}`) || + relative.split(path.sep).includes('node_modules') + ) { + throw new Error( + `Shared Oxlint config is outside the project or in node_modules: ${file.filename}`, + ); + } + for (const layer of [file.config, ...(file.config.overrides ?? [])]) { + for (const entry of layer.jsPlugins ?? []) { + const specifier = typeof entry === 'string' ? entry : entry.specifier; + if ( + !/^\.{1,2}(?:[/\\]|$)/.test(specifier) && + !path.isAbsolute(specifier) && + path.dirname(file.filename) !== path.dirname(viteConfigPath) + ) { + resolver ??= new ResolverFactory({ conditionNames: ['module-sync', 'node', 'import'] }); + const original = resolver.sync(path.dirname(file.filename), specifier); + const migrated = resolver.sync(path.dirname(viteConfigPath), specifier); + if (original.path && original.path !== migrated.path) { + throw new Error( + `JS plugin "${specifier}" in ${file.filename} resolves differently from the Vite config directory; migrate this plugin manually`, + ); + } + } + } + } + file.extends.forEach(validate); + } + root.extends.forEach(validate); + + function generate(file: OxlintConfigFile): string { + const filename = file.filename.replace(/\.jsonc?$/i, '') + moduleExtension(file.filename); + const existing = modules.get(filename); + if (existing && existing.file !== file) { + throw new Error(`Multiple Oxlint configs would create ${filename}`); + } + if (file.modulePath) { + return file.modulePath; + } + if (existing) { + return filename; + } + const runtimePath = filename.replace(/\.mts$/, '.mjs').replace(/\.ts$/, '.js'); + if (fs.existsSync(filename) || fs.existsSync(runtimePath)) { + throw new Error( + `Cannot create shared Oxlint config because ${filename} or ${runtimePath} already exists`, + ); + } + modules.set(filename, { file, content: '' }); + const imports = ['import type { OxlintConfig } from "vite-plus/lint";']; + const inherited = file.extends.map((child, index) => { + const name = `oxlintConfig${index + 1}`; + imports.push( + `import ${name} from ${JSON.stringify(relativeImport(filename, generate(child)))};`, + ); + return name; + }); + let needsFileUrl = false; + const pluginExpression = (specifier: string): string => { + if (/^\.{1,2}(?:[/\\]|$)/.test(specifier)) { + needsFileUrl = true; + const url = specifier.replaceAll('\\', '/').split('/').map(encodeURIComponent).join('/'); + return `fileURLToPath(new URL(${JSON.stringify(url)}, import.meta.url))`; + } + return JSON.stringify(specifier); + }; + const config = structuredClone(file.config); + ensureSvelteRuneGlobals(config); + const body = printConfig(config, inherited, pluginExpression); + if (needsFileUrl) { + imports.unshift('import { fileURLToPath } from "node:url";'); + } + modules.set(filename, { + file, + content: `${imports.join('\n')}\n\nexport default ${body} satisfies OxlintConfig;\n`, + }); + return filename; + } + + const imports: string[] = []; + let nextName = 1; + const inherited = root.extends.map((file) => { + let name: string; + do { + name = `oxlintConfig${nextName++}`; + } while (names.has(name)); + names.add(name); + imports.push( + `import ${name} from ${JSON.stringify(relativeImport(viteConfigPath, generate(file)))};`, + ); + return name; + }); + return { configContent: printConfig(config, inherited), imports, modules }; +} diff --git a/packages/cli/src/migration/migrator/shared.ts b/packages/cli/src/migration/migrator/shared.ts index 23a2a02d67..b412c6ea19 100644 --- a/packages/cli/src/migration/migrator/shared.ts +++ b/packages/cli/src/migration/migrator/shared.ts @@ -2,6 +2,7 @@ import fs from 'node:fs'; import path from 'node:path'; import * as prompts from '@voidzero-dev/vite-plus-prompts'; +import type { OxlintConfig } from 'oxlint'; import { coerce, satisfies } from 'verkit'; import { VITEST_VERSION, VITE_PLUS_OVERRIDE_PACKAGES } from '../../utils/constants.ts'; @@ -38,6 +39,20 @@ export function getCommandStartIndex(argv: readonly string[]): number { return start; } +export interface OxlintConfigFile { + filename: string; + config: OxlintConfig; + extends: OxlintConfigFile[]; + // Set only after a successful merge, so workspace consumers reuse the same module. + modulePath?: string; +} + +export interface OxlintModulePlan { + configContent: string; + imports: string[]; + modules: Map; +} + // All known lint-staged config file names. // JSON-parseable ones come first so rewriteLintStagedConfigFile can rewrite them. export const LINT_STAGED_JSON_CONFIG_FILES = ['.lintstagedrc.json', '.lintstagedrc'] as const; diff --git a/packages/cli/src/migration/migrator/vite-config.ts b/packages/cli/src/migration/migrator/vite-config.ts index 406e408104..b948c18964 100644 --- a/packages/cli/src/migration/migrator/vite-config.ts +++ b/packages/cli/src/migration/migrator/vite-config.ts @@ -2,13 +2,11 @@ import fs from 'node:fs'; import path from 'node:path'; import * as prompts from '@voidzero-dev/vite-plus-prompts'; -import { parse as parseJsonc, type ParseError } from 'jsonc-parser'; import { braceExpand } from 'minimatch'; import { type OxlintConfig } from 'oxlint'; import { hasConfigKey, - ResolverFactory, mergeJsonConfig, mergeTsdownConfig, migrateTaskCacheConfig, @@ -31,10 +29,14 @@ import { detectConfigs, type ConfigFiles } from '../detector.ts'; import { collectInstalledPackageNames, readRulesYaml, + resolveOxlintConfigExtends, + prepareOxlintConfigModules, sanitizeMigratedOxlintConfig, } from '../migrator.ts'; import { type MigrationReport } from '../report.ts'; import { + type OxlintConfigFile, + type OxlintModulePlan, LINT_STAGED_JSON_CONFIG_FILES, LINT_STAGED_OTHER_CONFIG_FILES, infoMigration, @@ -51,117 +53,10 @@ const SVELTE_RUNE_GLOBALS = { $host: 'readonly', } satisfies Record; -/** Resolve JSON inheritance once, during migration. Keep the tree so Oxlint - * still controls rule aliases, category precedence, plugins, and overrides. - */ -export function resolveOxlintConfigExtends(configPath: string): OxlintConfig { - const rootDir = path.dirname(path.resolve(configPath)); - const inheritedJsPlugins: NonNullable = []; - let pluginResolver: ResolverFactory | undefined; - - function visit(filename: string, stack: string[]): OxlintConfig { - const canonicalPath = fs.realpathSync(filename); - if (stack.includes(canonicalPath)) { - throw new Error(`Circular Oxlint extends: ${[...stack, canonicalPath].join(' -> ')}`); - } - const errors: ParseError[] = []; - const config = parseJsonc(fs.readFileSync(filename, 'utf8'), errors, { - allowTrailingComma: true, - }) as Record; - if (errors.length > 0 || !config || typeof config !== 'object' || Array.isArray(config)) { - throw new Error(`Invalid Oxlint JSON config: ${filename}`); - } - delete config.$schema; - if (config.extends !== undefined) { - if ( - !Array.isArray(config.extends) || - config.extends.some((entry) => typeof entry !== 'string') - ) { - throw new Error(`Expected an array of file paths in ${filename}: extends`); - } - config.extends = config.extends.map((entry: string) => - visit(path.resolve(path.dirname(filename), entry), [...stack, canonicalPath]), - ); - } - if (stack.length > 0) { - const lintConfig = config as OxlintConfig; - for (const layer of [lintConfig, ...(lintConfig.overrides ?? [])]) { - if (!layer.jsPlugins) { - continue; - } - layer.jsPlugins = layer.jsPlugins.filter((entry) => { - const specifier = typeof entry === 'string' ? entry : entry.specifier; - if (!/^\.{1,2}(?:[/\\]|$)/.test(specifier)) { - // An object extends has no origin directory. Do not silently load a - // different version of a package-local plugin after moving it. - if (!path.isAbsolute(specifier) && path.dirname(filename) !== rootDir) { - pluginResolver ??= new ResolverFactory({ - conditionNames: ['module-sync', 'node', 'import'], - }); - const original = pluginResolver.sync(path.dirname(filename), specifier); - const migrated = pluginResolver.sync(rootDir, specifier); - if (original.path && original.path !== migrated.path) { - throw new Error( - `JS plugin "${specifier}" in ${filename} resolves differently from the Vite config directory; migrate this plugin manually`, - ); - } - } - return true; - } - // Oxlint loads JS plugins globally, even those declared in overrides. - // Hoist relative paths to the root, where they remain supported. Keep - // them relative: absolute paths change string plugins' inferred names. - const relative = path - .relative(rootDir, path.resolve(path.dirname(filename), specifier)) - .split(path.sep) - .join('/'); - if (path.isAbsolute(relative)) { - throw new Error(`Cannot rebase JS plugin "${specifier}" in ${filename} across drives`); - } - const rebased = relative.startsWith('../') ? relative : `./${relative}`; - inheritedJsPlugins.push( - typeof entry === 'string' ? rebased : { ...entry, specifier: rebased }, - ); - return false; - }); - if (layer.jsPlugins.length === 0) { - delete layer.jsPlugins; - } - } - } - return config; - } - - const config = visit(configPath, []); - if (inheritedJsPlugins.length > 0) { - config.jsPlugins = [...(config.jsPlugins ?? []), ...inheritedJsPlugins]; - } - return config; -} - function oxlintConfigLayers(config: OxlintConfig): OxlintConfig[] { return [...(config.extends ?? []).flatMap(oxlintConfigLayers), config]; } -// Resolve every workspace entry before merging any of them: a root or sibling -// .oxlintrc.json can itself be an extends target and be deleted by its migration. -export function prepareOxlintConfigs(projectPaths: string[]): Map { - const configs = new Map(); - for (const projectPath of projectPaths) { - const filename = detectConfigs(projectPath).oxlintConfig; - if (!filename) { - continue; - } - const configPath = path.join(projectPath, filename); - try { - configs.set(configPath, resolveOxlintConfigExtends(configPath)); - } catch (error) { - configs.set(configPath, error instanceof Error ? error : new Error(String(error))); - } - } - return configs; -} - // Add Svelte's built-in rune globals to migrated Svelte overrides. // https://github.com/oxc-project/oxc/issues/20191 export function ensureSvelteRuneGlobals(config: OxlintConfig): void { @@ -365,7 +260,7 @@ export function mergeViteConfigFiles( // (correct for the top-level standalone/monorepo callers, where // projectPath IS the workspace root). workspaceRoot?: string, - preparedOxlintConfigs?: Map, + preparedOxlintConfigs?: Map, ): void { const configs = detectConfigs(projectPath); if (!configs.oxfmtConfig && !configs.oxlintConfig) { @@ -398,17 +293,17 @@ function mergeOxlintConfigFile( report?: MigrationReport, packages?: WorkspacePackage[], workspaceRoot?: string, - preparedOxlintConfigs?: Map, + preparedOxlintConfigs?: Map, ): void { // Inject options.typeAware and options.typeCheck defaults before merging const fullOxlintPath = path.join(projectPath, oxlintConfig); - let oxlintJson: OxlintConfig; + let file: OxlintConfigFile; try { const prepared = preparedOxlintConfigs?.get(fullOxlintPath); if (prepared instanceof Error) { throw prepared; } - oxlintJson = prepared ?? resolveOxlintConfigExtends(fullOxlintPath); + file = prepared ?? resolveOxlintConfigExtends(fullOxlintPath); } catch (error) { warnMigration( `Could not migrate ${displayRelative(fullOxlintPath)}: ${String(error)}. The original config was preserved; use vp lint -c ${oxlintConfig} until it is migrated manually.`, @@ -416,6 +311,7 @@ function mergeOxlintConfigFile( ); return; } + const oxlintJson = structuredClone(file.config); // Never discard an inheritance tree when a lint block already exists. if (oxlintJson.extends?.length && hasConfigKey(path.join(projectPath, viteConfig), 'lint')) { warnMigration( @@ -467,18 +363,39 @@ function mergeOxlintConfigFile( (layer) => layer.rules?.[PREFER_VITE_PLUS_IMPORTS_RULE] !== undefined, )?.rules?.[PREFER_VITE_PLUS_IMPORTS_RULE]; const normalizedOxlintConfig = ensureVitePlusImportRuleDefaults(oxlintJson); - if (inheritedImportRule !== undefined) { - normalizedOxlintConfig.rules![PREFER_VITE_PLUS_IMPORTS_RULE] = inheritedImportRule; + if ( + inheritedImportRule !== undefined && + oxlintJson.rules?.[PREFER_VITE_PLUS_IMPORTS_RULE] === undefined + ) { + // Keep the shared rule live: copying its value into the root would mask later edits. + delete normalizedOxlintConfig.rules![PREFER_VITE_PLUS_IMPORTS_RULE]; + } + try { + const plan = + file.extends.length > 0 + ? prepareOxlintConfigModules( + file, + normalizedOxlintConfig, + path.join(projectPath, viteConfig), + workspaceRoot ?? projectPath, + ) + : undefined; + mergeAndRemoveJsonConfig( + projectPath, + viteConfig, + oxlintConfig, + 'lint', + silent, + report, + plan?.configContent ?? JSON.stringify(normalizedOxlintConfig, null, 2), + plan, + ); + } catch (error) { + warnMigration( + `Could not migrate ${displayRelative(fullOxlintPath)}: ${String(error)}. The original config was preserved; use vp lint -c ${oxlintConfig} until it is migrated manually.`, + report, + ); } - mergeAndRemoveJsonConfig( - projectPath, - viteConfig, - oxlintConfig, - 'lint', - silent, - report, - JSON.stringify(normalizedOxlintConfig, null, 2), - ); } /** @@ -592,6 +509,7 @@ function mergeAndRemoveJsonConfig( silent = false, report?: MigrationReport, configContent?: string, + modulePlan?: OxlintModulePlan, ): void { const fullViteConfigPath = path.join(projectPath, viteConfigPath); const fullJsonConfigPath = path.join(projectPath, jsonConfigPath); @@ -626,8 +544,36 @@ function mergeAndRemoveJsonConfig( } } if (result.updated) { - fs.writeFileSync(fullViteConfigPath, result.content); - fs.unlinkSync(fullJsonConfigPath); + const originalViteConfig = fs.readFileSync(fullViteConfigPath, 'utf8'); + const writtenModules: string[] = []; + try { + for (const [filename, module] of modulePlan?.modules ?? []) { + const descriptor = fs.openSync(filename, 'wx'); + writtenModules.push(filename); + try { + fs.writeFileSync(descriptor, module.content); + } finally { + fs.closeSync(descriptor); + } + } + let content = result.content; + if (modulePlan?.imports.length) { + const offset = content.startsWith('#!') ? content.indexOf('\n') + 1 : 0; + content = + content.slice(0, offset) + modulePlan.imports.join('\n') + '\n' + content.slice(offset); + } + fs.writeFileSync(fullViteConfigPath, content); + fs.unlinkSync(fullJsonConfigPath); + } catch (error) { + for (const filename of writtenModules) { + fs.unlinkSync(filename); + } + fs.writeFileSync(fullViteConfigPath, originalViteConfig); + throw error; + } + for (const [filename, module] of modulePlan?.modules ?? []) { + module.file.modulePath = filename; + } if (report) { report.mergedConfigCount++; } From ef985f55449f1bb7ff78616265a17d6804dadd62 Mon Sep 17 00:00:00 2001 From: MK Date: Sun, 18 Oct 2026 01:11:05 +0800 Subject: [PATCH 04/24] fix(migrate): assume ESM for shared Oxlint configs --- docs/guide/migrate-rules.md | 2 +- .../__tests__/oxlint-extends.spec.ts | 21 +++++---------- .../src/migration/migrator/oxlint-config.ts | 26 +++---------------- 3 files changed, 12 insertions(+), 37 deletions(-) diff --git a/docs/guide/migrate-rules.md b/docs/guide/migrate-rules.md index 03538fc8b8..b76e91df85 100644 --- a/docs/guide/migrate-rules.md +++ b/docs/guide/migrate-rules.md @@ -27,7 +27,7 @@ When a default upgrade skips setup actions that would apply, it prints a hint to When `.oxlintrc.json` or `.oxlintrc.jsonc` uses `extends`, `vp migrate` resolves each file path relative to the config that declares it. It creates shared TypeScript configs and imports them through `lint.extends`, including nested inheritance. Oxlint applies the existing merge order, rule severities, and overrides. Plain `vp lint` uses the migrated rules without a `-c` flag. -Shared configs become sibling `.ts` files, or `.mts` files outside ESM packages. Generated TypeScript configs use `.js` or `.mjs` import specifiers, which Vite and TypeScript resolve to the generated source files without changing compiler settings. Workspace consumers reuse the same generated modules. Edit these TypeScript files to update shared settings. Original shared JSON and JSONC files remain available to other consumers; edits to those originals do not update the migrated configs. +Migration assumes ESM projects. Shared configs become sibling `.ts` files. Generated TypeScript configs use `.js` import specifiers, which Vite and TypeScript resolve to the generated source files without changing compiler settings. Workspace consumers reuse the same generated modules. Edit these TypeScript files to update shared settings. Original shared JSON and JSONC files remain available to other consumers; edits to those originals do not update the migrated configs. JS-plugin declarations stay in their original config or override. Relative plugin paths become absolute paths at runtime through `fileURLToPath(new URL(..., import.meta.url))`. Explicit inherited `typeAware` and `typeCheck` settings take precedence over migration defaults. diff --git a/packages/cli/src/migration/__tests__/oxlint-extends.spec.ts b/packages/cli/src/migration/__tests__/oxlint-extends.spec.ts index 7bf03036d7..451ea38d94 100644 --- a/packages/cli/src/migration/__tests__/oxlint-extends.spec.ts +++ b/packages/cli/src/migration/__tests__/oxlint-extends.spec.ts @@ -153,18 +153,6 @@ describe('Oxlint JSON extends migration', () => { expect(await loadConfig()).toHaveProperty('custom', 'original'); }); - it('generates ESM .mts modules in a project without type: module', async () => { - write('package.json', { name: 'commonjs-project' }); - write('base.json', { jsPlugins: ['./plugin.mjs'], rules: { 'no-debugger': 'warn' } }); - write('.oxlintrc.json', { extends: ['./base.json'] }); - mergeViteConfigFiles(project, true); - expect(fs.existsSync(path.join(project, 'base.mts'))).toBe(true); - expect(fs.readFileSync(path.join(project, 'vite.config.mjs'), 'utf8')).toContain('./base.mts'); - expect((await loadConfig()).lint!.extends![0].jsPlugins).toEqual([ - path.join(project, 'plugin.mjs'), - ]); - }); - it('allows a shared ancestor to occur in separate branches', () => { write('base.json', { rules: { 'no-debugger': 'error' } }); write('a.json', { extends: ['./base.json'] }); @@ -223,9 +211,14 @@ describe('Oxlint JSON extends migration', () => { it.each([false, true])( 'resolves workspace inheritance before configs are removed (extends root: %s)', async (extendsRoot) => { - write('package.json', { name: 'root', private: true, workspaces: ['packages/*'] }); + write('package.json', { + name: 'root', + type: 'module', + private: true, + workspaces: ['packages/*'], + }); for (const name of ['a', 'b']) { - write(`packages/${name}/package.json`, { name }); + write(`packages/${name}/package.json`, { name, type: 'module' }); write(`packages/${name}/vite.config.mjs`, 'export default {};'); } write('packages/a/.oxlintrc.json', { rules: { 'no-debugger': 'error' } }); diff --git a/packages/cli/src/migration/migrator/oxlint-config.ts b/packages/cli/src/migration/migrator/oxlint-config.ts index 1c6e5f651d..270418aa1a 100644 --- a/packages/cli/src/migration/migrator/oxlint-config.ts +++ b/packages/cli/src/migration/migrator/oxlint-config.ts @@ -6,7 +6,6 @@ import { parse as parseJsonc, type ParseError } from 'jsonc-parser'; import type { OxlintConfig } from 'oxlint'; import { analyzeMigrationSource, ResolverFactory } from '../../../binding/index.js'; -import { readJsonFile } from '../../utils/json.ts'; import { detectConfigs } from '../detector.ts'; import { ensureSvelteRuneGlobals } from '../migrator.ts'; import type { OxlintConfigFile, OxlintModulePlan } from './shared.ts'; @@ -74,31 +73,14 @@ export function prepareOxlintConfigs( return configs; } -function moduleExtension(filename: string): '.ts' | '.mts' { - let directory = path.dirname(filename); - for (;;) { - const packageJson = path.join(directory, 'package.json'); - if (fs.existsSync(packageJson)) { - return readJsonFile(packageJson).type === 'module' ? '.ts' : '.mts'; - } - const parent = path.dirname(directory); - if (parent === directory) { - return '.mts'; - } - directory = parent; - } -} - function relativeImport(from: string, to: string): string { const relative = path.relative(path.dirname(from), to).split(path.sep).join('/'); if (path.isAbsolute(relative)) { throw new Error(`Cannot import an Oxlint config across drives: ${to}`); } - // Vite and TypeScript resolve .js/.mjs imports to .ts/.mts source files. + // Vite and TypeScript resolve .js imports to .ts source files. // This also works with NodeNext without changing allowImportingTsExtensions. - const specifier = /\.[cm]?ts$/.test(from) - ? relative.replace(/\.mts$/, '.mjs').replace(/\.ts$/, '.js') - : relative; + const specifier = /\.m?ts$/.test(from) ? relative.replace(/\.ts$/, '.js') : relative; return specifier.startsWith('../') ? specifier : `./${specifier}`; } @@ -198,7 +180,7 @@ export function prepareOxlintConfigModules( root.extends.forEach(validate); function generate(file: OxlintConfigFile): string { - const filename = file.filename.replace(/\.jsonc?$/i, '') + moduleExtension(file.filename); + const filename = file.filename.replace(/\.jsonc?$/i, '') + '.ts'; const existing = modules.get(filename); if (existing && existing.file !== file) { throw new Error(`Multiple Oxlint configs would create ${filename}`); @@ -209,7 +191,7 @@ export function prepareOxlintConfigModules( if (existing) { return filename; } - const runtimePath = filename.replace(/\.mts$/, '.mjs').replace(/\.ts$/, '.js'); + const runtimePath = filename.replace(/\.ts$/, '.js'); if (fs.existsSync(filename) || fs.existsSync(runtimePath)) { throw new Error( `Cannot create shared Oxlint config because ${filename} or ${runtimePath} already exists`, From 13456dbc9d95d642ae0934245ab6520cfbdfdaf5 Mon Sep 17 00:00:00 2001 From: MK Date: Sun, 18 Oct 2026 13:05:22 +0800 Subject: [PATCH 05/24] test(migrate): avoid platform-dependent lint ordering --- .../migration_oxlint_extends/snapshots.toml | 15 +- .../snapshots/migration_oxlint_extends.md | 180 +++++++++++------- 2 files changed, 123 insertions(+), 72 deletions(-) diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends/snapshots.toml index 16495721f2..cbf08be74b 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends/snapshots.toml @@ -3,10 +3,10 @@ name = "migration_oxlint_extends" vp = "local" local-registry = true unset-env = ["VP_SKIP_INSTALL"] -# Use one lint thread so diagnostic order is stable across the three runs. -# Ignore the generated Vite config so each run checks the same source files. +# Lint each file separately because directory traversal order differs across platforms. steps = [ - { argv = ["vp", "lint", "--threads=1", "-c", ".oxlintrc.json"], comment = "Original inherited errors, warnings, and overrides", continue-on-failure = true }, + { argv = ["vp", "lint", "--threads=1", "-c", ".oxlintrc.json", "index.js"], comment = "Original inherited errors and warnings", continue-on-failure = true }, + { argv = ["vp", "lint", "--threads=1", "-c", ".oxlintrc.json", "example.test.js"], comment = "Original override rules and plugin", continue-on-failure = true }, { argv = ["vp", "migrate", "--no-interactive", "--no-agent", "--no-editor", "--no-hooks"] }, { argv = ["vpt", "print-file", "vite.config.ts"], comment = "Path extends become imports of shared TypeScript configs" }, { argv = ["vpt", "print-file", "shared/nested/first.ts"], comment = "Nested imports preserve overrides and their plugin declarations" }, @@ -15,10 +15,13 @@ steps = [ { argv = ["vpt", "stat-file", ".oxlintrc.json", "--assert-not", "file"] }, { argv = ["vpt", "print-file", "shared/second.jsonc"], comment = "Shared JSON configs are retained" }, { argv = ["vp", "exec", "tsc", "--noEmit"], comment = "Generated config types accept the resolved inheritance" }, - { argv = ["vp", "lint", "--threads=1"], comment = "The diagnostics and exit code match the original config without a config flag", continue-on-failure = true }, + { argv = ["vp", "lint", "--threads=1", "index.js"], comment = "The diagnostics and exit code match the original config without a config flag", continue-on-failure = true }, + { argv = ["vp", "lint", "--threads=1", "example.test.js"], comment = "The override diagnostics and exit code match the original config", continue-on-failure = true }, { argv = ["vp", "migrate", "--no-interactive", "--no-agent", "--no-editor", "--no-hooks"] }, { argv = ["vpt", "print-file", "vite.config.ts"], comment = "A second migration leaves the config unchanged" }, - { argv = ["vp", "lint", "--threads=1"], comment = "The diagnostics and exit code remain unchanged after the second migration", continue-on-failure = true }, + { argv = ["vp", "lint", "--threads=1", "index.js"], comment = "The diagnostics and exit code remain unchanged after the second migration", continue-on-failure = true }, + { argv = ["vp", "lint", "--threads=1", "example.test.js"], comment = "The override diagnostics and exit code also remain unchanged", continue-on-failure = true }, { argv = ["vpt", "replace-file-content", "shared/base.ts", '"no-debugger": "error"', '"no-debugger": "warn"'], comment = "Edits to the shared TS config propagate through its imports" }, - { argv = ["vp", "lint", "--threads=1"], comment = "The edited base rule now warns in index.js; the test override remains a warning", continue-on-failure = true }, + { argv = ["vp", "lint", "--threads=1", "index.js"], comment = "The edited base rule now warns in index.js", continue-on-failure = true }, + { argv = ["vp", "lint", "--threads=1", "example.test.js"], comment = "The test override remains unchanged after the shared config edit", continue-on-failure = true }, ] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends/snapshots/migration_oxlint_extends.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends/snapshots/migration_oxlint_extends.md index b92a3f84b0..0348146089 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends/snapshots/migration_oxlint_extends.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends/snapshots/migration_oxlint_extends.md @@ -1,28 +1,13 @@ # migration_oxlint_extends -## `vp lint --threads=1 -c .oxlintrc.json` +## `vp lint --threads=1 -c .oxlintrc.json index.js` -Original inherited errors, warnings, and overrides +Original inherited errors and warnings **Exit code:** 1 ``` - ⚠ eslint(no-debugger): `debugger` statement is not allowed - ╭─[example.test.js:1:1] - 1 │ debugger; - · ───────── - 2 │ export const bar = 1; - ╰──── - help: Remove the debugger statement - - × override(no-bar): Do not use bar. - ╭─[example.test.js:2:14] - 1 │ debugger; - 2 │ export const bar = 1; - · ─── - ╰──── - × eslint(no-debugger): `debugger` statement is not allowed ╭─[index.js:1:1] 1 │ debugger; @@ -48,8 +33,35 @@ Original inherited errors, warnings, and overrides 5 │ export const bar = 1; ╰──── -Found 2 warnings and 3 errors. -Finished in on 2 files with rules using threads. +Found 1 warning and 2 errors. +Finished in on 1 file with rules using threads. +``` + +## `vp lint --threads=1 -c .oxlintrc.json example.test.js` + +Original override rules and plugin + +**Exit code:** 1 + +``` + + ⚠ eslint(no-debugger): `debugger` statement is not allowed + ╭─[example.test.js:1:1] + 1 │ debugger; + · ───────── + 2 │ export const bar = 1; + ╰──── + help: Remove the debugger statement + + × override(no-bar): Do not use bar. + ╭─[example.test.js:2:14] + 1 │ debugger; + 2 │ export const bar = 1; + · ─── + ╰──── + +Found 1 warning and 1 error. +Finished in on 1 file with rules using threads. ``` ## `vp migrate --no-interactive --no-agent --no-editor --no-hooks` @@ -178,7 +190,7 @@ Generated config types accept the resolved inheritance ``` ``` -## `vp lint --threads=1` +## `vp lint --threads=1 index.js` The diagnostics and exit code match the original config without a config flag @@ -186,21 +198,6 @@ The diagnostics and exit code match the original config without a config flag ``` - ⚠ eslint(no-debugger): `debugger` statement is not allowed - ╭─[example.test.js:1:1] - 1 │ debugger; - · ───────── - 2 │ export const bar = 1; - ╰──── - help: Remove the debugger statement - - × override(no-bar): Do not use bar. - ╭─[example.test.js:2:14] - 1 │ debugger; - 2 │ export const bar = 1; - · ─── - ╰──── - × eslint(no-debugger): `debugger` statement is not allowed ╭─[index.js:1:1] 1 │ debugger; @@ -226,8 +223,35 @@ The diagnostics and exit code match the original config without a config flag 5 │ export const bar = 1; ╰──── -Found 2 warnings and 3 errors. -Finished in on 2 files with rules using threads. +Found 1 warning and 2 errors. +Finished in on 1 file with rules using threads. +``` + +## `vp lint --threads=1 example.test.js` + +The override diagnostics and exit code match the original config + +**Exit code:** 1 + +``` + + ⚠ eslint(no-debugger): `debugger` statement is not allowed + ╭─[example.test.js:1:1] + 1 │ debugger; + · ───────── + 2 │ export const bar = 1; + ╰──── + help: Remove the debugger statement + + × override(no-bar): Do not use bar. + ╭─[example.test.js:2:14] + 1 │ debugger; + 2 │ export const bar = 1; + · ─── + ╰──── + +Found 1 warning and 1 error. +Finished in on 1 file with rules using threads. ``` ## `vp migrate --no-interactive --no-agent --no-editor --no-hooks` @@ -264,7 +288,7 @@ export default defineConfig({ }); ``` -## `vp lint --threads=1` +## `vp lint --threads=1 index.js` The diagnostics and exit code remain unchanged after the second migration @@ -272,21 +296,6 @@ The diagnostics and exit code remain unchanged after the second migration ``` - ⚠ eslint(no-debugger): `debugger` statement is not allowed - ╭─[example.test.js:1:1] - 1 │ debugger; - · ───────── - 2 │ export const bar = 1; - ╰──── - help: Remove the debugger statement - - × override(no-bar): Do not use bar. - ╭─[example.test.js:2:14] - 1 │ debugger; - 2 │ export const bar = 1; - · ─── - ╰──── - × eslint(no-debugger): `debugger` statement is not allowed ╭─[index.js:1:1] 1 │ debugger; @@ -312,20 +321,13 @@ The diagnostics and exit code remain unchanged after the second migration 5 │ export const bar = 1; ╰──── -Found 2 warnings and 3 errors. -Finished in on 2 files with rules using threads. +Found 1 warning and 2 errors. +Finished in on 1 file with rules using threads. ``` -## `vpt replace-file-content shared/base.ts '"no-debugger": "error"' '"no-debugger": "warn"'` +## `vp lint --threads=1 example.test.js` -Edits to the shared TS config propagate through its imports - -``` -``` - -## `vp lint --threads=1` - -The edited base rule now warns in index.js; the test override remains a warning +The override diagnostics and exit code also remain unchanged **Exit code:** 1 @@ -346,6 +348,25 @@ The edited base rule now warns in index.js; the test override remains a warning · ─── ╰──── +Found 1 warning and 1 error. +Finished in on 1 file with rules using threads. +``` + +## `vpt replace-file-content shared/base.ts '"no-debugger": "error"' '"no-debugger": "warn"'` + +Edits to the shared TS config propagate through its imports + +``` +``` + +## `vp lint --threads=1 index.js` + +The edited base rule now warns in index.js + +**Exit code:** 1 + +``` + ⚠ eslint(no-debugger): `debugger` statement is not allowed ╭─[index.js:1:1] 1 │ debugger; @@ -371,6 +392,33 @@ The edited base rule now warns in index.js; the test override remains a warning 5 │ export const bar = 1; ╰──── -Found 3 warnings and 2 errors. -Finished in on 2 files with rules using threads. +Found 2 warnings and 1 error. +Finished in on 1 file with rules using threads. +``` + +## `vp lint --threads=1 example.test.js` + +The test override remains unchanged after the shared config edit + +**Exit code:** 1 + +``` + + ⚠ eslint(no-debugger): `debugger` statement is not allowed + ╭─[example.test.js:1:1] + 1 │ debugger; + · ───────── + 2 │ export const bar = 1; + ╰──── + help: Remove the debugger statement + + × override(no-bar): Do not use bar. + ╭─[example.test.js:2:14] + 1 │ debugger; + 2 │ export const bar = 1; + · ─── + ╰──── + +Found 1 warning and 1 error. +Finished in on 1 file with rules using threads. ``` From b8c02967877ea2eaca77d3632796902124b13422 Mon Sep 17 00:00:00 2001 From: MK Date: Sun, 18 Oct 2026 15:21:38 +0800 Subject: [PATCH 06/24] fix(migrate): preserve workspace Oxlint consumers --- .../.gitignore | 1 + .../.oxlintrc.json | 9 + .../index.js | 1 + .../package.json | 8 + .../packages/a/.oxlintrc.json | 3 + .../packages/a/index.js | 1 + .../packages/a/package.json | 5 + .../packages/a/vite.config.mjs | 1 + .../packages/b/.oxlintrc.json | 3 + .../packages/b/extra.json | 1 + .../packages/b/extra.ts | 1 + .../packages/b/index.js | 1 + .../packages/b/package.json | 5 + .../packages/b/vite.config.mjs | 1 + .../pnpm-workspace.yaml | 2 + .../snapshots.toml | 48 +++ ...nt_extends_workspace_preserved_consumer.md | 168 ++++++++++ ...n_oxlint_extends_workspace_shared_owner.md | 286 ++++++++++++++++++ .../vite.config.mjs | 1 + docs/guide/migrate-rules.md | 4 +- .../__tests__/oxlint-extends.spec.ts | 164 +++++++--- .../src/migration/migrator/orchestrators.ts | 2 + .../src/migration/migrator/oxlint-config.ts | 53 +++- packages/cli/src/migration/migrator/shared.ts | 4 + .../cli/src/migration/migrator/vite-config.ts | 31 +- 25 files changed, 739 insertions(+), 65 deletions(-) create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/.gitignore create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/.oxlintrc.json create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/index.js create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/package.json create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/packages/a/.oxlintrc.json create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/packages/a/index.js create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/packages/a/package.json create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/packages/a/vite.config.mjs create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/packages/b/.oxlintrc.json create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/packages/b/extra.json create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/packages/b/extra.ts create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/packages/b/index.js create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/packages/b/package.json create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/packages/b/vite.config.mjs create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/pnpm-workspace.yaml create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/snapshots.toml create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/snapshots/migration_oxlint_extends_workspace_preserved_consumer.md create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/snapshots/migration_oxlint_extends_workspace_shared_owner.md create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/vite.config.mjs diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/.gitignore b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/.gitignore new file mode 100644 index 0000000000..c2658d7d1b --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/.gitignore @@ -0,0 +1 @@ +node_modules/ diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/.oxlintrc.json b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/.oxlintrc.json new file mode 100644 index 0000000000..e36e1ae721 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/.oxlintrc.json @@ -0,0 +1,9 @@ +{ + "options": { + "typeAware": false, + "typeCheck": false + }, + "rules": { + "no-debugger": "warn" + } +} diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/index.js b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/index.js new file mode 100644 index 0000000000..eab7469213 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/index.js @@ -0,0 +1 @@ +debugger; diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/package.json b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/package.json new file mode 100644 index 0000000000..c15b74847e --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/package.json @@ -0,0 +1,8 @@ +{ + "name": "migration-oxlint-extends-workspace", + "private": true, + "type": "module", + "devDependencies": { + "oxlint": "1.88.0" + } +} diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/packages/a/.oxlintrc.json b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/packages/a/.oxlintrc.json new file mode 100644 index 0000000000..804b9227be --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/packages/a/.oxlintrc.json @@ -0,0 +1,3 @@ +{ + "extends": ["../../.oxlintrc.json"] +} diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/packages/a/index.js b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/packages/a/index.js new file mode 100644 index 0000000000..eab7469213 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/packages/a/index.js @@ -0,0 +1 @@ +debugger; diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/packages/a/package.json b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/packages/a/package.json new file mode 100644 index 0000000000..ee8ace35a5 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/packages/a/package.json @@ -0,0 +1,5 @@ +{ + "name": "a", + "private": true, + "type": "module" +} diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/packages/a/vite.config.mjs b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/packages/a/vite.config.mjs new file mode 100644 index 0000000000..ff8b4c5632 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/packages/a/vite.config.mjs @@ -0,0 +1 @@ +export default {}; diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/packages/b/.oxlintrc.json b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/packages/b/.oxlintrc.json new file mode 100644 index 0000000000..1104dda707 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/packages/b/.oxlintrc.json @@ -0,0 +1,3 @@ +{ + "extends": ["../a/.oxlintrc.json", "./extra.json"] +} diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/packages/b/extra.json b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/packages/b/extra.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/packages/b/extra.json @@ -0,0 +1 @@ +{} diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/packages/b/extra.ts b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/packages/b/extra.ts new file mode 100644 index 0000000000..ff8b4c5632 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/packages/b/extra.ts @@ -0,0 +1 @@ +export default {}; diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/packages/b/index.js b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/packages/b/index.js new file mode 100644 index 0000000000..eab7469213 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/packages/b/index.js @@ -0,0 +1 @@ +debugger; diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/packages/b/package.json b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/packages/b/package.json new file mode 100644 index 0000000000..f1741f9879 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/packages/b/package.json @@ -0,0 +1,5 @@ +{ + "name": "b", + "private": true, + "type": "module" +} diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/packages/b/vite.config.mjs b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/packages/b/vite.config.mjs new file mode 100644 index 0000000000..ff8b4c5632 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/packages/b/vite.config.mjs @@ -0,0 +1 @@ +export default {}; diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/pnpm-workspace.yaml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/pnpm-workspace.yaml new file mode 100644 index 0000000000..924b55f42e --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +packages: + - packages/* diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/snapshots.toml new file mode 100644 index 0000000000..77898d695f --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/snapshots.toml @@ -0,0 +1,48 @@ +[[case]] +name = "migration_oxlint_extends_workspace_shared_owner" +vp = "local" +local-registry = true +unset-env = ["VP_SKIP_INSTALL"] +steps = [ + { argv = ["vpt", "rm", "packages/b/extra.ts"], snapshot = false }, + { argv = ["vp", "lint", "-c", ".oxlintrc.json", "index.js"], comment = "The original owner uses the shared warning" }, + { argv = ["vp", "lint", "-c", ".oxlintrc.json", "index.js"], cwd = "packages/a", comment = "The direct consumer inherits the same warning" }, + { argv = ["vp", "lint", "-c", ".oxlintrc.json", "index.js"], cwd = "packages/b", comment = "The transitive consumer inherits the same warning" }, + { argv = ["vp", "migrate", "--no-interactive", "--no-agent", "--no-editor", "--no-hooks"] }, + { argv = ["vpt", "print-file", "vite.config.mjs"], comment = "The owner imports its own shared config" }, + { argv = ["vpt", "print-file", "packages/a/vite.config.mjs"], comment = "The intermediate owner also imports its own shared config" }, + { argv = ["vpt", "print-file", "packages/b/vite.config.mjs"] }, + { argv = ["vpt", "print-file", ".oxlintrc.ts"], comment = "Source rules remain in one shared module" }, + { argv = ["vpt", "print-file", "packages/a/.oxlintrc.ts"] }, + { argv = ["vpt", "stat-file", ".oxlintrc.json", "--assert-not", "file"] }, + { argv = ["vpt", "stat-file", "packages/a/.oxlintrc.json", "--assert-not", "file"] }, + { argv = ["vpt", "stat-file", "packages/b/.oxlintrc.json", "--assert-not", "file"] }, + { argv = ["vp", "lint", "index.js"], comment = "The migrated owner reports the same warning" }, + { argv = ["vp", "lint", "index.js"], cwd = "packages/a", comment = "The migrated direct consumer reports the same warning" }, + { argv = ["vp", "lint", "index.js"], cwd = "packages/b", comment = "The migrated transitive consumer reports the same warning" }, + { argv = ["vpt", "replace-file-content", ".oxlintrc.ts", '"no-debugger": "warn"', '"no-debugger": "error"'], comment = "Change the shared rule once" }, + { argv = ["vp", "lint", "index.js"], comment = "The owner now reports an error", continue-on-failure = true }, + { argv = ["vp", "lint", "index.js"], cwd = "packages/a", comment = "The direct consumer now reports the same error", continue-on-failure = true }, + { argv = ["vp", "lint", "index.js"], cwd = "packages/b", comment = "The transitive consumer now reports the same error", continue-on-failure = true }, +] + +[[case]] +name = "migration_oxlint_extends_workspace_preserved_consumer" +vp = "local" +local-registry = true +unset-env = ["VP_SKIP_INSTALL"] +steps = [ + { argv = ["vpt", "replace-file-content", ".oxlintrc.json", '"no-debugger": "warn"', '"no-debugger": "error"'], snapshot = false }, + { argv = ["vp", "lint", "-c", ".oxlintrc.json", "index.js"], cwd = "packages/b", comment = "The original JSON consumer inherits an error through both workspaces", continue-on-failure = true }, + { argv = ["vp", "migrate", "--no-interactive", "--no-agent", "--no-editor", "--no-hooks"], comment = "Root and package a migrate; package b keeps its JSON because extra.ts exists" }, + { argv = ["vpt", "stat-file", ".oxlintrc.json", "--assert", "file"] }, + { argv = ["vpt", "stat-file", "packages/a/.oxlintrc.json", "--assert", "file"] }, + { argv = ["vpt", "stat-file", "packages/b/.oxlintrc.json", "--assert", "file"] }, + { argv = ["vpt", "print-file", "vite.config.mjs"], comment = "Retaining JSON does not undo the owner's successful migration" }, + { argv = ["vpt", "print-file", "packages/a/vite.config.mjs"] }, + { argv = ["vpt", "print-file", "packages/b/vite.config.mjs"], comment = "The preserved consumer's Vite config has no partial lint config" }, + { argv = ["vpt", "print-file", "packages/b/extra.ts"], comment = "The conflicting file is unchanged" }, + { argv = ["vp", "lint", "-c", ".oxlintrc.json", "index.js"], cwd = "packages/b", comment = "The JSON fallback still reports the same diagnostic and exit code", continue-on-failure = true }, + { argv = ["vp", "lint", "index.js"], comment = "The migrated owner uses its shared module", continue-on-failure = true }, + { argv = ["vp", "lint", "index.js"], cwd = "packages/a", comment = "The migrated intermediate owner uses its shared module", continue-on-failure = true }, +] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/snapshots/migration_oxlint_extends_workspace_preserved_consumer.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/snapshots/migration_oxlint_extends_workspace_preserved_consumer.md new file mode 100644 index 0000000000..c4ec5a84c7 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/snapshots/migration_oxlint_extends_workspace_preserved_consumer.md @@ -0,0 +1,168 @@ +# migration_oxlint_extends_workspace_preserved_consumer + +## `vpt replace-file-content .oxlintrc.json '"no-debugger": "warn"' '"no-debugger": "error"'` + + +## `cd packages/b && vp lint -c .oxlintrc.json index.js` + +The original JSON consumer inherits an error through both workspaces + +**Exit code:** 1 + +``` + + × eslint(no-debugger): `debugger` statement is not allowed + ╭─[index.js:1:1] + 1 │ debugger; + · ───────── + ╰──── + help: Remove the debugger statement + +Found 0 warnings and 1 error. +Finished in on 1 file with rules using threads. +``` + +## `vp migrate --no-interactive --no-agent --no-editor --no-hooks` + +Root and package a migrate; package b keeps its JSON because extra.ts exists + +``` +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. +◆ ✔ Merged .oxlintrc.json into vite.config.mjs +● Formatting code... +● Code formatted +◇ Migrated . to Vite+ +• Node pnpm +✓ Dependencies installed in +• 1 config update applied +! Warnings: + - Could not migrate packages/b/.oxlintrc.json: Error: Cannot create shared Oxlint config because /packages/b/extra.ts or /packages/b/extra.js already exists. The original config was preserved; use vp lint -c .oxlintrc.json until it is migrated manually. +``` + +## `vpt stat-file .oxlintrc.json --assert file` + +``` +.oxlintrc.json: file +``` + +## `vpt stat-file packages/a/.oxlintrc.json --assert file` + +``` +packages/a/.oxlintrc.json: file +``` + +## `vpt stat-file packages/b/.oxlintrc.json --assert file` + +``` +packages/b/.oxlintrc.json: file +``` + +## `vpt print-file vite.config.mjs` + +Retaining JSON does not undo the owner's successful migration + +``` +import oxlintConfig1 from "./.oxlintrc.ts"; +export default { + fmt: {}, + lint: { + extends: [oxlintConfig1], + options: {}, + jsPlugins: [{ name: "vite-plus", specifier: "vite-plus/oxlint-plugin" }], + rules: { + "vite-plus/prefer-vite-plus-imports": "error", + }, + }, +}; +``` + +## `vpt print-file packages/a/vite.config.mjs` + +``` +import oxlintConfig1 from "./.oxlintrc.ts"; +export default { + lint: { + extends: [oxlintConfig1], + options: {}, + jsPlugins: [{ name: "vite-plus", specifier: "vite-plus/oxlint-plugin" }], + rules: { + "vite-plus/prefer-vite-plus-imports": "error", + }, + }, +}; +``` + +## `vpt print-file packages/b/vite.config.mjs` + +The preserved consumer's Vite config has no partial lint config + +``` +export default {}; +``` + +## `vpt print-file packages/b/extra.ts` + +The conflicting file is unchanged + +``` +export default {}; +``` + +## `cd packages/b && vp lint -c .oxlintrc.json index.js` + +The JSON fallback still reports the same diagnostic and exit code + +**Exit code:** 1 + +``` + + × eslint(no-debugger): `debugger` statement is not allowed + ╭─[index.js:1:1] + 1 │ debugger; + · ───────── + ╰──── + help: Remove the debugger statement + +Found 0 warnings and 1 error. +Finished in on 1 file with rules using threads. +``` + +## `vp lint index.js` + +The migrated owner uses its shared module + +**Exit code:** 1 + +``` + + × eslint(no-debugger): `debugger` statement is not allowed + ╭─[index.js:1:1] + 1 │ debugger; + · ───────── + ╰──── + help: Remove the debugger statement + +Found 0 warnings and 1 error. +Finished in on 1 file with rules using threads. +``` + +## `cd packages/a && vp lint index.js` + +The migrated intermediate owner uses its shared module + +**Exit code:** 1 + +``` + + × eslint(no-debugger): `debugger` statement is not allowed + ╭─[index.js:1:1] + 1 │ debugger; + · ───────── + ╰──── + help: Remove the debugger statement + +Found 0 warnings and 1 error. +Finished in on 1 file with rules using threads. +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/snapshots/migration_oxlint_extends_workspace_shared_owner.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/snapshots/migration_oxlint_extends_workspace_shared_owner.md new file mode 100644 index 0000000000..0aa8b1e8ef --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/snapshots/migration_oxlint_extends_workspace_shared_owner.md @@ -0,0 +1,286 @@ +# migration_oxlint_extends_workspace_shared_owner + +## `vpt rm packages/b/extra.ts` + + +## `vp lint -c .oxlintrc.json index.js` + +The original owner uses the shared warning + +``` + + ⚠ eslint(no-debugger): `debugger` statement is not allowed + ╭─[index.js:1:1] + 1 │ debugger; + · ───────── + ╰──── + help: Remove the debugger statement + +Found 1 warning and 0 errors. +Finished in on 1 file with rules using threads. +``` + +## `cd packages/a && vp lint -c .oxlintrc.json index.js` + +The direct consumer inherits the same warning + +``` + + ⚠ eslint(no-debugger): `debugger` statement is not allowed + ╭─[index.js:1:1] + 1 │ debugger; + · ───────── + ╰──── + help: Remove the debugger statement + +Found 1 warning and 0 errors. +Finished in on 1 file with rules using threads. +``` + +## `cd packages/b && vp lint -c .oxlintrc.json index.js` + +The transitive consumer inherits the same warning + +``` + + ⚠ eslint(no-debugger): `debugger` statement is not allowed + ╭─[index.js:1:1] + 1 │ debugger; + · ───────── + ╰──── + help: Remove the debugger statement + +Found 1 warning and 0 errors. +Finished in on 1 file with rules using threads. +``` + +## `vp migrate --no-interactive --no-agent --no-editor --no-hooks` + +``` +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. +◆ ✔ Merged .oxlintrc.json into vite.config.mjs +● Formatting code... +● Code formatted +◇ Migrated . to Vite+ +• Node pnpm +✓ Dependencies installed in +• 2 config updates applied +``` + +## `vpt print-file vite.config.mjs` + +The owner imports its own shared config + +``` +import oxlintConfig1 from "./.oxlintrc.ts"; +export default { + fmt: {}, + lint: { + extends: [oxlintConfig1], + options: {}, + jsPlugins: [{ name: "vite-plus", specifier: "vite-plus/oxlint-plugin" }], + rules: { + "vite-plus/prefer-vite-plus-imports": "error", + }, + }, +}; +``` + +## `vpt print-file packages/a/vite.config.mjs` + +The intermediate owner also imports its own shared config + +``` +import oxlintConfig1 from "./.oxlintrc.ts"; +export default { + lint: { + extends: [oxlintConfig1], + options: {}, + jsPlugins: [{ name: "vite-plus", specifier: "vite-plus/oxlint-plugin" }], + rules: { + "vite-plus/prefer-vite-plus-imports": "error", + }, + }, +}; +``` + +## `vpt print-file packages/b/vite.config.mjs` + +``` +import oxlintConfig1 from "../a/.oxlintrc.ts"; +import oxlintConfig2 from "./extra.ts"; +export default { + lint: { + extends: [oxlintConfig1, oxlintConfig2], + options: {}, + jsPlugins: [{ name: "vite-plus", specifier: "vite-plus/oxlint-plugin" }], + rules: { + "vite-plus/prefer-vite-plus-imports": "error", + }, + }, +}; +``` + +## `vpt print-file .oxlintrc.ts` + +Source rules remain in one shared module + +``` +import type { OxlintConfig } from "vite-plus/lint"; + +export default { + options: { + typeAware: false, + typeCheck: false, + }, + rules: { + "no-debugger": "warn", + }, +} satisfies OxlintConfig; +``` + +## `vpt print-file packages/a/.oxlintrc.ts` + +``` +import type { OxlintConfig } from "vite-plus/lint"; +import oxlintConfig1 from "../../.oxlintrc.js"; + +export default { + extends: [oxlintConfig1], +} satisfies OxlintConfig; +``` + +## `vpt stat-file .oxlintrc.json --assert-not file` + +``` +.oxlintrc.json: missing +``` + +## `vpt stat-file packages/a/.oxlintrc.json --assert-not file` + +``` +packages/a/.oxlintrc.json: missing +``` + +## `vpt stat-file packages/b/.oxlintrc.json --assert-not file` + +``` +packages/b/.oxlintrc.json: missing +``` + +## `vp lint index.js` + +The migrated owner reports the same warning + +``` + + ⚠ eslint(no-debugger): `debugger` statement is not allowed + ╭─[index.js:1:1] + 1 │ debugger; + · ───────── + ╰──── + help: Remove the debugger statement + +Found 1 warning and 0 errors. +Finished in on 1 file with rules using threads. +``` + +## `cd packages/a && vp lint index.js` + +The migrated direct consumer reports the same warning + +``` + + ⚠ eslint(no-debugger): `debugger` statement is not allowed + ╭─[index.js:1:1] + 1 │ debugger; + · ───────── + ╰──── + help: Remove the debugger statement + +Found 1 warning and 0 errors. +Finished in on 1 file with rules using threads. +``` + +## `cd packages/b && vp lint index.js` + +The migrated transitive consumer reports the same warning + +``` + + ⚠ eslint(no-debugger): `debugger` statement is not allowed + ╭─[index.js:1:1] + 1 │ debugger; + · ───────── + ╰──── + help: Remove the debugger statement + +Found 1 warning and 0 errors. +Finished in on 1 file with rules using threads. +``` + +## `vpt replace-file-content .oxlintrc.ts '"no-debugger": "warn"' '"no-debugger": "error"'` + +Change the shared rule once + +``` +``` + +## `vp lint index.js` + +The owner now reports an error + +**Exit code:** 1 + +``` + + × eslint(no-debugger): `debugger` statement is not allowed + ╭─[index.js:1:1] + 1 │ debugger; + · ───────── + ╰──── + help: Remove the debugger statement + +Found 0 warnings and 1 error. +Finished in on 1 file with rules using threads. +``` + +## `cd packages/a && vp lint index.js` + +The direct consumer now reports the same error + +**Exit code:** 1 + +``` + + × eslint(no-debugger): `debugger` statement is not allowed + ╭─[index.js:1:1] + 1 │ debugger; + · ───────── + ╰──── + help: Remove the debugger statement + +Found 0 warnings and 1 error. +Finished in on 1 file with rules using threads. +``` + +## `cd packages/b && vp lint index.js` + +The transitive consumer now reports the same error + +**Exit code:** 1 + +``` + + × eslint(no-debugger): `debugger` statement is not allowed + ╭─[index.js:1:1] + 1 │ debugger; + · ───────── + ╰──── + help: Remove the debugger statement + +Found 0 warnings and 1 error. +Finished in on 1 file with rules using threads. +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/vite.config.mjs b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/vite.config.mjs new file mode 100644 index 0000000000..ff8b4c5632 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/vite.config.mjs @@ -0,0 +1 @@ +export default {}; diff --git a/docs/guide/migrate-rules.md b/docs/guide/migrate-rules.md index b76e91df85..7922bf152e 100644 --- a/docs/guide/migrate-rules.md +++ b/docs/guide/migrate-rules.md @@ -27,11 +27,11 @@ When a default upgrade skips setup actions that would apply, it prints a hint to When `.oxlintrc.json` or `.oxlintrc.jsonc` uses `extends`, `vp migrate` resolves each file path relative to the config that declares it. It creates shared TypeScript configs and imports them through `lint.extends`, including nested inheritance. Oxlint applies the existing merge order, rule severities, and overrides. Plain `vp lint` uses the migrated rules without a `-c` flag. -Migration assumes ESM projects. Shared configs become sibling `.ts` files. Generated TypeScript configs use `.js` import specifiers, which Vite and TypeScript resolve to the generated source files without changing compiler settings. Workspace consumers reuse the same generated modules. Edit these TypeScript files to update shared settings. Original shared JSON and JSONC files remain available to other consumers; edits to those originals do not update the migrated configs. +Migration assumes ESM projects. Shared configs become sibling `.ts` files. Generated TypeScript configs use `.js` import specifiers, which Vite and TypeScript resolve to the generated source files without changing compiler settings. Workspace consumers reuse the same generated modules, including the workspace that owns a shared config. Migration defaults stay in each workspace's local `lint` block. Edit these TypeScript files to update shared settings. Original shared JSON and JSONC files remain available to other consumers; edits to those originals do not update the migrated configs. JS-plugin declarations stay in their original config or override. Relative plugin paths become absolute paths at runtime through `fileURLToPath(new URL(..., import.meta.url))`. Explicit inherited `typeAware` and `typeCheck` settings take precedence over migration defaults. -Missing files, invalid JSON, inheritance cycles, conflicting output files, and package-local plugins that resolve differently from the Vite config directory produce a warning and leave the original config in place. Configs outside the project or workspace, configs inside `node_modules`, CommonJS Vite configs, and an existing `lint` block require a manual merge. The root JSON config is removed only after a successful migration. `vp lint` continues to reject string paths in `lint.extends`; path resolution happens only during migration. +Missing files, invalid JSON, inheritance cycles, conflicting output files, and package-local plugins that resolve differently from the Vite config directory produce a warning and leave the original config in place. Configs outside the project or workspace, configs inside `node_modules`, CommonJS Vite configs, and an existing `lint` block require a manual merge. Workspace JSON removal waits until all migration outcomes are known. Preserved JSON consumers retain their transitive dependencies, including configs whose owners migrated successfully. If an inheritance graph cannot be read, its workspace JSON files are kept. `vp lint` continues to reject string paths in `lint.extends`; path resolution happens only during migration. ## Pack Configuration diff --git a/packages/cli/src/migration/__tests__/oxlint-extends.spec.ts b/packages/cli/src/migration/__tests__/oxlint-extends.spec.ts index 451ea38d94..a753dfddc4 100644 --- a/packages/cli/src/migration/__tests__/oxlint-extends.spec.ts +++ b/packages/cli/src/migration/__tests__/oxlint-extends.spec.ts @@ -34,6 +34,46 @@ describe('Oxlint JSON extends migration', () => { return result!.config; } + function createWorkspace(names = ['a', 'b']) { + write('package.json', { + name: 'root', + type: 'module', + private: true, + workspaces: ['packages/*'], + }); + for (const name of names) { + write(`packages/${name}/package.json`, { name, type: 'module' }); + write(`packages/${name}/vite.config.mjs`, 'export default {};'); + } + } + + function migrateWorkspace(names = ['a', 'b']) { + const report = createMigrationReport(); + rewriteMonorepo( + { + rootDir: project, + isMonorepo: true, + monorepoScope: '', + workspacePatterns: ['packages/*'], + parentDirs: ['packages'], + packageManager: PackageManager.npm, + packageManagerVersion: '11.0.0', + downloadPackageManager: { + name: PackageManager.npm, + installDir: project, + binPrefix: project, + packageName: 'npm', + version: '11.0.0', + }, + packages: names.map((name) => ({ name, path: `packages/${name}` })), + }, + true, + true, + report, + ); + return report; + } + beforeEach(() => { project = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'vp-oxlint-extends-'))); write('package.json', { name: 'extends-test', type: 'module' }); @@ -209,18 +249,9 @@ describe('Oxlint JSON extends migration', () => { }); it.each([false, true])( - 'resolves workspace inheritance before configs are removed (extends root: %s)', + 'shares live modules with their owning workspace (extends root: %s)', async (extendsRoot) => { - write('package.json', { - name: 'root', - type: 'module', - private: true, - workspaces: ['packages/*'], - }); - for (const name of ['a', 'b']) { - write(`packages/${name}/package.json`, { name, type: 'module' }); - write(`packages/${name}/vite.config.mjs`, 'export default {};'); - } + createWorkspace(); write('packages/a/.oxlintrc.json', { rules: { 'no-debugger': 'error' } }); write('packages/b/.oxlintrc.json', { extends: [extendsRoot ? '../../.oxlintrc.json' : '../a/.oxlintrc.json'], @@ -228,44 +259,25 @@ describe('Oxlint JSON extends migration', () => { write('.oxlintrc.json', { extends: [extendsRoot ? './packages/a/.oxlintrc.json' : './packages/b/.oxlintrc.json'], }); - const report = createMigrationReport(); - - rewriteMonorepo( - { - rootDir: project, - isMonorepo: true, - monorepoScope: '', - workspacePatterns: ['packages/*'], - parentDirs: ['packages'], - packageManager: PackageManager.npm, - packageManagerVersion: '11.0.0', - downloadPackageManager: { - name: PackageManager.npm, - installDir: project, - binPrefix: project, - packageName: 'npm', - version: '11.0.0', - }, - packages: [ - { name: 'a', path: 'packages/a' }, - { name: 'b', path: 'packages/b' }, - ], - }, - true, - true, - report, - ); - - const root = await loadConfig(); - const sibling = await loadConfig(path.join(project, 'packages/b')); - const inheritedRoot = extendsRoot - ? root.lint!.extends![0] - : root.lint!.extends![0].extends![0]; - const inheritedSibling = extendsRoot - ? sibling.lint!.extends![0].extends![0] - : sibling.lint!.extends![0]; - expect(inheritedRoot.rules!['no-debugger']).toBe('error'); - expect(inheritedSibling.rules!['no-debugger']).toBe('error'); + const report = migrateWorkspace(); + const modulePath = path.join(project, 'packages/a/.oxlintrc.ts'); + for (const severity of ['error', 'warn']) { + if (severity === 'warn') { + fs.writeFileSync( + modulePath, + fs.readFileSync(modulePath, 'utf8').replace('"error"', '"warn"'), + ); + } + const root = await loadConfig(); + const sibling = await loadConfig(path.join(project, 'packages/b')); + const owner = await loadConfig(path.join(project, 'packages/a')); + expect(root.lint!.extends![0].extends![0].rules!['no-debugger']).toBe(severity); + expect(sibling.lint!.extends![0].extends![0].rules!['no-debugger']).toBe(severity); + expect(owner.lint!.extends![0].rules!['no-debugger']).toBe(severity); + expect(owner.lint!.rules!['no-debugger']).toBeUndefined(); + expect(owner.lint!.options).toEqual({ typeAware: true, typeCheck: true }); + expect(owner.lint!.extends![0].options).toBeUndefined(); + } for (const dir of ['', 'packages/a', 'packages/b']) { expect(fs.existsSync(path.join(project, dir, '.oxlintrc.json'))).toBe(false); } @@ -273,6 +285,60 @@ describe('Oxlint JSON extends migration', () => { }, ); + it('retains transitive JSON dependencies of a workspace whose module output conflicts', async () => { + createWorkspace(['a', 'b', 'c']); + const root = write('.oxlintrc.json', { rules: { 'no-debugger': 'error' } }); + const shared = write('packages/a/.oxlintrc.json', { extends: ['../../.oxlintrc.json'] }); + const consumer = write('packages/b/.oxlintrc.json', { + extends: ['../a/.oxlintrc.json', './extra.json'], + }); + write('packages/b/extra.json', {}); + write('packages/b/extra.ts', 'export default {};'); + write('packages/c/.oxlintrc.json', { rules: { 'no-console': 'warn' } }); + const original = [root, shared, consumer].map((file) => fs.readFileSync(file, 'utf8')); + + const report = migrateWorkspace(['a', 'b', 'c']); + + expect(report.warnings).toHaveLength(1); + expect(report.warnings[0]).toContain('already exists'); + expect([root, shared, consumer].map((file) => fs.readFileSync(file, 'utf8'))).toEqual(original); + const fallback = resolveOxlintConfigExtends(consumer); + expect(fallback.config.extends![0].extends![0].rules!['no-debugger']).toBe('error'); + expect((await loadConfig()).lint!.extends![0].rules!['no-debugger']).toBe('error'); + expect((await loadConfig(path.join(project, 'packages/a'))).lint).toBeDefined(); + expect((await loadConfig(path.join(project, 'packages/b'))).lint).toBeUndefined(); + expect(fs.existsSync(path.join(project, 'packages/c/.oxlintrc.json'))).toBe(false); + expect(fs.readFileSync(path.join(project, 'packages/b/extra.ts'), 'utf8')).toBe( + 'export default {};', + ); + }); + + it('retains dependencies of shared JSON files even when all workspace consumers migrate', () => { + createWorkspace(['a']); + write('.oxlintrc.json', { rules: { 'no-debugger': 'error' } }); + const shared = write('shared.json', { extends: ['./.oxlintrc.json'] }); + write('packages/a/.oxlintrc.json', { extends: ['../../shared.json'] }); + + const report = migrateWorkspace(['a']); + + expect(report.warnings).toEqual([]); + expect(resolveOxlintConfigExtends(shared).config.extends![0].rules!['no-debugger']).toBe( + 'error', + ); + expect(fs.existsSync(path.join(project, 'packages/a/.oxlintrc.json'))).toBe(false); + }); + + it('preserves JSON candidates when a failed graph leaves its dependencies unknown', () => { + createWorkspace(['a']); + const root = write('.oxlintrc.json', { rules: { 'no-debugger': 'error' } }); + write('packages/a/.oxlintrc.json', { extends: ['./missing.json', '../../.oxlintrc.json'] }); + + const report = migrateWorkspace(['a']); + + expect(report.warnings).toHaveLength(1); + expect(fs.existsSync(root)).toBe(true); + }); + it('retains a bare plugin when it resolves to the same package from both directories', () => { write('node_modules/eslint-plugin-test/package.json', { name: 'eslint-plugin-test', diff --git a/packages/cli/src/migration/migrator/orchestrators.ts b/packages/cli/src/migration/migrator/orchestrators.ts index e9afca871a..f200cf8803 100644 --- a/packages/cli/src/migration/migrator/orchestrators.ts +++ b/packages/cli/src/migration/migrator/orchestrators.ts @@ -38,6 +38,7 @@ import { projectUsesVitestDirectly, pruneLegacyWrapperAliases, removeLintStagedFromPackageJson, + removeMigratedOxlintConfigs, removeManagedVitestEntry, removeVitestPeerDependencyRule, rewriteAllImports, @@ -495,6 +496,7 @@ export function rewriteMonorepo( workspaceInfo.rootDir, workspaceContext.oxlintConfigs, ); + removeMigratedOxlintConfigs(workspaceContext.oxlintConfigs); injectLintTypeCheckDefaults(workspaceInfo.rootDir, silent, report); injectFmtDefaults(workspaceInfo.rootDir, silent, report); mergeTsdownConfigFile(workspaceInfo.rootDir, silent, report); diff --git a/packages/cli/src/migration/migrator/oxlint-config.ts b/packages/cli/src/migration/migrator/oxlint-config.ts index 270418aa1a..adac847300 100644 --- a/packages/cli/src/migration/migrator/oxlint-config.ts +++ b/packages/cli/src/migration/migrator/oxlint-config.ts @@ -39,12 +39,14 @@ export function resolveOxlintConfigExtends( ) { throw new Error(`Expected an array of file paths in ${filename}: extends`); } - const children = ((inherited ?? []) as string[]).map((entry) => - resolveOxlintConfigExtends(path.resolve(path.dirname(filename), entry), cache, [ + const children = ((inherited ?? []) as string[]).map((entry) => { + const child = resolveOxlintConfigExtends(path.resolve(path.dirname(filename), entry), cache, [ ...stack, filename, - ]), - ); + ]); + child.shared = true; + return child; + }); if (inherited !== undefined) { config.extends = children.map((child) => child.config); } @@ -73,6 +75,44 @@ export function prepareOxlintConfigs( return configs; } +/** Retained JSON consumers still need their full inheritance graph on disk. */ +export function removeMigratedOxlintConfigs(configs: Map): void { + const files = new Set(); + function collect(file: OxlintConfigFile): void { + if (files.has(file)) { + return; + } + files.add(file); + file.extends.forEach(collect); + } + for (const file of configs.values()) { + // An unreadable graph can contain references we could not discover. + // Preserve all candidates rather than break that consumer's fallback. + if (file instanceof Error) { + return; + } + collect(file); + } + const retained = new Set(); + function retain(file: OxlintConfigFile): void { + if (retained.has(file)) { + return; + } + retained.add(file); + file.extends.forEach(retain); + } + for (const file of files) { + if (!file.migrated) { + retain(file); + } + } + for (const [filename, file] of configs) { + if (!(file instanceof Error) && file.migrated && !retained.has(file)) { + fs.unlinkSync(filename); + } + } +} + function relativeImport(from: string, to: string): string { const relative = path.relative(path.dirname(from), to).split(path.sep).join('/'); if (path.isAbsolute(relative)) { @@ -177,7 +217,8 @@ export function prepareOxlintConfigModules( } file.extends.forEach(validate); } - root.extends.forEach(validate); + const importedFiles = root.shared ? [root] : root.extends; + importedFiles.forEach(validate); function generate(file: OxlintConfigFile): string { const filename = file.filename.replace(/\.jsonc?$/i, '') + '.ts'; @@ -230,7 +271,7 @@ export function prepareOxlintConfigModules( const imports: string[] = []; let nextName = 1; - const inherited = root.extends.map((file) => { + const inherited = importedFiles.map((file) => { let name: string; do { name = `oxlintConfig${nextName++}`; diff --git a/packages/cli/src/migration/migrator/shared.ts b/packages/cli/src/migration/migrator/shared.ts index b412c6ea19..c152e9fb34 100644 --- a/packages/cli/src/migration/migrator/shared.ts +++ b/packages/cli/src/migration/migrator/shared.ts @@ -43,6 +43,10 @@ export interface OxlintConfigFile { filename: string; config: OxlintConfig; extends: OxlintConfigFile[]; + // A dependency must use the same generated module in its owning workspace. + shared?: boolean; + // Workspace JSON removal waits until every consumer's outcome is known. + migrated?: boolean; // Set only after a successful merge, so workspace consumers reuse the same module. modulePath?: string; } diff --git a/packages/cli/src/migration/migrator/vite-config.ts b/packages/cli/src/migration/migrator/vite-config.ts index b948c18964..5d4da6ea5c 100644 --- a/packages/cli/src/migration/migrator/vite-config.ts +++ b/packages/cli/src/migration/migrator/vite-config.ts @@ -311,7 +311,14 @@ function mergeOxlintConfigFile( ); return; } - const oxlintJson = structuredClone(file.config); + if (file.migrated) { + return; + } + // Keep shared rules in one module, including for the workspace that owns it. + // Only migration defaults belong in the local lint block. + const oxlintJson: OxlintConfig = file.shared + ? { extends: [structuredClone(file.config)] } + : structuredClone(file.config); // Never discard an inheritance tree when a lint block already exists. if (oxlintJson.extends?.length && hasConfigKey(path.join(projectPath, viteConfig), 'lint')) { warnMigration( @@ -372,7 +379,7 @@ function mergeOxlintConfigFile( } try { const plan = - file.extends.length > 0 + file.shared || file.extends.length > 0 ? prepareOxlintConfigModules( file, normalizedOxlintConfig, @@ -380,7 +387,7 @@ function mergeOxlintConfigFile( workspaceRoot ?? projectPath, ) : undefined; - mergeAndRemoveJsonConfig( + file.migrated = mergeAndRemoveJsonConfig( projectPath, viteConfig, oxlintConfig, @@ -389,6 +396,7 @@ function mergeOxlintConfigFile( report, plan?.configContent ?? JSON.stringify(normalizedOxlintConfig, null, 2), plan, + preparedOxlintConfigs !== undefined, ); } catch (error) { warnMigration( @@ -510,7 +518,8 @@ function mergeAndRemoveJsonConfig( report?: MigrationReport, configContent?: string, modulePlan?: OxlintModulePlan, -): void { + deferRemoval = false, +): boolean { const fullViteConfigPath = path.join(projectPath, viteConfigPath); const fullJsonConfigPath = path.join(projectPath, jsonConfigPath); // Skip merge when the key is already present in vite.config.ts — the Rust @@ -520,13 +529,15 @@ function mergeAndRemoveJsonConfig( // AST-based check ignores comments, string-literal occurrences, and nested // keys (e.g. `plugins: [{ fmt: ... }]`). if (hasConfigKey(fullViteConfigPath, configKey)) { - fs.unlinkSync(fullJsonConfigPath); + if (!deferRemoval) { + fs.unlinkSync(fullJsonConfigPath); + } if (!silent) { prompts.log.info( - `${configKey} config already present in ${displayRelative(fullViteConfigPath)} — removed redundant ${displayRelative(fullJsonConfigPath)}`, + `${configKey} config already present in ${displayRelative(fullViteConfigPath)} — ${deferRemoval ? 'deferred removal of' : 'removed redundant'} ${displayRelative(fullJsonConfigPath)}`, ); } - return; + return true; } // Only delete the original JSON after a successful merge. Resolved lint // configs contain object extends, which JSON Oxlint configs do not accept. @@ -563,7 +574,9 @@ function mergeAndRemoveJsonConfig( content.slice(0, offset) + modulePlan.imports.join('\n') + '\n' + content.slice(offset); } fs.writeFileSync(fullViteConfigPath, content); - fs.unlinkSync(fullJsonConfigPath); + if (!deferRemoval) { + fs.unlinkSync(fullJsonConfigPath); + } } catch (error) { for (const filename of writtenModules) { fs.unlinkSync(filename); @@ -582,6 +595,7 @@ function mergeAndRemoveJsonConfig( `✔ Merged ${displayRelative(fullJsonConfigPath)} into ${displayRelative(fullViteConfigPath)}`, ); } + return true; } else { warnMigration( `Failed to merge ${displayRelative(fullJsonConfigPath)} into ${displayRelative(fullViteConfigPath)}`, @@ -591,6 +605,7 @@ function mergeAndRemoveJsonConfig( `Please complete the merge manually and follow the instructions in the documentation: ${documentationUrl('/config/')}`, report, ); + return false; } } From 9bf086dbff8a20c78388106f787bae213abdf7bd Mon Sep 17 00:00:00 2001 From: MK Date: Sun, 18 Oct 2026 16:13:35 +0800 Subject: [PATCH 07/24] fix(migrate): retain shared owner ignore patterns --- .../snapshots.toml | 25 +++ ..._oxlint_extends_workspace_owner_ignores.md | 180 ++++++++++++++++++ docs/guide/migrate-rules.md | 2 + .../__tests__/oxlint-extends.spec.ts | 44 +++++ .../src/migration/migrator/oxlint-config.ts | 14 +- .../cli/src/migration/migrator/vite-config.ts | 9 +- 6 files changed, 271 insertions(+), 3 deletions(-) create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/snapshots/migration_oxlint_extends_workspace_owner_ignores.md diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/snapshots.toml index 77898d695f..2c39df2276 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/snapshots.toml @@ -46,3 +46,28 @@ steps = [ { argv = ["vp", "lint", "index.js"], comment = "The migrated owner uses its shared module", continue-on-failure = true }, { argv = ["vp", "lint", "index.js"], cwd = "packages/a", comment = "The migrated intermediate owner uses its shared module", continue-on-failure = true }, ] + +[[case]] +name = "migration_oxlint_extends_workspace_owner_ignores" +vp = "local" +local-registry = true +unset-env = ["VP_SKIP_INSTALL"] +steps = [ + { argv = ["vpt", "rm", "packages/b/extra.ts"], snapshot = false }, + { argv = ["vpt", "json-edit", ".oxlintrc.json", "ignorePatterns", '["generated/**"]'], snapshot = false }, + { argv = ["vpt", "json-edit", ".oxlintrc.json", "rules.no-debugger", '"error"'], snapshot = false }, + { argv = ["vpt", "write-file", "index.js", "export const clean = true;\n"], snapshot = false }, + { argv = ["vpt", "write-file", "packages/a/index.js", "export const clean = true;\n"], snapshot = false }, + { argv = ["vpt", "write-file", "generated/built.js", "debugger;\n"], snapshot = false }, + { argv = ["vpt", "write-file", "packages/a/generated/built.js", "debugger;\n"], snapshot = false }, + { argv = ["vp", "lint", "--threads=1", "-c", ".oxlintrc.json", "index.js", "generated/built.js"], comment = "The original owner excludes its generated file" }, + { argv = ["vp", "lint", "--threads=1", "-c", ".oxlintrc.json", "index.js", "generated/built.js"], cwd = "packages/a", comment = "The dependent does not inherit the owner's ignore patterns", continue-on-failure = true }, + { argv = ["vp", "migrate", "--no-interactive", "--no-agent", "--no-editor", "--no-hooks"] }, + { argv = ["vpt", "print-file", "vite.config.mjs"], comment = "The owner's top-level ignore patterns reference its shared module" }, + { argv = ["vpt", "print-file", "packages/a/vite.config.mjs"], comment = "The dependent does not receive top-level ignore patterns" }, + { argv = ["vp", "lint", "--threads=1", "index.js", "generated/built.js"], comment = "The owner still excludes the generated file and exits successfully" }, + { argv = ["vp", "lint", "--threads=1", "index.js", "generated/built.js"], cwd = "packages/a", comment = "The dependent still reports the same diagnostic and exit code", continue-on-failure = true }, + { argv = ["vpt", "replace-file-content", ".oxlintrc.ts", '"generated/**"', '"other/**"'], comment = "Edit the shared ignore patterns once" }, + { argv = ["vp", "lint", "--threads=1", "index.js", "generated/built.js"], comment = "The owner follows the edit and now reports the generated file", continue-on-failure = true }, + { argv = ["vp", "lint", "--threads=1", "index.js", "generated/built.js"], cwd = "packages/a", comment = "The dependent's diagnostics remain unchanged", continue-on-failure = true }, +] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/snapshots/migration_oxlint_extends_workspace_owner_ignores.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/snapshots/migration_oxlint_extends_workspace_owner_ignores.md new file mode 100644 index 0000000000..8aab5221b8 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/snapshots/migration_oxlint_extends_workspace_owner_ignores.md @@ -0,0 +1,180 @@ +# migration_oxlint_extends_workspace_owner_ignores + +## `vpt rm packages/b/extra.ts` + + +## `vpt json-edit .oxlintrc.json ignorePatterns '["generated/**"]'` + + +## `vpt json-edit .oxlintrc.json rules.no-debugger '"error"'` + + +## `vpt write-file index.js 'export const clean = true; +'` + + +## `vpt write-file packages/a/index.js 'export const clean = true; +'` + + +## `vpt write-file generated/built.js 'debugger; +'` + + +## `vpt write-file packages/a/generated/built.js 'debugger; +'` + + +## `vp lint --threads=1 -c .oxlintrc.json index.js generated/built.js` + +The original owner excludes its generated file + +``` +Found 0 warnings and 0 errors. +Finished in on 1 file with rules using threads. +``` + +## `cd packages/a && vp lint --threads=1 -c .oxlintrc.json index.js generated/built.js` + +The dependent does not inherit the owner's ignore patterns + +**Exit code:** 1 + +``` + + × eslint(no-debugger): `debugger` statement is not allowed + ╭─[generated/built.js:1:1] + 1 │ debugger; + · ───────── + ╰──── + help: Remove the debugger statement + +Found 0 warnings and 1 error. +Finished in on 2 files with rules using threads. +``` + +## `vp migrate --no-interactive --no-agent --no-editor --no-hooks` + +``` +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. +◆ ✔ Merged .oxlintrc.json into vite.config.mjs +● Formatting code... +● Code formatted +◇ Migrated . to Vite+ +• Node pnpm +✓ Dependencies installed in +• 2 config updates applied +``` + +## `vpt print-file vite.config.mjs` + +The owner's top-level ignore patterns reference its shared module + +``` +import oxlintConfig1 from "./.oxlintrc.ts"; +export default { + fmt: {}, + lint: { + extends: [oxlintConfig1], + ignorePatterns: oxlintConfig1.ignorePatterns, + options: {}, + jsPlugins: [{ name: "vite-plus", specifier: "vite-plus/oxlint-plugin" }], + rules: { + "vite-plus/prefer-vite-plus-imports": "error", + }, + }, +}; +``` + +## `vpt print-file packages/a/vite.config.mjs` + +The dependent does not receive top-level ignore patterns + +``` +import oxlintConfig1 from "./.oxlintrc.ts"; +export default { + lint: { + extends: [oxlintConfig1], + options: {}, + jsPlugins: [{ name: "vite-plus", specifier: "vite-plus/oxlint-plugin" }], + rules: { + "vite-plus/prefer-vite-plus-imports": "error", + }, + }, +}; +``` + +## `vp lint --threads=1 index.js generated/built.js` + +The owner still excludes the generated file and exits successfully + +``` +Found 0 warnings and 0 errors. +Finished in on 1 file with rules using threads. +``` + +## `cd packages/a && vp lint --threads=1 index.js generated/built.js` + +The dependent still reports the same diagnostic and exit code + +**Exit code:** 1 + +``` + + × eslint(no-debugger): `debugger` statement is not allowed + ╭─[generated/built.js:1:1] + 1 │ debugger; + · ───────── + ╰──── + help: Remove the debugger statement + +Found 0 warnings and 1 error. +Finished in on 2 files with rules using threads. +``` + +## `vpt replace-file-content .oxlintrc.ts '"generated/**"' '"other/**"'` + +Edit the shared ignore patterns once + +``` +``` + +## `vp lint --threads=1 index.js generated/built.js` + +The owner follows the edit and now reports the generated file + +**Exit code:** 1 + +``` + + × eslint(no-debugger): `debugger` statement is not allowed + ╭─[generated/built.js:1:1] + 1 │ debugger; + · ───────── + ╰──── + help: Remove the debugger statement + +Found 0 warnings and 1 error. +Finished in on 2 files with rules using threads. +``` + +## `cd packages/a && vp lint --threads=1 index.js generated/built.js` + +The dependent's diagnostics remain unchanged + +**Exit code:** 1 + +``` + + × eslint(no-debugger): `debugger` statement is not allowed + ╭─[generated/built.js:1:1] + 1 │ debugger; + · ───────── + ╰──── + help: Remove the debugger statement + +Found 0 warnings and 1 error. +Finished in on 2 files with rules using threads. +``` diff --git a/docs/guide/migrate-rules.md b/docs/guide/migrate-rules.md index 7922bf152e..7975472024 100644 --- a/docs/guide/migrate-rules.md +++ b/docs/guide/migrate-rules.md @@ -31,6 +31,8 @@ Migration assumes ESM projects. Shared configs become sibling `.ts` files. Gener JS-plugin declarations stay in their original config or override. Relative plugin paths become absolute paths at runtime through `fileURLToPath(new URL(..., import.meta.url))`. Explicit inherited `typeAware` and `typeCheck` settings take precedence over migration defaults. +A shared config's owner keeps `ignorePatterns` at the top level of its `lint` block through a reference to the shared module. Edits to that array apply to the owner. Other consumers do not inherit these patterns. + Missing files, invalid JSON, inheritance cycles, conflicting output files, and package-local plugins that resolve differently from the Vite config directory produce a warning and leave the original config in place. Configs outside the project or workspace, configs inside `node_modules`, CommonJS Vite configs, and an existing `lint` block require a manual merge. Workspace JSON removal waits until all migration outcomes are known. Preserved JSON consumers retain their transitive dependencies, including configs whose owners migrated successfully. If an inheritance graph cannot be read, its workspace JSON files are kept. `vp lint` continues to reject string paths in `lint.extends`; path resolution happens only during migration. ## Pack Configuration diff --git a/packages/cli/src/migration/__tests__/oxlint-extends.spec.ts b/packages/cli/src/migration/__tests__/oxlint-extends.spec.ts index a753dfddc4..3ba7f3df82 100644 --- a/packages/cli/src/migration/__tests__/oxlint-extends.spec.ts +++ b/packages/cli/src/migration/__tests__/oxlint-extends.spec.ts @@ -285,6 +285,50 @@ describe('Oxlint JSON extends migration', () => { }, ); + it('keeps ignore patterns live at each shared owner without applying them to dependents', async () => { + createWorkspace(); + write( + 'vite.config.mjs', + 'const oxlintConfig1 = "original"; export default { custom: oxlintConfig1 };', + ); + write('.oxlintrc.json', { + ignorePatterns: ['generated/**'], + rules: { 'no-debugger': 'error' }, + }); + write('packages/a/.oxlintrc.json', { + extends: ['../../.oxlintrc.json'], + ignorePatterns: ['local/**'], + }); + write('packages/b/.oxlintrc.json', { extends: ['../a/.oxlintrc.json'] }); + + const report = migrateWorkspace(); + + expect(report.warnings).toEqual([]); + expect(fs.readFileSync(path.join(project, 'vite.config.mjs'), 'utf8')).toContain( + 'oxlintConfig2.ignorePatterns', + ); + for (const updated of [false, true]) { + if (updated) { + const rootModule = path.join(project, '.oxlintrc.ts'); + fs.writeFileSync( + rootModule, + fs.readFileSync(rootModule, 'utf8').replace('generated/**', 'built/**'), + ); + const packageModule = path.join(project, 'packages/a/.oxlintrc.ts'); + fs.writeFileSync( + packageModule, + fs.readFileSync(packageModule, 'utf8').replace('local/**', 'vendor/**'), + ); + } + const owner = await loadConfig(); + const intermediate = await loadConfig(path.join(project, 'packages/a')); + const dependent = await loadConfig(path.join(project, 'packages/b')); + expect(owner.lint!.ignorePatterns).toEqual([updated ? 'built/**' : 'generated/**']); + expect(intermediate.lint!.ignorePatterns).toEqual([updated ? 'vendor/**' : 'local/**']); + expect(dependent.lint!.ignorePatterns).toBeUndefined(); + } + }); + it('retains transitive JSON dependencies of a workspace whose module output conflicts', async () => { createWorkspace(['a', 'b', 'c']); const root = write('.oxlintrc.json', { rules: { 'no-debugger': 'error' } }); diff --git a/packages/cli/src/migration/migrator/oxlint-config.ts b/packages/cli/src/migration/migrator/oxlint-config.ts index adac847300..20aa243e60 100644 --- a/packages/cli/src/migration/migrator/oxlint-config.ts +++ b/packages/cli/src/migration/migrator/oxlint-config.ts @@ -128,6 +128,7 @@ function printConfig( config: OxlintConfig, inheritedNames: string[], pluginExpression?: (specifier: string) => string, + ignorePatternsExpression?: string, ): string { const printPlugin = (entry: NonNullable[number]): string => { const specifier = typeof entry === 'string' ? entry : entry.specifier; @@ -142,6 +143,8 @@ ${Object.entries(config) let code = JSON.stringify(value, null, 2); if (key === 'extends') { code = `[${inheritedNames.join(', ')}]`; + } else if (key === 'ignorePatterns' && ignorePatternsExpression) { + code = ignorePatternsExpression; } else if (key === 'jsPlugins' && Array.isArray(config.jsPlugins)) { code = `[${config.jsPlugins.map(printPlugin).join(', ')}]`; } else if (key === 'overrides' && config.overrides) { @@ -282,5 +285,14 @@ export function prepareOxlintConfigModules( ); return name; }); - return { configContent: printConfig(config, inherited), imports, modules }; + return { + configContent: printConfig( + config, + inherited, + undefined, + root.shared ? `${inherited[0]}.ignorePatterns` : undefined, + ), + imports, + modules, + }; } diff --git a/packages/cli/src/migration/migrator/vite-config.ts b/packages/cli/src/migration/migrator/vite-config.ts index 5d4da6ea5c..7166cf97c8 100644 --- a/packages/cli/src/migration/migrator/vite-config.ts +++ b/packages/cli/src/migration/migrator/vite-config.ts @@ -315,9 +315,14 @@ function mergeOxlintConfigFile( return; } // Keep shared rules in one module, including for the workspace that owns it. - // Only migration defaults belong in the local lint block. + // Ignore patterns only apply at the top level, so keep them local as well. const oxlintJson: OxlintConfig = file.shared - ? { extends: [structuredClone(file.config)] } + ? { + extends: [structuredClone(file.config)], + ...(file.config.ignorePatterns !== undefined + ? { ignorePatterns: file.config.ignorePatterns } + : {}), + } : structuredClone(file.config); // Never discard an inheritance tree when a lint block already exists. if (oxlintJson.extends?.length && hasConfigKey(path.join(projectPath, viteConfig), 'lint')) { From c6388657a4ce8f390b889c1e99603c37e304236c Mon Sep 17 00:00:00 2001 From: MK Date: Sun, 18 Oct 2026 18:39:26 +0800 Subject: [PATCH 08/24] fix(migrate): track completion per workspace config --- .../create-config-links.mjs | 5 + .../snapshots.toml | 27 +++ ...ation_oxlint_extends_workspace_symlinks.md | 193 ++++++++++++++++++ .../__tests__/oxlint-extends.spec.ts | 73 +++++++ .../cli/src/migration/migrator/catalog.ts | 4 +- .../src/migration/migrator/oxlint-config.ts | 36 ++-- packages/cli/src/migration/migrator/shared.ts | 8 +- .../cli/src/migration/migrator/vite-config.ts | 15 +- 8 files changed, 338 insertions(+), 23 deletions(-) create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/create-config-links.mjs create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/snapshots/migration_oxlint_extends_workspace_symlinks.md diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/create-config-links.mjs b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/create-config-links.mjs new file mode 100644 index 0000000000..8cf6f33309 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/create-config-links.mjs @@ -0,0 +1,5 @@ +import { symlinkSync } from 'node:fs'; + +for (const name of ['a', 'b']) { + symlinkSync('../../shared/lint.json', `packages/${name}/.oxlintrc.json`, 'file'); +} diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/snapshots.toml index 2c39df2276..c9566217f2 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/snapshots.toml @@ -71,3 +71,30 @@ steps = [ { argv = ["vp", "lint", "--threads=1", "index.js", "generated/built.js"], comment = "The owner follows the edit and now reports the generated file", continue-on-failure = true }, { argv = ["vp", "lint", "--threads=1", "index.js", "generated/built.js"], cwd = "packages/a", comment = "The dependent's diagnostics remain unchanged", continue-on-failure = true }, ] + +[[case]] +name = "migration_oxlint_extends_workspace_symlinks" +vp = "local" +local-registry = true +unset-env = ["VP_SKIP_INSTALL"] +steps = [ + { argv = ["vpt", "rm", "packages/a/.oxlintrc.json"], snapshot = false }, + { argv = ["vpt", "rm", "packages/b/.oxlintrc.json"], snapshot = false }, + { argv = ["vpt", "json-edit", ".oxlintrc.json", "rules.no-console", '"off"'], snapshot = false }, + { argv = ["vpt", "write-file", "shared/lint.json", '{"rules":{"no-console":"error"},"options":{"typeAware":false,"typeCheck":false}}'], snapshot = false }, + { argv = ["vpt", "write-file", "packages/a/index.js", 'console.log("hello");'], snapshot = false }, + { argv = ["vpt", "write-file", "packages/b/index.js", 'console.log("hello");'], snapshot = false }, + { argv = ["node", "create-config-links.mjs"], snapshot = false }, + { argv = ["vpt", "stat-file", "packages/a/.oxlintrc.json", "--assert", "symlink"] }, + { argv = ["vpt", "stat-file", "packages/b/.oxlintrc.json", "--assert", "symlink"] }, + { argv = ["vp", "lint", "--threads=1", "-c", ".oxlintrc.json", "index.js"], cwd = "packages/a", comment = "The first symlinked consumer reports no-console before migration", continue-on-failure = true }, + { argv = ["vp", "lint", "--threads=1", "-c", ".oxlintrc.json", "index.js"], cwd = "packages/b", comment = "The second symlinked consumer reports the same error before migration", continue-on-failure = true }, + { argv = ["vp", "migrate", "--no-interactive", "--no-agent", "--no-editor", "--no-hooks"] }, + { argv = ["vpt", "print-file", "packages/a/vite.config.mjs"], comment = "Each consumer receives its own lint block" }, + { argv = ["vpt", "print-file", "packages/b/vite.config.mjs"] }, + { argv = ["vpt", "stat-file", "packages/a/.oxlintrc.json", "--assert", "missing"] }, + { argv = ["vpt", "stat-file", "packages/b/.oxlintrc.json", "--assert", "missing"] }, + { argv = ["vpt", "print-file", "shared/lint.json"], comment = "Removing workspace symlinks preserves their shared JSON target" }, + { argv = ["vp", "lint", "--threads=1", "index.js"], cwd = "packages/a", comment = "The first migrated consumer keeps the same diagnostic and exit code", continue-on-failure = true }, + { argv = ["vp", "lint", "--threads=1", "index.js"], cwd = "packages/b", comment = "The second migrated consumer keeps the same diagnostic and exit code", continue-on-failure = true }, +] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/snapshots/migration_oxlint_extends_workspace_symlinks.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/snapshots/migration_oxlint_extends_workspace_symlinks.md new file mode 100644 index 0000000000..deef12b00f --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/snapshots/migration_oxlint_extends_workspace_symlinks.md @@ -0,0 +1,193 @@ +# migration_oxlint_extends_workspace_symlinks + +## `vpt rm packages/a/.oxlintrc.json` + + +## `vpt rm packages/b/.oxlintrc.json` + + +## `vpt json-edit .oxlintrc.json rules.no-console '"off"'` + + +## `vpt write-file shared/lint.json '{"rules":{"no-console":"error"},"options":{"typeAware":false,"typeCheck":false}}'` + + +## `vpt write-file packages/a/index.js 'console.log("hello");'` + + +## `vpt write-file packages/b/index.js 'console.log("hello");'` + + +## `node create-config-links.mjs` + + +## `vpt stat-file packages/a/.oxlintrc.json --assert symlink` + +``` +packages/a/.oxlintrc.json: symlink +``` + +## `vpt stat-file packages/b/.oxlintrc.json --assert symlink` + +``` +packages/b/.oxlintrc.json: symlink +``` + +## `cd packages/a && vp lint --threads=1 -c .oxlintrc.json index.js` + +The first symlinked consumer reports no-console before migration + +**Exit code:** 1 + +``` + + × eslint(no-console): Unexpected console statement. + ╭─[index.js:1:1] + 1 │ console.log("hello"); + · ─────────── + ╰──── + help: Delete this console statement. + +Found 0 warnings and 1 error. +Finished in on 1 file with rules using threads. +``` + +## `cd packages/b && vp lint --threads=1 -c .oxlintrc.json index.js` + +The second symlinked consumer reports the same error before migration + +**Exit code:** 1 + +``` + + × eslint(no-console): Unexpected console statement. + ╭─[index.js:1:1] + 1 │ console.log("hello"); + · ─────────── + ╰──── + help: Delete this console statement. + +Found 0 warnings and 1 error. +Finished in on 1 file with rules using threads. +``` + +## `vp migrate --no-interactive --no-agent --no-editor --no-hooks` + +``` +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. +◆ ✔ Merged .oxlintrc.json into vite.config.mjs +● Formatting code... +● Code formatted +◇ Migrated . to Vite+ +• Node pnpm +✓ Dependencies installed in +• 2 config updates applied +``` + +## `vpt print-file packages/a/vite.config.mjs` + +Each consumer receives its own lint block + +``` +export default { + lint: { + rules: { + "no-console": "error", + "vite-plus/prefer-vite-plus-imports": "error", + }, + options: { + typeAware: false, + typeCheck: false, + }, + jsPlugins: [ + { + name: "vite-plus", + specifier: "vite-plus/oxlint-plugin", + }, + ], + }, +}; +``` + +## `vpt print-file packages/b/vite.config.mjs` + +``` +export default { + lint: { + rules: { + "no-console": "error", + "vite-plus/prefer-vite-plus-imports": "error", + }, + options: { + typeAware: false, + typeCheck: false, + }, + jsPlugins: [ + { + name: "vite-plus", + specifier: "vite-plus/oxlint-plugin", + }, + ], + }, +}; +``` + +## `vpt stat-file packages/a/.oxlintrc.json --assert missing` + +``` +packages/a/.oxlintrc.json: missing +``` + +## `vpt stat-file packages/b/.oxlintrc.json --assert missing` + +``` +packages/b/.oxlintrc.json: missing +``` + +## `vpt print-file shared/lint.json` + +Removing workspace symlinks preserves their shared JSON target + +``` +{ "rules": { "no-console": "error" }, "options": { "typeAware": false, "typeCheck": false } } +``` + +## `cd packages/a && vp lint --threads=1 index.js` + +The first migrated consumer keeps the same diagnostic and exit code + +**Exit code:** 1 + +``` + + × eslint(no-console): Unexpected console statement. + ╭─[index.js:1:1] + 1 │ console.log("hello"); + · ─────────── + ╰──── + help: Delete this console statement. + +Found 0 warnings and 1 error. +Finished in on 1 file with rules using threads. +``` + +## `cd packages/b && vp lint --threads=1 index.js` + +The second migrated consumer keeps the same diagnostic and exit code + +**Exit code:** 1 + +``` + + × eslint(no-console): Unexpected console statement. + ╭─[index.js:1:1] + 1 │ console.log("hello"); + · ─────────── + ╰──── + help: Delete this console statement. + +Found 0 warnings and 1 error. +Finished in on 1 file with rules using threads. +``` diff --git a/packages/cli/src/migration/__tests__/oxlint-extends.spec.ts b/packages/cli/src/migration/__tests__/oxlint-extends.spec.ts index 3ba7f3df82..5c20cf10fd 100644 --- a/packages/cli/src/migration/__tests__/oxlint-extends.spec.ts +++ b/packages/cli/src/migration/__tests__/oxlint-extends.spec.ts @@ -329,6 +329,79 @@ describe('Oxlint JSON extends migration', () => { } }); + it.each([false, true])( + 'migrates both symlinked workspace configs (shared target extends root: %s)', + async (extendsRoot) => { + createWorkspace(); + const root = write('.oxlintrc.json', { rules: { 'no-console': 'off' } }); + const shared = write('shared/lint.json', { + ...(extendsRoot ? { extends: ['../.oxlintrc.json'] } : {}), + rules: { 'no-console': 'error' }, + options: { typeAware: false, typeCheck: false }, + }); + const original = fs.readFileSync(shared, 'utf8'); + for (const name of ['a', 'b']) { + fs.symlinkSync( + '../../shared/lint.json', + path.join(project, `packages/${name}/.oxlintrc.json`), + ); + } + + const report = migrateWorkspace(); + + expect(report.warnings).toEqual([]); + for (const name of ['a', 'b']) { + const directory = path.join(project, `packages/${name}`); + const { lint } = await loadConfig(directory); + expect(lint!.rules!['no-console']).toBe('error'); + expect(lint!.options).toEqual({ typeAware: false, typeCheck: false }); + expect(fs.existsSync(path.join(directory, '.oxlintrc.json'))).toBe(false); + if (extendsRoot) { + expect(lint!.extends![0].rules!['no-console']).toBe('off'); + } + } + expect(fs.readFileSync(shared, 'utf8')).toBe(original); + // Removing the links leaves their target on disk, so its dependencies must remain too. + expect(fs.existsSync(root)).toBe(extendsRoot); + if (extendsRoot) { + expect(resolveOxlintConfigExtends(shared).config.extends![0].rules!['no-console']).toBe( + 'off', + ); + } + }, + ); + + it('preserves a failed symlinked consumer after its sibling completes migration', async () => { + createWorkspace(); + write('.oxlintrc.json', { rules: { 'no-console': 'off' } }); + write('shared/lint.json', { + extends: ['../.oxlintrc.json'], + rules: { 'no-console': 'error' }, + }); + for (const name of ['a', 'b']) { + fs.symlinkSync( + '../../shared/lint.json', + path.join(project, `packages/${name}/.oxlintrc.json`), + ); + } + const original = "export { default } from './another-config.mjs';"; + const viteConfig = write('packages/b/vite.config.mjs', original); + + const report = migrateWorkspace(); + + expect(report.warnings).toHaveLength(1); + expect(report.warnings[0]).toContain('ESM default export'); + expect((await loadConfig(path.join(project, 'packages/a'))).lint!.rules!['no-console']).toBe( + 'error', + ); + expect(fs.readFileSync(viteConfig, 'utf8')).toBe(original); + const preserved = path.join(project, 'packages/b/.oxlintrc.json'); + expect(fs.lstatSync(preserved).isSymbolicLink()).toBe(true); + expect(resolveOxlintConfigExtends(preserved).config.extends![0].rules!['no-console']).toBe( + 'off', + ); + }); + it('retains transitive JSON dependencies of a workspace whose module output conflicts', async () => { createWorkspace(['a', 'b', 'c']); const root = write('.oxlintrc.json', { rules: { 'no-debugger': 'error' } }); diff --git a/packages/cli/src/migration/migrator/catalog.ts b/packages/cli/src/migration/migrator/catalog.ts index 7b6af8e975..81730beaeb 100644 --- a/packages/cli/src/migration/migrator/catalog.ts +++ b/packages/cli/src/migration/migrator/catalog.ts @@ -32,7 +32,7 @@ import { workspaceUsesWebdriverioProvider, } from '../migrator.ts'; import { - type OxlintConfigFile, + type OxlintConfigMigration, LEGACY_WRAPPER_FALLBACK_VERSIONS, PROVIDER_OVERRIDE_DROP_NAMES, REMOVE_PACKAGES, @@ -1152,7 +1152,7 @@ export function rewriteRootWorkspacePackageJson( // catalogs require Yarn >= 4.10.0; pnpm/bun monorepos always manage a catalog. // When false, the root `vite-plus` edge and the direct `vite` edge are concrete. supportCatalog = true, - preparedOxlintConfigs?: Map, + preparedOxlintConfigs?: Map, ): void { const packageJsonPath = path.join(projectPath, 'package.json'); if (!fs.existsSync(packageJsonPath)) { diff --git a/packages/cli/src/migration/migrator/oxlint-config.ts b/packages/cli/src/migration/migrator/oxlint-config.ts index 20aa243e60..7223bd11ed 100644 --- a/packages/cli/src/migration/migrator/oxlint-config.ts +++ b/packages/cli/src/migration/migrator/oxlint-config.ts @@ -8,7 +8,7 @@ import type { OxlintConfig } from 'oxlint'; import { analyzeMigrationSource, ResolverFactory } from '../../../binding/index.js'; import { detectConfigs } from '../detector.ts'; import { ensureSvelteRuneGlobals } from '../migrator.ts'; -import type { OxlintConfigFile, OxlintModulePlan } from './shared.ts'; +import type { OxlintConfigFile, OxlintConfigMigration, OxlintModulePlan } from './shared.ts'; /** Read the inheritance graph before migration removes any workspace configs. */ export function resolveOxlintConfigExtends( @@ -57,8 +57,8 @@ export function resolveOxlintConfigExtends( export function prepareOxlintConfigs( projectPaths: string[], -): Map { - const configs = new Map(); +): Map { + const configs = new Map(); const cache = new Map(); for (const projectPath of projectPaths) { const filename = detectConfigs(projectPath).oxlintConfig; @@ -67,7 +67,7 @@ export function prepareOxlintConfigs( } const configPath = path.join(projectPath, filename); try { - configs.set(configPath, resolveOxlintConfigExtends(configPath, cache)); + configs.set(configPath, { file: resolveOxlintConfigExtends(configPath, cache) }); } catch (error) { configs.set(configPath, error instanceof Error ? error : new Error(String(error))); } @@ -76,8 +76,11 @@ export function prepareOxlintConfigs( } /** Retained JSON consumers still need their full inheritance graph on disk. */ -export function removeMigratedOxlintConfigs(configs: Map): void { +export function removeMigratedOxlintConfigs( + configs: Map, +): void { const files = new Set(); + const removableSources = new Set(); function collect(file: OxlintConfigFile): void { if (files.has(file)) { return; @@ -85,13 +88,17 @@ export function removeMigratedOxlintConfigs(configs: Map(); function retain(file: OxlintConfigFile): void { @@ -102,12 +109,17 @@ export function removeMigratedOxlintConfigs(configs: Map, + preparedOxlintConfigs?: Map, ): void { const configs = detectConfigs(projectPath); if (!configs.oxfmtConfig && !configs.oxlintConfig) { @@ -293,17 +293,17 @@ function mergeOxlintConfigFile( report?: MigrationReport, packages?: WorkspacePackage[], workspaceRoot?: string, - preparedOxlintConfigs?: Map, + preparedOxlintConfigs?: Map, ): void { // Inject options.typeAware and options.typeCheck defaults before merging const fullOxlintPath = path.join(projectPath, oxlintConfig); - let file: OxlintConfigFile; + let migration: OxlintConfigMigration; try { const prepared = preparedOxlintConfigs?.get(fullOxlintPath); if (prepared instanceof Error) { throw prepared; } - file = prepared ?? resolveOxlintConfigExtends(fullOxlintPath); + migration = prepared ?? { file: resolveOxlintConfigExtends(fullOxlintPath) }; } catch (error) { warnMigration( `Could not migrate ${displayRelative(fullOxlintPath)}: ${String(error)}. The original config was preserved; use vp lint -c ${oxlintConfig} until it is migrated manually.`, @@ -311,9 +311,10 @@ function mergeOxlintConfigFile( ); return; } - if (file.migrated) { + if (migration.migrated) { return; } + const { file } = migration; // Keep shared rules in one module, including for the workspace that owns it. // Ignore patterns only apply at the top level, so keep them local as well. const oxlintJson: OxlintConfig = file.shared @@ -392,7 +393,7 @@ function mergeOxlintConfigFile( workspaceRoot ?? projectPath, ) : undefined; - file.migrated = mergeAndRemoveJsonConfig( + migration.migrated = mergeAndRemoveJsonConfig( projectPath, viteConfig, oxlintConfig, From e115cc23b922ab5839f9eb0a91b1ab23066e7836 Mon Sep 17 00:00:00 2001 From: MK Date: Sun, 18 Oct 2026 19:12:19 +0800 Subject: [PATCH 09/24] refactor(migrate): simplify Oxlint config migration --- .../src/migration/migrator/oxlint-config.ts | 134 +++++++++--------- .../cli/src/migration/migrator/vite-config.ts | 110 +++++++------- 2 files changed, 123 insertions(+), 121 deletions(-) diff --git a/packages/cli/src/migration/migrator/oxlint-config.ts b/packages/cli/src/migration/migrator/oxlint-config.ts index 7223bd11ed..c31331b448 100644 --- a/packages/cli/src/migration/migrator/oxlint-config.ts +++ b/packages/cli/src/migration/migrator/oxlint-config.ts @@ -75,51 +75,51 @@ export function prepareOxlintConfigs( return configs; } +function collectConfigFiles(file: OxlintConfigFile, target: Set): void { + if (target.has(file)) { + return; + } + target.add(file); + for (const child of file.extends) { + collectConfigFiles(child, target); + } +} + /** Retained JSON consumers still need their full inheritance graph on disk. */ export function removeMigratedOxlintConfigs( configs: Map, ): void { const files = new Set(); + const retained = new Set(); const removableSources = new Set(); - function collect(file: OxlintConfigFile): void { - if (files.has(file)) { - return; - } - files.add(file); - file.extends.forEach(collect); - } + const removalCandidates = new Map(); for (const [filename, migration] of configs) { // An unreadable graph can contain references we could not discover. // Preserve all candidates rather than break that consumer's fallback. if (migration instanceof Error) { return; } - collect(migration.file); - // Unlinking a workspace symlink leaves its shared JSON target on disk. - if (migration.migrated && !fs.lstatSync(filename).isSymbolicLink()) { - removableSources.add(migration.file); + const { file, migrated } = migration; + collectConfigFiles(file, files); + if (!migrated) { + collectConfigFiles(file, retained); + continue; } - } - const retained = new Set(); - function retain(file: OxlintConfigFile): void { - if (retained.has(file)) { - return; + removalCandidates.set(filename, file); + // Unlinking a workspace symlink leaves its shared JSON target on disk. + if (!fs.lstatSync(filename).isSymbolicLink()) { + removableSources.add(file); } - retained.add(file); - file.extends.forEach(retain); } for (const file of files) { if (!removableSources.has(file)) { - file.extends.forEach(retain); - } - } - for (const migration of configs.values()) { - if (!(migration instanceof Error) && !migration.migrated) { - retain(migration.file); + for (const child of file.extends) { + collectConfigFiles(child, retained); + } } } - for (const [filename, migration] of configs) { - if (!(migration instanceof Error) && migration.migrated && !retained.has(migration.file)) { + for (const [filename, file] of removalCandidates) { + if (!retained.has(file)) { fs.unlinkSync(filename); } } @@ -139,20 +139,23 @@ function relativeImport(from: string, to: string): string { function printConfig( config: OxlintConfig, inheritedNames: string[], - pluginExpression?: (specifier: string) => string, - ignorePatternsExpression?: string, + { + pluginExpression, + ignorePatternsExpression, + }: { + pluginExpression?: (specifier: string) => string; + ignorePatternsExpression?: string; + } = {}, ): string { - const printPlugin = (entry: NonNullable[number]): string => { + function printPlugin(entry: NonNullable[number]): string { const specifier = typeof entry === 'string' ? entry : entry.specifier; const expression = pluginExpression?.(specifier) ?? JSON.stringify(specifier); return typeof entry === 'string' ? expression : `{ "name": ${JSON.stringify(entry.name)}, "specifier": ${expression} }`; - }; - return `{ -${Object.entries(config) - .map(([key, value]) => { - let code = JSON.stringify(value, null, 2); + } + const properties = Object.entries(config).map(([key, value]) => { + let code: string; if (key === 'extends') { code = `[${inheritedNames.join(', ')}]`; } else if (key === 'ignorePatterns' && ignorePatternsExpression) { @@ -160,12 +163,17 @@ ${Object.entries(config) } else if (key === 'jsPlugins' && Array.isArray(config.jsPlugins)) { code = `[${config.jsPlugins.map(printPlugin).join(', ')}]`; } else if (key === 'overrides' && config.overrides) { - code = `[${config.overrides.map((override) => printConfig(override, [], pluginExpression)).join(',\n')}]`; + code = `[${config.overrides.map((override) => printConfig(override, [], { pluginExpression })).join(',\n')}]`; + } else { + code = JSON.stringify(value, null, 2); } return ` ${JSON.stringify(key)}: ${code.replaceAll('\n', '\n ')}`; - }) - .join(',\n')} -}`; + }); + return `{\n${properties.join(',\n')}\n}`; +} + +function isRelativePluginPath(specifier: string): boolean { + return /^\.{1,2}(?:[/\\]|$)/.test(specifier); } /** Generate portable shared modules, leaving their plugins in their original layers. */ @@ -176,6 +184,7 @@ export function prepareOxlintConfigModules( workspaceRoot: string, ): OxlintModulePlan { viteConfigPath = fs.realpathSync(viteConfigPath); + const viteConfigDir = path.dirname(viteConfigPath); const source = fs.readFileSync(viteConfigPath, 'utf8'); const names = new Set(); const { ast } = JSON.parse(analyzeMigrationSource(viteConfigPath, source), (key, value) => { @@ -211,22 +220,24 @@ export function prepareOxlintConfigModules( `Shared Oxlint config is outside the project or in node_modules: ${file.filename}`, ); } + const configDir = path.dirname(file.filename); for (const layer of [file.config, ...(file.config.overrides ?? [])]) { for (const entry of layer.jsPlugins ?? []) { const specifier = typeof entry === 'string' ? entry : entry.specifier; if ( - !/^\.{1,2}(?:[/\\]|$)/.test(specifier) && - !path.isAbsolute(specifier) && - path.dirname(file.filename) !== path.dirname(viteConfigPath) + isRelativePluginPath(specifier) || + path.isAbsolute(specifier) || + configDir === viteConfigDir ) { - resolver ??= new ResolverFactory({ conditionNames: ['module-sync', 'node', 'import'] }); - const original = resolver.sync(path.dirname(file.filename), specifier); - const migrated = resolver.sync(path.dirname(viteConfigPath), specifier); - if (original.path && original.path !== migrated.path) { - throw new Error( - `JS plugin "${specifier}" in ${file.filename} resolves differently from the Vite config directory; migrate this plugin manually`, - ); - } + continue; + } + resolver ??= new ResolverFactory({ conditionNames: ['module-sync', 'node', 'import'] }); + const original = resolver.sync(configDir, specifier); + const migrated = resolver.sync(viteConfigDir, specifier); + if (original.path && original.path !== migrated.path) { + throw new Error( + `JS plugin "${specifier}" in ${file.filename} resolves differently from the Vite config directory; migrate this plugin manually`, + ); } } } @@ -253,7 +264,8 @@ export function prepareOxlintConfigModules( `Cannot create shared Oxlint config because ${filename} or ${runtimePath} already exists`, ); } - modules.set(filename, { file, content: '' }); + const module = { file, content: '' }; + modules.set(filename, module); const imports = ['import type { OxlintConfig } from "vite-plus/lint";']; const inherited = file.extends.map((child, index) => { const name = `oxlintConfig${index + 1}`; @@ -263,24 +275,21 @@ export function prepareOxlintConfigModules( return name; }); let needsFileUrl = false; - const pluginExpression = (specifier: string): string => { - if (/^\.{1,2}(?:[/\\]|$)/.test(specifier)) { + function pluginExpression(specifier: string): string { + if (isRelativePluginPath(specifier)) { needsFileUrl = true; const url = specifier.replaceAll('\\', '/').split('/').map(encodeURIComponent).join('/'); return `fileURLToPath(new URL(${JSON.stringify(url)}, import.meta.url))`; } return JSON.stringify(specifier); - }; + } const config = structuredClone(file.config); ensureSvelteRuneGlobals(config); - const body = printConfig(config, inherited, pluginExpression); + const body = printConfig(config, inherited, { pluginExpression }); if (needsFileUrl) { imports.unshift('import { fileURLToPath } from "node:url";'); } - modules.set(filename, { - file, - content: `${imports.join('\n')}\n\nexport default ${body} satisfies OxlintConfig;\n`, - }); + module.content = `${imports.join('\n')}\n\nexport default ${body} satisfies OxlintConfig;\n`; return filename; } @@ -298,12 +307,9 @@ export function prepareOxlintConfigModules( return name; }); return { - configContent: printConfig( - config, - inherited, - undefined, - root.shared ? `${inherited[0]}.ignorePatterns` : undefined, - ), + configContent: printConfig(config, inherited, { + ignorePatternsExpression: root.shared ? `${inherited[0]}.ignorePatterns` : undefined, + }), imports, modules, }; diff --git a/packages/cli/src/migration/migrator/vite-config.ts b/packages/cli/src/migration/migrator/vite-config.ts index 750cac4b23..8f6e113fcc 100644 --- a/packages/cli/src/migration/migrator/vite-config.ts +++ b/packages/cli/src/migration/migrator/vite-config.ts @@ -317,14 +317,13 @@ function mergeOxlintConfigFile( const { file } = migration; // Keep shared rules in one module, including for the workspace that owns it. // Ignore patterns only apply at the top level, so keep them local as well. - const oxlintJson: OxlintConfig = file.shared - ? { - extends: [structuredClone(file.config)], - ...(file.config.ignorePatterns !== undefined - ? { ignorePatterns: file.config.ignorePatterns } - : {}), - } - : structuredClone(file.config); + let oxlintJson: OxlintConfig = structuredClone(file.config); + if (file.shared) { + oxlintJson = { extends: [oxlintJson] }; + if (file.config.ignorePatterns !== undefined) { + oxlintJson.ignorePatterns = file.config.ignorePatterns; + } + } // Never discard an inheritance tree when a lint block already exists. if (oxlintJson.extends?.length && hasConfigKey(path.join(projectPath, viteConfig), 'lint')) { warnMigration( @@ -372,14 +371,11 @@ function mergeOxlintConfigFile( for (const layer of layers) { ensureSvelteRuneGlobals(layer); } - const inheritedImportRule = layers.findLast( + const hasInheritedImportRule = layers.some( (layer) => layer.rules?.[PREFER_VITE_PLUS_IMPORTS_RULE] !== undefined, - )?.rules?.[PREFER_VITE_PLUS_IMPORTS_RULE]; + ); const normalizedOxlintConfig = ensureVitePlusImportRuleDefaults(oxlintJson); - if ( - inheritedImportRule !== undefined && - oxlintJson.rules?.[PREFER_VITE_PLUS_IMPORTS_RULE] === undefined - ) { + if (hasInheritedImportRule && oxlintJson.rules?.[PREFER_VITE_PLUS_IMPORTS_RULE] === undefined) { // Keep the shared rule live: copying its value into the root would mask later edits. delete normalizedOxlintConfig.rules![PREFER_VITE_PLUS_IMPORTS_RULE]; } @@ -560,49 +556,7 @@ function mergeAndRemoveJsonConfig( fs.rmSync(tempDir, { recursive: true, force: true }); } } - if (result.updated) { - const originalViteConfig = fs.readFileSync(fullViteConfigPath, 'utf8'); - const writtenModules: string[] = []; - try { - for (const [filename, module] of modulePlan?.modules ?? []) { - const descriptor = fs.openSync(filename, 'wx'); - writtenModules.push(filename); - try { - fs.writeFileSync(descriptor, module.content); - } finally { - fs.closeSync(descriptor); - } - } - let content = result.content; - if (modulePlan?.imports.length) { - const offset = content.startsWith('#!') ? content.indexOf('\n') + 1 : 0; - content = - content.slice(0, offset) + modulePlan.imports.join('\n') + '\n' + content.slice(offset); - } - fs.writeFileSync(fullViteConfigPath, content); - if (!deferRemoval) { - fs.unlinkSync(fullJsonConfigPath); - } - } catch (error) { - for (const filename of writtenModules) { - fs.unlinkSync(filename); - } - fs.writeFileSync(fullViteConfigPath, originalViteConfig); - throw error; - } - for (const [filename, module] of modulePlan?.modules ?? []) { - module.file.modulePath = filename; - } - if (report) { - report.mergedConfigCount++; - } - if (!silent) { - prompts.log.success( - `✔ Merged ${displayRelative(fullJsonConfigPath)} into ${displayRelative(fullViteConfigPath)}`, - ); - } - return true; - } else { + if (!result.updated) { warnMigration( `Failed to merge ${displayRelative(fullJsonConfigPath)} into ${displayRelative(fullViteConfigPath)}`, report, @@ -613,6 +567,48 @@ function mergeAndRemoveJsonConfig( ); return false; } + + const originalViteConfig = fs.readFileSync(fullViteConfigPath, 'utf8'); + const writtenModules: string[] = []; + try { + for (const [filename, module] of modulePlan?.modules ?? []) { + const descriptor = fs.openSync(filename, 'wx'); + writtenModules.push(filename); + try { + fs.writeFileSync(descriptor, module.content); + } finally { + fs.closeSync(descriptor); + } + } + let content = result.content; + if (modulePlan?.imports.length) { + const offset = content.startsWith('#!') ? content.indexOf('\n') + 1 : 0; + content = + content.slice(0, offset) + modulePlan.imports.join('\n') + '\n' + content.slice(offset); + } + fs.writeFileSync(fullViteConfigPath, content); + if (!deferRemoval) { + fs.unlinkSync(fullJsonConfigPath); + } + } catch (error) { + for (const filename of writtenModules) { + fs.unlinkSync(filename); + } + fs.writeFileSync(fullViteConfigPath, originalViteConfig); + throw error; + } + for (const [filename, module] of modulePlan?.modules ?? []) { + module.file.modulePath = filename; + } + if (report) { + report.mergedConfigCount++; + } + if (!silent) { + prompts.log.success( + `✔ Merged ${displayRelative(fullJsonConfigPath)} into ${displayRelative(fullViteConfigPath)}`, + ); + } + return true; } /** From b29c0285efc89e5daf738ac996a8821d988943d1 Mon Sep 17 00:00:00 2001 From: MK Date: Mon, 19 Oct 2026 15:32:59 +0800 Subject: [PATCH 10/24] fix(migrate): preserve owner fields and resume partial migrations --- .../migration_oxlint_extends/snapshots.toml | 23 ++ .../snapshots/migration_oxlint_extends.md | 3 + .../migration_oxlint_extends_type_options.md | 161 +++++++++ .../settings-plugin.mjs | 17 + .../snapshots.toml | 57 ++++ ...n_oxlint_extends_workspace_owner_fields.md | 317 ++++++++++++++++++ ...igration_oxlint_extends_workspace_retry.md | 112 +++++++ ...n_oxlint_extends_workspace_shared_owner.md | 2 + docs/guide/migrate-rules.md | 4 +- .../__tests__/oxlint-extends.spec.ts | 95 ++++++ packages/cli/src/migration/bin.ts | 12 + .../src/migration/migrator/oxlint-config.ts | 45 ++- packages/cli/src/migration/migrator/shared.ts | 5 +- .../cli/src/migration/migrator/vite-config.ts | 56 +++- 14 files changed, 895 insertions(+), 14 deletions(-) create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends/snapshots/migration_oxlint_extends_type_options.md create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/settings-plugin.mjs create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/snapshots/migration_oxlint_extends_workspace_owner_fields.md create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/snapshots/migration_oxlint_extends_workspace_retry.md diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends/snapshots.toml index cbf08be74b..daef9e7df2 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends/snapshots.toml @@ -25,3 +25,26 @@ steps = [ { argv = ["vp", "lint", "--threads=1", "index.js"], comment = "The edited base rule now warns in index.js", continue-on-failure = true }, { argv = ["vp", "lint", "--threads=1", "example.test.js"], comment = "The test override remains unchanged after the shared config edit", continue-on-failure = true }, ] + +[[case]] +name = "migration_oxlint_extends_type_options" +vp = "local" +local-registry = true +unset-env = ["VP_SKIP_INSTALL"] +steps = [ + { argv = ["vpt", "json-edit", "shared/base.json", "options", '{"typeAware":true,"typeCheck":true}'], snapshot = false }, + { argv = ["vpt", "json-edit", ".oxlintrc.json", "rules.typescript/no-floating-promises", '"error"'], snapshot = false }, + { argv = ["vpt", "write-file", "tsconfig.json", '{"compilerOptions":{"target":"ESNext","strict":true},"include":["*.ts"]}'], snapshot = false }, + { argv = ["vpt", "write-file", "type-error.ts", "export const value: string = 1;\n"], snapshot = false }, + { argv = ["vpt", "write-file", "type-aware.ts", "Promise.resolve(1);\n"], snapshot = false }, + { argv = ["vp", "lint", "--threads=1", "-c", ".oxlintrc.json", "type-error.ts"], comment = "Inherited typeCheck reports the original TypeScript diagnostic", continue-on-failure = true }, + { argv = ["vp", "lint", "--threads=1", "-c", ".oxlintrc.json", "type-aware.ts"], comment = "Inherited typeAware enables no-floating-promises", continue-on-failure = true }, + { argv = ["vp", "migrate", "--no-interactive", "--no-agent", "--no-editor", "--no-hooks"] }, + { argv = ["vpt", "print-file", "vite.config.ts"], comment = "Local options stay empty so inherited settings remain live" }, + { argv = ["vp", "lint", "--threads=1", "type-error.ts"], comment = "Imported typeCheck produces the same diagnostic and exit code", continue-on-failure = true }, + { argv = ["vp", "lint", "--threads=1", "type-aware.ts"], comment = "Imported typeAware produces the same diagnostic and exit code", continue-on-failure = true }, + { argv = ["vpt", "replace-file-content", "shared/base.ts", "typeAware: true", "typeAware: false"] }, + { argv = ["vpt", "replace-file-content", "shared/base.ts", "typeCheck: true", "typeCheck: false"] }, + { argv = ["vp", "lint", "--threads=1", "type-error.ts"], comment = "Disabling the shared options removes the TypeScript diagnostic" }, + { argv = ["vp", "lint", "--threads=1", "type-aware.ts"], comment = "Disabling the shared options removes the type-aware diagnostic" }, +] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends/snapshots/migration_oxlint_extends.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends/snapshots/migration_oxlint_extends.md index 0348146089..2156fb2f54 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends/snapshots/migration_oxlint_extends.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends/snapshots/migration_oxlint_extends.md @@ -107,6 +107,7 @@ export default defineConfig({ Nested imports preserve overrides and their plugin declarations ``` +// Generated by vp migrate from first.json. import { fileURLToPath } from "node:url"; import type { OxlintConfig } from "vite-plus/lint"; import oxlintConfig1 from "../base.js"; @@ -134,6 +135,7 @@ export default { ## `vpt print-file shared/base.ts` ``` +// Generated by vp migrate from base.json. import type { OxlintConfig } from "vite-plus/lint"; export default { @@ -154,6 +156,7 @@ export default { Relative plugin paths resolve from the shared module ``` +// Generated by vp migrate from second.jsonc. import { fileURLToPath } from "node:url"; import type { OxlintConfig } from "vite-plus/lint"; diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends/snapshots/migration_oxlint_extends_type_options.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends/snapshots/migration_oxlint_extends_type_options.md new file mode 100644 index 0000000000..dd4f60cfa4 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends/snapshots/migration_oxlint_extends_type_options.md @@ -0,0 +1,161 @@ +# migration_oxlint_extends_type_options + +## `vpt json-edit shared/base.json options '{"typeAware":true,"typeCheck":true}'` + + +## `vpt json-edit .oxlintrc.json rules.typescript/no-floating-promises '"error"'` + + +## `vpt write-file tsconfig.json '{"compilerOptions":{"target":"ESNext","strict":true},"include":["*.ts"]}'` + + +## `vpt write-file type-error.ts 'export const value: string = 1; +'` + + +## `vpt write-file type-aware.ts 'Promise.resolve(1); +'` + + +## `vp lint --threads=1 -c .oxlintrc.json type-error.ts` + +Inherited typeCheck reports the original TypeScript diagnostic + +**Exit code:** 1 + +``` + + × typescript(TS2322): Type 'number' is not assignable to type 'string'. + ╭─[type-error.ts:1:14] + 1 │ export const value: string = 1; + · ───── + ╰──── + +Found 0 warnings and 1 error. +Finished in on 1 file with rules using threads. +``` + +## `vp lint --threads=1 -c .oxlintrc.json type-aware.ts` + +Inherited typeAware enables no-floating-promises + +**Exit code:** 1 + +``` + + × typescript(no-floating-promises): Promises must be awaited, add void operator to ignore. + ╭─[type-aware.ts:1:1] + 1 │ Promise.resolve(1); + · ─────────┬───────── + · ╰── This unhandled promise-like value has type `Promise`. + ╰──── + help: The promise must end with a call to .catch, or end with a call to .then with a rejection handler, or be explicitly marked as ignored with the `void` operator. + +Found 0 warnings and 1 error. +Finished in on 1 file with rules using threads. +``` + +## `vp migrate --no-interactive --no-agent --no-editor --no-hooks` + +``` +VITE+ - The Unified Toolchain for the Web + +● Formatting code... +● Code formatted +◇ Migrated . to Vite+ +• Node pnpm +✓ Dependencies installed in +• 2 config updates applied +``` + +## `vpt print-file vite.config.ts` + +Local options stay empty so inherited settings remain live + +``` +import oxlintConfig1 from "./shared/nested/first.js"; +import oxlintConfig2 from "./shared/second.js"; +import { defineConfig } from "vite-plus"; + +export default defineConfig({ + fmt: {}, + lint: { + extends: [oxlintConfig1, oxlintConfig2], + ignorePatterns: ["shared/**", "vite.config.ts"], + rules: { + "local/no-foo": "error", + "no-alert": "off", + "typescript/no-floating-promises": "error", + "vite-plus/prefer-vite-plus-imports": "error", + }, + options: {}, + jsPlugins: [{ name: "vite-plus", specifier: "vite-plus/oxlint-plugin" }], + }, +}); +``` + +## `vp lint --threads=1 type-error.ts` + +Imported typeCheck produces the same diagnostic and exit code + +**Exit code:** 1 + +``` + + × typescript(TS2322): Type 'number' is not assignable to type 'string'. + ╭─[type-error.ts:1:14] + 1 │ export const value: string = 1; + · ───── + ╰──── + +Found 0 warnings and 1 error. +Finished in on 1 file with rules using threads. +``` + +## `vp lint --threads=1 type-aware.ts` + +Imported typeAware produces the same diagnostic and exit code + +**Exit code:** 1 + +``` + + × typescript(no-floating-promises): Promises must be awaited, add void operator to ignore. + ╭─[type-aware.ts:1:1] + 1 │ Promise.resolve(1); + · ─────────┬───────── + · ╰── This unhandled promise-like value has type `Promise`. + ╰──── + help: The promise must end with a call to .catch, or end with a call to .then with a rejection handler, or be explicitly marked as ignored with the `void` operator. + +Found 0 warnings and 1 error. +Finished in on 1 file with rules using threads. +``` + +## `vpt replace-file-content shared/base.ts 'typeAware: true' 'typeAware: false'` + +``` +``` + +## `vpt replace-file-content shared/base.ts 'typeCheck: true' 'typeCheck: false'` + +``` +``` + +## `vp lint --threads=1 type-error.ts` + +Disabling the shared options removes the TypeScript diagnostic + +``` +Found 0 warnings and 0 errors. +Finished in on 1 file with rules using threads. +``` + +## `vp lint --threads=1 type-aware.ts` + +Disabling the shared options removes the type-aware diagnostic + +``` +Found 0 warnings and 0 errors. +Finished in on 1 file with rules using threads. +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/settings-plugin.mjs b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/settings-plugin.mjs new file mode 100644 index 0000000000..eb8770c458 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/settings-plugin.mjs @@ -0,0 +1,17 @@ +export default { + meta: { name: 'fixture' }, + rules: { + 'require-setting': { + meta: { schema: [] }, + create(context) { + return { + Program(node) { + if (context.settings.fixture?.enabled !== true) { + context.report({ node, message: 'The fixture setting must be enabled.' }); + } + }, + }; + }, + }, + }, +}; diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/snapshots.toml index c9566217f2..fa6ffc9f7f 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/snapshots.toml @@ -98,3 +98,60 @@ steps = [ { argv = ["vp", "lint", "--threads=1", "index.js"], cwd = "packages/a", comment = "The first migrated consumer keeps the same diagnostic and exit code", continue-on-failure = true }, { argv = ["vp", "lint", "--threads=1", "index.js"], cwd = "packages/b", comment = "The second migrated consumer keeps the same diagnostic and exit code", continue-on-failure = true }, ] + +[[case]] +name = "migration_oxlint_extends_workspace_retry" +vp = "local" +local-registry = true +unset-env = ["VP_SKIP_INSTALL"] +steps = [ + { argv = ["vp", "migrate", "--no-interactive", "--no-agent", "--no-editor", "--no-hooks"], comment = "Package b cannot migrate while extra.ts conflicts with its JSON config" }, + { argv = ["vpt", "replace-file-content", ".oxlintrc.ts", '"no-debugger": "warn"', '"no-debugger": "error"'], comment = "Edit the shared module created by the successful owner" }, + { argv = ["vpt", "rm", "packages/b/extra.ts"], comment = "Remove the conflict before retrying" }, + { argv = ["vp", "migrate", "--no-interactive", "--no-agent", "--no-editor", "--no-hooks"], comment = "Retry reuses previously generated modules" }, + { argv = ["vpt", "print-file", "packages/b/vite.config.mjs"] }, + { argv = ["vpt", "stat-file", "packages/b/.oxlintrc.json", "--assert", "missing"] }, + { argv = ["vpt", "print-file", ".oxlintrc.ts"], comment = "The user's shared rule edit is preserved" }, + { argv = ["vp", "lint", "--threads=1", "index.js"], cwd = "packages/b", comment = "The recovered consumer uses the edited shared rule without a config flag", continue-on-failure = true }, +] + +[[case]] +name = "migration_oxlint_extends_workspace_owner_fields" +vp = "local" +local-registry = true +unset-env = ["VP_SKIP_INSTALL"] +steps = [ + { argv = ["vpt", "rm", "packages/b/extra.ts"], snapshot = false }, + { argv = ["vpt", "json-edit", ".oxlintrc.json", "env", '{"browser":true}'], snapshot = false }, + { argv = ["vpt", "json-edit", ".oxlintrc.json", "globals", '{"fixtureGlobal":"readonly"}'], snapshot = false }, + { argv = ["vpt", "json-edit", ".oxlintrc.json", "settings", '{"fixture":{"enabled":true}}'], snapshot = false }, + { argv = ["vpt", "json-edit", ".oxlintrc.json", "jsPlugins", '["./settings-plugin.mjs"]'], snapshot = false }, + { argv = ["vpt", "json-edit", ".oxlintrc.json", "rules", '{"no-undef":"error"}'], snapshot = false }, + { argv = ["vpt", "json-edit", ".oxlintrc.json", "overrides", '[{"files":["settings.js"],"rules":{"fixture/require-setting":"error"}}]'], snapshot = false }, + { argv = ["vpt", "write-file", "env.js", "export const value = window;\n"], snapshot = false }, + { argv = ["vpt", "write-file", "globals.js", "export const value = fixtureGlobal;\n"], snapshot = false }, + { argv = ["vpt", "write-file", "settings.js", "export const value = 1;\n"], snapshot = false }, + { argv = ["vpt", "cp", "env.js", "packages/a/env.js"], snapshot = false }, + { argv = ["vpt", "cp", "globals.js", "packages/a/globals.js"], snapshot = false }, + { argv = ["vpt", "cp", "settings.js", "packages/a/settings.js"], snapshot = false }, + { argv = ["vp", "lint", "--threads=1", "-c", ".oxlintrc.json", "env.js"], comment = "The original owner uses its environment, globals, and plugin settings" }, + { argv = ["vp", "lint", "--threads=1", "-c", ".oxlintrc.json", "globals.js"], comment = "The original owner uses its environment, globals, and plugin settings" }, + { argv = ["vp", "lint", "--threads=1", "-c", ".oxlintrc.json", "settings.js"], comment = "The original owner uses its environment, globals, and plugin settings" }, + { argv = ["vp", "lint", "--threads=1", "-c", ".oxlintrc.json", "env.js"], cwd = "packages/a", comment = "A dependent does not inherit the owner's local fields", continue-on-failure = true }, + { argv = ["vp", "lint", "--threads=1", "-c", ".oxlintrc.json", "globals.js"], cwd = "packages/a", comment = "A dependent does not inherit the owner's local fields", continue-on-failure = true }, + { argv = ["vp", "lint", "--threads=1", "-c", ".oxlintrc.json", "settings.js"], cwd = "packages/a", comment = "A dependent does not inherit the owner's local fields", continue-on-failure = true }, + { argv = ["vp", "migrate", "--no-interactive", "--no-agent", "--no-editor", "--no-hooks"] }, + { argv = ["vpt", "print-file", "vite.config.mjs"], comment = "The owner references its fields at the top level" }, + { argv = ["vp", "lint", "--threads=1", "env.js"], comment = "The owner retains its exclusions from no-undef and the plugin diagnostic" }, + { argv = ["vp", "lint", "--threads=1", "globals.js"], comment = "The owner retains its exclusions from no-undef and the plugin diagnostic" }, + { argv = ["vp", "lint", "--threads=1", "settings.js"], comment = "The owner retains its exclusions from no-undef and the plugin diagnostic" }, + { argv = ["vp", "lint", "--threads=1", "env.js"], cwd = "packages/a", comment = "The dependent retains the same diagnostics and exit code", continue-on-failure = true }, + { argv = ["vp", "lint", "--threads=1", "globals.js"], cwd = "packages/a", comment = "The dependent retains the same diagnostics and exit code", continue-on-failure = true }, + { argv = ["vp", "lint", "--threads=1", "settings.js"], cwd = "packages/a", comment = "The dependent retains the same diagnostics and exit code", continue-on-failure = true }, + { argv = ["vpt", "replace-file-content", ".oxlintrc.ts", "browser: true", "browser: false"] }, + { argv = ["vpt", "replace-file-content", ".oxlintrc.ts", 'fixtureGlobal: "readonly"', 'fixtureGlobal: "off"'] }, + { argv = ["vpt", "replace-file-content", ".oxlintrc.ts", "enabled: true", "enabled: false"] }, + { argv = ["vp", "lint", "--threads=1", "env.js"], comment = "Shared-module edits update all three owner fields", continue-on-failure = true }, + { argv = ["vp", "lint", "--threads=1", "globals.js"], comment = "Shared-module edits update all three owner fields", continue-on-failure = true }, + { argv = ["vp", "lint", "--threads=1", "settings.js"], comment = "Shared-module edits update all three owner fields", continue-on-failure = true }, +] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/snapshots/migration_oxlint_extends_workspace_owner_fields.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/snapshots/migration_oxlint_extends_workspace_owner_fields.md new file mode 100644 index 0000000000..92034062e3 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/snapshots/migration_oxlint_extends_workspace_owner_fields.md @@ -0,0 +1,317 @@ +# migration_oxlint_extends_workspace_owner_fields + +## `vpt rm packages/b/extra.ts` + + +## `vpt json-edit .oxlintrc.json env '{"browser":true}'` + + +## `vpt json-edit .oxlintrc.json globals '{"fixtureGlobal":"readonly"}'` + + +## `vpt json-edit .oxlintrc.json settings '{"fixture":{"enabled":true}}'` + + +## `vpt json-edit .oxlintrc.json jsPlugins '["./settings-plugin.mjs"]'` + + +## `vpt json-edit .oxlintrc.json rules '{"no-undef":"error"}'` + + +## `vpt json-edit .oxlintrc.json overrides '[{"files":["settings.js"],"rules":{"fixture/require-setting":"error"}}]'` + + +## `vpt write-file env.js 'export const value = window; +'` + + +## `vpt write-file globals.js 'export const value = fixtureGlobal; +'` + + +## `vpt write-file settings.js 'export const value = 1; +'` + + +## `vpt cp env.js packages/a/env.js` + + +## `vpt cp globals.js packages/a/globals.js` + + +## `vpt cp settings.js packages/a/settings.js` + + +## `vp lint --threads=1 -c .oxlintrc.json env.js` + +The original owner uses its environment, globals, and plugin settings + +``` +Found 0 warnings and 0 errors. +Finished in on 1 file with rules using threads. +``` + +## `vp lint --threads=1 -c .oxlintrc.json globals.js` + +The original owner uses its environment, globals, and plugin settings + +``` +Found 0 warnings and 0 errors. +Finished in on 1 file with rules using threads. +``` + +## `vp lint --threads=1 -c .oxlintrc.json settings.js` + +The original owner uses its environment, globals, and plugin settings + +``` +Found 0 warnings and 0 errors. +Finished in on 1 file with rules using threads. +``` + +## `cd packages/a && vp lint --threads=1 -c .oxlintrc.json env.js` + +A dependent does not inherit the owner's local fields + +**Exit code:** 1 + +``` + + × eslint(no-undef): 'window' is not defined. + ╭─[env.js:1:22] + 1 │ export const value = window; + · ────── + ╰──── + help: Either define 'window' or remove the reference to it. If 'window' is a global variable, add it to the 'globals' configuration. + +Found 0 warnings and 1 error. +Finished in on 1 file with rules using threads. +``` + +## `cd packages/a && vp lint --threads=1 -c .oxlintrc.json globals.js` + +A dependent does not inherit the owner's local fields + +**Exit code:** 1 + +``` + + × eslint(no-undef): 'fixtureGlobal' is not defined. + ╭─[globals.js:1:22] + 1 │ export const value = fixtureGlobal; + · ───────────── + ╰──── + help: Either define 'fixtureGlobal' or remove the reference to it. If 'fixtureGlobal' is a global variable, add it to the 'globals' configuration. + +Found 0 warnings and 1 error. +Finished in on 1 file with rules using threads. +``` + +## `cd packages/a && vp lint --threads=1 -c .oxlintrc.json settings.js` + +A dependent does not inherit the owner's local fields + +**Exit code:** 1 + +``` + + × fixture(require-setting): The fixture setting must be enabled. + ╭─[settings.js:1:1] + 1 │ export const value = 1; + · ──────────────────────── + ╰──── + +Found 0 warnings and 1 error. +Finished in on 1 file with rules using threads. +``` + +## `vp migrate --no-interactive --no-agent --no-editor --no-hooks` + +``` +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. +◆ ✔ Merged .oxlintrc.json into vite.config.mjs +● Formatting code... +● Code formatted +◇ Migrated . to Vite+ +• Node pnpm +✓ Dependencies installed in +• 2 config updates applied +``` + +## `vpt print-file vite.config.mjs` + +The owner references its fields at the top level + +``` +import oxlintConfig1 from "./.oxlintrc.ts"; +export default { + fmt: {}, + lint: { + extends: [oxlintConfig1], + env: oxlintConfig1.env, + globals: oxlintConfig1.globals, + settings: oxlintConfig1.settings, + options: {}, + jsPlugins: [{ name: "vite-plus", specifier: "vite-plus/oxlint-plugin" }], + rules: { + "vite-plus/prefer-vite-plus-imports": "error", + }, + }, +}; +``` + +## `vp lint --threads=1 env.js` + +The owner retains its exclusions from no-undef and the plugin diagnostic + +``` +Found 0 warnings and 0 errors. +Finished in on 1 file with rules using threads. +``` + +## `vp lint --threads=1 globals.js` + +The owner retains its exclusions from no-undef and the plugin diagnostic + +``` +Found 0 warnings and 0 errors. +Finished in on 1 file with rules using threads. +``` + +## `vp lint --threads=1 settings.js` + +The owner retains its exclusions from no-undef and the plugin diagnostic + +``` +Found 0 warnings and 0 errors. +Finished in on 1 file with rules using threads. +``` + +## `cd packages/a && vp lint --threads=1 env.js` + +The dependent retains the same diagnostics and exit code + +**Exit code:** 1 + +``` + + × eslint(no-undef): 'window' is not defined. + ╭─[env.js:1:22] + 1 │ export const value = window; + · ────── + ╰──── + help: Either define 'window' or remove the reference to it. If 'window' is a global variable, add it to the 'globals' configuration. + +Found 0 warnings and 1 error. +Finished in on 1 file with rules using threads. +``` + +## `cd packages/a && vp lint --threads=1 globals.js` + +The dependent retains the same diagnostics and exit code + +**Exit code:** 1 + +``` + + × eslint(no-undef): 'fixtureGlobal' is not defined. + ╭─[globals.js:1:22] + 1 │ export const value = fixtureGlobal; + · ───────────── + ╰──── + help: Either define 'fixtureGlobal' or remove the reference to it. If 'fixtureGlobal' is a global variable, add it to the 'globals' configuration. + +Found 0 warnings and 1 error. +Finished in on 1 file with rules using threads. +``` + +## `cd packages/a && vp lint --threads=1 settings.js` + +The dependent retains the same diagnostics and exit code + +**Exit code:** 1 + +``` + + × fixture(require-setting): The fixture setting must be enabled. + ╭─[settings.js:1:1] + 1 │ export const value = 1; + · ──────────────────────── + ╰──── + +Found 0 warnings and 1 error. +Finished in on 1 file with rules using threads. +``` + +## `vpt replace-file-content .oxlintrc.ts 'browser: true' 'browser: false'` + +``` +``` + +## `vpt replace-file-content .oxlintrc.ts 'fixtureGlobal: "readonly"' 'fixtureGlobal: "off"'` + +``` +``` + +## `vpt replace-file-content .oxlintrc.ts 'enabled: true' 'enabled: false'` + +``` +``` + +## `vp lint --threads=1 env.js` + +Shared-module edits update all three owner fields + +**Exit code:** 1 + +``` + + × eslint(no-undef): 'window' is not defined. + ╭─[env.js:1:22] + 1 │ export const value = window; + · ────── + ╰──── + help: Either define 'window' or remove the reference to it. If 'window' is a global variable, add it to the 'globals' configuration. + +Found 0 warnings and 1 error. +Finished in on 1 file with rules using threads. +``` + +## `vp lint --threads=1 globals.js` + +Shared-module edits update all three owner fields + +**Exit code:** 1 + +``` + + × eslint(no-undef): 'fixtureGlobal' is not defined. + ╭─[globals.js:1:22] + 1 │ export const value = fixtureGlobal; + · ───────────── + ╰──── + help: Either define 'fixtureGlobal' or remove the reference to it. If 'fixtureGlobal' is a global variable, add it to the 'globals' configuration. + +Found 0 warnings and 1 error. +Finished in on 1 file with rules using threads. +``` + +## `vp lint --threads=1 settings.js` + +Shared-module edits update all three owner fields + +**Exit code:** 1 + +``` + + × fixture(require-setting): The fixture setting must be enabled. + ╭─[settings.js:1:1] + 1 │ export const value = 1; + · ──────────────────────── + ╰──── + +Found 0 warnings and 1 error. +Finished in on 1 file with rules using threads. +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/snapshots/migration_oxlint_extends_workspace_retry.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/snapshots/migration_oxlint_extends_workspace_retry.md new file mode 100644 index 0000000000..5db59b37b7 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/snapshots/migration_oxlint_extends_workspace_retry.md @@ -0,0 +1,112 @@ +# migration_oxlint_extends_workspace_retry + +## `vp migrate --no-interactive --no-agent --no-editor --no-hooks` + +Package b cannot migrate while extra.ts conflicts with its JSON config + +``` +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. +◆ ✔ Merged .oxlintrc.json into vite.config.mjs +● Formatting code... +● Code formatted +◇ Migrated . to Vite+ +• Node pnpm +✓ Dependencies installed in +• 1 config update applied +! Warnings: + - Could not migrate packages/b/.oxlintrc.json: Error: Cannot create shared Oxlint config because /packages/b/extra.ts or /packages/b/extra.js already exists. The original config was preserved; use vp lint -c .oxlintrc.json until it is migrated manually. +``` + +## `vpt replace-file-content .oxlintrc.ts '"no-debugger": "warn"' '"no-debugger": "error"'` + +Edit the shared module created by the successful owner + +``` +``` + +## `vpt rm packages/b/extra.ts` + +Remove the conflict before retrying + +``` +``` + +## `vp migrate --no-interactive --no-agent --no-editor --no-hooks` + +Retry reuses previously generated modules + +``` +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. +● Formatting code... +● Code formatted +◇ Updated . to Vite+ +• Node pnpm +• Dependencies: + vite → +• 1 config update applied +``` + +## `vpt print-file packages/b/vite.config.mjs` + +``` +import oxlintConfig1 from "../a/.oxlintrc.ts"; +import oxlintConfig2 from "./extra.ts"; +export default { + lint: { + extends: [oxlintConfig1, oxlintConfig2], + options: {}, + jsPlugins: [{ name: "vite-plus", specifier: "vite-plus/oxlint-plugin" }], + rules: { + "vite-plus/prefer-vite-plus-imports": "error", + }, + }, +}; +``` + +## `vpt stat-file packages/b/.oxlintrc.json --assert missing` + +``` +packages/b/.oxlintrc.json: missing +``` + +## `vpt print-file .oxlintrc.ts` + +The user's shared rule edit is preserved + +``` +// Generated by vp migrate from .oxlintrc.json. +import type { OxlintConfig } from "vite-plus/lint"; + +export default { + options: { + typeAware: false, + typeCheck: false, + }, + rules: { + "no-debugger": "error", + }, +} satisfies OxlintConfig; +``` + +## `cd packages/b && vp lint --threads=1 index.js` + +The recovered consumer uses the edited shared rule without a config flag + +**Exit code:** 1 + +``` + + × eslint(no-debugger): `debugger` statement is not allowed + ╭─[index.js:1:1] + 1 │ debugger; + · ───────── + ╰──── + help: Remove the debugger statement + +Found 0 warnings and 1 error. +Finished in on 1 file with rules using threads. +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/snapshots/migration_oxlint_extends_workspace_shared_owner.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/snapshots/migration_oxlint_extends_workspace_shared_owner.md index 0aa8b1e8ef..af53edf0f0 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/snapshots/migration_oxlint_extends_workspace_shared_owner.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/snapshots/migration_oxlint_extends_workspace_shared_owner.md @@ -128,6 +128,7 @@ export default { Source rules remain in one shared module ``` +// Generated by vp migrate from .oxlintrc.json. import type { OxlintConfig } from "vite-plus/lint"; export default { @@ -144,6 +145,7 @@ export default { ## `vpt print-file packages/a/.oxlintrc.ts` ``` +// Generated by vp migrate from .oxlintrc.json. import type { OxlintConfig } from "vite-plus/lint"; import oxlintConfig1 from "../../.oxlintrc.js"; diff --git a/docs/guide/migrate-rules.md b/docs/guide/migrate-rules.md index 7975472024..5cbe853fbe 100644 --- a/docs/guide/migrate-rules.md +++ b/docs/guide/migrate-rules.md @@ -31,7 +31,9 @@ Migration assumes ESM projects. Shared configs become sibling `.ts` files. Gener JS-plugin declarations stay in their original config or override. Relative plugin paths become absolute paths at runtime through `fileURLToPath(new URL(..., import.meta.url))`. Explicit inherited `typeAware` and `typeCheck` settings take precedence over migration defaults. -A shared config's owner keeps `ignorePatterns` at the top level of its `lint` block through a reference to the shared module. Edits to that array apply to the owner. Other consumers do not inherit these patterns. +A shared config's owner keeps `env`, `globals`, `settings`, and `ignorePatterns` at the top level of its `lint` block through references to the shared module. Edits to these fields apply to the owner. Other consumers do not inherit them. + +Generated modules include a source marker. A later migration attempt reuses these modules without replacing user edits, so a preserved consumer can migrate after its conflict is resolved. Existing modules without a matching marker remain conflicts. Missing files, invalid JSON, inheritance cycles, conflicting output files, and package-local plugins that resolve differently from the Vite config directory produce a warning and leave the original config in place. Configs outside the project or workspace, configs inside `node_modules`, CommonJS Vite configs, and an existing `lint` block require a manual merge. Workspace JSON removal waits until all migration outcomes are known. Preserved JSON consumers retain their transitive dependencies, including configs whose owners migrated successfully. If an inheritance graph cannot be read, its workspace JSON files are kept. `vp lint` continues to reject string paths in `lint.extends`; path resolution happens only during migration. diff --git a/packages/cli/src/migration/__tests__/oxlint-extends.spec.ts b/packages/cli/src/migration/__tests__/oxlint-extends.spec.ts index 5c20cf10fd..296e007aa9 100644 --- a/packages/cli/src/migration/__tests__/oxlint-extends.spec.ts +++ b/packages/cli/src/migration/__tests__/oxlint-extends.spec.ts @@ -10,6 +10,7 @@ import { injectLintTypeCheckDefaults, mergeViteConfigFiles, resolveOxlintConfigExtends, + retryPreservedOxlintConfigs, rewriteMonorepo, } from '../migrator.ts'; import { createMigrationReport } from '../report.ts'; @@ -430,6 +431,100 @@ describe('Oxlint JSON extends migration', () => { ); }); + it('reuses edited modules on a retry after another workspace could not be merged', async () => { + createWorkspace(); + write('.oxlintrc.json', { rules: { 'no-debugger': 'warn' } }); + write('packages/a/.oxlintrc.json', { extends: ['../../.oxlintrc.json'] }); + write('packages/b/.oxlintrc.json', { extends: ['../a/.oxlintrc.json'] }); + write('packages/b/vite.config.mjs', "export { default } from './other.mjs';"); + expect(migrateWorkspace().warnings).toHaveLength(1); + + const modulePath = path.join(project, '.oxlintrc.ts'); + const edited = fs.readFileSync(modulePath, 'utf8').replace('"warn"', '"error"'); + fs.writeFileSync(modulePath, edited); + write('packages/b/vite.config.mjs', 'export default {};'); + + const report = createMigrationReport(); + expect( + retryPreservedOxlintConfigs( + project, + true, + report, + ['a', 'b'].map((name) => ({ name, path: `packages/${name}` })), + ), + ).toBe(true); + + expect(report.warnings).toEqual([]); + expect(fs.existsSync(path.join(project, 'packages/b/.oxlintrc.json'))).toBe(false); + expect(fs.readFileSync(modulePath, 'utf8')).toBe(edited); + const { lint } = await loadConfig(path.join(project, 'packages/b')); + expect(lint!.extends![0].extends![0].rules!['no-debugger']).toBe('error'); + }); + + it('does not retry unrelated JSON configs in an existing Vite+ project', () => { + write('base.json', { rules: { 'no-debugger': 'error' } }); + write('.oxlintrc.json', { extends: ['./base.json'] }); + const report = createMigrationReport(); + + expect(retryPreservedOxlintConfigs(project, true, report)).toBe(false); + expect(report.warnings).toEqual([]); + expect(fs.existsSync(path.join(project, '.oxlintrc.json'))).toBe(true); + expect(fs.existsSync(path.join(project, 'base.ts'))).toBe(false); + expect(fs.readFileSync(path.join(project, 'vite.config.mjs'), 'utf8')).toBe( + 'export default {};', + ); + }); + + it('does not reuse a generated module for a different JSON source', () => { + write('base.json', { rules: { 'no-debugger': 'warn' } }); + write('.oxlintrc.json', { extends: ['./base.json'] }); + mergeViteConfigFiles(project, true); + const original = fs.readFileSync(path.join(project, 'base.ts'), 'utf8'); + write('base.jsonc', { rules: { 'no-debugger': 'error' } }); + write('vite.config.mjs', 'export default {};'); + write('.oxlintrc.json', { extends: ['./base.jsonc'] }); + + const report = createMigrationReport(); + mergeViteConfigFiles(project, true, report); + + expect(report.warnings[0]).toContain('already exists'); + expect(fs.readFileSync(path.join(project, 'base.ts'), 'utf8')).toBe(original); + expect(fs.existsSync(path.join(project, '.oxlintrc.json'))).toBe(true); + }); + + it('keeps owner env, globals, and settings live without applying them to dependents', async () => { + createWorkspace(['a']); + write('.oxlintrc.json', { + env: { browser: true }, + globals: { fixtureGlobal: 'readonly' }, + settings: { fixture: { enabled: true } }, + }); + write('packages/a/.oxlintrc.json', { extends: ['../../.oxlintrc.json'] }); + expect(migrateWorkspace(['a']).warnings).toEqual([]); + + for (const enabled of [true, false]) { + if (!enabled) { + const modulePath = path.join(project, '.oxlintrc.ts'); + fs.writeFileSync( + modulePath, + fs + .readFileSync(modulePath, 'utf8') + .replace('"browser": true', '"browser": false') + .replace('"readonly"', '"off"') + .replace('"enabled": true', '"enabled": false'), + ); + } + const owner = await loadConfig(); + const dependent = await loadConfig(path.join(project, 'packages/a')); + expect(owner.lint!.env).toEqual({ browser: enabled }); + expect(owner.lint!.globals).toEqual({ fixtureGlobal: enabled ? 'readonly' : 'off' }); + expect(owner.lint!.settings).toEqual({ fixture: { enabled } }); + expect(dependent.lint!.env).toBeUndefined(); + expect(dependent.lint!.globals).toBeUndefined(); + expect(dependent.lint!.settings).toBeUndefined(); + } + }); + it('retains dependencies of shared JSON files even when all workspace consumers migrate', () => { createWorkspace(['a']); write('.oxlintrc.json', { rules: { 'no-debugger': 'error' } }); diff --git a/packages/cli/src/migration/bin.ts b/packages/cli/src/migration/bin.ts index e4ed7080d1..79a360981b 100644 --- a/packages/cli/src/migration/bin.ts +++ b/packages/cli/src/migration/bin.ts @@ -77,6 +77,7 @@ import { rewriteStandaloneProject, planVitestV5Migration, refreshVitestV5Migration, + retryPreservedOxlintConfigs, shouldSkipStagedMigrationForHooks, warnPackageLevelPrettier, vitestV5NeedsMigration, @@ -1202,6 +1203,17 @@ async function main() { didMigrate = true; } + if ( + retryPreservedOxlintConfigs( + workspaceInfoOptional.rootDir, + true, + report, + workspaceInfoOptional.packages, + ) + ) { + didMigrate = true; + } + // finalizeCore has finished; stop the spinner before the interactive setup // prompts below (collectMigrationSetupPlan / framework shims, in --full mode) // so they don't render on top of the still-running progress line. diff --git a/packages/cli/src/migration/migrator/oxlint-config.ts b/packages/cli/src/migration/migrator/oxlint-config.ts index c31331b448..23616fd1b5 100644 --- a/packages/cli/src/migration/migrator/oxlint-config.ts +++ b/packages/cli/src/migration/migrator/oxlint-config.ts @@ -8,7 +8,12 @@ import type { OxlintConfig } from 'oxlint'; import { analyzeMigrationSource, ResolverFactory } from '../../../binding/index.js'; import { detectConfigs } from '../detector.ts'; import { ensureSvelteRuneGlobals } from '../migrator.ts'; -import type { OxlintConfigFile, OxlintConfigMigration, OxlintModulePlan } from './shared.ts'; +import { + OXLINT_OWNER_CONFIG_KEYS, + type OxlintConfigFile, + type OxlintConfigMigration, + type OxlintModulePlan, +} from './shared.ts'; /** Read the inheritance graph before migration removes any workspace configs. */ export function resolveOxlintConfigExtends( @@ -141,10 +146,10 @@ function printConfig( inheritedNames: string[], { pluginExpression, - ignorePatternsExpression, + ownerConfigName, }: { pluginExpression?: (specifier: string) => string; - ignorePatternsExpression?: string; + ownerConfigName?: string; } = {}, ): string { function printPlugin(entry: NonNullable[number]): string { @@ -158,8 +163,8 @@ function printConfig( let code: string; if (key === 'extends') { code = `[${inheritedNames.join(', ')}]`; - } else if (key === 'ignorePatterns' && ignorePatternsExpression) { - code = ignorePatternsExpression; + } else if (ownerConfigName && OXLINT_OWNER_CONFIG_KEYS.has(key)) { + code = `${ownerConfigName}.${key}`; } else if (key === 'jsPlugins' && Array.isArray(config.jsPlugins)) { code = `[${config.jsPlugins.map(printPlugin).join(', ')}]`; } else if (key === 'overrides' && config.overrides) { @@ -176,6 +181,26 @@ function isRelativePluginPath(specifier: string): boolean { return /^\.{1,2}(?:[/\\]|$)/.test(specifier); } +function moduleProvenance(file: OxlintConfigFile): string { + return `// Generated by vp migrate from ${encodeURIComponent(path.basename(file.filename))}.`; +} + +function reusableModulePath(file: OxlintConfigFile): string | undefined { + const filename = file.filename.replace(/\.jsonc?$/i, '') + '.ts'; + if ( + !fs.existsSync(filename.replace(/\.ts$/, '.js')) && + fs.existsSync(filename) && + fs.readFileSync(filename, 'utf8').split(/\r?\n/, 1)[0] === moduleProvenance(file) + ) { + return filename; + } + return undefined; +} + +export function hasGeneratedOxlintConfig(file: OxlintConfigFile): boolean { + return reusableModulePath(file) !== undefined || file.extends.some(hasGeneratedOxlintConfig); +} + /** Generate portable shared modules, leaving their plugins in their original layers. */ export function prepareOxlintConfigModules( root: OxlintConfigFile, @@ -259,6 +284,12 @@ export function prepareOxlintConfigModules( return filename; } const runtimePath = filename.replace(/\.ts$/, '.js'); + const reusable = reusableModulePath(file); + if (reusable) { + // Reuse earlier output, including user edits, without rewriting it. + file.modulePath = reusable; + return reusable; + } if (fs.existsSync(filename) || fs.existsSync(runtimePath)) { throw new Error( `Cannot create shared Oxlint config because ${filename} or ${runtimePath} already exists`, @@ -289,7 +320,7 @@ export function prepareOxlintConfigModules( if (needsFileUrl) { imports.unshift('import { fileURLToPath } from "node:url";'); } - module.content = `${imports.join('\n')}\n\nexport default ${body} satisfies OxlintConfig;\n`; + module.content = `${moduleProvenance(file)}\n${imports.join('\n')}\n\nexport default ${body} satisfies OxlintConfig;\n`; return filename; } @@ -308,7 +339,7 @@ export function prepareOxlintConfigModules( }); return { configContent: printConfig(config, inherited, { - ignorePatternsExpression: root.shared ? `${inherited[0]}.ignorePatterns` : undefined, + ownerConfigName: root.shared ? inherited[0] : undefined, }), imports, modules, diff --git a/packages/cli/src/migration/migrator/shared.ts b/packages/cli/src/migration/migrator/shared.ts index 03f68acbaa..ca1bd06dce 100644 --- a/packages/cli/src/migration/migrator/shared.ts +++ b/packages/cli/src/migration/migrator/shared.ts @@ -39,13 +39,16 @@ export function getCommandStartIndex(argv: readonly string[]): number { return start; } +// Oxlint does not inherit these fields through `extends`. +export const OXLINT_OWNER_CONFIG_KEYS = new Set(['env', 'globals', 'settings', 'ignorePatterns']); + export interface OxlintConfigFile { filename: string; config: OxlintConfig; extends: OxlintConfigFile[]; // A dependency must use the same generated module in its owning workspace. shared?: boolean; - // Set only after a successful merge, so workspace consumers reuse the same module. + // A committed or previously generated module that workspace consumers can reuse. modulePath?: string; } diff --git a/packages/cli/src/migration/migrator/vite-config.ts b/packages/cli/src/migration/migrator/vite-config.ts index 8f6e113fcc..efb9b66f13 100644 --- a/packages/cli/src/migration/migrator/vite-config.ts +++ b/packages/cli/src/migration/migrator/vite-config.ts @@ -28,7 +28,10 @@ import { hasBaseUrlInTsconfig } from '../../utils/tsconfig.ts'; import { detectConfigs, type ConfigFiles } from '../detector.ts'; import { collectInstalledPackageNames, + hasGeneratedOxlintConfig, + prepareOxlintConfigs, readRulesYaml, + removeMigratedOxlintConfigs, resolveOxlintConfigExtends, prepareOxlintConfigModules, sanitizeMigratedOxlintConfig, @@ -37,6 +40,7 @@ import { type MigrationReport } from '../report.ts'; import { type OxlintConfigMigration, type OxlintModulePlan, + OXLINT_OWNER_CONFIG_KEYS, LINT_STAGED_JSON_CONFIG_FILES, LINT_STAGED_OTHER_CONFIG_FILES, infoMigration, @@ -285,6 +289,44 @@ export function mergeViteConfigFiles( } } +/** Resume partial migrations without applying lint setup to other existing Vite+ projects. */ +export function retryPreservedOxlintConfigs( + workspaceRoot: string, + silent = false, + report?: MigrationReport, + packages?: WorkspacePackage[], +): boolean { + const prepared = prepareOxlintConfigs([ + workspaceRoot, + ...(packages ?? []).map((pkg) => path.join(workspaceRoot, pkg.path)), + ]); + let updated = false; + for (const [filename, migration] of prepared) { + if (migration instanceof Error || !hasGeneratedOxlintConfig(migration.file)) { + continue; + } + const projectPath = path.dirname(filename); + const configs = detectConfigs(projectPath); + if (configs.viteConfig && hasConfigKey(path.join(projectPath, configs.viteConfig), 'lint')) { + continue; + } + const viteConfig = ensureViteConfig(projectPath, configs, silent, report); + mergeOxlintConfigFile( + projectPath, + viteConfig, + path.basename(filename), + silent, + report, + packages, + workspaceRoot, + prepared, + ); + updated ||= migration.migrated === true; + } + removeMigratedOxlintConfigs(prepared); + return updated; +} + function mergeOxlintConfigFile( projectPath: string, viteConfig: string, @@ -316,13 +358,17 @@ function mergeOxlintConfigFile( } const { file } = migration; // Keep shared rules in one module, including for the workspace that owns it. - // Ignore patterns only apply at the top level, so keep them local as well. + // Fields that Oxlint does not inherit must also remain in the owner's local block. let oxlintJson: OxlintConfig = structuredClone(file.config); if (file.shared) { - oxlintJson = { extends: [oxlintJson] }; - if (file.config.ignorePatterns !== undefined) { - oxlintJson.ignorePatterns = file.config.ignorePatterns; - } + oxlintJson = { + extends: [oxlintJson], + ...Object.fromEntries( + Object.entries(file.config).filter( + ([key, value]) => OXLINT_OWNER_CONFIG_KEYS.has(key) && value !== undefined, + ), + ), + }; } // Never discard an inheritance tree when a lint block already exists. if (oxlintJson.extends?.length && hasConfigKey(path.join(projectPath, viteConfig), 'lint')) { From 593551915ea3017d422c7e16bc3a637cc612b870 Mon Sep 17 00:00:00 2001 From: MK Date: Mon, 19 Oct 2026 17:07:12 +0800 Subject: [PATCH 11/24] fix(migrate): preserve configs with unsupported import paths --- .../migration_oxlint_extends/snapshots.toml | 35 ++++ .../migration_oxlint_extends_import_paths.md | 158 ++++++++++++++++++ .../migration_oxlint_extends_query_path.md | 116 +++++++++++++ docs/guide/migrate-rules.md | 2 + .../__tests__/oxlint-extends.spec.ts | 39 +++++ .../src/migration/migrator/oxlint-config.ts | 5 + 6 files changed, 355 insertions(+) create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends/snapshots/migration_oxlint_extends_import_paths.md create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends/snapshots/migration_oxlint_extends_query_path.md diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends/snapshots.toml index daef9e7df2..a63621583a 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends/snapshots.toml @@ -48,3 +48,38 @@ steps = [ { argv = ["vp", "lint", "--threads=1", "type-error.ts"], comment = "Disabling the shared options removes the TypeScript diagnostic" }, { argv = ["vp", "lint", "--threads=1", "type-aware.ts"], comment = "Disabling the shared options removes the type-aware diagnostic" }, ] + +[[case]] +name = "migration_oxlint_extends_import_paths" +vp = "local" +local-registry = true +unset-env = ["VP_SKIP_INSTALL"] +steps = [ + { argv = ["vpt", "cp", "shared/base.json", "shared/base#%.json"], snapshot = false }, + { argv = ["vpt", "cp", "shared/nested/first.json", "shared/nested/first#%.json"], snapshot = false }, + { argv = ["vpt", "json-edit", "shared/nested/first#%.json", "extends", '["../base#%.json"]'], snapshot = false }, + { argv = ["vpt", "json-edit", ".oxlintrc.json", "extends", '["./shared/nested/first#%.json","./shared/second.jsonc"]'], snapshot = false }, + { argv = ["vp", "lint", "--threads=1", "-c", ".oxlintrc.json", "index.js"], comment = "Original diagnostics with special characters in shared config paths", continue-on-failure = true }, + { argv = ["vp", "migrate", "--no-interactive", "--no-agent", "--no-editor", "--no-hooks"] }, + { argv = ["vpt", "print-file", "vite.config.ts"] }, + { argv = ["vpt", "print-file", "shared/nested/first#%.ts"] }, + { argv = ["vp", "exec", "tsc", "--noEmit"], comment = "TypeScript resolves the literal import paths" }, + { argv = ["vp", "lint", "--threads=1", "index.js"], comment = "The migrated imports retain the same diagnostics and exit code", continue-on-failure = true }, +] + +[[case]] +name = "migration_oxlint_extends_query_path" +vp = "local" +local-registry = true +unset-env = ["VP_SKIP_INSTALL"] +skip-platforms = ["windows"] +steps = [ + { argv = ["vpt", "cp", "shared/base.json", "shared/base?.json"], snapshot = false }, + { argv = ["vpt", "json-edit", "shared/nested/first.json", "extends", '["../base?.json"]'], snapshot = false }, + { argv = ["vp", "lint", "--threads=1", "-c", ".oxlintrc.json", "index.js"], comment = "The original JSON resolves the question mark as part of the filename", continue-on-failure = true }, + { argv = ["vp", "migrate", "--no-interactive", "--no-agent", "--no-editor", "--no-hooks"], comment = "Warn and preserve the JSON instead of emitting an unresolvable import" }, + { argv = ["vpt", "stat-file", ".oxlintrc.json", "--assert", "file"] }, + { argv = ["vpt", "stat-file", "shared/nested/first.ts", "--assert", "missing"] }, + { argv = ["vpt", "stat-file", "shared/base?.ts", "--assert", "missing"] }, + { argv = ["vp", "lint", "--threads=1", "-c", ".oxlintrc.json", "index.js"], comment = "The retained JSON still produces the same diagnostics and exit code", continue-on-failure = true }, +] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends/snapshots/migration_oxlint_extends_import_paths.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends/snapshots/migration_oxlint_extends_import_paths.md new file mode 100644 index 0000000000..96bcddd749 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends/snapshots/migration_oxlint_extends_import_paths.md @@ -0,0 +1,158 @@ +# migration_oxlint_extends_import_paths + +## `vpt cp shared/base.json shared/base#%.json` + + +## `vpt cp shared/nested/first.json shared/nested/first#%.json` + + +## `vpt json-edit shared/nested/first#%.json extends '["../base#%.json"]'` + + +## `vpt json-edit .oxlintrc.json extends '["./shared/nested/first#%.json","./shared/second.jsonc"]'` + + +## `vp lint --threads=1 -c .oxlintrc.json index.js` + +Original diagnostics with special characters in shared config paths + +**Exit code:** 1 + +``` + + × eslint(no-debugger): `debugger` statement is not allowed + ╭─[index.js:1:1] + 1 │ debugger; + · ───────── + 2 │ console.log("test"); + ╰──── + help: Remove the debugger statement + + ⚠ eslint(no-console): Unexpected console statement. + ╭─[index.js:2:1] + 1 │ debugger; + 2 │ console.log("test"); + · ─────────── + 3 │ alert("allowed"); + ╰──── + help: Delete this console statement. + + × local(no-foo): Do not use foo. + ╭─[index.js:4:14] + 3 │ alert("allowed"); + 4 │ export const foo = 1; + · ─── + 5 │ export const bar = 1; + ╰──── + +Found 1 warning and 2 errors. +Finished in on 1 file with rules using threads. +``` + +## `vp migrate --no-interactive --no-agent --no-editor --no-hooks` + +``` +VITE+ - The Unified Toolchain for the Web + +● Formatting code... +● Code formatted +◇ Migrated . to Vite+ +• Node pnpm +✓ Dependencies installed in +• 2 config updates applied +``` + +## `vpt print-file vite.config.ts` + +``` +import oxlintConfig1 from "./shared/nested/first#%.js"; +import oxlintConfig2 from "./shared/second.js"; +import { defineConfig } from "vite-plus"; + +export default defineConfig({ + fmt: {}, + lint: { + extends: [oxlintConfig1, oxlintConfig2], + ignorePatterns: ["shared/**", "vite.config.ts"], + rules: { + "local/no-foo": "error", + "no-alert": "off", + "vite-plus/prefer-vite-plus-imports": "error", + }, + options: {}, + jsPlugins: [{ name: "vite-plus", specifier: "vite-plus/oxlint-plugin" }], + }, +}); +``` + +## `vpt print-file shared/nested/first#%.ts` + +``` +// Generated by vp migrate from first%23%25.json. +import { fileURLToPath } from "node:url"; +import type { OxlintConfig } from "vite-plus/lint"; +import oxlintConfig1 from "../base#%.js"; + +export default { + extends: [oxlintConfig1], + overrides: [ + { + files: ["**/*.test.js"], + jsPlugins: [ + { + name: "override", + specifier: fileURLToPath(new URL("../override-plugin.mjs", import.meta.url)), + }, + ], + rules: { + "no-debugger": "warn", + "override/no-bar": "error", + }, + }, + ], +} satisfies OxlintConfig; +``` + +## `vp exec tsc --noEmit` + +TypeScript resolves the literal import paths + +``` +``` + +## `vp lint --threads=1 index.js` + +The migrated imports retain the same diagnostics and exit code + +**Exit code:** 1 + +``` + + × eslint(no-debugger): `debugger` statement is not allowed + ╭─[index.js:1:1] + 1 │ debugger; + · ───────── + 2 │ console.log("test"); + ╰──── + help: Remove the debugger statement + + ⚠ eslint(no-console): Unexpected console statement. + ╭─[index.js:2:1] + 1 │ debugger; + 2 │ console.log("test"); + · ─────────── + 3 │ alert("allowed"); + ╰──── + help: Delete this console statement. + + × local(no-foo): Do not use foo. + ╭─[index.js:4:14] + 3 │ alert("allowed"); + 4 │ export const foo = 1; + · ─── + 5 │ export const bar = 1; + ╰──── + +Found 1 warning and 2 errors. +Finished in on 1 file with rules using threads. +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends/snapshots/migration_oxlint_extends_query_path.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends/snapshots/migration_oxlint_extends_query_path.md new file mode 100644 index 0000000000..84909a7ead --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends/snapshots/migration_oxlint_extends_query_path.md @@ -0,0 +1,116 @@ +# migration_oxlint_extends_query_path + +## `vpt cp shared/base.json shared/base?.json` + + +## `vpt json-edit shared/nested/first.json extends '["../base?.json"]'` + + +## `vp lint --threads=1 -c .oxlintrc.json index.js` + +The original JSON resolves the question mark as part of the filename + +**Exit code:** 1 + +``` + + × eslint(no-debugger): `debugger` statement is not allowed + ╭─[index.js:1:1] + 1 │ debugger; + · ───────── + 2 │ console.log("test"); + ╰──── + help: Remove the debugger statement + + ⚠ eslint(no-console): Unexpected console statement. + ╭─[index.js:2:1] + 1 │ debugger; + 2 │ console.log("test"); + · ─────────── + 3 │ alert("allowed"); + ╰──── + help: Delete this console statement. + + × local(no-foo): Do not use foo. + ╭─[index.js:4:14] + 3 │ alert("allowed"); + 4 │ export const foo = 1; + · ─── + 5 │ export const bar = 1; + ╰──── + +Found 1 warning and 2 errors. +Finished in on 1 file with rules using threads. +``` + +## `vp migrate --no-interactive --no-agent --no-editor --no-hooks` + +Warn and preserve the JSON instead of emitting an unresolvable import + +``` +VITE+ - The Unified Toolchain for the Web + +● Formatting code... +● Code formatted +◇ Migrated . to Vite+ +• Node pnpm +✓ Dependencies installed in +• 1 config update applied +! Warnings: + - Could not migrate .oxlintrc.json: Error: Cannot import a shared Oxlint config whose relative path contains "?": /shared/base?.ts. The original config was preserved; use vp lint -c .oxlintrc.json until it is migrated manually. +``` + +## `vpt stat-file .oxlintrc.json --assert file` + +``` +.oxlintrc.json: file +``` + +## `vpt stat-file shared/nested/first.ts --assert missing` + +``` +shared/nested/first.ts: missing +``` + +## `vpt stat-file shared/base?.ts --assert missing` + +``` +shared/base?.ts: missing +``` + +## `vp lint --threads=1 -c .oxlintrc.json index.js` + +The retained JSON still produces the same diagnostics and exit code + +**Exit code:** 1 + +``` + + × eslint(no-debugger): `debugger` statement is not allowed + ╭─[index.js:1:1] + 1 │ debugger; + · ───────── + 2 │ console.log("test"); + ╰──── + help: Remove the debugger statement + + ⚠ eslint(no-console): Unexpected console statement. + ╭─[index.js:2:1] + 1 │ debugger; + 2 │ console.log("test"); + · ─────────── + 3 │ alert("allowed"); + ╰──── + help: Delete this console statement. + + × local(no-foo): Do not use foo. + ╭─[index.js:4:14] + 3 │ alert("allowed"); + 4 │ export const foo = 1; + · ─── + 5 │ export const bar = 1; + ╰──── + +Found 1 warning and 2 errors. +Finished in on 1 file with rules using threads. +``` diff --git a/docs/guide/migrate-rules.md b/docs/guide/migrate-rules.md index 5cbe853fbe..1d0f9d997e 100644 --- a/docs/guide/migrate-rules.md +++ b/docs/guide/migrate-rules.md @@ -35,6 +35,8 @@ A shared config's owner keeps `env`, `globals`, `settings`, and `ignorePatterns` Generated modules include a source marker. A later migration attempt reuses these modules without replacing user edits, so a preserved consumer can migrate after its conflict is resolved. Existing modules without a matching marker remain conflicts. +Shared config imports use literal paths so Vite and TypeScript can resolve filenames containing `#` or `%`. A relative import path containing `?` requires a manual migration because Vite treats it as a query delimiter. Migration preserves the JSON config and reports a warning in that case. + Missing files, invalid JSON, inheritance cycles, conflicting output files, and package-local plugins that resolve differently from the Vite config directory produce a warning and leave the original config in place. Configs outside the project or workspace, configs inside `node_modules`, CommonJS Vite configs, and an existing `lint` block require a manual merge. Workspace JSON removal waits until all migration outcomes are known. Preserved JSON consumers retain their transitive dependencies, including configs whose owners migrated successfully. If an inheritance graph cannot be read, its workspace JSON files are kept. `vp lint` continues to reject string paths in `lint.extends`; path resolution happens only during migration. ## Pack Configuration diff --git a/packages/cli/src/migration/__tests__/oxlint-extends.spec.ts b/packages/cli/src/migration/__tests__/oxlint-extends.spec.ts index 296e007aa9..42a5ef6d99 100644 --- a/packages/cli/src/migration/__tests__/oxlint-extends.spec.ts +++ b/packages/cli/src/migration/__tests__/oxlint-extends.spec.ts @@ -214,6 +214,45 @@ describe('Oxlint JSON extends migration', () => { ]); }); + it.each(['#', '%'])('loads shared config imports with %s in their paths', async (character) => { + write(`shared${character}/base${character}.json`, { + rules: { 'no-debugger': 'error' }, + }); + write(`shared${character}/nested/first${character}.json`, { + extends: [`../base${character}.json`], + }); + write('.oxlintrc.json', { + extends: [`./shared${character}/nested/first${character}.json`], + }); + + const report = createMigrationReport(); + mergeViteConfigFiles(project, true, report); + + expect(report.warnings).toEqual([]); + const config = await loadConfig(); + expect(config.lint!.extends![0].extends![0].rules!['no-debugger']).toBe('error'); + }); + + it.skipIf(process.platform === 'win32')( + 'preserves JSON when Vite cannot resolve a shared config path containing a question mark', + () => { + write('shared?/base.json', { rules: { 'no-debugger': 'error' } }); + write('first.json', { extends: ['./shared?/base.json'] }); + write('.oxlintrc.json', { extends: ['./first.json'] }); + const report = createMigrationReport(); + + mergeViteConfigFiles(project, true, report); + + expect(report.warnings[0]).toContain('relative path contains "?"'); + expect(fs.existsSync(path.join(project, '.oxlintrc.json'))).toBe(true); + expect(fs.existsSync(path.join(project, 'first.ts'))).toBe(false); + expect(fs.existsSync(path.join(project, 'shared?/base.ts'))).toBe(false); + expect(fs.readFileSync(path.join(project, 'vite.config.mjs'), 'utf8')).toBe( + 'export default {};', + ); + }, + ); + it('preserves external shared configs without writing outside the project', () => { write('external/base.json', { rules: { 'no-debugger': 'warn' } }); write('app/vite.config.mjs', 'export default {};'); diff --git a/packages/cli/src/migration/migrator/oxlint-config.ts b/packages/cli/src/migration/migrator/oxlint-config.ts index 23616fd1b5..c8bf1501d9 100644 --- a/packages/cli/src/migration/migrator/oxlint-config.ts +++ b/packages/cli/src/migration/migrator/oxlint-config.ts @@ -135,6 +135,11 @@ function relativeImport(from: string, to: string): string { if (path.isAbsolute(relative)) { throw new Error(`Cannot import an Oxlint config across drives: ${to}`); } + // Vite resolves literal filesystem paths, but treats `?` as a query delimiter. + // Percent-encoding also breaks resolution of otherwise valid paths such as `#`. + if (relative.includes('?')) { + throw new Error(`Cannot import a shared Oxlint config whose relative path contains "?": ${to}`); + } // Vite and TypeScript resolve .js imports to .ts source files. // This also works with NodeNext without changing allowImportingTsExtensions. const specifier = /\.m?ts$/.test(from) ? relative.replace(/\.ts$/, '.js') : relative; From 835b7a83aaf452030408b26756762b2fdc837127 Mon Sep 17 00:00:00 2001 From: MK Date: Mon, 19 Oct 2026 17:25:23 +0800 Subject: [PATCH 12/24] fix(migrate): clean up completed config aliases independently --- .../create-config-links.mjs | 3 + .../snapshots.toml | 50 +++++ ...xlint_extends_workspace_symlink_plugins.md | 143 +++++++++++++ ..._oxlint_extends_workspace_symlink_retry.md | 191 ++++++++++++++++++ docs/guide/migrate-rules.md | 2 + .../__tests__/oxlint-extends.spec.ts | 50 +++++ .../src/migration/migrator/oxlint-config.ts | 55 +++-- packages/cli/src/migration/migrator/shared.ts | 2 + 8 files changed, 483 insertions(+), 13 deletions(-) create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/snapshots/migration_oxlint_extends_workspace_symlink_plugins.md create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/snapshots/migration_oxlint_extends_workspace_symlink_retry.md diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/create-config-links.mjs b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/create-config-links.mjs index 8cf6f33309..912a2b4a9c 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/create-config-links.mjs +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/create-config-links.mjs @@ -2,4 +2,7 @@ import { symlinkSync } from 'node:fs'; for (const name of ['a', 'b']) { symlinkSync('../../shared/lint.json', `packages/${name}/.oxlintrc.json`, 'file'); + if (process.argv.includes('--with-base')) { + symlinkSync('../../shared/base.json', `packages/${name}/base.json`, 'file'); + } } diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/snapshots.toml index fa6ffc9f7f..31e13f09e1 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/snapshots.toml @@ -155,3 +155,53 @@ steps = [ { argv = ["vp", "lint", "--threads=1", "globals.js"], comment = "Shared-module edits update all three owner fields", continue-on-failure = true }, { argv = ["vp", "lint", "--threads=1", "settings.js"], comment = "Shared-module edits update all three owner fields", continue-on-failure = true }, ] + +[[case]] +name = "migration_oxlint_extends_workspace_symlink_retry" +vp = "local" +local-registry = true +unset-env = ["VP_SKIP_INSTALL"] +steps = [ + { argv = ["vpt", "rm", "packages/a/.oxlintrc.json"], snapshot = false }, + { argv = ["vpt", "rm", "packages/b/.oxlintrc.json"], snapshot = false }, + { argv = ["vpt", "write-file", "shared/lint.json", '{"extends":["./base.json"],"rules":{"no-console":"error"},"options":{"typeAware":false,"typeCheck":false}}'], snapshot = false }, + { argv = ["vpt", "write-file", "shared/base.json", '{}'], snapshot = false }, + { argv = ["vpt", "write-file", "packages/a/index.js", 'console.log("hello");'], snapshot = false }, + { argv = ["vpt", "write-file", "packages/b/index.js", 'console.log("hello");'], snapshot = false }, + { argv = ["node", "create-config-links.mjs", "--with-base"], snapshot = false }, + { argv = ["vpt", "write-file", "packages/b/vite.config.mjs", "export { default } from './another-config.mjs';"], snapshot = false }, + { argv = ["vp", "lint", "--threads=1", "-c", ".oxlintrc.json", "index.js"], cwd = "packages/a", comment = "The original symlinked consumer reports no-console", continue-on-failure = true }, + { argv = ["vp", "lint", "--threads=1", "-c", ".oxlintrc.json", "index.js"], cwd = "packages/b", comment = "The second alias uses the same rules", continue-on-failure = true }, + { argv = ["vp", "migrate", "--no-interactive", "--no-agent", "--no-editor", "--no-hooks"] }, + { argv = ["vpt", "stat-file", "packages/a/.oxlintrc.json", "--assert", "missing"], comment = "The successful alias is removed independently" }, + { argv = ["vpt", "stat-file", "packages/b/.oxlintrc.json", "--assert", "symlink"], comment = "The failed consumer retains its own alias" }, + { argv = ["vp", "lint", "--threads=1", "index.js"], cwd = "packages/a", comment = "The migrated consumer keeps the same diagnostic", continue-on-failure = true }, + { argv = ["vp", "lint", "--threads=1", "-c", ".oxlintrc.json", "index.js"], cwd = "packages/b", comment = "The preserved consumer keeps a working JSON fallback", continue-on-failure = true }, + { argv = ["vpt", "write-file", "packages/b/vite.config.mjs", "export default {};"], comment = "Fix the unsupported export before retrying" }, + { argv = ["vp", "migrate", "--no-interactive", "--no-agent", "--no-editor", "--no-hooks"] }, + { argv = ["vpt", "stat-file", "packages/b/.oxlintrc.json", "--assert", "missing"], comment = "The repaired alias is removed after its own migration completes" }, + { argv = ["vpt", "stat-file", "shared/lint.json", "--assert", "file"], comment = "Unlinking both consumers preserves the shared source" }, + { argv = ["vp", "lint", "--threads=1", "index.js"], cwd = "packages/b", comment = "The recovered consumer keeps the original diagnostic", continue-on-failure = true }, +] + +[[case]] +name = "migration_oxlint_extends_workspace_symlink_plugins" +vp = "local" +local-registry = true +unset-env = ["VP_SKIP_INSTALL"] +steps = [ + { argv = ["vpt", "rm", "packages/a/.oxlintrc.json"], snapshot = false }, + { argv = ["vpt", "rm", "packages/b/.oxlintrc.json"], snapshot = false }, + { argv = ["vpt", "write-file", "shared/lint.json", '{"jsPlugins":[{"name":"fixture","specifier":"./plugin.mjs"}],"rules":{"fixture/require-setting":"error"},"options":{"typeAware":false,"typeCheck":false}}'], snapshot = false }, + { argv = ["vpt", "cp", "settings-plugin.mjs", "packages/a/plugin.mjs"], snapshot = false }, + { argv = ["vpt", "cp", "settings-plugin.mjs", "packages/b/plugin.mjs"], snapshot = false }, + { argv = ["vpt", "write-file", "packages/a/index.js", "export const value = 1;\n"], snapshot = false }, + { argv = ["vpt", "write-file", "packages/b/index.js", "export const value = 1;\n"], snapshot = false }, + { argv = ["node", "create-config-links.mjs"], snapshot = false }, + { argv = ["vp", "lint", "--threads=1", "-c", ".oxlintrc.json", "index.js"], cwd = "packages/a", comment = "Oxlint resolves the relative root plugin from the alias directory", continue-on-failure = true }, + { argv = ["vp", "lint", "--threads=1", "-c", ".oxlintrc.json", "index.js"], cwd = "packages/b", comment = "Each workspace uses its own relative plugin", continue-on-failure = true }, + { argv = ["vp", "migrate", "--no-interactive", "--no-agent", "--no-editor", "--no-hooks"] }, + { argv = ["vpt", "print-file", "packages/a/vite.config.mjs"], comment = "The root plugin path stays relative to the workspace" }, + { argv = ["vp", "lint", "--threads=1", "index.js"], cwd = "packages/a", comment = "The first consumer keeps the original plugin diagnostic", continue-on-failure = true }, + { argv = ["vp", "lint", "--threads=1", "index.js"], cwd = "packages/b", comment = "The second consumer keeps the original plugin diagnostic", continue-on-failure = true }, +] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/snapshots/migration_oxlint_extends_workspace_symlink_plugins.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/snapshots/migration_oxlint_extends_workspace_symlink_plugins.md new file mode 100644 index 0000000000..d2cfd2e0fd --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/snapshots/migration_oxlint_extends_workspace_symlink_plugins.md @@ -0,0 +1,143 @@ +# migration_oxlint_extends_workspace_symlink_plugins + +## `vpt rm packages/a/.oxlintrc.json` + + +## `vpt rm packages/b/.oxlintrc.json` + + +## `vpt write-file shared/lint.json '{"jsPlugins":[{"name":"fixture","specifier":"./plugin.mjs"}],"rules":{"fixture/require-setting":"error"},"options":{"typeAware":false,"typeCheck":false}}'` + + +## `vpt cp settings-plugin.mjs packages/a/plugin.mjs` + + +## `vpt cp settings-plugin.mjs packages/b/plugin.mjs` + + +## `vpt write-file packages/a/index.js 'export const value = 1; +'` + + +## `vpt write-file packages/b/index.js 'export const value = 1; +'` + + +## `node create-config-links.mjs` + + +## `cd packages/a && vp lint --threads=1 -c .oxlintrc.json index.js` + +Oxlint resolves the relative root plugin from the alias directory + +**Exit code:** 1 + +``` + + × fixture(require-setting): The fixture setting must be enabled. + ╭─[index.js:1:1] + 1 │ export const value = 1; + · ──────────────────────── + ╰──── + +Found 0 warnings and 1 error. +Finished in on 1 file with rules using threads. +``` + +## `cd packages/b && vp lint --threads=1 -c .oxlintrc.json index.js` + +Each workspace uses its own relative plugin + +**Exit code:** 1 + +``` + + × fixture(require-setting): The fixture setting must be enabled. + ╭─[index.js:1:1] + 1 │ export const value = 1; + · ──────────────────────── + ╰──── + +Found 0 warnings and 1 error. +Finished in on 1 file with rules using threads. +``` + +## `vp migrate --no-interactive --no-agent --no-editor --no-hooks` + +``` +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. +◆ ✔ Merged .oxlintrc.json into vite.config.mjs +● Formatting code... +● Code formatted +◇ Migrated . to Vite+ +• Node pnpm +✓ Dependencies installed in +• 2 config updates applied +``` + +## `vpt print-file packages/a/vite.config.mjs` + +The root plugin path stays relative to the workspace + +``` +export default { + lint: { + jsPlugins: [ + { + name: "fixture", + specifier: "./plugin.mjs", + }, + { + name: "vite-plus", + specifier: "vite-plus/oxlint-plugin", + }, + ], + rules: { + "fixture/require-setting": "error", + "vite-plus/prefer-vite-plus-imports": "error", + }, + options: { + typeAware: false, + typeCheck: false, + }, + }, +}; +``` + +## `cd packages/a && vp lint --threads=1 index.js` + +The first consumer keeps the original plugin diagnostic + +**Exit code:** 1 + +``` + + × fixture(require-setting): The fixture setting must be enabled. + ╭─[index.js:1:1] + 1 │ export const value = 1; + · ──────────────────────── + ╰──── + +Found 0 warnings and 1 error. +Finished in on 1 file with rules using threads. +``` + +## `cd packages/b && vp lint --threads=1 index.js` + +The second consumer keeps the original plugin diagnostic + +**Exit code:** 1 + +``` + + × fixture(require-setting): The fixture setting must be enabled. + ╭─[index.js:1:1] + 1 │ export const value = 1; + · ──────────────────────── + ╰──── + +Found 0 warnings and 1 error. +Finished in on 1 file with rules using threads. +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/snapshots/migration_oxlint_extends_workspace_symlink_retry.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/snapshots/migration_oxlint_extends_workspace_symlink_retry.md new file mode 100644 index 0000000000..d31df4c761 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/snapshots/migration_oxlint_extends_workspace_symlink_retry.md @@ -0,0 +1,191 @@ +# migration_oxlint_extends_workspace_symlink_retry + +## `vpt rm packages/a/.oxlintrc.json` + + +## `vpt rm packages/b/.oxlintrc.json` + + +## `vpt write-file shared/lint.json '{"extends":["./base.json"],"rules":{"no-console":"error"},"options":{"typeAware":false,"typeCheck":false}}'` + + +## `vpt write-file shared/base.json {}` + + +## `vpt write-file packages/a/index.js 'console.log("hello");'` + + +## `vpt write-file packages/b/index.js 'console.log("hello");'` + + +## `node create-config-links.mjs --with-base` + + +## `vpt write-file packages/b/vite.config.mjs 'export { default } from '\''./another-config.mjs'\'';'` + + +## `cd packages/a && vp lint --threads=1 -c .oxlintrc.json index.js` + +The original symlinked consumer reports no-console + +**Exit code:** 1 + +``` + + × eslint(no-console): Unexpected console statement. + ╭─[index.js:1:1] + 1 │ console.log("hello"); + · ─────────── + ╰──── + help: Delete this console statement. + +Found 0 warnings and 1 error. +Finished in on 1 file with rules using threads. +``` + +## `cd packages/b && vp lint --threads=1 -c .oxlintrc.json index.js` + +The second alias uses the same rules + +**Exit code:** 1 + +``` + + × eslint(no-console): Unexpected console statement. + ╭─[index.js:1:1] + 1 │ console.log("hello"); + · ─────────── + ╰──── + help: Delete this console statement. + +Found 0 warnings and 1 error. +Finished in on 1 file with rules using threads. +``` + +## `vp migrate --no-interactive --no-agent --no-editor --no-hooks` + +``` +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. +◆ ✔ Merged .oxlintrc.json into vite.config.mjs +● Formatting code... +● Code formatted +◇ Migrated . to Vite+ +• Node pnpm +✓ Dependencies installed in +• 1 config update applied +! Warnings: + - Could not migrate packages/b/.oxlintrc.json: Error: Shared Oxlint configs require a Vite config with an ESM default export. The original config was preserved; use vp lint -c .oxlintrc.json until it is migrated manually. +``` + +## `vpt stat-file packages/a/.oxlintrc.json --assert missing` + +The successful alias is removed independently + +``` +packages/a/.oxlintrc.json: missing +``` + +## `vpt stat-file packages/b/.oxlintrc.json --assert symlink` + +The failed consumer retains its own alias + +``` +packages/b/.oxlintrc.json: symlink +``` + +## `cd packages/a && vp lint --threads=1 index.js` + +The migrated consumer keeps the same diagnostic + +**Exit code:** 1 + +``` + + × eslint(no-console): Unexpected console statement. + ╭─[index.js:1:1] + 1 │ console.log("hello"); + · ─────────── + ╰──── + help: Delete this console statement. + +Found 0 warnings and 1 error. +Finished in on 1 file with rules using threads. +``` + +## `cd packages/b && vp lint --threads=1 -c .oxlintrc.json index.js` + +The preserved consumer keeps a working JSON fallback + +**Exit code:** 1 + +``` + + × eslint(no-console): Unexpected console statement. + ╭─[index.js:1:1] + 1 │ console.log("hello"); + · ─────────── + ╰──── + help: Delete this console statement. + +Found 0 warnings and 1 error. +Finished in on 1 file with rules using threads. +``` + +## `vpt write-file packages/b/vite.config.mjs 'export default {};'` + +Fix the unsupported export before retrying + +``` +``` + +## `vp migrate --no-interactive --no-agent --no-editor --no-hooks` + +``` +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. +● Formatting code... +● Code formatted +◇ Updated . to Vite+ +• Node pnpm +• Dependencies: + vite → +• 1 config update applied +``` + +## `vpt stat-file packages/b/.oxlintrc.json --assert missing` + +The repaired alias is removed after its own migration completes + +``` +packages/b/.oxlintrc.json: missing +``` + +## `vpt stat-file shared/lint.json --assert file` + +Unlinking both consumers preserves the shared source + +``` +shared/lint.json: file +``` + +## `cd packages/b && vp lint --threads=1 index.js` + +The recovered consumer keeps the original diagnostic + +**Exit code:** 1 + +``` + + × eslint(no-console): Unexpected console statement. + ╭─[index.js:1:1] + 1 │ console.log("hello"); + · ─────────── + ╰──── + help: Delete this console statement. + +Found 0 warnings and 1 error. +Finished in on 1 file with rules using threads. +``` diff --git a/docs/guide/migrate-rules.md b/docs/guide/migrate-rules.md index 1d0f9d997e..3031a4ee6c 100644 --- a/docs/guide/migrate-rules.md +++ b/docs/guide/migrate-rules.md @@ -31,6 +31,8 @@ Migration assumes ESM projects. Shared configs become sibling `.ts` files. Gener JS-plugin declarations stay in their original config or override. Relative plugin paths become absolute paths at runtime through `fileURLToPath(new URL(..., import.meta.url))`. Explicit inherited `typeAware` and `typeCheck` settings take precedence over migration defaults. +Cleanup removes each completed symlink alias independently, but retains aliases and link chains that remaining JSON consumers reference. + A shared config's owner keeps `env`, `globals`, `settings`, and `ignorePatterns` at the top level of its `lint` block through references to the shared module. Edits to these fields apply to the owner. Other consumers do not inherit them. Generated modules include a source marker. A later migration attempt reuses these modules without replacing user edits, so a preserved consumer can migrate after its conflict is resolved. Existing modules without a matching marker remain conflicts. diff --git a/packages/cli/src/migration/__tests__/oxlint-extends.spec.ts b/packages/cli/src/migration/__tests__/oxlint-extends.spec.ts index 42a5ef6d99..f60d65a6cc 100644 --- a/packages/cli/src/migration/__tests__/oxlint-extends.spec.ts +++ b/packages/cli/src/migration/__tests__/oxlint-extends.spec.ts @@ -434,12 +434,62 @@ describe('Oxlint JSON extends migration', () => { expect((await loadConfig(path.join(project, 'packages/a'))).lint!.rules!['no-console']).toBe( 'error', ); + expect(fs.existsSync(path.join(project, 'packages/a/.oxlintrc.json'))).toBe(false); expect(fs.readFileSync(viteConfig, 'utf8')).toBe(original); const preserved = path.join(project, 'packages/b/.oxlintrc.json'); expect(fs.lstatSync(preserved).isSymbolicLink()).toBe(true); expect(resolveOxlintConfigExtends(preserved).config.extends![0].rules!['no-console']).toBe( 'off', ); + + write('packages/b/vite.config.mjs', 'export default {};'); + const retryReport = createMigrationReport(); + expect( + retryPreservedOxlintConfigs( + project, + true, + retryReport, + ['a', 'b'].map((name) => ({ name, path: `packages/${name}` })), + ), + ).toBe(true); + expect(retryReport.warnings).toEqual([]); + expect(fs.existsSync(preserved)).toBe(false); + expect(fs.existsSync(path.join(project, 'shared/lint.json'))).toBe(true); + }); + + it.each(['extends', 'symlink'])( + 'retains an alias referenced through a preserved %s', + (reference) => { + createWorkspace(); + write('shared/lint.json', { rules: { 'no-console': 'error' } }); + const alias = path.join(project, 'packages/a/.oxlintrc.json'); + const consumer = path.join(project, 'packages/b/.oxlintrc.json'); + fs.symlinkSync('../../shared/lint.json', alias); + if (reference === 'symlink') { + fs.symlinkSync('../a/.oxlintrc.json', consumer); + } else { + write('packages/b/.oxlintrc.json', { extends: ['../a/.oxlintrc.json'] }); + } + write('packages/b/vite.config.mjs', "export { default } from './another-config.mjs';"); + + expect(migrateWorkspace().warnings).toHaveLength(1); + + expect(fs.lstatSync(alias).isSymbolicLink()).toBe(true); + const fallback = resolveOxlintConfigExtends(consumer).config; + const lint = reference === 'symlink' ? fallback : fallback.extends![0]; + expect(lint.rules!['no-console']).toBe('error'); + }, + ); + + it('keeps relative plugins in symlinked roots anchored to their workspace', async () => { + createWorkspace(['a']); + write('shared/lint.json', { jsPlugins: ['./plugin.mjs'] }); + fs.symlinkSync('../../shared/lint.json', path.join(project, 'packages/a/.oxlintrc.json')); + + expect(migrateWorkspace(['a']).warnings).toEqual([]); + + const { lint } = await loadConfig(path.join(project, 'packages/a')); + expect(lint!.jsPlugins).toContain('./plugin.mjs'); }); it('retains transitive JSON dependencies of a workspace whose module output conflicts', async () => { diff --git a/packages/cli/src/migration/migrator/oxlint-config.ts b/packages/cli/src/migration/migrator/oxlint-config.ts index c8bf1501d9..75409f70ca 100644 --- a/packages/cli/src/migration/migrator/oxlint-config.ts +++ b/packages/cli/src/migration/migrator/oxlint-config.ts @@ -44,18 +44,23 @@ export function resolveOxlintConfigExtends( ) { throw new Error(`Expected an array of file paths in ${filename}: extends`); } - const children = ((inherited ?? []) as string[]).map((entry) => { - const child = resolveOxlintConfigExtends(path.resolve(path.dirname(filename), entry), cache, [ - ...stack, - filename, - ]); + const extendsPaths = ((inherited ?? []) as string[]).map((entry) => + path.resolve(path.dirname(filename), entry), + ); + const children = extendsPaths.map((childPath) => { + const child = resolveOxlintConfigExtends(childPath, cache, [...stack, filename]); child.shared = true; return child; }); if (inherited !== undefined) { config.extends = children.map((child) => child.config); } - const file: OxlintConfigFile = { filename, config, extends: children }; + const file: OxlintConfigFile = { + filename, + config, + extends: children, + extendsPaths, + }; cache.set(filename, file); return file; } @@ -90,14 +95,33 @@ function collectConfigFiles(file: OxlintConfigFile, target: Set): void { + filename = configPathInRealDirectory(filename); + while (!retained.has(filename)) { + retained.add(filename); + if (!fs.lstatSync(filename).isSymbolicLink()) { + return; + } + filename = configPathInRealDirectory( + path.resolve(path.dirname(filename), fs.readlinkSync(filename)), + ); + } +} + /** Retained JSON consumers still need their full inheritance graph on disk. */ export function removeMigratedOxlintConfigs( configs: Map, ): void { const files = new Set(); const retained = new Set(); + const retainedPaths = new Set(); const removableSources = new Set(); - const removalCandidates = new Map(); + const removalCandidates = new Set(); for (const [filename, migration] of configs) { // An unreadable graph can contain references we could not discover. // Preserve all candidates rather than break that consumer's fallback. @@ -107,10 +131,11 @@ export function removeMigratedOxlintConfigs( const { file, migrated } = migration; collectConfigFiles(file, files); if (!migrated) { + retainConfigPath(filename, retainedPaths); collectConfigFiles(file, retained); continue; } - removalCandidates.set(filename, file); + removalCandidates.add(filename); // Unlinking a workspace symlink leaves its shared JSON target on disk. if (!fs.lstatSync(filename).isSymbolicLink()) { removableSources.add(file); @@ -118,13 +143,17 @@ export function removeMigratedOxlintConfigs( } for (const file of files) { if (!removableSources.has(file)) { - for (const child of file.extends) { - collectConfigFiles(child, retained); - } + collectConfigFiles(file, retained); + } + } + for (const file of retained) { + retainConfigPath(file.filename, retainedPaths); + for (const filename of file.extendsPaths) { + retainConfigPath(filename, retainedPaths); } } - for (const [filename, file] of removalCandidates) { - if (!retained.has(file)) { + for (const filename of removalCandidates) { + if (!retainedPaths.has(configPathInRealDirectory(filename))) { fs.unlinkSync(filename); } } diff --git a/packages/cli/src/migration/migrator/shared.ts b/packages/cli/src/migration/migrator/shared.ts index ca1bd06dce..37bea25e78 100644 --- a/packages/cli/src/migration/migrator/shared.ts +++ b/packages/cli/src/migration/migrator/shared.ts @@ -46,6 +46,8 @@ export interface OxlintConfigFile { filename: string; config: OxlintConfig; extends: OxlintConfigFile[]; + // Keep declared paths as well as canonical sources for JSON fallback cleanup. + extendsPaths: string[]; // A dependency must use the same generated module in its owning workspace. shared?: boolean; // A committed or previously generated module that workspace consumers can reuse. From 1e32e5a47903822418c45381da07def3e626ba81 Mon Sep 17 00:00:00 2001 From: MK Date: Mon, 19 Oct 2026 17:46:13 +0800 Subject: [PATCH 13/24] fix(migrate): resolve configs from their declared paths --- .../snapshots.toml | 24 +++ ...lint_extends_workspace_symlink_contexts.md | 153 ++++++++++++++++++ .../__tests__/oxlint-extends.spec.ts | 105 +++++++++++- .../src/migration/migrator/oxlint-config.ts | 96 ++++++++--- 4 files changed, 350 insertions(+), 28 deletions(-) create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/snapshots/migration_oxlint_extends_workspace_symlink_contexts.md diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/snapshots.toml index 31e13f09e1..bf26079b5d 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/snapshots.toml @@ -205,3 +205,27 @@ steps = [ { argv = ["vp", "lint", "--threads=1", "index.js"], cwd = "packages/a", comment = "The first consumer keeps the original plugin diagnostic", continue-on-failure = true }, { argv = ["vp", "lint", "--threads=1", "index.js"], cwd = "packages/b", comment = "The second consumer keeps the original plugin diagnostic", continue-on-failure = true }, ] + +[[case]] +name = "migration_oxlint_extends_workspace_symlink_contexts" +vp = "local" +local-registry = true +unset-env = ["VP_SKIP_INSTALL"] +steps = [ + { argv = ["vpt", "rm", "packages/a/.oxlintrc.json"], snapshot = false }, + { argv = ["vpt", "rm", "packages/b/.oxlintrc.json"], snapshot = false }, + { argv = ["vpt", "write-file", "shared/lint.json", '{"extends":["./base.json"],"options":{"typeAware":false,"typeCheck":false}}'], snapshot = false }, + { argv = ["vpt", "write-file", "shared/base.json", '{"rules":{"no-console":"off"}}'], snapshot = false }, + { argv = ["vpt", "write-file", "packages/a/base.json", '{"rules":{"no-console":"error"}}'], snapshot = false }, + { argv = ["vpt", "write-file", "packages/b/base.json", '{"rules":{"no-console":"warn"}}'], snapshot = false }, + { argv = ["vpt", "write-file", "packages/a/index.js", 'console.log("hello");'], snapshot = false }, + { argv = ["vpt", "write-file", "packages/b/index.js", 'console.log("hello");'], snapshot = false }, + { argv = ["node", "create-config-links.mjs"], snapshot = false }, + { argv = ["vp", "lint", "--threads=1", "-c", ".oxlintrc.json", "index.js"], cwd = "packages/a", comment = "The first alias resolves its local base to an error", continue-on-failure = true }, + { argv = ["vp", "lint", "--threads=1", "-c", ".oxlintrc.json", "index.js"], cwd = "packages/b", comment = "The second alias resolves the same extends string to a warning" }, + { argv = ["vp", "migrate", "--no-interactive", "--no-agent", "--no-editor", "--no-hooks"] }, + { argv = ["vpt", "print-file", "packages/a/vite.config.mjs"] }, + { argv = ["vpt", "print-file", "packages/b/vite.config.mjs"] }, + { argv = ["vp", "lint", "--threads=1", "index.js"], cwd = "packages/a", comment = "The migrated first consumer retains its error", continue-on-failure = true }, + { argv = ["vp", "lint", "--threads=1", "index.js"], cwd = "packages/b", comment = "The migrated second consumer retains its warning" }, +] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/snapshots/migration_oxlint_extends_workspace_symlink_contexts.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/snapshots/migration_oxlint_extends_workspace_symlink_contexts.md new file mode 100644 index 0000000000..d94d2e7d03 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/snapshots/migration_oxlint_extends_workspace_symlink_contexts.md @@ -0,0 +1,153 @@ +# migration_oxlint_extends_workspace_symlink_contexts + +## `vpt rm packages/a/.oxlintrc.json` + + +## `vpt rm packages/b/.oxlintrc.json` + + +## `vpt write-file shared/lint.json '{"extends":["./base.json"],"options":{"typeAware":false,"typeCheck":false}}'` + + +## `vpt write-file shared/base.json '{"rules":{"no-console":"off"}}'` + + +## `vpt write-file packages/a/base.json '{"rules":{"no-console":"error"}}'` + + +## `vpt write-file packages/b/base.json '{"rules":{"no-console":"warn"}}'` + + +## `vpt write-file packages/a/index.js 'console.log("hello");'` + + +## `vpt write-file packages/b/index.js 'console.log("hello");'` + + +## `node create-config-links.mjs` + + +## `cd packages/a && vp lint --threads=1 -c .oxlintrc.json index.js` + +The first alias resolves its local base to an error + +**Exit code:** 1 + +``` + + × eslint(no-console): Unexpected console statement. + ╭─[index.js:1:1] + 1 │ console.log("hello"); + · ─────────── + ╰──── + help: Delete this console statement. + +Found 0 warnings and 1 error. +Finished in on 1 file with rules using threads. +``` + +## `cd packages/b && vp lint --threads=1 -c .oxlintrc.json index.js` + +The second alias resolves the same extends string to a warning + +``` + + ⚠ eslint(no-console): Unexpected console statement. + ╭─[index.js:1:1] + 1 │ console.log("hello"); + · ─────────── + ╰──── + help: Delete this console statement. + +Found 1 warning and 0 errors. +Finished in on 1 file with rules using threads. +``` + +## `vp migrate --no-interactive --no-agent --no-editor --no-hooks` + +``` +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. +◆ ✔ Merged .oxlintrc.json into vite.config.mjs +● Formatting code... +● Code formatted +◇ Migrated . to Vite+ +• Node pnpm +✓ Dependencies installed in +• 2 config updates applied +``` + +## `vpt print-file packages/a/vite.config.mjs` + +``` +import oxlintConfig1 from "./base.ts"; +export default { + lint: { + extends: [oxlintConfig1], + options: { + typeAware: false, + typeCheck: false, + }, + jsPlugins: [{ name: "vite-plus", specifier: "vite-plus/oxlint-plugin" }], + rules: { + "vite-plus/prefer-vite-plus-imports": "error", + }, + }, +}; +``` + +## `vpt print-file packages/b/vite.config.mjs` + +``` +import oxlintConfig1 from "./base.ts"; +export default { + lint: { + extends: [oxlintConfig1], + options: { + typeAware: false, + typeCheck: false, + }, + jsPlugins: [{ name: "vite-plus", specifier: "vite-plus/oxlint-plugin" }], + rules: { + "vite-plus/prefer-vite-plus-imports": "error", + }, + }, +}; +``` + +## `cd packages/a && vp lint --threads=1 index.js` + +The migrated first consumer retains its error + +**Exit code:** 1 + +``` + + × eslint(no-console): Unexpected console statement. + ╭─[index.js:1:1] + 1 │ console.log("hello"); + · ─────────── + ╰──── + help: Delete this console statement. + +Found 0 warnings and 1 error. +Finished in on 1 file with rules using threads. +``` + +## `cd packages/b && vp lint --threads=1 index.js` + +The migrated second consumer retains its warning + +``` + + ⚠ eslint(no-console): Unexpected console statement. + ╭─[index.js:1:1] + 1 │ console.log("hello"); + · ─────────── + ╰──── + help: Delete this console statement. + +Found 1 warning and 0 errors. +Finished in on 1 file with rules using threads. +``` diff --git a/packages/cli/src/migration/__tests__/oxlint-extends.spec.ts b/packages/cli/src/migration/__tests__/oxlint-extends.spec.ts index f60d65a6cc..e9c81d7005 100644 --- a/packages/cli/src/migration/__tests__/oxlint-extends.spec.ts +++ b/packages/cli/src/migration/__tests__/oxlint-extends.spec.ts @@ -375,7 +375,7 @@ describe('Oxlint JSON extends migration', () => { createWorkspace(); const root = write('.oxlintrc.json', { rules: { 'no-console': 'off' } }); const shared = write('shared/lint.json', { - ...(extendsRoot ? { extends: ['../.oxlintrc.json'] } : {}), + ...(extendsRoot ? { extends: [root] } : {}), rules: { 'no-console': 'error' }, options: { typeAware: false, typeCheck: false }, }); @@ -413,9 +413,9 @@ describe('Oxlint JSON extends migration', () => { it('preserves a failed symlinked consumer after its sibling completes migration', async () => { createWorkspace(); - write('.oxlintrc.json', { rules: { 'no-console': 'off' } }); + const root = write('.oxlintrc.json', { rules: { 'no-console': 'off' } }); write('shared/lint.json', { - extends: ['../.oxlintrc.json'], + extends: [root], rules: { 'no-console': 'error' }, }); for (const name of ['a', 'b']) { @@ -492,6 +492,105 @@ describe('Oxlint JSON extends migration', () => { expect(lint!.jsPlugins).toContain('./plugin.mjs'); }); + it('resolves each symlinked root inheritance graph from its own directory', async () => { + createWorkspace(); + write('shared/lint.json', { extends: ['./base.json'] }); + write('shared/base.json', { rules: { 'no-console': 'off' } }); + for (const [name, severity] of [ + ['a', 'error'], + ['b', 'warn'], + ]) { + write(`packages/${name}/base.json`, { rules: { 'no-console': severity } }); + fs.symlinkSync( + '../../shared/lint.json', + path.join(project, `packages/${name}/.oxlintrc.json`), + ); + } + + expect(migrateWorkspace().warnings).toEqual([]); + + expect( + (await loadConfig(path.join(project, 'packages/a'))).lint!.extends![0].rules!['no-console'], + ).toBe('error'); + expect( + (await loadConfig(path.join(project, 'packages/b'))).lint!.extends![0].rules!['no-console'], + ).toBe('warn'); + }); + + it('resolves nested symlink dependencies and plugins from their declared directory', async () => { + write('shared/lint.json', { extends: ['./base.json'], jsPlugins: ['./plugin.mjs'] }); + write('shared/base.json', { rules: { 'no-console': 'off' } }); + write('base.json', { rules: { 'no-console': 'error' } }); + fs.symlinkSync('./shared/lint.json', path.join(project, 'alias.json')); + write('.oxlintrc.json', { extends: ['./alias.json'] }); + + const report = createMigrationReport(); + mergeViteConfigFiles(project, true, report); + + expect(report.warnings).toEqual([]); + const { lint } = await loadConfig(); + expect(lint!.extends![0].extends![0].rules!['no-console']).toBe('error'); + expect(lint!.extends![0].jsPlugins).toEqual([path.join(project, 'plugin.mjs')]); + }); + + it('does not reuse a shared module resolved from a different symlink context', async () => { + createWorkspace(); + write('shared/lint.json', { extends: ['./base.json'] }); + write('shared/base.json', { rules: { 'no-console': 'off' } }); + for (const [name, severity] of [ + ['a', 'error'], + ['b', 'warn'], + ]) { + write(`packages/${name}/base.json`, { rules: { 'no-console': severity } }); + fs.symlinkSync('../../shared/lint.json', path.join(project, `packages/${name}/alias.json`)); + write(`packages/${name}/.oxlintrc.json`, { extends: ['./alias.json'] }); + } + + const report = migrateWorkspace(); + + expect(report.warnings).toHaveLength(1); + expect(report.warnings[0]).toContain('already exists'); + expect( + (await loadConfig(path.join(project, 'packages/a'))).lint!.extends![0].extends![0].rules![ + 'no-console' + ], + ).toBe('error'); + expect( + resolveOxlintConfigExtends(path.join(project, 'packages/b/.oxlintrc.json')).config.extends![0] + .extends![0].rules!['no-console'], + ).toBe('warn'); + }); + + it('shares module edits with an owner reached through a symlink alias', async () => { + createWorkspace(['a']); + write('.oxlintrc.json', { rules: { 'no-console': 'warn' } }); + fs.symlinkSync('../../.oxlintrc.json', path.join(project, 'packages/a/alias.json')); + write('packages/a/.oxlintrc.json', { extends: ['./alias.json'] }); + + expect(migrateWorkspace(['a']).warnings).toEqual([]); + + const modulePath = path.join(project, '.oxlintrc.ts'); + fs.writeFileSync(modulePath, fs.readFileSync(modulePath, 'utf8').replace('"warn"', '"error"')); + expect((await loadConfig()).lint!.extends![0].rules!['no-console']).toBe('error'); + expect( + (await loadConfig(path.join(project, 'packages/a'))).lint!.extends![0].rules!['no-console'], + ).toBe('error'); + }); + + it.skipIf(process.platform === 'win32')( + 'preserves literal backslashes in plugin filenames', + async () => { + write('base.json', { jsPlugins: ['./plugin\\name.mjs'] }); + write('.oxlintrc.json', { extends: ['./base.json'] }); + + mergeViteConfigFiles(project, true); + + expect((await loadConfig()).lint!.extends![0].jsPlugins).toEqual([ + path.join(project, 'plugin\\name.mjs'), + ]); + }, + ); + it('retains transitive JSON dependencies of a workspace whose module output conflicts', async () => { createWorkspace(['a', 'b', 'c']); const root = write('.oxlintrc.json', { rules: { 'no-debugger': 'error' } }); diff --git a/packages/cli/src/migration/migrator/oxlint-config.ts b/packages/cli/src/migration/migrator/oxlint-config.ts index 75409f70ca..e529c69859 100644 --- a/packages/cli/src/migration/migrator/oxlint-config.ts +++ b/packages/cli/src/migration/migrator/oxlint-config.ts @@ -15,27 +15,41 @@ import { type OxlintModulePlan, } from './shared.ts'; +function createConfigCache() { + return { + sources: new Map>(), + files: new Map(), + }; +} + /** Read the inheritance graph before migration removes any workspace configs. */ export function resolveOxlintConfigExtends( configPath: string, - cache = new Map(), + cache = createConfigCache(), stack: string[] = [], ): OxlintConfigFile { - const filename = fs.realpathSync(configPath); - if (stack.includes(filename)) { - throw new Error(`Circular Oxlint extends: ${[...stack, filename].join(' -> ')}`); + const filename = configPathInRealDirectory(configPath); + const sourcePath = fs.realpathSync(filename); + if (stack.includes(sourcePath)) { + throw new Error(`Circular Oxlint extends: ${[...stack, sourcePath].join(' -> ')}`); } - const cached = cache.get(filename); + const cached = cache.files.get(filename); if (cached) { return cached; } - const errors: ParseError[] = []; - const config = parseJsonc(fs.readFileSync(filename, 'utf8'), errors, { - allowTrailingComma: true, - }) as Record; - if (errors.length > 0 || !config || typeof config !== 'object' || Array.isArray(config)) { - throw new Error(`Invalid Oxlint JSON config: ${filename}`); + let source = cache.sources.get(sourcePath); + if (!source) { + const errors: ParseError[] = []; + source = parseJsonc(fs.readFileSync(sourcePath, 'utf8'), errors, { + allowTrailingComma: true, + }) as Record; + if (errors.length > 0 || !source || typeof source !== 'object' || Array.isArray(source)) { + throw new Error(`Invalid Oxlint JSON config: ${filename}`); + } + cache.sources.set(sourcePath, source); } + // Parsing is shared by source identity, but relative paths belong to each declaring config. + const config = structuredClone(source); delete config.$schema; const inherited = config.extends; if ( @@ -48,7 +62,7 @@ export function resolveOxlintConfigExtends( path.resolve(path.dirname(filename), entry), ); const children = extendsPaths.map((childPath) => { - const child = resolveOxlintConfigExtends(childPath, cache, [...stack, filename]); + const child = resolveOxlintConfigExtends(childPath, cache, [...stack, sourcePath]); child.shared = true; return child; }); @@ -61,7 +75,7 @@ export function resolveOxlintConfigExtends( extends: children, extendsPaths, }; - cache.set(filename, file); + cache.files.set(filename, file); return file; } @@ -69,7 +83,7 @@ export function prepareOxlintConfigs( projectPaths: string[], ): Map { const configs = new Map(); - const cache = new Map(); + const cache = createConfigCache(); for (const projectPath of projectPaths) { const filename = detectConfigs(projectPath).oxlintConfig; if (!filename) { @@ -82,6 +96,17 @@ export function prepareOxlintConfigs( configs.set(configPath, error instanceof Error ? error : new Error(String(error))); } } + // An owner must also import its source when another consumer reaches it through an alias. + const sharedSources = new Set( + [...cache.files.values()] + .filter((file) => file.shared) + .map((file) => fs.realpathSync(file.filename)), + ); + for (const file of cache.files.values()) { + if (sharedSources.has(fs.realpathSync(file.filename))) { + file.shared = true; + } + } return configs; } @@ -120,7 +145,7 @@ export function removeMigratedOxlintConfigs( const files = new Set(); const retained = new Set(); const retainedPaths = new Set(); - const removableSources = new Set(); + const removableSources = new Set(); const removalCandidates = new Set(); for (const [filename, migration] of configs) { // An unreadable graph can contain references we could not discover. @@ -138,13 +163,23 @@ export function removeMigratedOxlintConfigs( removalCandidates.add(filename); // Unlinking a workspace symlink leaves its shared JSON target on disk. if (!fs.lstatSync(filename).isSymbolicLink()) { - removableSources.add(file); + removableSources.add(fs.realpathSync(filename)); } } - for (const file of files) { - if (!removableSources.has(file)) { - collectConfigFiles(file, retained); + const cache = createConfigCache(); + try { + for (const file of files) { + const sourcePath = fs.realpathSync(file.filename); + if (!removableSources.has(sourcePath)) { + // A symlink target can resolve different dependencies from its aliases. + const source = resolveOxlintConfigExtends(sourcePath, cache); + collectConfigFiles(source, retained); + collectConfigFiles(source, files); + } } + } catch { + // Keep all JSON paths when a remaining source's dependencies cannot be determined. + return; } for (const file of retained) { retainConfigPath(file.filename, retainedPaths); @@ -216,11 +251,19 @@ function isRelativePluginPath(specifier: string): boolean { } function moduleProvenance(file: OxlintConfigFile): string { - return `// Generated by vp migrate from ${encodeURIComponent(path.basename(file.filename))}.`; + const sourcePath = fs.realpathSync(file.filename); + const hasRelativePlugins = [file.config, ...(file.config.overrides ?? [])].some((layer) => + layer.jsPlugins?.some((entry) => + isRelativePluginPath(typeof entry === 'string' ? entry : entry.specifier), + ), + ); + const contextPath = file.extends.length || hasRelativePlugins ? file.filename : sourcePath; + const relative = path.relative(path.dirname(sourcePath), contextPath).split(path.sep).join('/'); + return `// Generated by vp migrate from ${encodeURIComponent(relative)}.`; } function reusableModulePath(file: OxlintConfigFile): string | undefined { - const filename = file.filename.replace(/\.jsonc?$/i, '') + '.ts'; + const filename = fs.realpathSync(file.filename).replace(/\.jsonc?$/i, '') + '.ts'; if ( !fs.existsSync(filename.replace(/\.ts$/, '.js')) && fs.existsSync(filename) && @@ -268,7 +311,7 @@ export function prepareOxlintConfigModules( return; } validated.add(file); - const relative = path.relative(boundary, file.filename); + const relative = path.relative(boundary, fs.realpathSync(file.filename)); if ( path.isAbsolute(relative) || relative === '..' || @@ -306,9 +349,9 @@ export function prepareOxlintConfigModules( importedFiles.forEach(validate); function generate(file: OxlintConfigFile): string { - const filename = file.filename.replace(/\.jsonc?$/i, '') + '.ts'; + const filename = fs.realpathSync(file.filename).replace(/\.jsonc?$/i, '') + '.ts'; const existing = modules.get(filename); - if (existing && existing.file !== file) { + if (existing && moduleProvenance(existing.file) !== moduleProvenance(file)) { throw new Error(`Multiple Oxlint configs would create ${filename}`); } if (file.modulePath) { @@ -343,7 +386,10 @@ export function prepareOxlintConfigModules( function pluginExpression(specifier: string): string { if (isRelativePluginPath(specifier)) { needsFileUrl = true; - const url = specifier.replaceAll('\\', '/').split('/').map(encodeURIComponent).join('/'); + const pluginPath = path.resolve(path.dirname(file.filename), specifier); + const relative = path.relative(path.dirname(filename), pluginPath); + const encoded = relative.split(path.sep).map(encodeURIComponent).join('/'); + const url = encoded.startsWith('../') ? encoded : `./${encoded}`; return `fileURLToPath(new URL(${JSON.stringify(url)}, import.meta.url))`; } return JSON.stringify(specifier); From a0ff2b1d773d3d925ec0eef462517b5f311b6ac8 Mon Sep 17 00:00:00 2001 From: MK Date: Mon, 19 Oct 2026 18:11:04 +0800 Subject: [PATCH 14/24] fix(migrate): clean up completed owners on retry --- .../create-directory-link.mjs | 3 + .../migration_oxlint_extends/snapshots.toml | 41 +++++ ...igration_oxlint_extends_directory_alias.md | 87 ++++++++++ ...migration_oxlint_extends_override_paths.md | 164 ++++++++++++++++++ .../snapshots.toml | 2 + ...n_oxlint_extends_workspace_owner_fields.md | 1 + ..._oxlint_extends_workspace_owner_ignores.md | 2 + ...nt_extends_workspace_preserved_consumer.md | 2 + ...igration_oxlint_extends_workspace_retry.md | 17 ++ ...n_oxlint_extends_workspace_shared_owner.md | 3 + ...lint_extends_workspace_symlink_contexts.md | 2 + .../__tests__/oxlint-extends.spec.ts | 23 +++ .../src/migration/migrator/oxlint-config.ts | 9 +- packages/cli/src/migration/migrator/shared.ts | 1 + .../cli/src/migration/migrator/vite-config.ts | 55 +++++- 15 files changed, 405 insertions(+), 7 deletions(-) create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends/create-directory-link.mjs create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends/snapshots/migration_oxlint_extends_directory_alias.md create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends/snapshots/migration_oxlint_extends_override_paths.md diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends/create-directory-link.mjs b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends/create-directory-link.mjs new file mode 100644 index 0000000000..cc0eef5dec --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends/create-directory-link.mjs @@ -0,0 +1,3 @@ +import { symlinkSync } from 'node:fs'; + +symlinkSync('./configs/nested', 'aliases', process.platform === 'win32' ? 'junction' : 'dir'); diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends/snapshots.toml index a63621583a..8b4820c025 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends/snapshots.toml @@ -83,3 +83,44 @@ steps = [ { argv = ["vpt", "stat-file", "shared/base?.ts", "--assert", "missing"] }, { argv = ["vp", "lint", "--threads=1", "-c", ".oxlintrc.json", "index.js"], comment = "The retained JSON still produces the same diagnostics and exit code", continue-on-failure = true }, ] + +[[case]] +name = "migration_oxlint_extends_override_paths" +vp = "local" +local-registry = true +unset-env = ["VP_SKIP_INSTALL"] +steps = [ + { argv = ["vpt", "write-file", ".oxlintrc.json", '{"extends":["./configs/base.json"]}'], snapshot = false }, + { argv = ["vpt", "write-file", "configs/base.json", '{"options":{"typeAware":false,"typeCheck":false},"rules":{"no-debugger":"error"},"overrides":[{"files":["src/**"],"excludeFiles":["src/skip.js"],"rules":{"no-debugger":"off"}}]}'], snapshot = false }, + { argv = ["vpt", "write-file", "src/allowed.js", "debugger;\n"], snapshot = false }, + { argv = ["vpt", "write-file", "src/skip.js", "debugger;\n"], snapshot = false }, + { argv = ["vpt", "write-file", "configs/src/allowed.js", "debugger;\n"], snapshot = false }, + { argv = ["vpt", "write-file", "configs/src/skip.js", "debugger;\n"], snapshot = false }, + { argv = ["vp", "lint", "--threads=1", "-c", ".oxlintrc.json", "src/allowed.js"], comment = "Original inherited override matches the consumer-relative path", continue-on-failure = true }, + { argv = ["vp", "lint", "--threads=1", "-c", ".oxlintrc.json", "src/skip.js"], comment = "Original excludeFiles prevents the override", continue-on-failure = true }, + { argv = ["vp", "lint", "--threads=1", "-c", ".oxlintrc.json", "configs/src/allowed.js"], comment = "The declaring directory does not rebase the inherited glob", continue-on-failure = true }, + { argv = ["vp", "lint", "--threads=1", "-c", ".oxlintrc.json", "configs/src/skip.js"], continue-on-failure = true }, + { argv = ["vp", "migrate", "--no-interactive", "--no-agent", "--no-editor", "--no-hooks"] }, + { argv = ["vp", "lint", "--threads=1", "src/allowed.js"], comment = "The imported override matches the same path", continue-on-failure = true }, + { argv = ["vp", "lint", "--threads=1", "src/skip.js"], comment = "The imported exclusion has the same effect", continue-on-failure = true }, + { argv = ["vp", "lint", "--threads=1", "configs/src/allowed.js"], comment = "The declaring-directory path retains its original diagnostic", continue-on-failure = true }, + { argv = ["vp", "lint", "--threads=1", "configs/src/skip.js"], continue-on-failure = true }, +] + +[[case]] +name = "migration_oxlint_extends_directory_alias" +vp = "local" +local-registry = true +unset-env = ["VP_SKIP_INSTALL"] +steps = [ + { argv = ["vpt", "write-file", ".oxlintrc.json", '{"extends":["./aliases/base.json"]}'], snapshot = false }, + { argv = ["vpt", "write-file", "configs/nested/base.json", '{"extends":["../common.json"],"options":{"typeAware":false,"typeCheck":false}}'], snapshot = false }, + { argv = ["vpt", "write-file", "configs/common.json", '{"rules":{"no-debugger":"error"}}'], snapshot = false }, + { argv = ["vpt", "write-file", "common.json", '{"rules":{"no-debugger":"off"}}'], snapshot = false }, + { argv = ["vpt", "write-file", "index.js", "debugger;\n"], snapshot = false }, + { argv = ["node", "create-directory-link.mjs"], snapshot = false }, + { argv = ["vp", "lint", "--threads=1", "-c", ".oxlintrc.json", "index.js"], comment = "Oxlint follows the directory link before resolving the parent component", continue-on-failure = true }, + { argv = ["vp", "migrate", "--no-interactive", "--no-agent", "--no-editor", "--no-hooks"] }, + { argv = ["vpt", "print-file", "configs/nested/base.ts"] }, + { argv = ["vp", "lint", "--threads=1", "index.js"], comment = "The migrated graph selects the same physical parent config", continue-on-failure = true }, +] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends/snapshots/migration_oxlint_extends_directory_alias.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends/snapshots/migration_oxlint_extends_directory_alias.md new file mode 100644 index 0000000000..3a37443704 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends/snapshots/migration_oxlint_extends_directory_alias.md @@ -0,0 +1,87 @@ +# migration_oxlint_extends_directory_alias + +## `vpt write-file .oxlintrc.json '{"extends":["./aliases/base.json"]}'` + + +## `vpt write-file configs/nested/base.json '{"extends":["../common.json"],"options":{"typeAware":false,"typeCheck":false}}'` + + +## `vpt write-file configs/common.json '{"rules":{"no-debugger":"error"}}'` + + +## `vpt write-file common.json '{"rules":{"no-debugger":"off"}}'` + + +## `vpt write-file index.js 'debugger; +'` + + +## `node create-directory-link.mjs` + + +## `vp lint --threads=1 -c .oxlintrc.json index.js` + +Oxlint follows the directory link before resolving the parent component + +**Exit code:** 1 + +``` + + × eslint(no-debugger): `debugger` statement is not allowed + ╭─[index.js:1:1] + 1 │ debugger; + · ───────── + ╰──── + help: Remove the debugger statement + +Found 0 warnings and 1 error. +Finished in on 1 file with rules using threads. +``` + +## `vp migrate --no-interactive --no-agent --no-editor --no-hooks` + +``` +VITE+ - The Unified Toolchain for the Web + +● Formatting code... +● Code formatted +◇ Migrated . to Vite+ +• Node pnpm +✓ Dependencies installed in +• 2 config updates applied +``` + +## `vpt print-file configs/nested/base.ts` + +``` +// Generated by vp migrate from base.json. +import type { OxlintConfig } from "vite-plus/lint"; +import oxlintConfig1 from "../common.js"; + +export default { + extends: [oxlintConfig1], + options: { + typeAware: false, + typeCheck: false, + }, +} satisfies OxlintConfig; +``` + +## `vp lint --threads=1 index.js` + +The migrated graph selects the same physical parent config + +**Exit code:** 1 + +``` + + × eslint(no-debugger): `debugger` statement is not allowed + ╭─[index.js:1:1] + 1 │ debugger; + · ───────── + ╰──── + help: Remove the debugger statement + +Found 0 warnings and 1 error. +Finished in on 1 file with rules using threads. +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends/snapshots/migration_oxlint_extends_override_paths.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends/snapshots/migration_oxlint_extends_override_paths.md new file mode 100644 index 0000000000..3d251c5c9f --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends/snapshots/migration_oxlint_extends_override_paths.md @@ -0,0 +1,164 @@ +# migration_oxlint_extends_override_paths + +## `vpt write-file .oxlintrc.json '{"extends":["./configs/base.json"]}'` + + +## `vpt write-file configs/base.json '{"options":{"typeAware":false,"typeCheck":false},"rules":{"no-debugger":"error"},"overrides":[{"files":["src/**"],"excludeFiles":["src/skip.js"],"rules":{"no-debugger":"off"}}]}'` + + +## `vpt write-file src/allowed.js 'debugger; +'` + + +## `vpt write-file src/skip.js 'debugger; +'` + + +## `vpt write-file configs/src/allowed.js 'debugger; +'` + + +## `vpt write-file configs/src/skip.js 'debugger; +'` + + +## `vp lint --threads=1 -c .oxlintrc.json src/allowed.js` + +Original inherited override matches the consumer-relative path + +``` +Found 0 warnings and 0 errors. +Finished in on 1 file with rules using threads. +``` + +## `vp lint --threads=1 -c .oxlintrc.json src/skip.js` + +Original excludeFiles prevents the override + +**Exit code:** 1 + +``` + + × eslint(no-debugger): `debugger` statement is not allowed + ╭─[src/skip.js:1:1] + 1 │ debugger; + · ───────── + ╰──── + help: Remove the debugger statement + +Found 0 warnings and 1 error. +Finished in on 1 file with rules using threads. +``` + +## `vp lint --threads=1 -c .oxlintrc.json configs/src/allowed.js` + +The declaring directory does not rebase the inherited glob + +**Exit code:** 1 + +``` + + × eslint(no-debugger): `debugger` statement is not allowed + ╭─[configs/src/allowed.js:1:1] + 1 │ debugger; + · ───────── + ╰──── + help: Remove the debugger statement + +Found 0 warnings and 1 error. +Finished in on 1 file with rules using threads. +``` + +## `vp lint --threads=1 -c .oxlintrc.json configs/src/skip.js` + +**Exit code:** 1 + +``` + + × eslint(no-debugger): `debugger` statement is not allowed + ╭─[configs/src/skip.js:1:1] + 1 │ debugger; + · ───────── + ╰──── + help: Remove the debugger statement + +Found 0 warnings and 1 error. +Finished in on 1 file with rules using threads. +``` + +## `vp migrate --no-interactive --no-agent --no-editor --no-hooks` + +``` +VITE+ - The Unified Toolchain for the Web + +● Formatting code... +● Code formatted +◇ Migrated . to Vite+ +• Node pnpm +✓ Dependencies installed in +• 2 config updates applied +``` + +## `vp lint --threads=1 src/allowed.js` + +The imported override matches the same path + +``` +Found 0 warnings and 0 errors. +Finished in on 1 file with rules using threads. +``` + +## `vp lint --threads=1 src/skip.js` + +The imported exclusion has the same effect + +**Exit code:** 1 + +``` + + × eslint(no-debugger): `debugger` statement is not allowed + ╭─[src/skip.js:1:1] + 1 │ debugger; + · ───────── + ╰──── + help: Remove the debugger statement + +Found 0 warnings and 1 error. +Finished in on 1 file with rules using threads. +``` + +## `vp lint --threads=1 configs/src/allowed.js` + +The declaring-directory path retains its original diagnostic + +**Exit code:** 1 + +``` + + × eslint(no-debugger): `debugger` statement is not allowed + ╭─[configs/src/allowed.js:1:1] + 1 │ debugger; + · ───────── + ╰──── + help: Remove the debugger statement + +Found 0 warnings and 1 error. +Finished in on 1 file with rules using threads. +``` + +## `vp lint --threads=1 configs/src/skip.js` + +**Exit code:** 1 + +``` + + × eslint(no-debugger): `debugger` statement is not allowed + ╭─[configs/src/skip.js:1:1] + 1 │ debugger; + · ───────── + ╰──── + help: Remove the debugger statement + +Found 0 warnings and 1 error. +Finished in on 1 file with rules using threads. +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/snapshots.toml index bf26079b5d..d936b857fe 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/snapshots.toml @@ -111,6 +111,8 @@ steps = [ { argv = ["vp", "migrate", "--no-interactive", "--no-agent", "--no-editor", "--no-hooks"], comment = "Retry reuses previously generated modules" }, { argv = ["vpt", "print-file", "packages/b/vite.config.mjs"] }, { argv = ["vpt", "stat-file", "packages/b/.oxlintrc.json", "--assert", "missing"] }, + { argv = ["vpt", "stat-file", ".oxlintrc.json", "--assert", "missing"], comment = "The previously migrated owner no longer needs its JSON" }, + { argv = ["vpt", "stat-file", "packages/a/.oxlintrc.json", "--assert", "missing"], comment = "The previously migrated intermediate config is also cleaned up" }, { argv = ["vpt", "print-file", ".oxlintrc.ts"], comment = "The user's shared rule edit is preserved" }, { argv = ["vp", "lint", "--threads=1", "index.js"], cwd = "packages/b", comment = "The recovered consumer uses the edited shared rule without a config flag", continue-on-failure = true }, ] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/snapshots/migration_oxlint_extends_workspace_owner_fields.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/snapshots/migration_oxlint_extends_workspace_owner_fields.md index 92034062e3..fb35bd8d6f 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/snapshots/migration_oxlint_extends_workspace_owner_fields.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/snapshots/migration_oxlint_extends_workspace_owner_fields.md @@ -145,6 +145,7 @@ VITE+ - The Unified Toolchain for the Web The owner references its fields at the top level ``` +// vp migrate: merged .oxlintrc.json (776c7037d8d471e35fd2209aa88a3a33). import oxlintConfig1 from "./.oxlintrc.ts"; export default { fmt: {}, diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/snapshots/migration_oxlint_extends_workspace_owner_ignores.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/snapshots/migration_oxlint_extends_workspace_owner_ignores.md index 8aab5221b8..fb25a8c7a3 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/snapshots/migration_oxlint_extends_workspace_owner_ignores.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/snapshots/migration_oxlint_extends_workspace_owner_ignores.md @@ -73,6 +73,7 @@ VITE+ - The Unified Toolchain for the Web The owner's top-level ignore patterns reference its shared module ``` +// vp migrate: merged .oxlintrc.json (4d2ecd8da8b74c19aab2ebbba9d98fe6). import oxlintConfig1 from "./.oxlintrc.ts"; export default { fmt: {}, @@ -93,6 +94,7 @@ export default { The dependent does not receive top-level ignore patterns ``` +// vp migrate: merged packages%2Fa%2F.oxlintrc.json (b829c0806f4ea2e6444bd91df0061b20). import oxlintConfig1 from "./.oxlintrc.ts"; export default { lint: { diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/snapshots/migration_oxlint_extends_workspace_preserved_consumer.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/snapshots/migration_oxlint_extends_workspace_preserved_consumer.md index c4ec5a84c7..43d98139cd 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/snapshots/migration_oxlint_extends_workspace_preserved_consumer.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/snapshots/migration_oxlint_extends_workspace_preserved_consumer.md @@ -64,6 +64,7 @@ packages/b/.oxlintrc.json: file Retaining JSON does not undo the owner's successful migration ``` +// vp migrate: merged .oxlintrc.json (bf57dd1b2fb51cd1cb30e45e36742e3b). import oxlintConfig1 from "./.oxlintrc.ts"; export default { fmt: {}, @@ -81,6 +82,7 @@ export default { ## `vpt print-file packages/a/vite.config.mjs` ``` +// vp migrate: merged packages%2Fa%2F.oxlintrc.json (c59f567503f7d6b118c01736f9206ec4). import oxlintConfig1 from "./.oxlintrc.ts"; export default { lint: { diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/snapshots/migration_oxlint_extends_workspace_retry.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/snapshots/migration_oxlint_extends_workspace_retry.md index 5db59b37b7..57dd5d73ca 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/snapshots/migration_oxlint_extends_workspace_retry.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/snapshots/migration_oxlint_extends_workspace_retry.md @@ -53,6 +53,7 @@ VITE+ - The Unified Toolchain for the Web ## `vpt print-file packages/b/vite.config.mjs` ``` +// vp migrate: merged packages%2Fb%2F.oxlintrc.json (e5c417b92177153603bad60d94de6188). import oxlintConfig1 from "../a/.oxlintrc.ts"; import oxlintConfig2 from "./extra.ts"; export default { @@ -73,6 +74,22 @@ export default { packages/b/.oxlintrc.json: missing ``` +## `vpt stat-file .oxlintrc.json --assert missing` + +The previously migrated owner no longer needs its JSON + +``` +.oxlintrc.json: missing +``` + +## `vpt stat-file packages/a/.oxlintrc.json --assert missing` + +The previously migrated intermediate config is also cleaned up + +``` +packages/a/.oxlintrc.json: missing +``` + ## `vpt print-file .oxlintrc.ts` The user's shared rule edit is preserved diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/snapshots/migration_oxlint_extends_workspace_shared_owner.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/snapshots/migration_oxlint_extends_workspace_shared_owner.md index af53edf0f0..8ef6097346 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/snapshots/migration_oxlint_extends_workspace_shared_owner.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/snapshots/migration_oxlint_extends_workspace_shared_owner.md @@ -74,6 +74,7 @@ VITE+ - The Unified Toolchain for the Web The owner imports its own shared config ``` +// vp migrate: merged .oxlintrc.json (cca48660331feaa20cf74e4f147c8e10). import oxlintConfig1 from "./.oxlintrc.ts"; export default { fmt: {}, @@ -93,6 +94,7 @@ export default { The intermediate owner also imports its own shared config ``` +// vp migrate: merged packages%2Fa%2F.oxlintrc.json (e93b0fadd405db856ff1b8847eb5f2c3). import oxlintConfig1 from "./.oxlintrc.ts"; export default { lint: { @@ -109,6 +111,7 @@ export default { ## `vpt print-file packages/b/vite.config.mjs` ``` +// vp migrate: merged packages%2Fb%2F.oxlintrc.json (e5c417b92177153603bad60d94de6188). import oxlintConfig1 from "../a/.oxlintrc.ts"; import oxlintConfig2 from "./extra.ts"; export default { diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/snapshots/migration_oxlint_extends_workspace_symlink_contexts.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/snapshots/migration_oxlint_extends_workspace_symlink_contexts.md index d94d2e7d03..a000369e68 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/snapshots/migration_oxlint_extends_workspace_symlink_contexts.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/snapshots/migration_oxlint_extends_workspace_symlink_contexts.md @@ -81,6 +81,7 @@ VITE+ - The Unified Toolchain for the Web ## `vpt print-file packages/a/vite.config.mjs` ``` +// vp migrate: merged packages%2Fa%2F.oxlintrc.json (5aa76017b601250136404623f0a0a3dd). import oxlintConfig1 from "./base.ts"; export default { lint: { @@ -100,6 +101,7 @@ export default { ## `vpt print-file packages/b/vite.config.mjs` ``` +// vp migrate: merged packages%2Fb%2F.oxlintrc.json (56118339eefbe7bfe3d4f1a40696a3e9). import oxlintConfig1 from "./base.ts"; export default { lint: { diff --git a/packages/cli/src/migration/__tests__/oxlint-extends.spec.ts b/packages/cli/src/migration/__tests__/oxlint-extends.spec.ts index e9c81d7005..c9f71d644c 100644 --- a/packages/cli/src/migration/__tests__/oxlint-extends.spec.ts +++ b/packages/cli/src/migration/__tests__/oxlint-extends.spec.ts @@ -644,11 +644,34 @@ describe('Oxlint JSON extends migration', () => { expect(report.warnings).toEqual([]); expect(fs.existsSync(path.join(project, 'packages/b/.oxlintrc.json'))).toBe(false); + expect(fs.existsSync(path.join(project, '.oxlintrc.json'))).toBe(false); + expect(fs.existsSync(path.join(project, 'packages/a/.oxlintrc.json'))).toBe(false); expect(fs.readFileSync(modulePath, 'utf8')).toBe(edited); const { lint } = await loadConfig(path.join(project, 'packages/b')); expect(lint!.extends![0].extends![0].rules!['no-debugger']).toBe('error'); }); + it.each(['lint block', 'JSON config'])( + 'retains an owner whose %s changed after migration', + (changed) => { + createWorkspace(['a']); + write('.oxlintrc.json', { rules: { 'no-debugger': 'warn' } }); + write('packages/a/.oxlintrc.json', { extends: ['../../.oxlintrc.json'] }); + write('packages/a/vite.config.mjs', "export { default } from './another.mjs';"); + expect(migrateWorkspace(['a']).warnings).toHaveLength(1); + fs.unlinkSync(path.join(project, 'packages/a/.oxlintrc.json')); + if (changed === 'lint block') { + write('vite.config.mjs', 'export default { lint: { rules: { "no-debugger": "off" } } };'); + } else { + write('.oxlintrc.json', { rules: { 'no-debugger': 'error' } }); + } + + expect(retryPreservedOxlintConfigs(project, true)).toBe(false); + + expect(fs.existsSync(path.join(project, '.oxlintrc.json'))).toBe(true); + }, + ); + it('does not retry unrelated JSON configs in an existing Vite+ project', () => { write('base.json', { rules: { 'no-debugger': 'error' } }); write('.oxlintrc.json', { extends: ['./base.json'] }); diff --git a/packages/cli/src/migration/migrator/oxlint-config.ts b/packages/cli/src/migration/migrator/oxlint-config.ts index e529c69859..0a272f32ec 100644 --- a/packages/cli/src/migration/migrator/oxlint-config.ts +++ b/packages/cli/src/migration/migrator/oxlint-config.ts @@ -141,7 +141,7 @@ function retainConfigPath(filename: string, retained: Set): void { /** Retained JSON consumers still need their full inheritance graph on disk. */ export function removeMigratedOxlintConfigs( configs: Map, -): void { +): boolean { const files = new Set(); const retained = new Set(); const retainedPaths = new Set(); @@ -151,7 +151,7 @@ export function removeMigratedOxlintConfigs( // An unreadable graph can contain references we could not discover. // Preserve all candidates rather than break that consumer's fallback. if (migration instanceof Error) { - return; + return false; } const { file, migrated } = migration; collectConfigFiles(file, files); @@ -179,7 +179,7 @@ export function removeMigratedOxlintConfigs( } } catch { // Keep all JSON paths when a remaining source's dependencies cannot be determined. - return; + return false; } for (const file of retained) { retainConfigPath(file.filename, retainedPaths); @@ -187,11 +187,14 @@ export function removeMigratedOxlintConfigs( retainConfigPath(filename, retainedPaths); } } + let removed = false; for (const filename of removalCandidates) { if (!retainedPaths.has(configPathInRealDirectory(filename))) { fs.unlinkSync(filename); + removed = true; } } + return removed; } function relativeImport(from: string, to: string): string { diff --git a/packages/cli/src/migration/migrator/shared.ts b/packages/cli/src/migration/migrator/shared.ts index 37bea25e78..33e5281a63 100644 --- a/packages/cli/src/migration/migrator/shared.ts +++ b/packages/cli/src/migration/migrator/shared.ts @@ -63,6 +63,7 @@ export interface OxlintConfigMigration { export interface OxlintModulePlan { configContent: string; imports: string[]; + completionMarker?: string; modules: Map; } diff --git a/packages/cli/src/migration/migrator/vite-config.ts b/packages/cli/src/migration/migrator/vite-config.ts index efb9b66f13..041acab4a6 100644 --- a/packages/cli/src/migration/migrator/vite-config.ts +++ b/packages/cli/src/migration/migrator/vite-config.ts @@ -1,3 +1,4 @@ +import { createHash } from 'node:crypto'; import fs from 'node:fs'; import path from 'node:path'; @@ -38,6 +39,7 @@ import { } from '../migrator.ts'; import { type MigrationReport } from '../report.ts'; import { + type OxlintConfigFile, type OxlintConfigMigration, type OxlintModulePlan, OXLINT_OWNER_CONFIG_KEYS, @@ -289,6 +291,40 @@ export function mergeViteConfigFiles( } } +function oxlintMigrationMarker( + filename: string, + file: OxlintConfigFile, + workspaceRoot: string, +): string { + const relative = (filename: string) => + path.relative(workspaceRoot, filename).split(path.sep).join('/'); + const inputs: unknown[] = [relative(filename)]; + const visited = new Set(); + function visit(file: OxlintConfigFile): void { + if (visited.has(file)) { + return; + } + visited.add(file); + const config = { ...file.config }; + delete config.extends; + inputs.push([relative(file.filename), config, file.extendsPaths.map(relative)]); + file.extends.forEach(visit); + } + visit(file); + const serialized = JSON.stringify(inputs, (_key, value) => { + if (value && typeof value === 'object' && !Array.isArray(value)) { + return Object.fromEntries( + Object.keys(value) + .toSorted() + .map((key) => [key, value[key]]), + ); + } + return value; + }); + const digest = createHash('sha256').update(serialized).digest('hex').slice(0, 32); + return `// vp migrate: merged ${encodeURIComponent(relative(filename))} (${digest}).`; +} + /** Resume partial migrations without applying lint setup to other existing Vite+ projects. */ export function retryPreservedOxlintConfigs( workspaceRoot: string, @@ -308,6 +344,9 @@ export function retryPreservedOxlintConfigs( const projectPath = path.dirname(filename); const configs = detectConfigs(projectPath); if (configs.viteConfig && hasConfigKey(path.join(projectPath, configs.viteConfig), 'lint')) { + const source = fs.readFileSync(path.join(projectPath, configs.viteConfig), 'utf8'); + const marker = oxlintMigrationMarker(filename, migration.file, workspaceRoot); + migration.migrated = source.split(/\r?\n/).includes(marker); continue; } const viteConfig = ensureViteConfig(projectPath, configs, silent, report); @@ -323,8 +362,7 @@ export function retryPreservedOxlintConfigs( ); updated ||= migration.migrated === true; } - removeMigratedOxlintConfigs(prepared); - return updated; + return removeMigratedOxlintConfigs(prepared) || updated; } function mergeOxlintConfigFile( @@ -435,6 +473,13 @@ function mergeOxlintConfigFile( workspaceRoot ?? projectPath, ) : undefined; + if (plan && preparedOxlintConfigs) { + plan.completionMarker = oxlintMigrationMarker( + fullOxlintPath, + file, + workspaceRoot ?? projectPath, + ); + } migration.migrated = mergeAndRemoveJsonConfig( projectPath, viteConfig, @@ -629,8 +674,10 @@ function mergeAndRemoveJsonConfig( let content = result.content; if (modulePlan?.imports.length) { const offset = content.startsWith('#!') ? content.indexOf('\n') + 1 : 0; - content = - content.slice(0, offset) + modulePlan.imports.join('\n') + '\n' + content.slice(offset); + const prefix = [modulePlan.completionMarker, ...modulePlan.imports] + .filter(Boolean) + .join('\n'); + content = content.slice(0, offset) + prefix + '\n' + content.slice(offset); } fs.writeFileSync(fullViteConfigPath, content); if (!deferRemoval) { From b4b272d9eddbe9ee953b078e568fab266d8254bc Mon Sep 17 00:00:00 2001 From: MK Date: Mon, 19 Oct 2026 18:21:57 +0800 Subject: [PATCH 15/24] fix(migrate): preserve Windows junction path resolution --- .../migration_oxlint_extends/snapshots.toml | 20 ++++++ ...ation_oxlint_extends_directory_junction.md | 67 +++++++++++++++++++ .../__tests__/oxlint-extends.spec.ts | 21 ++++++ .../src/migration/migrator/oxlint-config.ts | 5 +- 4 files changed, 112 insertions(+), 1 deletion(-) create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends/snapshots/migration_oxlint_extends_directory_junction.md diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends/snapshots.toml index 8b4820c025..745b99cdbb 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends/snapshots.toml @@ -110,6 +110,7 @@ steps = [ [[case]] name = "migration_oxlint_extends_directory_alias" vp = "local" +skip-platforms = ["windows"] local-registry = true unset-env = ["VP_SKIP_INSTALL"] steps = [ @@ -124,3 +125,22 @@ steps = [ { argv = ["vpt", "print-file", "configs/nested/base.ts"] }, { argv = ["vp", "lint", "--threads=1", "index.js"], comment = "The migrated graph selects the same physical parent config", continue-on-failure = true }, ] + +[[case]] +name = "migration_oxlint_extends_directory_junction" +vp = "local" +skip-platforms = ["linux", "macos"] +local-registry = true +unset-env = ["VP_SKIP_INSTALL"] +steps = [ + { argv = ["vpt", "write-file", ".oxlintrc.json", '{"extends":["./aliases/base.json"]}'], snapshot = false }, + { argv = ["vpt", "write-file", "configs/nested/base.json", '{"extends":["../common.json"],"options":{"typeAware":false,"typeCheck":false}}'], snapshot = false }, + { argv = ["vpt", "write-file", "configs/common.json", '{"rules":{"no-debugger":"error"}}'], snapshot = false }, + { argv = ["vpt", "write-file", "common.json", '{"rules":{"no-debugger":"off"}}'], snapshot = false }, + { argv = ["vpt", "write-file", "index.js", "debugger;\n"], snapshot = false }, + { argv = ["node", "create-directory-link.mjs"], snapshot = false }, + { argv = ["vp", "lint", "--threads=1", "-c", ".oxlintrc.json", "index.js"], comment = "Windows resolves the parent component before following the directory junction", continue-on-failure = true }, + { argv = ["vp", "migrate", "--no-interactive", "--no-agent", "--no-editor", "--no-hooks"] }, + { argv = ["vpt", "print-file", "configs/nested/base.ts"] }, + { argv = ["vp", "lint", "--threads=1", "index.js"], comment = "The migrated graph selects the same lexical parent config", continue-on-failure = true }, +] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends/snapshots/migration_oxlint_extends_directory_junction.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends/snapshots/migration_oxlint_extends_directory_junction.md new file mode 100644 index 0000000000..baa3d3ba14 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends/snapshots/migration_oxlint_extends_directory_junction.md @@ -0,0 +1,67 @@ +# migration_oxlint_extends_directory_junction + +## `vpt write-file .oxlintrc.json '{"extends":["./aliases/base.json"]}'` + + +## `vpt write-file configs/nested/base.json '{"extends":["../common.json"],"options":{"typeAware":false,"typeCheck":false}}'` + + +## `vpt write-file configs/common.json '{"rules":{"no-debugger":"error"}}'` + + +## `vpt write-file common.json '{"rules":{"no-debugger":"off"}}'` + + +## `vpt write-file index.js 'debugger; +'` + + +## `node create-directory-link.mjs` + + +## `vp lint --threads=1 -c .oxlintrc.json index.js` + +Windows resolves the parent component before following the directory junction + +``` +Found 0 warnings and 0 errors. +Finished in on 1 file with rules using threads. +``` + +## `vp migrate --no-interactive --no-agent --no-editor --no-hooks` + +``` +VITE+ - The Unified Toolchain for the Web + +● Formatting code... +● Code formatted +◇ Migrated . to Vite+ +• Node pnpm +✓ Dependencies installed in +• 2 config updates applied +``` + +## `vpt print-file configs/nested/base.ts` + +``` +// Generated by vp migrate from ..%2F..%2Faliases%2Fbase.json. +import type { OxlintConfig } from "vite-plus/lint"; +import oxlintConfig1 from "../../common.js"; + +export default { + extends: [oxlintConfig1], + options: { + typeAware: false, + typeCheck: false, + }, +} satisfies OxlintConfig; +``` + +## `vp lint --threads=1 index.js` + +The migrated graph selects the same lexical parent config + +``` +Found 0 warnings and 0 errors. +Finished in on 1 file with rules using threads. +``` diff --git a/packages/cli/src/migration/__tests__/oxlint-extends.spec.ts b/packages/cli/src/migration/__tests__/oxlint-extends.spec.ts index c9f71d644c..62d57985e5 100644 --- a/packages/cli/src/migration/__tests__/oxlint-extends.spec.ts +++ b/packages/cli/src/migration/__tests__/oxlint-extends.spec.ts @@ -533,6 +533,27 @@ describe('Oxlint JSON extends migration', () => { expect(lint!.extends![0].jsPlugins).toEqual([path.join(project, 'plugin.mjs')]); }); + it('resolves parent dependencies through directory links with native filesystem semantics', async () => { + write('configs/nested/base.json', { extends: ['../common.json'] }); + write('configs/common.json', { rules: { 'no-console': 'error' } }); + write('common.json', { rules: { 'no-console': 'off' } }); + fs.symlinkSync( + path.join(project, 'configs/nested'), + path.join(project, 'aliases'), + process.platform === 'win32' ? 'junction' : 'dir', + ); + write('.oxlintrc.json', { extends: ['./aliases/base.json'] }); + + const report = createMigrationReport(); + mergeViteConfigFiles(project, true, report); + + expect(report.warnings).toEqual([]); + const { lint } = await loadConfig(); + expect(lint!.extends![0].extends![0].rules!['no-console']).toBe( + process.platform === 'win32' ? 'off' : 'error', + ); + }); + it('does not reuse a shared module resolved from a different symlink context', async () => { createWorkspace(); write('shared/lint.json', { extends: ['./base.json'] }); diff --git a/packages/cli/src/migration/migrator/oxlint-config.ts b/packages/cli/src/migration/migrator/oxlint-config.ts index 0a272f32ec..f101a05ffc 100644 --- a/packages/cli/src/migration/migrator/oxlint-config.ts +++ b/packages/cli/src/migration/migrator/oxlint-config.ts @@ -28,7 +28,10 @@ export function resolveOxlintConfigExtends( cache = createConfigCache(), stack: string[] = [], ): OxlintConfigFile { - const filename = configPathInRealDirectory(configPath); + // Windows collapses parent components before following directory junctions. + // POSIX follows the directory link first, so its physical parent is the context. + const filename = + process.platform === 'win32' ? path.resolve(configPath) : configPathInRealDirectory(configPath); const sourcePath = fs.realpathSync(filename); if (stack.includes(sourcePath)) { throw new Error(`Circular Oxlint extends: ${[...stack, sourcePath].join(' -> ')}`); From 9e3372365ef5303349e69d622c3ab2ab73d1eb54 Mon Sep 17 00:00:00 2001 From: MK Date: Mon, 19 Oct 2026 18:34:27 +0800 Subject: [PATCH 16/24] fix(migrate): retain configs with unsupported import paths --- .../migration_oxlint_extends/snapshots.toml | 17 +++ ...migration_oxlint_extends_backslash_path.md | 116 ++++++++++++++++++ .../__tests__/oxlint-extends.spec.ts | 14 +-- .../src/migration/migrator/oxlint-config.ts | 9 +- 4 files changed, 146 insertions(+), 10 deletions(-) create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends/snapshots/migration_oxlint_extends_backslash_path.md diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends/snapshots.toml index 745b99cdbb..12cd926062 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends/snapshots.toml @@ -67,6 +67,23 @@ steps = [ { argv = ["vp", "lint", "--threads=1", "index.js"], comment = "The migrated imports retain the same diagnostics and exit code", continue-on-failure = true }, ] +[[case]] +name = "migration_oxlint_extends_backslash_path" +vp = "local" +local-registry = true +unset-env = ["VP_SKIP_INSTALL"] +skip-platforms = ["windows"] +steps = [ + { argv = ["vpt", "cp", "shared/base.json", 'shared/base\name.json'], snapshot = false }, + { argv = ["vpt", "json-edit", "shared/nested/first.json", "extends", '["../base\\name.json"]'], snapshot = false }, + { argv = ["vp", "lint", "--threads=1", "-c", ".oxlintrc.json", "index.js"], comment = "Original diagnostics with a literal backslash in the shared config filename", continue-on-failure = true }, + { argv = ["vp", "migrate", "--no-interactive", "--no-agent", "--no-editor", "--no-hooks"], comment = "Warn and preserve the JSON because TypeScript treats the backslash as a separator" }, + { argv = ["vpt", "stat-file", ".oxlintrc.json", "--assert", "file"] }, + { argv = ["vpt", "stat-file", "shared/nested/first.ts", "--assert", "missing"] }, + { argv = ["vpt", "stat-file", 'shared/base\name.ts', "--assert", "missing"] }, + { argv = ["vp", "lint", "--threads=1", "-c", ".oxlintrc.json", "index.js"], comment = "The retained JSON still produces the same diagnostics and exit code", continue-on-failure = true }, +] + [[case]] name = "migration_oxlint_extends_query_path" vp = "local" diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends/snapshots/migration_oxlint_extends_backslash_path.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends/snapshots/migration_oxlint_extends_backslash_path.md new file mode 100644 index 0000000000..4caf70435f --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends/snapshots/migration_oxlint_extends_backslash_path.md @@ -0,0 +1,116 @@ +# migration_oxlint_extends_backslash_path + +## `vpt cp shared/base.json shared/base\name.json` + + +## `vpt json-edit shared/nested/first.json extends '["../base\\name.json"]'` + + +## `vp lint --threads=1 -c .oxlintrc.json index.js` + +Original diagnostics with a literal backslash in the shared config filename + +**Exit code:** 1 + +``` + + × eslint(no-debugger): `debugger` statement is not allowed + ╭─[index.js:1:1] + 1 │ debugger; + · ───────── + 2 │ console.log("test"); + ╰──── + help: Remove the debugger statement + + ⚠ eslint(no-console): Unexpected console statement. + ╭─[index.js:2:1] + 1 │ debugger; + 2 │ console.log("test"); + · ─────────── + 3 │ alert("allowed"); + ╰──── + help: Delete this console statement. + + × local(no-foo): Do not use foo. + ╭─[index.js:4:14] + 3 │ alert("allowed"); + 4 │ export const foo = 1; + · ─── + 5 │ export const bar = 1; + ╰──── + +Found 1 warning and 2 errors. +Finished in on 1 file with rules using threads. +``` + +## `vp migrate --no-interactive --no-agent --no-editor --no-hooks` + +Warn and preserve the JSON because TypeScript treats the backslash as a separator + +``` +VITE+ - The Unified Toolchain for the Web + +● Formatting code... +● Code formatted +◇ Migrated . to Vite+ +• Node pnpm +✓ Dependencies installed in +• 1 config update applied +! Warnings: + - Could not migrate .oxlintrc.json: Error: Cannot import a shared Oxlint config whose relative path contains "\\": /shared/base\name.ts. The original config was preserved; use vp lint -c .oxlintrc.json until it is migrated manually. +``` + +## `vpt stat-file .oxlintrc.json --assert file` + +``` +.oxlintrc.json: file +``` + +## `vpt stat-file shared/nested/first.ts --assert missing` + +``` +shared/nested/first.ts: missing +``` + +## `vpt stat-file shared/base\name.ts --assert missing` + +``` +shared/base\name.ts: missing +``` + +## `vp lint --threads=1 -c .oxlintrc.json index.js` + +The retained JSON still produces the same diagnostics and exit code + +**Exit code:** 1 + +``` + + × eslint(no-debugger): `debugger` statement is not allowed + ╭─[index.js:1:1] + 1 │ debugger; + · ───────── + 2 │ console.log("test"); + ╰──── + help: Remove the debugger statement + + ⚠ eslint(no-console): Unexpected console statement. + ╭─[index.js:2:1] + 1 │ debugger; + 2 │ console.log("test"); + · ─────────── + 3 │ alert("allowed"); + ╰──── + help: Delete this console statement. + + × local(no-foo): Do not use foo. + ╭─[index.js:4:14] + 3 │ alert("allowed"); + 4 │ export const foo = 1; + · ─── + 5 │ export const bar = 1; + ╰──── + +Found 1 warning and 2 errors. +Finished in on 1 file with rules using threads. +``` diff --git a/packages/cli/src/migration/__tests__/oxlint-extends.spec.ts b/packages/cli/src/migration/__tests__/oxlint-extends.spec.ts index 62d57985e5..ea031a8c6b 100644 --- a/packages/cli/src/migration/__tests__/oxlint-extends.spec.ts +++ b/packages/cli/src/migration/__tests__/oxlint-extends.spec.ts @@ -233,20 +233,20 @@ describe('Oxlint JSON extends migration', () => { expect(config.lint!.extends![0].extends![0].rules!['no-debugger']).toBe('error'); }); - it.skipIf(process.platform === 'win32')( - 'preserves JSON when Vite cannot resolve a shared config path containing a question mark', - () => { - write('shared?/base.json', { rules: { 'no-debugger': 'error' } }); - write('first.json', { extends: ['./shared?/base.json'] }); + it.skipIf(process.platform === 'win32').each(['?', '\\'])( + 'preserves JSON when an import resolver cannot handle a shared config path containing %s', + (character) => { + write(`shared${character}/base.json`, { rules: { 'no-debugger': 'error' } }); + write('first.json', { extends: [`./shared${character}/base.json`] }); write('.oxlintrc.json', { extends: ['./first.json'] }); const report = createMigrationReport(); mergeViteConfigFiles(project, true, report); - expect(report.warnings[0]).toContain('relative path contains "?"'); + expect(report.warnings[0]).toContain(`relative path contains ${JSON.stringify(character)}`); expect(fs.existsSync(path.join(project, '.oxlintrc.json'))).toBe(true); expect(fs.existsSync(path.join(project, 'first.ts'))).toBe(false); - expect(fs.existsSync(path.join(project, 'shared?/base.ts'))).toBe(false); + expect(fs.existsSync(path.join(project, `shared${character}/base.ts`))).toBe(false); expect(fs.readFileSync(path.join(project, 'vite.config.mjs'), 'utf8')).toBe( 'export default {};', ); diff --git a/packages/cli/src/migration/migrator/oxlint-config.ts b/packages/cli/src/migration/migrator/oxlint-config.ts index f101a05ffc..0ed3dcc32a 100644 --- a/packages/cli/src/migration/migrator/oxlint-config.ts +++ b/packages/cli/src/migration/migrator/oxlint-config.ts @@ -205,10 +205,13 @@ function relativeImport(from: string, to: string): string { if (path.isAbsolute(relative)) { throw new Error(`Cannot import an Oxlint config across drives: ${to}`); } - // Vite resolves literal filesystem paths, but treats `?` as a query delimiter. + // Vite treats `?` as a query delimiter; TypeScript treats literal `\` as a separator. // Percent-encoding also breaks resolution of otherwise valid paths such as `#`. - if (relative.includes('?')) { - throw new Error(`Cannot import a shared Oxlint config whose relative path contains "?": ${to}`); + const unsupported = ['?', '\\'].find((character) => relative.includes(character)); + if (unsupported) { + throw new Error( + `Cannot import a shared Oxlint config whose relative path contains ${JSON.stringify(unsupported)}: ${to}`, + ); } // Vite and TypeScript resolve .js imports to .ts source files. // This also works with NodeNext without changing allowImportingTsExtensions. From bfec065a5f8edbeef1424606fbee3dabe238ad0c Mon Sep 17 00:00:00 2001 From: MK Date: Mon, 19 Oct 2026 23:07:18 +0800 Subject: [PATCH 17/24] refactor(migrate): share Oxlint graph traversal --- .../cli/src/migration/migrator/catalog.ts | 4 +- .../src/migration/migrator/orchestrators.ts | 3 +- .../src/migration/migrator/oxlint-config.ts | 86 +++++++++++++------ packages/cli/src/migration/migrator/shared.ts | 2 + .../cli/src/migration/migrator/vite-config.ts | 68 ++++----------- 5 files changed, 80 insertions(+), 83 deletions(-) diff --git a/packages/cli/src/migration/migrator/catalog.ts b/packages/cli/src/migration/migrator/catalog.ts index 81730beaeb..cd6b13e998 100644 --- a/packages/cli/src/migration/migrator/catalog.ts +++ b/packages/cli/src/migration/migrator/catalog.ts @@ -32,7 +32,7 @@ import { workspaceUsesWebdriverioProvider, } from '../migrator.ts'; import { - type OxlintConfigMigration, + type PreparedOxlintConfigs, LEGACY_WRAPPER_FALLBACK_VERSIONS, PROVIDER_OVERRIDE_DROP_NAMES, REMOVE_PACKAGES, @@ -1152,7 +1152,7 @@ export function rewriteRootWorkspacePackageJson( // catalogs require Yarn >= 4.10.0; pnpm/bun monorepos always manage a catalog. // When false, the root `vite-plus` edge and the direct `vite` edge are concrete. supportCatalog = true, - preparedOxlintConfigs?: Map, + preparedOxlintConfigs?: PreparedOxlintConfigs, ): void { const packageJsonPath = path.join(projectPath, 'package.json'); if (!fs.existsSync(packageJsonPath)) { diff --git a/packages/cli/src/migration/migrator/orchestrators.ts b/packages/cli/src/migration/migrator/orchestrators.ts index f200cf8803..dc4a2c5550 100644 --- a/packages/cli/src/migration/migrator/orchestrators.ts +++ b/packages/cli/src/migration/migrator/orchestrators.ts @@ -65,6 +65,7 @@ import { pnpmMajor, type CatalogDependencyResolver, type PnpmPackageJsonSettings, + type PreparedOxlintConfigs, } from './shared.ts'; export function rewriteStandaloneProject( @@ -535,7 +536,7 @@ export function rewriteMonorepoProject( workspaceContext?: { rootDir: string; packages: WorkspacePackage[]; - oxlintConfigs?: ReturnType; + oxlintConfigs?: PreparedOxlintConfigs; }, deferLazyPluginWrapping = false, // Whether this workspace's edges use catalog references. Yarn catalogs require diff --git a/packages/cli/src/migration/migrator/oxlint-config.ts b/packages/cli/src/migration/migrator/oxlint-config.ts index 0ed3dcc32a..95f5b02223 100644 --- a/packages/cli/src/migration/migrator/oxlint-config.ts +++ b/packages/cli/src/migration/migrator/oxlint-config.ts @@ -1,3 +1,4 @@ +import { createHash } from 'node:crypto'; import fs from 'node:fs'; import path from 'node:path'; @@ -11,14 +12,19 @@ import { ensureSvelteRuneGlobals } from '../migrator.ts'; import { OXLINT_OWNER_CONFIG_KEYS, type OxlintConfigFile, - type OxlintConfigMigration, type OxlintModulePlan, + type PreparedOxlintConfigs, } from './shared.ts'; -function createConfigCache() { +interface OxlintConfigCache { + sources: Map>; + files: Map; +} + +function createConfigCache(): OxlintConfigCache { return { - sources: new Map>(), - files: new Map(), + sources: new Map(), + files: new Map(), }; } @@ -82,10 +88,8 @@ export function resolveOxlintConfigExtends( return file; } -export function prepareOxlintConfigs( - projectPaths: string[], -): Map { - const configs = new Map(); +export function prepareOxlintConfigs(projectPaths: string[]): PreparedOxlintConfigs { + const configs: PreparedOxlintConfigs = new Map(); const cache = createConfigCache(); for (const projectPath of projectPaths) { const filename = detectConfigs(projectPath).oxlintConfig; @@ -123,6 +127,37 @@ function collectConfigFiles(file: OxlintConfigFile, target: Set(); + collectConfigFiles(root, files); + const inputs: unknown[] = [relative(filename)]; + for (const file of files) { + const config = { ...file.config }; + delete config.extends; + inputs.push([relative(file.filename), config, file.extendsPaths.map(relative)]); + } + const serialized = JSON.stringify(inputs, (_key, value) => { + if (value && typeof value === 'object' && !Array.isArray(value)) { + return Object.fromEntries( + Object.keys(value) + .toSorted() + .map((key) => [key, value[key]]), + ); + } + return value; + }); + const digest = createHash('sha256').update(serialized).digest('hex').slice(0, 32); + return `// vp migrate: merged ${encodeURIComponent(relative(filename))} (${digest}).`; +} + function configPathInRealDirectory(filename: string): string { return path.join(fs.realpathSync(path.dirname(filename)), path.basename(filename)); } @@ -142,9 +177,7 @@ function retainConfigPath(filename: string, retained: Set): void { } /** Retained JSON consumers still need their full inheritance graph on disk. */ -export function removeMigratedOxlintConfigs( - configs: Map, -): boolean { +export function removeMigratedOxlintConfigs(configs: PreparedOxlintConfigs): boolean { const files = new Set(); const retained = new Set(); const retainedPaths = new Set(); @@ -314,12 +347,12 @@ export function prepareOxlintConfigModules( const boundary = fs.realpathSync(workspaceRoot); let resolver: ResolverFactory | undefined; - const validated = new Set(); - function validate(file: OxlintConfigFile): void { - if (validated.has(file)) { - return; - } - validated.add(file); + const importedFiles = root.shared ? [root] : root.extends; + const files = new Set(); + for (const file of importedFiles) { + collectConfigFiles(file, files); + } + for (const file of files) { const relative = path.relative(boundary, fs.realpathSync(file.filename)); if ( path.isAbsolute(relative) || @@ -352,10 +385,7 @@ export function prepareOxlintConfigModules( } } } - file.extends.forEach(validate); } - const importedFiles = root.shared ? [root] : root.extends; - importedFiles.forEach(validate); function generate(file: OxlintConfigFile): string { const filename = fs.realpathSync(file.filename).replace(/\.jsonc?$/i, '') + '.ts'; @@ -393,15 +423,15 @@ export function prepareOxlintConfigModules( }); let needsFileUrl = false; function pluginExpression(specifier: string): string { - if (isRelativePluginPath(specifier)) { - needsFileUrl = true; - const pluginPath = path.resolve(path.dirname(file.filename), specifier); - const relative = path.relative(path.dirname(filename), pluginPath); - const encoded = relative.split(path.sep).map(encodeURIComponent).join('/'); - const url = encoded.startsWith('../') ? encoded : `./${encoded}`; - return `fileURLToPath(new URL(${JSON.stringify(url)}, import.meta.url))`; + if (!isRelativePluginPath(specifier)) { + return JSON.stringify(specifier); } - return JSON.stringify(specifier); + needsFileUrl = true; + const pluginPath = path.resolve(path.dirname(file.filename), specifier); + const relative = path.relative(path.dirname(filename), pluginPath); + const encoded = relative.split(path.sep).map(encodeURIComponent).join('/'); + const url = encoded.startsWith('../') ? encoded : `./${encoded}`; + return `fileURLToPath(new URL(${JSON.stringify(url)}, import.meta.url))`; } const config = structuredClone(file.config); ensureSvelteRuneGlobals(config); diff --git a/packages/cli/src/migration/migrator/shared.ts b/packages/cli/src/migration/migrator/shared.ts index 33e5281a63..ecff286023 100644 --- a/packages/cli/src/migration/migrator/shared.ts +++ b/packages/cli/src/migration/migrator/shared.ts @@ -60,6 +60,8 @@ export interface OxlintConfigMigration { migrated?: boolean; } +export type PreparedOxlintConfigs = Map; + export interface OxlintModulePlan { configContent: string; imports: string[]; diff --git a/packages/cli/src/migration/migrator/vite-config.ts b/packages/cli/src/migration/migrator/vite-config.ts index 041acab4a6..cd6e068598 100644 --- a/packages/cli/src/migration/migrator/vite-config.ts +++ b/packages/cli/src/migration/migrator/vite-config.ts @@ -1,4 +1,3 @@ -import { createHash } from 'node:crypto'; import fs from 'node:fs'; import path from 'node:path'; @@ -30,6 +29,7 @@ import { detectConfigs, type ConfigFiles } from '../detector.ts'; import { collectInstalledPackageNames, hasGeneratedOxlintConfig, + oxlintMigrationMarker, prepareOxlintConfigs, readRulesYaml, removeMigratedOxlintConfigs, @@ -39,9 +39,9 @@ import { } from '../migrator.ts'; import { type MigrationReport } from '../report.ts'; import { - type OxlintConfigFile, type OxlintConfigMigration, type OxlintModulePlan, + type PreparedOxlintConfigs, OXLINT_OWNER_CONFIG_KEYS, LINT_STAGED_JSON_CONFIG_FILES, LINT_STAGED_OTHER_CONFIG_FILES, @@ -266,7 +266,7 @@ export function mergeViteConfigFiles( // (correct for the top-level standalone/monorepo callers, where // projectPath IS the workspace root). workspaceRoot?: string, - preparedOxlintConfigs?: Map, + preparedOxlintConfigs?: PreparedOxlintConfigs, ): void { const configs = detectConfigs(projectPath); if (!configs.oxfmtConfig && !configs.oxlintConfig) { @@ -291,40 +291,6 @@ export function mergeViteConfigFiles( } } -function oxlintMigrationMarker( - filename: string, - file: OxlintConfigFile, - workspaceRoot: string, -): string { - const relative = (filename: string) => - path.relative(workspaceRoot, filename).split(path.sep).join('/'); - const inputs: unknown[] = [relative(filename)]; - const visited = new Set(); - function visit(file: OxlintConfigFile): void { - if (visited.has(file)) { - return; - } - visited.add(file); - const config = { ...file.config }; - delete config.extends; - inputs.push([relative(file.filename), config, file.extendsPaths.map(relative)]); - file.extends.forEach(visit); - } - visit(file); - const serialized = JSON.stringify(inputs, (_key, value) => { - if (value && typeof value === 'object' && !Array.isArray(value)) { - return Object.fromEntries( - Object.keys(value) - .toSorted() - .map((key) => [key, value[key]]), - ); - } - return value; - }); - const digest = createHash('sha256').update(serialized).digest('hex').slice(0, 32); - return `// vp migrate: merged ${encodeURIComponent(relative(filename))} (${digest}).`; -} - /** Resume partial migrations without applying lint setup to other existing Vite+ projects. */ export function retryPreservedOxlintConfigs( workspaceRoot: string, @@ -343,8 +309,9 @@ export function retryPreservedOxlintConfigs( } const projectPath = path.dirname(filename); const configs = detectConfigs(projectPath); - if (configs.viteConfig && hasConfigKey(path.join(projectPath, configs.viteConfig), 'lint')) { - const source = fs.readFileSync(path.join(projectPath, configs.viteConfig), 'utf8'); + const existingViteConfig = configs.viteConfig && path.join(projectPath, configs.viteConfig); + if (existingViteConfig && hasConfigKey(existingViteConfig, 'lint')) { + const source = fs.readFileSync(existingViteConfig, 'utf8'); const marker = oxlintMigrationMarker(filename, migration.file, workspaceRoot); migration.migrated = source.split(/\r?\n/).includes(marker); continue; @@ -373,10 +340,15 @@ function mergeOxlintConfigFile( report?: MigrationReport, packages?: WorkspacePackage[], workspaceRoot?: string, - preparedOxlintConfigs?: Map, + preparedOxlintConfigs?: PreparedOxlintConfigs, ): void { - // Inject options.typeAware and options.typeCheck defaults before merging const fullOxlintPath = path.join(projectPath, oxlintConfig); + function warnPreservedConfig(error: unknown): void { + warnMigration( + `Could not migrate ${displayRelative(fullOxlintPath)}: ${String(error)}. The original config was preserved; use vp lint -c ${oxlintConfig} until it is migrated manually.`, + report, + ); + } let migration: OxlintConfigMigration; try { const prepared = preparedOxlintConfigs?.get(fullOxlintPath); @@ -385,10 +357,7 @@ function mergeOxlintConfigFile( } migration = prepared ?? { file: resolveOxlintConfigExtends(fullOxlintPath) }; } catch (error) { - warnMigration( - `Could not migrate ${displayRelative(fullOxlintPath)}: ${String(error)}. The original config was preserved; use vp lint -c ${oxlintConfig} until it is migrated manually.`, - report, - ); + warnPreservedConfig(error); return; } if (migration.migrated) { @@ -421,9 +390,7 @@ function mergeOxlintConfigFile( for (const layer of layers) { Object.assign(inheritedOptions, layer.options); } - if (!oxlintJson.options) { - oxlintJson.options = {}; - } + oxlintJson.options ||= {}; // Skip typeAware/typeCheck when tsconfig.json has baseUrl (unsupported by tsgolint) if (!hasBaseUrlInTsconfig(projectPath)) { if (inheritedOptions.typeAware === undefined) { @@ -492,10 +459,7 @@ function mergeOxlintConfigFile( preparedOxlintConfigs !== undefined, ); } catch (error) { - warnMigration( - `Could not migrate ${displayRelative(fullOxlintPath)}: ${String(error)}. The original config was preserved; use vp lint -c ${oxlintConfig} until it is migrated manually.`, - report, - ); + warnPreservedConfig(error); } } From 11445f3c1f25f58249e7dee8300255f06461608f Mon Sep 17 00:00:00 2001 From: MK Date: Mon, 19 Oct 2026 23:31:36 +0800 Subject: [PATCH 18/24] fix(migrate): preserve workspace Oxlint migration state --- .../create_monorepo_oxlint_extends/.gitignore | 1 + .../package.json | 6 + .../packages/starter-template/bin/index.mjs | 15 ++ .../packages/starter-template/package.json | 7 + .../pnpm-workspace.yaml | 2 + .../shared/lint.json | 4 + .../snapshots.toml | 17 ++ .../create_monorepo_oxlint_extends.md | 123 ++++++++++++++ .../vite.config.mjs | 11 ++ .../snapshots/migration_monorepo_bun.md | 1 + .../snapshots/migration_monorepo_pnpm.md | 2 + .../snapshots/migration_monorepo_yarn4.md | 1 + .../create-config-links.mjs | 5 + .../snapshots.toml | 39 +++++ ...xlint_extends_workspace_inherited_alias.md | 96 +++++++++++ ...n_oxlint_extends_workspace_inline_retry.md | 150 ++++++++++++++++++ ...xlint_extends_workspace_symlink_plugins.md | 1 + ...ation_oxlint_extends_workspace_symlinks.md | 2 + docs/guide/migrate-rules.md | 6 +- packages/cli/src/create/bin.ts | 8 + .../__tests__/oxlint-extends.spec.ts | 35 ++++ .../src/migration/migrator/oxlint-config.ts | 9 +- .../cli/src/migration/migrator/vite-config.ts | 32 +++- 23 files changed, 563 insertions(+), 10 deletions(-) create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/create_monorepo_oxlint_extends/.gitignore create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/create_monorepo_oxlint_extends/package.json create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/create_monorepo_oxlint_extends/packages/starter-template/bin/index.mjs create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/create_monorepo_oxlint_extends/packages/starter-template/package.json create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/create_monorepo_oxlint_extends/pnpm-workspace.yaml create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/create_monorepo_oxlint_extends/shared/lint.json create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/create_monorepo_oxlint_extends/snapshots.toml create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/create_monorepo_oxlint_extends/snapshots/create_monorepo_oxlint_extends.md create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/create_monorepo_oxlint_extends/vite.config.mjs create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/snapshots/migration_oxlint_extends_workspace_inherited_alias.md create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/snapshots/migration_oxlint_extends_workspace_inline_retry.md diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/create_monorepo_oxlint_extends/.gitignore b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/create_monorepo_oxlint_extends/.gitignore new file mode 100644 index 0000000000..cf7098890e --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/create_monorepo_oxlint_extends/.gitignore @@ -0,0 +1 @@ +**/node_modules diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/create_monorepo_oxlint_extends/package.json b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/create_monorepo_oxlint_extends/package.json new file mode 100644 index 0000000000..2459b1e885 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/create_monorepo_oxlint_extends/package.json @@ -0,0 +1,6 @@ +{ + "name": "create-oxlint-workspace", + "private": true, + "type": "module", + "packageManager": "pnpm@10.0.0" +} diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/create_monorepo_oxlint_extends/packages/starter-template/bin/index.mjs b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/create_monorepo_oxlint_extends/packages/starter-template/bin/index.mjs new file mode 100644 index 0000000000..fe60b3838f --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/create_monorepo_oxlint_extends/packages/starter-template/bin/index.mjs @@ -0,0 +1,15 @@ +import { mkdirSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; + +const directoryFlag = process.argv.indexOf('--directory'); +const directory = directoryFlag === -1 ? 'app' : process.argv[directoryFlag + 1]; +mkdirSync(directory, { recursive: true }); + +function write(filename, content) { + writeFileSync(path.join(directory, filename), content); +} + +write('package.json', JSON.stringify({ name: path.basename(directory), type: 'module' })); +write('vite.config.mjs', 'export default {};\n'); +write('.oxlintrc.json', JSON.stringify({ extends: ['../../shared/lint.json'] })); +write('index.js', 'debugger;\n'); diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/create_monorepo_oxlint_extends/packages/starter-template/package.json b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/create_monorepo_oxlint_extends/packages/starter-template/package.json new file mode 100644 index 0000000000..6ed094f451 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/create_monorepo_oxlint_extends/packages/starter-template/package.json @@ -0,0 +1,7 @@ +{ + "name": "starter-template", + "version": "0.0.0", + "private": true, + "type": "module", + "bin": "./bin/index.mjs" +} diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/create_monorepo_oxlint_extends/pnpm-workspace.yaml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/create_monorepo_oxlint_extends/pnpm-workspace.yaml new file mode 100644 index 0000000000..924b55f42e --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/create_monorepo_oxlint_extends/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +packages: + - packages/* diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/create_monorepo_oxlint_extends/shared/lint.json b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/create_monorepo_oxlint_extends/shared/lint.json new file mode 100644 index 0000000000..e54ba4dbb0 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/create_monorepo_oxlint_extends/shared/lint.json @@ -0,0 +1,4 @@ +{ + "rules": { "no-debugger": "error" }, + "options": { "typeAware": false, "typeCheck": false } +} diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/create_monorepo_oxlint_extends/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/create_monorepo_oxlint_extends/snapshots.toml new file mode 100644 index 0000000000..058083f841 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/create_monorepo_oxlint_extends/snapshots.toml @@ -0,0 +1,17 @@ +[[case]] +name = "create_monorepo_oxlint_extends" +vp = "local" +local-registry = true +unset-env = ["VP_SKIP_INSTALL"] +steps = [ + { argv = ["node", "packages/starter-template/bin/index.mjs", "--directory", "packages/before"], snapshot = false }, + { argv = ["vp", "lint", "--threads=1", "-c", ".oxlintrc.json", "index.js"], cwd = "packages/before", comment = "Original template rules inherit from the workspace", continue-on-failure = true }, + { argv = ["vpt", "rm", "-r", "packages/before"], snapshot = false }, + { argv = ["vp", "create", "starter", "--no-interactive", "--no-agent", "--no-editor", "--no-hooks", "--", "--directory", "app"] }, + { argv = ["vpt", "print-file", "packages/app/vite.config.mjs"] }, + { argv = ["vpt", "print-file", "shared/lint.ts"] }, + { argv = ["vpt", "stat-file", "packages/app/.oxlintrc.json", "--assert", "missing"] }, + { argv = ["vp", "lint", "--threads=1", "index.js"], cwd = "packages/app", comment = "The created package keeps the original diagnostic without a config flag", continue-on-failure = true }, + { argv = ["vpt", "replace-file-content", "shared/lint.ts", '"error"', '"warn"'] }, + { argv = ["vp", "lint", "--threads=1", "index.js"], cwd = "packages/app", comment = "The created package follows shared module edits" }, +] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/create_monorepo_oxlint_extends/snapshots/create_monorepo_oxlint_extends.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/create_monorepo_oxlint_extends/snapshots/create_monorepo_oxlint_extends.md new file mode 100644 index 0000000000..cacef05e17 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/create_monorepo_oxlint_extends/snapshots/create_monorepo_oxlint_extends.md @@ -0,0 +1,123 @@ +# create_monorepo_oxlint_extends + +## `node packages/starter-template/bin/index.mjs --directory packages/before` + + +## `cd packages/before && vp lint --threads=1 -c .oxlintrc.json index.js` + +Original template rules inherit from the workspace + +**Exit code:** 1 + +``` + + × eslint(no-debugger): `debugger` statement is not allowed + ╭─[index.js:1:1] + 1 │ debugger; + · ───────── + ╰──── + help: Remove the debugger statement + +Found 0 warnings and 1 error. +Finished in on 1 file with rules using threads. +``` + +## `vpt rm -r packages/before` + + +## `vp create starter --no-interactive --no-agent --no-editor --no-hooks -- --directory app` + +``` +◇ Generating project… +● Running: node /packages/starter-template/bin/index.mjs --directory app +◇ Monorepo integration... +◆ ✔ Merged packages/app/.oxlintrc.json into packages/app/vite.config.mjs +● Installing dependencies... +● Dependencies installed +● Formatting code... +● Code formatted +◇ Scaffolded packages/app +• Node pnpm +✓ Dependencies installed in +→ Next: cd packages/app && vp run +``` + +## `vpt print-file packages/app/vite.config.mjs` + +``` +import oxlintConfig1 from "../../shared/lint.ts"; +export default { + lint: { + extends: [oxlintConfig1], + options: {}, + jsPlugins: [{ name: "vite-plus", specifier: "vite-plus/oxlint-plugin" }], + rules: { + "vite-plus/prefer-vite-plus-imports": "error", + }, + }, +}; +``` + +## `vpt print-file shared/lint.ts` + +``` +// Generated by vp migrate from lint.json. +import type { OxlintConfig } from "vite-plus/lint"; + +export default { + "rules": { + "no-debugger": "error" + }, + "options": { + "typeAware": false, + "typeCheck": false + } +} satisfies OxlintConfig; +``` + +## `vpt stat-file packages/app/.oxlintrc.json --assert missing` + +``` +packages/app/.oxlintrc.json: missing +``` + +## `cd packages/app && vp lint --threads=1 index.js` + +The created package keeps the original diagnostic without a config flag + +**Exit code:** 1 + +``` + + × eslint(no-debugger): `debugger` statement is not allowed + ╭─[index.js:1:1] + 1 │ debugger; + · ───────── + ╰──── + help: Remove the debugger statement + +Found 0 warnings and 1 error. +Finished in on 1 file with rules using threads. +``` + +## `vpt replace-file-content shared/lint.ts '"error"' '"warn"'` + +``` +``` + +## `cd packages/app && vp lint --threads=1 index.js` + +The created package follows shared module edits + +``` + + ⚠ eslint(no-debugger): `debugger` statement is not allowed + ╭─[index.js:1:1] + 1 │ debugger; + · ───────── + ╰──── + help: Remove the debugger statement + +Found 1 warning and 0 errors. +Finished in on 1 file with rules using threads. +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/create_monorepo_oxlint_extends/vite.config.mjs b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/create_monorepo_oxlint_extends/vite.config.mjs new file mode 100644 index 0000000000..60906c1d8a --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/create_monorepo_oxlint_extends/vite.config.mjs @@ -0,0 +1,11 @@ +export default { + create: { + templates: [ + { + name: 'starter', + description: 'A package that inherits workspace lint rules.', + template: './packages/starter-template', + }, + ], + }, +}; diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_monorepo_bun/snapshots/migration_monorepo_bun.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_monorepo_bun/snapshots/migration_monorepo_bun.md index 7870efd952..eaf0906253 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_monorepo_bun/snapshots/migration_monorepo_bun.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_monorepo_bun/snapshots/migration_monorepo_bun.md @@ -19,6 +19,7 @@ VITE+ - The Unified Toolchain for the Web check vite.config.ts ``` +// vp migrate: merged .oxlintrc.json (aba19784a1d2c46ab69279abb5cab90f). import react from '@vitejs/plugin-react'; import { defineConfig, lazyPlugins } from 'vite-plus'; diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_monorepo_pnpm/snapshots/migration_monorepo_pnpm.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_monorepo_pnpm/snapshots/migration_monorepo_pnpm.md index 705de1f549..9b74a136c7 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_monorepo_pnpm/snapshots/migration_monorepo_pnpm.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_monorepo_pnpm/snapshots/migration_monorepo_pnpm.md @@ -20,6 +20,7 @@ VITE+ - The Unified Toolchain for the Web check vite.config.ts ``` +// vp migrate: merged .oxlintrc.json (aba19784a1d2c46ab69279abb5cab90f). import react from '@vitejs/plugin-react'; import { defineConfig, lazyPlugins } from 'vite-plus'; @@ -225,6 +226,7 @@ check only-oxlint package.json check only-oxlint vite.config.ts ``` +// vp migrate: merged packages%2Fonly-oxlint%2F.oxlintrc.json (b648b436b7a8c2627c4008844cff0e45). import { defineConfig } from 'vite-plus'; export default defineConfig({ diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_monorepo_yarn4/snapshots/migration_monorepo_yarn4.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_monorepo_yarn4/snapshots/migration_monorepo_yarn4.md index 32aef9609d..cf17652598 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_monorepo_yarn4/snapshots/migration_monorepo_yarn4.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_monorepo_yarn4/snapshots/migration_monorepo_yarn4.md @@ -27,6 +27,7 @@ VITE+ - The Unified Toolchain for the Web check vite.config.ts ``` +// vp migrate: merged .oxlintrc.json (aba19784a1d2c46ab69279abb5cab90f). import react from '@vitejs/plugin-react'; import { defineConfig, lazyPlugins } from 'vite-plus'; diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/create-config-links.mjs b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/create-config-links.mjs index 912a2b4a9c..d8ad36af5c 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/create-config-links.mjs +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/create-config-links.mjs @@ -1,5 +1,10 @@ import { symlinkSync } from 'node:fs'; +if (process.argv.includes('--owner-alias')) { + symlinkSync('../../.oxlintrc.json', 'packages/a/alias.json', 'file'); + process.exit(0); +} + for (const name of ['a', 'b']) { symlinkSync('../../shared/lint.json', `packages/${name}/.oxlintrc.json`, 'file'); if (process.argv.includes('--with-base')) { diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/snapshots.toml index d936b857fe..6ee4494429 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/snapshots.toml @@ -231,3 +231,42 @@ steps = [ { argv = ["vp", "lint", "--threads=1", "index.js"], cwd = "packages/a", comment = "The migrated first consumer retains its error", continue-on-failure = true }, { argv = ["vp", "lint", "--threads=1", "index.js"], cwd = "packages/b", comment = "The migrated second consumer retains its warning" }, ] +[[case]] +name = "migration_oxlint_extends_workspace_inline_retry" +vp = "local" +local-registry = true +unset-env = ["VP_SKIP_INSTALL"] +steps = [ + { argv = ["vpt", "rm", ".oxlintrc.json"], snapshot = false }, + { argv = ["vpt", "write-file", "packages/a/.oxlintrc.json", '{"rules":{"no-debugger":"error"},"options":{"typeAware":false,"typeCheck":false}}'], snapshot = false }, + { argv = ["vpt", "write-file", "packages/b/.oxlintrc.json", '{"extends":["./missing.json"]}'], snapshot = false }, + { argv = ["vp", "lint", "--threads=1", "-c", ".oxlintrc.json", "index.js"], cwd = "packages/a", comment = "Original inline rules", continue-on-failure = true }, + { argv = ["vp", "migrate", "--no-interactive", "--no-agent", "--no-editor", "--no-hooks"] }, + { argv = ["vpt", "stat-file", "packages/a/.oxlintrc.json", "--assert", "file"], comment = "An unreadable graph keeps the completed inline config on disk" }, + { argv = ["vpt", "stat-file", "packages/a/.oxlintrc.ts", "--assert", "missing"] }, + { argv = ["vpt", "write-file", "packages/b/missing.json", '{"rules":{"no-debugger":"error"},"options":{"typeAware":false,"typeCheck":false}}'], comment = "Repair the unreadable inheritance graph" }, + { argv = ["vp", "lint", "--threads=1", "-c", ".oxlintrc.json", "index.js"], cwd = "packages/b", comment = "The repaired JSON loads before retrying", continue-on-failure = true }, + { argv = ["vp", "migrate", "--no-interactive", "--no-agent", "--no-editor", "--no-hooks"] }, + { argv = ["vpt", "stat-file", "packages/a/.oxlintrc.json", "--assert", "missing"] }, + { argv = ["vpt", "stat-file", "packages/b/.oxlintrc.json", "--assert", "missing"] }, + { argv = ["vp", "lint", "--threads=1", "index.js"], cwd = "packages/a", comment = "The completed inline consumer retains its diagnostic", continue-on-failure = true }, + { argv = ["vp", "lint", "--threads=1", "index.js"], cwd = "packages/b", comment = "The repaired consumer retains its diagnostic after retrying", continue-on-failure = true }, +] + +[[case]] +name = "migration_oxlint_extends_workspace_inherited_alias" +vp = "local" +local-registry = true +unset-env = ["VP_SKIP_INSTALL"] +steps = [ + { argv = ["vpt", "rm", "packages/b/extra.ts"], snapshot = false }, + { argv = ["vpt", "write-file", "packages/a/.oxlintrc.json", '{"extends":["./alias.json"]}'], snapshot = false }, + { argv = ["node", "create-config-links.mjs", "--owner-alias"], snapshot = false }, + { argv = ["vp", "lint", "--threads=1", "-c", "alias.json", "index.js"], cwd = "packages/a", comment = "The inherited alias loads the original owner config" }, + { argv = ["vp", "migrate", "--no-interactive", "--no-agent", "--no-editor", "--no-hooks"] }, + { argv = ["vpt", "stat-file", ".oxlintrc.json", "--assert", "file"], comment = "The surviving inherited alias retains its target" }, + { argv = ["vpt", "stat-file", "packages/a/alias.json", "--assert", "symlink"] }, + { argv = ["vpt", "stat-file", "packages/a/.oxlintrc.json", "--assert", "missing"] }, + { argv = ["vp", "lint", "--threads=1", "-c", "alias.json", "index.js"], cwd = "packages/a", comment = "The inherited alias still loads with the same diagnostic" }, + { argv = ["vp", "lint", "--threads=1", "index.js"], cwd = "packages/a", comment = "The migrated consumer uses its shared module" }, +] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/snapshots/migration_oxlint_extends_workspace_inherited_alias.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/snapshots/migration_oxlint_extends_workspace_inherited_alias.md new file mode 100644 index 0000000000..7fdfd813ae --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/snapshots/migration_oxlint_extends_workspace_inherited_alias.md @@ -0,0 +1,96 @@ +# migration_oxlint_extends_workspace_inherited_alias + +## `vpt rm packages/b/extra.ts` + + +## `vpt write-file packages/a/.oxlintrc.json '{"extends":["./alias.json"]}'` + + +## `node create-config-links.mjs --owner-alias` + + +## `cd packages/a && vp lint --threads=1 -c alias.json index.js` + +The inherited alias loads the original owner config + +``` + + ⚠ eslint(no-debugger): `debugger` statement is not allowed + ╭─[index.js:1:1] + 1 │ debugger; + · ───────── + ╰──── + help: Remove the debugger statement + +Found 1 warning and 0 errors. +Finished in on 1 file with rules using threads. +``` + +## `vp migrate --no-interactive --no-agent --no-editor --no-hooks` + +``` +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. +◆ ✔ Merged .oxlintrc.json into vite.config.mjs +● Formatting code... +● Code formatted +◇ Migrated . to Vite+ +• Node pnpm +✓ Dependencies installed in +• 2 config updates applied +``` + +## `vpt stat-file .oxlintrc.json --assert file` + +The surviving inherited alias retains its target + +``` +.oxlintrc.json: file +``` + +## `vpt stat-file packages/a/alias.json --assert symlink` + +``` +packages/a/alias.json: symlink +``` + +## `vpt stat-file packages/a/.oxlintrc.json --assert missing` + +``` +packages/a/.oxlintrc.json: missing +``` + +## `cd packages/a && vp lint --threads=1 -c alias.json index.js` + +The inherited alias still loads with the same diagnostic + +``` + + ⚠ eslint(no-debugger): `debugger` statement is not allowed + ╭─[index.js:1:1] + 1 │ debugger; + · ───────── + ╰──── + help: Remove the debugger statement + +Found 1 warning and 0 errors. +Finished in on 1 file with rules using threads. +``` + +## `cd packages/a && vp lint --threads=1 index.js` + +The migrated consumer uses its shared module + +``` + + ⚠ eslint(no-debugger): `debugger` statement is not allowed + ╭─[index.js:1:1] + 1 │ debugger; + · ───────── + ╰──── + help: Remove the debugger statement + +Found 1 warning and 0 errors. +Finished in on 1 file with rules using threads. +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/snapshots/migration_oxlint_extends_workspace_inline_retry.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/snapshots/migration_oxlint_extends_workspace_inline_retry.md new file mode 100644 index 0000000000..f6161784b7 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/snapshots/migration_oxlint_extends_workspace_inline_retry.md @@ -0,0 +1,150 @@ +# migration_oxlint_extends_workspace_inline_retry + +## `vpt rm .oxlintrc.json` + + +## `vpt write-file packages/a/.oxlintrc.json '{"rules":{"no-debugger":"error"},"options":{"typeAware":false,"typeCheck":false}}'` + + +## `vpt write-file packages/b/.oxlintrc.json '{"extends":["./missing.json"]}'` + + +## `cd packages/a && vp lint --threads=1 -c .oxlintrc.json index.js` + +Original inline rules + +**Exit code:** 1 + +``` + + × eslint(no-debugger): `debugger` statement is not allowed + ╭─[index.js:1:1] + 1 │ debugger; + · ───────── + ╰──── + help: Remove the debugger statement + +Found 0 warnings and 1 error. +Finished in on 1 file with rules using threads. +``` + +## `vp migrate --no-interactive --no-agent --no-editor --no-hooks` + +``` +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. +● Formatting code... +● Code formatted +◇ Migrated . to Vite+ +• Node pnpm +✓ Dependencies installed in +• 1 config update applied +! Warnings: + - Could not migrate packages/b/.oxlintrc.json: Error: ENOENT: no such file or directory, lstat '/packages/b/missing.json'. The original config was preserved; use vp lint -c .oxlintrc.json until it is migrated manually. +``` + +## `vpt stat-file packages/a/.oxlintrc.json --assert file` + +An unreadable graph keeps the completed inline config on disk + +``` +packages/a/.oxlintrc.json: file +``` + +## `vpt stat-file packages/a/.oxlintrc.ts --assert missing` + +``` +packages/a/.oxlintrc.ts: missing +``` + +## `vpt write-file packages/b/missing.json '{"rules":{"no-debugger":"error"},"options":{"typeAware":false,"typeCheck":false}}'` + +Repair the unreadable inheritance graph + +``` +``` + +## `cd packages/b && vp lint --threads=1 -c .oxlintrc.json index.js` + +The repaired JSON loads before retrying + +**Exit code:** 1 + +``` + + × eslint(no-debugger): `debugger` statement is not allowed + ╭─[index.js:1:1] + 1 │ debugger; + · ───────── + ╰──── + help: Remove the debugger statement + +Found 0 warnings and 1 error. +Finished in on 1 file with rules using threads. +``` + +## `vp migrate --no-interactive --no-agent --no-editor --no-hooks` + +``` +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. +● Formatting code... +● Code formatted +◇ Updated . to Vite+ +• Node pnpm +• Dependencies: + vite → +• 1 config update applied +``` + +## `vpt stat-file packages/a/.oxlintrc.json --assert missing` + +``` +packages/a/.oxlintrc.json: missing +``` + +## `vpt stat-file packages/b/.oxlintrc.json --assert missing` + +``` +packages/b/.oxlintrc.json: missing +``` + +## `cd packages/a && vp lint --threads=1 index.js` + +The completed inline consumer retains its diagnostic + +**Exit code:** 1 + +``` + + × eslint(no-debugger): `debugger` statement is not allowed + ╭─[index.js:1:1] + 1 │ debugger; + · ───────── + ╰──── + help: Remove the debugger statement + +Found 0 warnings and 1 error. +Finished in on 1 file with rules using threads. +``` + +## `cd packages/b && vp lint --threads=1 index.js` + +The repaired consumer retains its diagnostic after retrying + +**Exit code:** 1 + +``` + + × eslint(no-debugger): `debugger` statement is not allowed + ╭─[index.js:1:1] + 1 │ debugger; + · ───────── + ╰──── + help: Remove the debugger statement + +Found 0 warnings and 1 error. +Finished in on 1 file with rules using threads. +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/snapshots/migration_oxlint_extends_workspace_symlink_plugins.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/snapshots/migration_oxlint_extends_workspace_symlink_plugins.md index d2cfd2e0fd..ca10abe614 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/snapshots/migration_oxlint_extends_workspace_symlink_plugins.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/snapshots/migration_oxlint_extends_workspace_symlink_plugins.md @@ -82,6 +82,7 @@ VITE+ - The Unified Toolchain for the Web The root plugin path stays relative to the workspace ``` +// vp migrate: merged packages%2Fa%2F.oxlintrc.json (2e3968680d769859f24911af4896427f). export default { lint: { jsPlugins: [ diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/snapshots/migration_oxlint_extends_workspace_symlinks.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/snapshots/migration_oxlint_extends_workspace_symlinks.md index deef12b00f..50b03e98d5 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/snapshots/migration_oxlint_extends_workspace_symlinks.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/snapshots/migration_oxlint_extends_workspace_symlinks.md @@ -91,6 +91,7 @@ VITE+ - The Unified Toolchain for the Web Each consumer receives its own lint block ``` +// vp migrate: merged packages%2Fa%2F.oxlintrc.json (c8b042efd6403025ed6a8239c29b3114). export default { lint: { rules: { @@ -114,6 +115,7 @@ export default { ## `vpt print-file packages/b/vite.config.mjs` ``` +// vp migrate: merged packages%2Fb%2F.oxlintrc.json (b9baaacbd5e6b450f2ea1acce04edac9). export default { lint: { rules: { diff --git a/docs/guide/migrate-rules.md b/docs/guide/migrate-rules.md index 3031a4ee6c..0c62a3f919 100644 --- a/docs/guide/migrate-rules.md +++ b/docs/guide/migrate-rules.md @@ -31,13 +31,15 @@ Migration assumes ESM projects. Shared configs become sibling `.ts` files. Gener JS-plugin declarations stay in their original config or override. Relative plugin paths become absolute paths at runtime through `fileURLToPath(new URL(..., import.meta.url))`. Explicit inherited `typeAware` and `typeCheck` settings take precedence over migration defaults. -Cleanup removes each completed symlink alias independently, but retains aliases and link chains that remaining JSON consumers reference. +Cleanup removes each completed workspace symlink independently. Shared aliases that remain on disk keep their targets and dependencies available. A shared config's owner keeps `env`, `globals`, `settings`, and `ignorePatterns` at the top level of its `lint` block through references to the shared module. Edits to these fields apply to the owner. Other consumers do not inherit them. Generated modules include a source marker. A later migration attempt reuses these modules without replacing user edits, so a preserved consumer can migrate after its conflict is resolved. Existing modules without a matching marker remain conflicts. -Shared config imports use literal paths so Vite and TypeScript can resolve filenames containing `#` or `%`. A relative import path containing `?` requires a manual migration because Vite treats it as a query delimiter. Migration preserves the JSON config and reports a warning in that case. +Completed workspace merges also record a completion marker, including configs merged inline. After fixing an unreadable config, run `vp migrate` again to migrate the remaining consumers and remove JSON files that are no longer needed. + +Shared config imports use literal paths so Vite and TypeScript can resolve filenames containing `#` or `%`. A relative import path containing `?` or a literal backslash requires a manual migration: Vite treats `?` as a query delimiter, and TypeScript treats backslashes as path separators. Migration preserves the JSON config and reports a warning in these cases. Missing files, invalid JSON, inheritance cycles, conflicting output files, and package-local plugins that resolve differently from the Vite config directory produce a warning and leave the original config in place. Configs outside the project or workspace, configs inside `node_modules`, CommonJS Vite configs, and an existing `lint` block require a manual merge. Workspace JSON removal waits until all migration outcomes are known. Preserved JSON consumers retain their transitive dependencies, including configs whose owners migrated successfully. If an inheritance graph cannot be read, its workspace JSON files are kept. `vp lint` continues to reject string paths in `lint.extends`; path resolution happens only during migration. diff --git a/packages/cli/src/create/bin.ts b/packages/cli/src/create/bin.ts index 355f7838e9..bca384dd70 100644 --- a/packages/cli/src/create/bin.ts +++ b/packages/cli/src/create/bin.ts @@ -58,6 +58,7 @@ import { import { accent, formatDuration, muted, log, printHeader, success } from '../utils/terminal.ts'; import { detectWorkspace, + discoverWorkspacePackages, updatePackageJsonWithDeps, updateWorkspaceConfig, } from '../utils/workspace.ts'; @@ -1266,6 +1267,13 @@ Use \`vp create --list\` to list all available templates, or run \`vp create --h workspaceInfo.packageManager === PackageManager.pnpm ? createCatalogDependencyResolver(workspaceInfo.rootDir, workspaceInfo.packageManager) : undefined, + { + rootDir: workspaceInfo.rootDir, + packages: discoverWorkspacePackages( + [...workspaceInfo.workspacePatterns, projectDir], + workspaceInfo.rootDir, + ), + }, ); if (workspaceInfo.packageManager === PackageManager.pnpm) { ensurePnpmCreateCatalogEntries(workspaceInfo.rootDir, fullPath); diff --git a/packages/cli/src/migration/__tests__/oxlint-extends.spec.ts b/packages/cli/src/migration/__tests__/oxlint-extends.spec.ts index ea031a8c6b..a3fa557af0 100644 --- a/packages/cli/src/migration/__tests__/oxlint-extends.spec.ts +++ b/packages/cli/src/migration/__tests__/oxlint-extends.spec.ts @@ -590,6 +590,10 @@ describe('Oxlint JSON extends migration', () => { expect(migrateWorkspace(['a']).warnings).toEqual([]); + expect(fs.existsSync(path.join(project, '.oxlintrc.json'))).toBe(true); + expect(fs.realpathSync(path.join(project, 'packages/a/alias.json'))).toBe( + path.join(project, '.oxlintrc.json'), + ); const modulePath = path.join(project, '.oxlintrc.ts'); fs.writeFileSync(modulePath, fs.readFileSync(modulePath, 'utf8').replace('"warn"', '"error"')); expect((await loadConfig()).lint!.extends![0].rules!['no-console']).toBe('error'); @@ -707,6 +711,37 @@ describe('Oxlint JSON extends migration', () => { ); }); + it('resumes inline merges after an unreadable workspace graph is repaired', async () => { + createWorkspace(); + write('packages/a/.oxlintrc.json', { rules: { 'no-debugger': 'error' } }); + write('packages/b/.oxlintrc.json', { extends: ['./missing.json'] }); + expect(migrateWorkspace().warnings).toHaveLength(1); + expect(fs.existsSync(path.join(project, 'packages/a/.oxlintrc.json'))).toBe(true); + expect(fs.existsSync(path.join(project, 'packages/a/.oxlintrc.ts'))).toBe(false); + write('packages/b/missing.json', { rules: { 'no-console': 'warn' } }); + + const report = createMigrationReport(); + expect( + retryPreservedOxlintConfigs( + project, + true, + report, + ['b', 'a'].map((name) => ({ name, path: `packages/${name}` })), + ), + ).toBe(true); + + expect(report.warnings).toEqual([]); + for (const name of ['a', 'b']) { + expect(fs.existsSync(path.join(project, `packages/${name}/.oxlintrc.json`))).toBe(false); + } + expect((await loadConfig(path.join(project, 'packages/a'))).lint!.rules!['no-debugger']).toBe( + 'error', + ); + expect( + (await loadConfig(path.join(project, 'packages/b'))).lint!.extends![0].rules!['no-console'], + ).toBe('warn'); + }); + it('does not reuse a generated module for a different JSON source', () => { write('base.json', { rules: { 'no-debugger': 'warn' } }); write('.oxlintrc.json', { extends: ['./base.json'] }); diff --git a/packages/cli/src/migration/migrator/oxlint-config.ts b/packages/cli/src/migration/migrator/oxlint-config.ts index 95f5b02223..92322f65d0 100644 --- a/packages/cli/src/migration/migrator/oxlint-config.ts +++ b/packages/cli/src/migration/migrator/oxlint-config.ts @@ -202,11 +202,18 @@ export function removeMigratedOxlintConfigs(configs: PreparedOxlintConfigs): boo removableSources.add(fs.realpathSync(filename)); } } + const removablePaths = new Set([...removalCandidates].map(configPathInRealDirectory)); const cache = createConfigCache(); try { for (const file of files) { const sourcePath = fs.realpathSync(file.filename); - if (!removableSources.has(sourcePath)) { + const survivingAlias = + fs.lstatSync(file.filename).isSymbolicLink() && + !removablePaths.has(configPathInRealDirectory(file.filename)); + if (survivingAlias) { + collectConfigFiles(file, retained); + } + if (survivingAlias || !removableSources.has(sourcePath)) { // A symlink target can resolve different dependencies from its aliases. const source = resolveOxlintConfigExtends(sourcePath, cache); collectConfigFiles(source, retained); diff --git a/packages/cli/src/migration/migrator/vite-config.ts b/packages/cli/src/migration/migrator/vite-config.ts index cd6e068598..4c6898a64d 100644 --- a/packages/cli/src/migration/migrator/vite-config.ts +++ b/packages/cli/src/migration/migrator/vite-config.ts @@ -302,9 +302,14 @@ export function retryPreservedOxlintConfigs( workspaceRoot, ...(packages ?? []).map((pkg) => path.join(workspaceRoot, pkg.path)), ]); - let updated = false; + const pending: Array<{ + filename: string; + migration: OxlintConfigMigration; + configs: ConfigFiles; + }> = []; + let hasCompletedMerge = false; for (const [filename, migration] of prepared) { - if (migration instanceof Error || !hasGeneratedOxlintConfig(migration.file)) { + if (migration instanceof Error) { continue; } const projectPath = path.dirname(filename); @@ -314,8 +319,17 @@ export function retryPreservedOxlintConfigs( const source = fs.readFileSync(existingViteConfig, 'utf8'); const marker = oxlintMigrationMarker(filename, migration.file, workspaceRoot); migration.migrated = source.split(/\r?\n/).includes(marker); + hasCompletedMerge ||= migration.migrated; + continue; + } + pending.push({ filename, migration, configs }); + } + let updated = false; + for (const { filename, migration, configs } of pending) { + if (!hasCompletedMerge && !hasGeneratedOxlintConfig(migration.file)) { continue; } + const projectPath = path.dirname(filename); const viteConfig = ensureViteConfig(projectPath, configs, silent, report); mergeOxlintConfigFile( projectPath, @@ -431,7 +445,7 @@ function mergeOxlintConfigFile( delete normalizedOxlintConfig.rules![PREFER_VITE_PLUS_IMPORTS_RULE]; } try { - const plan = + const plan: OxlintModulePlan = file.shared || file.extends.length > 0 ? prepareOxlintConfigModules( file, @@ -439,8 +453,12 @@ function mergeOxlintConfigFile( path.join(projectPath, viteConfig), workspaceRoot ?? projectPath, ) - : undefined; - if (plan && preparedOxlintConfigs) { + : { + configContent: JSON.stringify(normalizedOxlintConfig, null, 2), + imports: [], + modules: new Map(), + }; + if (preparedOxlintConfigs) { plan.completionMarker = oxlintMigrationMarker( fullOxlintPath, file, @@ -454,7 +472,7 @@ function mergeOxlintConfigFile( 'lint', silent, report, - plan?.configContent ?? JSON.stringify(normalizedOxlintConfig, null, 2), + plan.configContent, plan, preparedOxlintConfigs !== undefined, ); @@ -636,7 +654,7 @@ function mergeAndRemoveJsonConfig( } } let content = result.content; - if (modulePlan?.imports.length) { + if (modulePlan && (modulePlan.imports.length || modulePlan.completionMarker)) { const offset = content.startsWith('#!') ? content.indexOf('\n') + 1 : 0; const prefix = [modulePlan.completionMarker, ...modulePlan.imports] .filter(Boolean) From 8443afe25b1147bd177c519f6e58a62dee1b1cce Mon Sep 17 00:00:00 2001 From: MK Date: Mon, 19 Oct 2026 23:53:43 +0800 Subject: [PATCH 19/24] fix(migrate): retry preserved Oxlint configs reliably --- .../create_monorepo_oxlint_extends.md | 2 +- .../migration_oxlint_extends/snapshots.toml | 51 ++++++ .../migration_oxlint_extends_check_js.md | 129 +++++++++++++++ .../migration_oxlint_extends_check_mjs.md | 129 +++++++++++++++ ...migration_oxlint_extends_isolated_retry.md | 122 ++++++++++++++ .../snapshots.toml | 31 +++- ...tends_workspace_incomplete_graph_retry.md} | 14 +- ...n_oxlint_extends_workspace_owner_fields.md | 2 +- ..._oxlint_extends_workspace_owner_ignores.md | 4 +- ...nt_extends_workspace_preserved_consumer.md | 26 ++- ...extends_workspace_repaired_shared_owner.md | 156 ++++++++++++++++++ ...igration_oxlint_extends_workspace_retry.md | 6 +- ...n_oxlint_extends_workspace_shared_owner.md | 8 +- ...lint_extends_workspace_symlink_contexts.md | 4 +- ..._oxlint_extends_workspace_symlink_retry.md | 2 +- docs/guide/migrate-rules.md | 6 +- .../__tests__/oxlint-extends.spec.ts | 80 +++++++-- .../src/migration/migrator/oxlint-config.ts | 11 +- .../cli/src/migration/migrator/vite-config.ts | 64 ++++--- 19 files changed, 782 insertions(+), 65 deletions(-) create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends/snapshots/migration_oxlint_extends_check_js.md create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends/snapshots/migration_oxlint_extends_check_mjs.md create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends/snapshots/migration_oxlint_extends_isolated_retry.md rename crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/snapshots/{migration_oxlint_extends_workspace_inline_retry.md => migration_oxlint_extends_workspace_incomplete_graph_retry.md} (89%) create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/snapshots/migration_oxlint_extends_workspace_repaired_shared_owner.md diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/create_monorepo_oxlint_extends/snapshots/create_monorepo_oxlint_extends.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/create_monorepo_oxlint_extends/snapshots/create_monorepo_oxlint_extends.md index cacef05e17..9debd837a0 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/create_monorepo_oxlint_extends/snapshots/create_monorepo_oxlint_extends.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/create_monorepo_oxlint_extends/snapshots/create_monorepo_oxlint_extends.md @@ -45,7 +45,7 @@ Finished in on 1 file with rules using threads. ## `vpt print-file packages/app/vite.config.mjs` ``` -import oxlintConfig1 from "../../shared/lint.ts"; +import oxlintConfig1 from "../../shared/lint.js"; export default { lint: { extends: [oxlintConfig1], diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends/snapshots.toml index 12cd926062..796ed27c61 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends/snapshots.toml @@ -161,3 +161,54 @@ steps = [ { argv = ["vpt", "print-file", "configs/nested/base.ts"] }, { argv = ["vp", "lint", "--threads=1", "index.js"], comment = "The migrated graph selects the same lexical parent config", continue-on-failure = true }, ] + +[[case]] +name = "migration_oxlint_extends_check_js" +vp = "local" +local-registry = true +unset-env = ["VP_SKIP_INSTALL"] +steps = [ + { argv = ["vpt", "write-file", "vite.config.js", "export default {};\n"], snapshot = false }, + { argv = ["vpt", "json-edit", "tsconfig.json", "compilerOptions.allowJs", "true"], snapshot = false }, + { argv = ["vpt", "json-edit", "tsconfig.json", "compilerOptions.checkJs", "true"], snapshot = false }, + { argv = ["vpt", "json-edit", "tsconfig.json", "include", '["vite.config.js"]'], snapshot = false }, + { argv = ["vp", "lint", "--threads=1", "-c", ".oxlintrc.json", "index.js"], comment = "Original inherited diagnostics", continue-on-failure = true }, + { argv = ["vp", "migrate", "--no-interactive", "--no-agent", "--no-editor", "--no-hooks"] }, + { argv = ["vpt", "print-file", "vite.config.js"] }, + { argv = ["vp", "exec", "tsc", "--noEmit"], comment = "JavaScript config imports type-check without allowImportingTsExtensions" }, + { argv = ["vp", "lint", "--threads=1", "index.js"], comment = "The generated imports preserve the diagnostics and exit code", continue-on-failure = true }, +] + +[[case]] +name = "migration_oxlint_extends_check_mjs" +vp = "local" +local-registry = true +unset-env = ["VP_SKIP_INSTALL"] +steps = [ + { argv = ["vpt", "write-file", "vite.config.mjs", "export default {};\n"], snapshot = false }, + { argv = ["vpt", "json-edit", "tsconfig.json", "compilerOptions.allowJs", "true"], snapshot = false }, + { argv = ["vpt", "json-edit", "tsconfig.json", "compilerOptions.checkJs", "true"], snapshot = false }, + { argv = ["vpt", "json-edit", "tsconfig.json", "include", '["vite.config.mjs"]'], snapshot = false }, + { argv = ["vp", "lint", "--threads=1", "-c", ".oxlintrc.json", "index.js"], comment = "Original inherited diagnostics", continue-on-failure = true }, + { argv = ["vp", "migrate", "--no-interactive", "--no-agent", "--no-editor", "--no-hooks"] }, + { argv = ["vpt", "print-file", "vite.config.mjs"] }, + { argv = ["vp", "exec", "tsc", "--noEmit"], comment = "JavaScript config imports type-check without allowImportingTsExtensions" }, + { argv = ["vp", "lint", "--threads=1", "index.js"], comment = "The generated imports preserve the diagnostics and exit code", continue-on-failure = true }, +] + +[[case]] +name = "migration_oxlint_extends_isolated_retry" +vp = "local" +local-registry = true +unset-env = ["VP_SKIP_INSTALL"] +steps = [ + { argv = ["vpt", "write-file", ".oxlintrc.json", '{"extends":["./missing.json"]}'], snapshot = false }, + { argv = ["vp", "migrate", "--no-interactive", "--no-agent", "--no-editor", "--no-hooks"] }, + { argv = ["vpt", "print-file", "vite.config.ts"], comment = "The failed migration records a pending marker without a lint block" }, + { argv = ["vpt", "write-file", "missing.json", '{"rules":{"no-debugger":"error"},"options":{"typeAware":false,"typeCheck":false}}'], comment = "Repair the only unreadable config" }, + { argv = ["vp", "lint", "--threads=1", "-c", ".oxlintrc.json", "index.js"], comment = "The repaired JSON loads before retrying", continue-on-failure = true }, + { argv = ["vp", "migrate", "--no-interactive", "--no-agent", "--no-editor", "--no-hooks"] }, + { argv = ["vpt", "stat-file", ".oxlintrc.json", "--assert", "missing"] }, + { argv = ["vpt", "print-file", "vite.config.ts"], comment = "The successful retry replaces the pending marker with the lint config" }, + { argv = ["vp", "lint", "--threads=1", "index.js"], comment = "The migrated config retains the same diagnostic and exit code", continue-on-failure = true }, +] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends/snapshots/migration_oxlint_extends_check_js.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends/snapshots/migration_oxlint_extends_check_js.md new file mode 100644 index 0000000000..56645a7e54 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends/snapshots/migration_oxlint_extends_check_js.md @@ -0,0 +1,129 @@ +# migration_oxlint_extends_check_js + +## `vpt write-file vite.config.js 'export default {}; +'` + + +## `vpt json-edit tsconfig.json compilerOptions.allowJs true` + + +## `vpt json-edit tsconfig.json compilerOptions.checkJs true` + + +## `vpt json-edit tsconfig.json include '["vite.config.js"]'` + + +## `vp lint --threads=1 -c .oxlintrc.json index.js` + +Original inherited diagnostics + +**Exit code:** 1 + +``` + + × eslint(no-debugger): `debugger` statement is not allowed + ╭─[index.js:1:1] + 1 │ debugger; + · ───────── + 2 │ console.log("test"); + ╰──── + help: Remove the debugger statement + + ⚠ eslint(no-console): Unexpected console statement. + ╭─[index.js:2:1] + 1 │ debugger; + 2 │ console.log("test"); + · ─────────── + 3 │ alert("allowed"); + ╰──── + help: Delete this console statement. + + × local(no-foo): Do not use foo. + ╭─[index.js:4:14] + 3 │ alert("allowed"); + 4 │ export const foo = 1; + · ─── + 5 │ export const bar = 1; + ╰──── + +Found 1 warning and 2 errors. +Finished in on 1 file with rules using threads. +``` + +## `vp migrate --no-interactive --no-agent --no-editor --no-hooks` + +``` +VITE+ - The Unified Toolchain for the Web + +● Formatting code... +● Code formatted +◇ Migrated . to Vite+ +• Node pnpm +✓ Dependencies installed in +• 1 config update applied +``` + +## `vpt print-file vite.config.js` + +``` +import oxlintConfig1 from "./shared/nested/first.js"; +import oxlintConfig2 from "./shared/second.js"; +export default { + fmt: {}, + lint: { + extends: [oxlintConfig1, oxlintConfig2], + rules: { + "no-alert": "off", + "local/no-foo": "error", + "vite-plus/prefer-vite-plus-imports": "error", + }, + ignorePatterns: ["shared/**", "vite.config.ts"], + options: {}, + jsPlugins: [{ name: "vite-plus", specifier: "vite-plus/oxlint-plugin" }], + }, +}; +``` + +## `vp exec tsc --noEmit` + +JavaScript config imports type-check without allowImportingTsExtensions + +``` +``` + +## `vp lint --threads=1 index.js` + +The generated imports preserve the diagnostics and exit code + +**Exit code:** 1 + +``` + + × eslint(no-debugger): `debugger` statement is not allowed + ╭─[index.js:1:1] + 1 │ debugger; + · ───────── + 2 │ console.log("test"); + ╰──── + help: Remove the debugger statement + + ⚠ eslint(no-console): Unexpected console statement. + ╭─[index.js:2:1] + 1 │ debugger; + 2 │ console.log("test"); + · ─────────── + 3 │ alert("allowed"); + ╰──── + help: Delete this console statement. + + × local(no-foo): Do not use foo. + ╭─[index.js:4:14] + 3 │ alert("allowed"); + 4 │ export const foo = 1; + · ─── + 5 │ export const bar = 1; + ╰──── + +Found 1 warning and 2 errors. +Finished in on 1 file with rules using threads. +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends/snapshots/migration_oxlint_extends_check_mjs.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends/snapshots/migration_oxlint_extends_check_mjs.md new file mode 100644 index 0000000000..847205f635 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends/snapshots/migration_oxlint_extends_check_mjs.md @@ -0,0 +1,129 @@ +# migration_oxlint_extends_check_mjs + +## `vpt write-file vite.config.mjs 'export default {}; +'` + + +## `vpt json-edit tsconfig.json compilerOptions.allowJs true` + + +## `vpt json-edit tsconfig.json compilerOptions.checkJs true` + + +## `vpt json-edit tsconfig.json include '["vite.config.mjs"]'` + + +## `vp lint --threads=1 -c .oxlintrc.json index.js` + +Original inherited diagnostics + +**Exit code:** 1 + +``` + + × eslint(no-debugger): `debugger` statement is not allowed + ╭─[index.js:1:1] + 1 │ debugger; + · ───────── + 2 │ console.log("test"); + ╰──── + help: Remove the debugger statement + + ⚠ eslint(no-console): Unexpected console statement. + ╭─[index.js:2:1] + 1 │ debugger; + 2 │ console.log("test"); + · ─────────── + 3 │ alert("allowed"); + ╰──── + help: Delete this console statement. + + × local(no-foo): Do not use foo. + ╭─[index.js:4:14] + 3 │ alert("allowed"); + 4 │ export const foo = 1; + · ─── + 5 │ export const bar = 1; + ╰──── + +Found 1 warning and 2 errors. +Finished in on 1 file with rules using threads. +``` + +## `vp migrate --no-interactive --no-agent --no-editor --no-hooks` + +``` +VITE+ - The Unified Toolchain for the Web + +● Formatting code... +● Code formatted +◇ Migrated . to Vite+ +• Node pnpm +✓ Dependencies installed in +• 1 config update applied +``` + +## `vpt print-file vite.config.mjs` + +``` +import oxlintConfig1 from "./shared/nested/first.js"; +import oxlintConfig2 from "./shared/second.js"; +export default { + fmt: {}, + lint: { + extends: [oxlintConfig1, oxlintConfig2], + rules: { + "no-alert": "off", + "local/no-foo": "error", + "vite-plus/prefer-vite-plus-imports": "error", + }, + ignorePatterns: ["shared/**", "vite.config.ts"], + options: {}, + jsPlugins: [{ name: "vite-plus", specifier: "vite-plus/oxlint-plugin" }], + }, +}; +``` + +## `vp exec tsc --noEmit` + +JavaScript config imports type-check without allowImportingTsExtensions + +``` +``` + +## `vp lint --threads=1 index.js` + +The generated imports preserve the diagnostics and exit code + +**Exit code:** 1 + +``` + + × eslint(no-debugger): `debugger` statement is not allowed + ╭─[index.js:1:1] + 1 │ debugger; + · ───────── + 2 │ console.log("test"); + ╰──── + help: Remove the debugger statement + + ⚠ eslint(no-console): Unexpected console statement. + ╭─[index.js:2:1] + 1 │ debugger; + 2 │ console.log("test"); + · ─────────── + 3 │ alert("allowed"); + ╰──── + help: Delete this console statement. + + × local(no-foo): Do not use foo. + ╭─[index.js:4:14] + 3 │ alert("allowed"); + 4 │ export const foo = 1; + · ─── + 5 │ export const bar = 1; + ╰──── + +Found 1 warning and 2 errors. +Finished in on 1 file with rules using threads. +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends/snapshots/migration_oxlint_extends_isolated_retry.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends/snapshots/migration_oxlint_extends_isolated_retry.md new file mode 100644 index 0000000000..f87910b6b2 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends/snapshots/migration_oxlint_extends_isolated_retry.md @@ -0,0 +1,122 @@ +# migration_oxlint_extends_isolated_retry + +## `vpt write-file .oxlintrc.json '{"extends":["./missing.json"]}'` + + +## `vp migrate --no-interactive --no-agent --no-editor --no-hooks` + +``` +VITE+ - The Unified Toolchain for the Web + +● Formatting code... +● Code formatted +◇ Migrated . to Vite+ +• Node pnpm +✓ Dependencies installed in +• 1 config update applied +! Warnings: + - Could not migrate .oxlintrc.json: Error: ENOENT: no such file or directory, lstat '/missing.json'. The original config was preserved; use vp lint -c .oxlintrc.json until it is migrated manually. +``` + +## `vpt print-file vite.config.ts` + +The failed migration records a pending marker without a lint block + +``` +import { defineConfig } from "vite-plus"; + +export default defineConfig({ + fmt: {}, +}); +// vp migrate: pending .oxlintrc.json. +``` + +## `vpt write-file missing.json '{"rules":{"no-debugger":"error"},"options":{"typeAware":false,"typeCheck":false}}'` + +Repair the only unreadable config + +``` +``` + +## `vp lint --threads=1 -c .oxlintrc.json index.js` + +The repaired JSON loads before retrying + +**Exit code:** 1 + +``` + + × eslint(no-debugger): `debugger` statement is not allowed + ╭─[index.js:1:1] + 1 │ debugger; + · ───────── + 2 │ console.log("test"); + ╰──── + help: Remove the debugger statement + +Found 0 warnings and 1 error. +Finished in on 1 file with rules using threads. +``` + +## `vp migrate --no-interactive --no-agent --no-editor --no-hooks` + +``` +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. +● Formatting code... +● Code formatted +◇ Updated . to Vite+ +• Node pnpm +• Dependencies: + vite → +• 1 config update applied +``` + +## `vpt stat-file .oxlintrc.json --assert missing` + +``` +.oxlintrc.json: missing +``` + +## `vpt print-file vite.config.ts` + +The successful retry replaces the pending marker with the lint config + +``` +// vp migrate: merged .oxlintrc.json (ddb1d7ee7999d49e4e56c1776d652081). +import oxlintConfig1 from "./missing.js"; +import { defineConfig } from "vite-plus"; + +export default defineConfig({ + lint: { + extends: [oxlintConfig1], + options: {}, + jsPlugins: [{ name: "vite-plus", specifier: "vite-plus/oxlint-plugin" }], + rules: { + "vite-plus/prefer-vite-plus-imports": "error", + }, + }, + fmt: {}, +}); +``` + +## `vp lint --threads=1 index.js` + +The migrated config retains the same diagnostic and exit code + +**Exit code:** 1 + +``` + + × eslint(no-debugger): `debugger` statement is not allowed + ╭─[index.js:1:1] + 1 │ debugger; + · ───────── + 2 │ console.log("test"); + ╰──── + help: Remove the debugger statement + +Found 0 warnings and 1 error. +Finished in on 1 file with rules using threads. +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/snapshots.toml index 6ee4494429..b741a5bf14 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/snapshots.toml @@ -43,6 +43,7 @@ steps = [ { argv = ["vpt", "print-file", "packages/b/vite.config.mjs"], comment = "The preserved consumer's Vite config has no partial lint config" }, { argv = ["vpt", "print-file", "packages/b/extra.ts"], comment = "The conflicting file is unchanged" }, { argv = ["vp", "lint", "-c", ".oxlintrc.json", "index.js"], cwd = "packages/b", comment = "The JSON fallback still reports the same diagnostic and exit code", continue-on-failure = true }, + { argv = ["vp", "lint", "--threads=1", "-c", "packages/b/.oxlintrc.json", "packages/b/index.js"], comment = "The fallback command shown at the workspace root selects the preserved package config", continue-on-failure = true }, { argv = ["vp", "lint", "index.js"], comment = "The migrated owner uses its shared module", continue-on-failure = true }, { argv = ["vp", "lint", "index.js"], cwd = "packages/a", comment = "The migrated intermediate owner uses its shared module", continue-on-failure = true }, ] @@ -232,7 +233,7 @@ steps = [ { argv = ["vp", "lint", "--threads=1", "index.js"], cwd = "packages/b", comment = "The migrated second consumer retains its warning" }, ] [[case]] -name = "migration_oxlint_extends_workspace_inline_retry" +name = "migration_oxlint_extends_workspace_incomplete_graph_retry" vp = "local" local-registry = true unset-env = ["VP_SKIP_INSTALL"] @@ -240,16 +241,16 @@ steps = [ { argv = ["vpt", "rm", ".oxlintrc.json"], snapshot = false }, { argv = ["vpt", "write-file", "packages/a/.oxlintrc.json", '{"rules":{"no-debugger":"error"},"options":{"typeAware":false,"typeCheck":false}}'], snapshot = false }, { argv = ["vpt", "write-file", "packages/b/.oxlintrc.json", '{"extends":["./missing.json"]}'], snapshot = false }, - { argv = ["vp", "lint", "--threads=1", "-c", ".oxlintrc.json", "index.js"], cwd = "packages/a", comment = "Original inline rules", continue-on-failure = true }, + { argv = ["vp", "lint", "--threads=1", "-c", ".oxlintrc.json", "index.js"], cwd = "packages/a", comment = "Original rules without inheritance", continue-on-failure = true }, { argv = ["vp", "migrate", "--no-interactive", "--no-agent", "--no-editor", "--no-hooks"] }, - { argv = ["vpt", "stat-file", "packages/a/.oxlintrc.json", "--assert", "file"], comment = "An unreadable graph keeps the completed inline config on disk" }, - { argv = ["vpt", "stat-file", "packages/a/.oxlintrc.ts", "--assert", "missing"] }, + { argv = ["vpt", "stat-file", "packages/a/.oxlintrc.json", "--assert", "file"], comment = "An unreadable graph keeps completed JSON configs on disk" }, + { argv = ["vpt", "stat-file", "packages/a/.oxlintrc.ts", "--assert", "file"] }, { argv = ["vpt", "write-file", "packages/b/missing.json", '{"rules":{"no-debugger":"error"},"options":{"typeAware":false,"typeCheck":false}}'], comment = "Repair the unreadable inheritance graph" }, { argv = ["vp", "lint", "--threads=1", "-c", ".oxlintrc.json", "index.js"], cwd = "packages/b", comment = "The repaired JSON loads before retrying", continue-on-failure = true }, { argv = ["vp", "migrate", "--no-interactive", "--no-agent", "--no-editor", "--no-hooks"] }, { argv = ["vpt", "stat-file", "packages/a/.oxlintrc.json", "--assert", "missing"] }, { argv = ["vpt", "stat-file", "packages/b/.oxlintrc.json", "--assert", "missing"] }, - { argv = ["vp", "lint", "--threads=1", "index.js"], cwd = "packages/a", comment = "The completed inline consumer retains its diagnostic", continue-on-failure = true }, + { argv = ["vp", "lint", "--threads=1", "index.js"], cwd = "packages/a", comment = "The completed consumer retains its diagnostic", continue-on-failure = true }, { argv = ["vp", "lint", "--threads=1", "index.js"], cwd = "packages/b", comment = "The repaired consumer retains its diagnostic after retrying", continue-on-failure = true }, ] @@ -270,3 +271,23 @@ steps = [ { argv = ["vp", "lint", "--threads=1", "-c", "alias.json", "index.js"], cwd = "packages/a", comment = "The inherited alias still loads with the same diagnostic" }, { argv = ["vp", "lint", "--threads=1", "index.js"], cwd = "packages/a", comment = "The migrated consumer uses its shared module" }, ] + +[[case]] +name = "migration_oxlint_extends_workspace_repaired_shared_owner" +vp = "local" +local-registry = true +unset-env = ["VP_SKIP_INSTALL"] +steps = [ + { argv = ["vpt", "rm", "packages/b/.oxlintrc.json"], snapshot = false }, + { argv = ["vpt", "write-file", "packages/a/.oxlintrc.json", '{"extends":["./missing.json"]}'], snapshot = false }, + { argv = ["vp", "lint", "--threads=1", "-c", ".oxlintrc.json", "index.js"], comment = "The original owner reports a warning" }, + { argv = ["vp", "migrate", "--no-interactive", "--no-agent", "--no-editor", "--no-hooks"] }, + { argv = ["vpt", "write-file", "packages/a/missing.json", '{"extends":["../../.oxlintrc.json"]}'], comment = "The repaired graph reveals that the root is a shared owner" }, + { argv = ["vp", "lint", "--threads=1", "-c", ".oxlintrc.json", "index.js"], cwd = "packages/a", comment = "The repaired JSON inherits the original warning" }, + { argv = ["vp", "migrate", "--no-interactive", "--no-agent", "--no-editor", "--no-hooks"] }, + { argv = ["vp", "lint", "--threads=1", "index.js"], comment = "The migrated owner retains its warning" }, + { argv = ["vp", "lint", "--threads=1", "index.js"], cwd = "packages/a", comment = "The repaired consumer retains the same warning" }, + { argv = ["vpt", "replace-file-content", ".oxlintrc.ts", '"no-debugger": "warn"', '"no-debugger": "error"'] }, + { argv = ["vp", "lint", "--threads=1", "index.js"], comment = "The owner follows the shared module edit", continue-on-failure = true }, + { argv = ["vp", "lint", "--threads=1", "index.js"], cwd = "packages/a", comment = "The repaired consumer follows the same edit", continue-on-failure = true }, +] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/snapshots/migration_oxlint_extends_workspace_inline_retry.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/snapshots/migration_oxlint_extends_workspace_incomplete_graph_retry.md similarity index 89% rename from crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/snapshots/migration_oxlint_extends_workspace_inline_retry.md rename to crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/snapshots/migration_oxlint_extends_workspace_incomplete_graph_retry.md index f6161784b7..428990e2d5 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/snapshots/migration_oxlint_extends_workspace_inline_retry.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/snapshots/migration_oxlint_extends_workspace_incomplete_graph_retry.md @@ -1,4 +1,4 @@ -# migration_oxlint_extends_workspace_inline_retry +# migration_oxlint_extends_workspace_incomplete_graph_retry ## `vpt rm .oxlintrc.json` @@ -11,7 +11,7 @@ ## `cd packages/a && vp lint --threads=1 -c .oxlintrc.json index.js` -Original inline rules +Original rules without inheritance **Exit code:** 1 @@ -41,21 +41,21 @@ VITE+ - The Unified Toolchain for the Web ✓ Dependencies installed in • 1 config update applied ! Warnings: - - Could not migrate packages/b/.oxlintrc.json: Error: ENOENT: no such file or directory, lstat '/packages/b/missing.json'. The original config was preserved; use vp lint -c .oxlintrc.json until it is migrated manually. + - Could not migrate packages/b/.oxlintrc.json: Error: ENOENT: no such file or directory, lstat '/packages/b/missing.json'. The original config was preserved; use vp lint -c packages/b/.oxlintrc.json until it is migrated manually. ``` ## `vpt stat-file packages/a/.oxlintrc.json --assert file` -An unreadable graph keeps the completed inline config on disk +An unreadable graph keeps completed JSON configs on disk ``` packages/a/.oxlintrc.json: file ``` -## `vpt stat-file packages/a/.oxlintrc.ts --assert missing` +## `vpt stat-file packages/a/.oxlintrc.ts --assert file` ``` -packages/a/.oxlintrc.ts: missing +packages/a/.oxlintrc.ts: file ``` ## `vpt write-file packages/b/missing.json '{"rules":{"no-debugger":"error"},"options":{"typeAware":false,"typeCheck":false}}'` @@ -113,7 +113,7 @@ packages/b/.oxlintrc.json: missing ## `cd packages/a && vp lint --threads=1 index.js` -The completed inline consumer retains its diagnostic +The completed consumer retains its diagnostic **Exit code:** 1 diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/snapshots/migration_oxlint_extends_workspace_owner_fields.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/snapshots/migration_oxlint_extends_workspace_owner_fields.md index fb35bd8d6f..f18b5799b7 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/snapshots/migration_oxlint_extends_workspace_owner_fields.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/snapshots/migration_oxlint_extends_workspace_owner_fields.md @@ -146,7 +146,7 @@ The owner references its fields at the top level ``` // vp migrate: merged .oxlintrc.json (776c7037d8d471e35fd2209aa88a3a33). -import oxlintConfig1 from "./.oxlintrc.ts"; +import oxlintConfig1 from "./.oxlintrc.js"; export default { fmt: {}, lint: { diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/snapshots/migration_oxlint_extends_workspace_owner_ignores.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/snapshots/migration_oxlint_extends_workspace_owner_ignores.md index fb25a8c7a3..6c0792d246 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/snapshots/migration_oxlint_extends_workspace_owner_ignores.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/snapshots/migration_oxlint_extends_workspace_owner_ignores.md @@ -74,7 +74,7 @@ The owner's top-level ignore patterns reference its shared module ``` // vp migrate: merged .oxlintrc.json (4d2ecd8da8b74c19aab2ebbba9d98fe6). -import oxlintConfig1 from "./.oxlintrc.ts"; +import oxlintConfig1 from "./.oxlintrc.js"; export default { fmt: {}, lint: { @@ -95,7 +95,7 @@ The dependent does not receive top-level ignore patterns ``` // vp migrate: merged packages%2Fa%2F.oxlintrc.json (b829c0806f4ea2e6444bd91df0061b20). -import oxlintConfig1 from "./.oxlintrc.ts"; +import oxlintConfig1 from "./.oxlintrc.js"; export default { lint: { extends: [oxlintConfig1], diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/snapshots/migration_oxlint_extends_workspace_preserved_consumer.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/snapshots/migration_oxlint_extends_workspace_preserved_consumer.md index 43d98139cd..f0deb5f077 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/snapshots/migration_oxlint_extends_workspace_preserved_consumer.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/snapshots/migration_oxlint_extends_workspace_preserved_consumer.md @@ -38,7 +38,7 @@ VITE+ - The Unified Toolchain for the Web ✓ Dependencies installed in • 1 config update applied ! Warnings: - - Could not migrate packages/b/.oxlintrc.json: Error: Cannot create shared Oxlint config because /packages/b/extra.ts or /packages/b/extra.js already exists. The original config was preserved; use vp lint -c .oxlintrc.json until it is migrated manually. + - Could not migrate packages/b/.oxlintrc.json: Error: Cannot create shared Oxlint config because /packages/b/extra.ts or /packages/b/extra.js already exists. The original config was preserved; use vp lint -c packages/b/.oxlintrc.json until it is migrated manually. ``` ## `vpt stat-file .oxlintrc.json --assert file` @@ -65,7 +65,7 @@ Retaining JSON does not undo the owner's successful migration ``` // vp migrate: merged .oxlintrc.json (bf57dd1b2fb51cd1cb30e45e36742e3b). -import oxlintConfig1 from "./.oxlintrc.ts"; +import oxlintConfig1 from "./.oxlintrc.js"; export default { fmt: {}, lint: { @@ -83,7 +83,7 @@ export default { ``` // vp migrate: merged packages%2Fa%2F.oxlintrc.json (c59f567503f7d6b118c01736f9206ec4). -import oxlintConfig1 from "./.oxlintrc.ts"; +import oxlintConfig1 from "./.oxlintrc.js"; export default { lint: { extends: [oxlintConfig1], @@ -102,6 +102,7 @@ The preserved consumer's Vite config has no partial lint config ``` export default {}; +// vp migrate: pending .oxlintrc.json. ``` ## `vpt print-file packages/b/extra.ts` @@ -131,6 +132,25 @@ Found 0 warnings and 1 error. Finished in on 1 file with rules using threads. ``` +## `vp lint --threads=1 -c packages/b/.oxlintrc.json packages/b/index.js` + +The fallback command shown at the workspace root selects the preserved package config + +**Exit code:** 1 + +``` + + × eslint(no-debugger): `debugger` statement is not allowed + ╭─[packages/b/index.js:1:1] + 1 │ debugger; + · ───────── + ╰──── + help: Remove the debugger statement + +Found 0 warnings and 1 error. +Finished in on 1 file with rules using threads. +``` + ## `vp lint index.js` The migrated owner uses its shared module diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/snapshots/migration_oxlint_extends_workspace_repaired_shared_owner.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/snapshots/migration_oxlint_extends_workspace_repaired_shared_owner.md new file mode 100644 index 0000000000..30ede20f28 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/snapshots/migration_oxlint_extends_workspace_repaired_shared_owner.md @@ -0,0 +1,156 @@ +# migration_oxlint_extends_workspace_repaired_shared_owner + +## `vpt rm packages/b/.oxlintrc.json` + + +## `vpt write-file packages/a/.oxlintrc.json '{"extends":["./missing.json"]}'` + + +## `vp lint --threads=1 -c .oxlintrc.json index.js` + +The original owner reports a warning + +``` + + ⚠ eslint(no-debugger): `debugger` statement is not allowed + ╭─[index.js:1:1] + 1 │ debugger; + · ───────── + ╰──── + help: Remove the debugger statement + +Found 1 warning and 0 errors. +Finished in on 1 file with rules using threads. +``` + +## `vp migrate --no-interactive --no-agent --no-editor --no-hooks` + +``` +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. +◆ ✔ Merged .oxlintrc.json into vite.config.mjs +● Formatting code... +● Code formatted +◇ Migrated . to Vite+ +• Node pnpm +✓ Dependencies installed in +! Warnings: + - Could not migrate packages/a/.oxlintrc.json: Error: ENOENT: no such file or directory, lstat '/packages/a/missing.json'. The original config was preserved; use vp lint -c packages/a/.oxlintrc.json until it is migrated manually. +``` + +## `vpt write-file packages/a/missing.json '{"extends":["../../.oxlintrc.json"]}'` + +The repaired graph reveals that the root is a shared owner + +``` +``` + +## `cd packages/a && vp lint --threads=1 -c .oxlintrc.json index.js` + +The repaired JSON inherits the original warning + +``` + + ⚠ eslint(no-debugger): `debugger` statement is not allowed + ╭─[index.js:1:1] + 1 │ debugger; + · ───────── + ╰──── + help: Remove the debugger statement + +Found 1 warning and 0 errors. +Finished in on 1 file with rules using threads. +``` + +## `vp migrate --no-interactive --no-agent --no-editor --no-hooks` + +``` +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. +● Formatting code... +● Code formatted +◇ Updated . to Vite+ +• Node pnpm +• Dependencies: + vite → +• 1 config update applied +``` + +## `vp lint --threads=1 index.js` + +The migrated owner retains its warning + +``` + + ⚠ eslint(no-debugger): `debugger` statement is not allowed + ╭─[index.js:1:1] + 1 │ debugger; + · ───────── + ╰──── + help: Remove the debugger statement + +Found 1 warning and 0 errors. +Finished in on 1 file with rules using threads. +``` + +## `cd packages/a && vp lint --threads=1 index.js` + +The repaired consumer retains the same warning + +``` + + ⚠ eslint(no-debugger): `debugger` statement is not allowed + ╭─[index.js:1:1] + 1 │ debugger; + · ───────── + ╰──── + help: Remove the debugger statement + +Found 1 warning and 0 errors. +Finished in on 1 file with rules using threads. +``` + +## `vpt replace-file-content .oxlintrc.ts '"no-debugger": "warn"' '"no-debugger": "error"'` + +``` +``` + +## `vp lint --threads=1 index.js` + +The owner follows the shared module edit + +**Exit code:** 1 + +``` + + × eslint(no-debugger): `debugger` statement is not allowed + ╭─[index.js:1:1] + 1 │ debugger; + · ───────── + ╰──── + help: Remove the debugger statement + +Found 0 warnings and 1 error. +Finished in on 1 file with rules using threads. +``` + +## `cd packages/a && vp lint --threads=1 index.js` + +The repaired consumer follows the same edit + +**Exit code:** 1 + +``` + + × eslint(no-debugger): `debugger` statement is not allowed + ╭─[index.js:1:1] + 1 │ debugger; + · ───────── + ╰──── + help: Remove the debugger statement + +Found 0 warnings and 1 error. +Finished in on 1 file with rules using threads. +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/snapshots/migration_oxlint_extends_workspace_retry.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/snapshots/migration_oxlint_extends_workspace_retry.md index 57dd5d73ca..3a9de1d787 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/snapshots/migration_oxlint_extends_workspace_retry.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/snapshots/migration_oxlint_extends_workspace_retry.md @@ -16,7 +16,7 @@ VITE+ - The Unified Toolchain for the Web ✓ Dependencies installed in • 1 config update applied ! Warnings: - - Could not migrate packages/b/.oxlintrc.json: Error: Cannot create shared Oxlint config because /packages/b/extra.ts or /packages/b/extra.js already exists. The original config was preserved; use vp lint -c .oxlintrc.json until it is migrated manually. + - Could not migrate packages/b/.oxlintrc.json: Error: Cannot create shared Oxlint config because /packages/b/extra.ts or /packages/b/extra.js already exists. The original config was preserved; use vp lint -c packages/b/.oxlintrc.json until it is migrated manually. ``` ## `vpt replace-file-content .oxlintrc.ts '"no-debugger": "warn"' '"no-debugger": "error"'` @@ -54,8 +54,8 @@ VITE+ - The Unified Toolchain for the Web ``` // vp migrate: merged packages%2Fb%2F.oxlintrc.json (e5c417b92177153603bad60d94de6188). -import oxlintConfig1 from "../a/.oxlintrc.ts"; -import oxlintConfig2 from "./extra.ts"; +import oxlintConfig1 from "../a/.oxlintrc.js"; +import oxlintConfig2 from "./extra.js"; export default { lint: { extends: [oxlintConfig1, oxlintConfig2], diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/snapshots/migration_oxlint_extends_workspace_shared_owner.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/snapshots/migration_oxlint_extends_workspace_shared_owner.md index 8ef6097346..b6ed2c23e9 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/snapshots/migration_oxlint_extends_workspace_shared_owner.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/snapshots/migration_oxlint_extends_workspace_shared_owner.md @@ -75,7 +75,7 @@ The owner imports its own shared config ``` // vp migrate: merged .oxlintrc.json (cca48660331feaa20cf74e4f147c8e10). -import oxlintConfig1 from "./.oxlintrc.ts"; +import oxlintConfig1 from "./.oxlintrc.js"; export default { fmt: {}, lint: { @@ -95,7 +95,7 @@ The intermediate owner also imports its own shared config ``` // vp migrate: merged packages%2Fa%2F.oxlintrc.json (e93b0fadd405db856ff1b8847eb5f2c3). -import oxlintConfig1 from "./.oxlintrc.ts"; +import oxlintConfig1 from "./.oxlintrc.js"; export default { lint: { extends: [oxlintConfig1], @@ -112,8 +112,8 @@ export default { ``` // vp migrate: merged packages%2Fb%2F.oxlintrc.json (e5c417b92177153603bad60d94de6188). -import oxlintConfig1 from "../a/.oxlintrc.ts"; -import oxlintConfig2 from "./extra.ts"; +import oxlintConfig1 from "../a/.oxlintrc.js"; +import oxlintConfig2 from "./extra.js"; export default { lint: { extends: [oxlintConfig1, oxlintConfig2], diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/snapshots/migration_oxlint_extends_workspace_symlink_contexts.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/snapshots/migration_oxlint_extends_workspace_symlink_contexts.md index a000369e68..32ea79b396 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/snapshots/migration_oxlint_extends_workspace_symlink_contexts.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/snapshots/migration_oxlint_extends_workspace_symlink_contexts.md @@ -82,7 +82,7 @@ VITE+ - The Unified Toolchain for the Web ``` // vp migrate: merged packages%2Fa%2F.oxlintrc.json (5aa76017b601250136404623f0a0a3dd). -import oxlintConfig1 from "./base.ts"; +import oxlintConfig1 from "./base.js"; export default { lint: { extends: [oxlintConfig1], @@ -102,7 +102,7 @@ export default { ``` // vp migrate: merged packages%2Fb%2F.oxlintrc.json (56118339eefbe7bfe3d4f1a40696a3e9). -import oxlintConfig1 from "./base.ts"; +import oxlintConfig1 from "./base.js"; export default { lint: { extends: [oxlintConfig1], diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/snapshots/migration_oxlint_extends_workspace_symlink_retry.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/snapshots/migration_oxlint_extends_workspace_symlink_retry.md index d31df4c761..e870d02a9c 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/snapshots/migration_oxlint_extends_workspace_symlink_retry.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/snapshots/migration_oxlint_extends_workspace_symlink_retry.md @@ -76,7 +76,7 @@ VITE+ - The Unified Toolchain for the Web ✓ Dependencies installed in • 1 config update applied ! Warnings: - - Could not migrate packages/b/.oxlintrc.json: Error: Shared Oxlint configs require a Vite config with an ESM default export. The original config was preserved; use vp lint -c .oxlintrc.json until it is migrated manually. + - Could not migrate packages/b/.oxlintrc.json: Error: Shared Oxlint configs require a Vite config with an ESM default export. The original config was preserved; use vp lint -c packages/b/.oxlintrc.json until it is migrated manually. ``` ## `vpt stat-file packages/a/.oxlintrc.json --assert missing` diff --git a/docs/guide/migrate-rules.md b/docs/guide/migrate-rules.md index 0c62a3f919..1171368514 100644 --- a/docs/guide/migrate-rules.md +++ b/docs/guide/migrate-rules.md @@ -27,7 +27,7 @@ When a default upgrade skips setup actions that would apply, it prints a hint to When `.oxlintrc.json` or `.oxlintrc.jsonc` uses `extends`, `vp migrate` resolves each file path relative to the config that declares it. It creates shared TypeScript configs and imports them through `lint.extends`, including nested inheritance. Oxlint applies the existing merge order, rule severities, and overrides. Plain `vp lint` uses the migrated rules without a `-c` flag. -Migration assumes ESM projects. Shared configs become sibling `.ts` files. Generated TypeScript configs use `.js` import specifiers, which Vite and TypeScript resolve to the generated source files without changing compiler settings. Workspace consumers reuse the same generated modules, including the workspace that owns a shared config. Migration defaults stay in each workspace's local `lint` block. Edit these TypeScript files to update shared settings. Original shared JSON and JSONC files remain available to other consumers; edits to those originals do not update the migrated configs. +Migration assumes ESM projects. Shared configs become sibling `.ts` files. Imports from JavaScript and TypeScript configs use `.js` specifiers, which Vite and TypeScript resolve to the generated source files without changing compiler settings. Workspace consumers reuse the same generated modules, including the workspace that owns a shared config. Migration defaults stay in each workspace's local `lint` block. Edit these TypeScript files to update shared settings. Original shared JSON and JSONC files remain available to other consumers; edits to those originals do not update the migrated configs. JS-plugin declarations stay in their original config or override. Relative plugin paths become absolute paths at runtime through `fileURLToPath(new URL(..., import.meta.url))`. Explicit inherited `typeAware` and `typeCheck` settings take precedence over migration defaults. @@ -37,11 +37,11 @@ A shared config's owner keeps `env`, `globals`, `settings`, and `ignorePatterns` Generated modules include a source marker. A later migration attempt reuses these modules without replacing user edits, so a preserved consumer can migrate after its conflict is resolved. Existing modules without a matching marker remain conflicts. -Completed workspace merges also record a completion marker, including configs merged inline. After fixing an unreadable config, run `vp migrate` again to migrate the remaining consumers and remove JSON files that are no longer needed. +Failed merges record a pending marker in the Vite config, so an isolated failure can be retried after repair. A successful merge removes this marker. Completed workspace merges record a completion marker, including configs merged inline. After fixing an unreadable config, run `vp migrate` again to migrate the remaining consumers and remove JSON files that are no longer needed. Shared config imports use literal paths so Vite and TypeScript can resolve filenames containing `#` or `%`. A relative import path containing `?` or a literal backslash requires a manual migration: Vite treats `?` as a query delimiter, and TypeScript treats backslashes as path separators. Migration preserves the JSON config and reports a warning in these cases. -Missing files, invalid JSON, inheritance cycles, conflicting output files, and package-local plugins that resolve differently from the Vite config directory produce a warning and leave the original config in place. Configs outside the project or workspace, configs inside `node_modules`, CommonJS Vite configs, and an existing `lint` block require a manual merge. Workspace JSON removal waits until all migration outcomes are known. Preserved JSON consumers retain their transitive dependencies, including configs whose owners migrated successfully. If an inheritance graph cannot be read, its workspace JSON files are kept. `vp lint` continues to reject string paths in `lint.extends`; path resolution happens only during migration. +Missing files, invalid JSON, inheritance cycles, conflicting output files, and package-local plugins that resolve differently from the Vite config directory produce a warning and leave the original config in place. Configs outside the project or workspace, configs inside `node_modules`, CommonJS Vite configs, and an existing `lint` block require a manual merge. Workspace JSON removal waits until all migration outcomes are known. Preserved JSON consumers retain their transitive dependencies, including configs whose owners migrated successfully. If an inheritance graph cannot be read, workspace JSON files are kept and their owners use shared modules, because the unreadable consumer can reference them. `vp lint` continues to reject string paths in `lint.extends`; path resolution happens only during migration. ## Pack Configuration diff --git a/packages/cli/src/migration/__tests__/oxlint-extends.spec.ts b/packages/cli/src/migration/__tests__/oxlint-extends.spec.ts index a3fa557af0..f52be18aa4 100644 --- a/packages/cli/src/migration/__tests__/oxlint-extends.spec.ts +++ b/packages/cli/src/migration/__tests__/oxlint-extends.spec.ts @@ -164,7 +164,7 @@ describe('Oxlint JSON extends migration', () => { expect(fs.readFileSync(path.join(project, filename), 'utf8')).toBe(original); expect(fs.existsSync(path.join(project, '.oxlintrc.json'))).toBe(true); expect(fs.readFileSync(path.join(project, 'vite.config.mjs'), 'utf8')).toBe( - 'export default {};', + 'export default {};\n// vp migrate: pending .oxlintrc.json.\n', ); expect(report.warnings[0]).toContain('already exists'); }); @@ -248,7 +248,7 @@ describe('Oxlint JSON extends migration', () => { expect(fs.existsSync(path.join(project, 'first.ts'))).toBe(false); expect(fs.existsSync(path.join(project, `shared${character}/base.ts`))).toBe(false); expect(fs.readFileSync(path.join(project, 'vite.config.mjs'), 'utf8')).toBe( - 'export default {};', + 'export default {};\n// vp migrate: pending .oxlintrc.json.\n', ); }, ); @@ -284,7 +284,7 @@ describe('Oxlint JSON extends migration', () => { expect(fs.existsSync(path.join(project, 'base.ts'))).toBe(false); expect(fs.existsSync(path.join(project, '.oxlintrc.json'))).toBe(true); expect(fs.readFileSync(path.join(project, 'vite.config.mjs'), 'utf8')).toBe( - 'export default {};', + 'export default {};\n// vp migrate: pending .oxlintrc.json.\n', ); }); @@ -435,7 +435,9 @@ describe('Oxlint JSON extends migration', () => { 'error', ); expect(fs.existsSync(path.join(project, 'packages/a/.oxlintrc.json'))).toBe(false); - expect(fs.readFileSync(viteConfig, 'utf8')).toBe(original); + expect(fs.readFileSync(viteConfig, 'utf8')).toBe( + `${original}\n// vp migrate: pending .oxlintrc.json.\n`, + ); const preserved = path.join(project, 'packages/b/.oxlintrc.json'); expect(fs.lstatSync(preserved).isSymbolicLink()).toBe(true); expect(resolveOxlintConfigExtends(preserved).config.extends![0].rules!['no-console']).toBe( @@ -711,13 +713,13 @@ describe('Oxlint JSON extends migration', () => { ); }); - it('resumes inline merges after an unreadable workspace graph is repaired', async () => { + it('resumes completed workspace merges after an unreadable graph is repaired', async () => { createWorkspace(); write('packages/a/.oxlintrc.json', { rules: { 'no-debugger': 'error' } }); write('packages/b/.oxlintrc.json', { extends: ['./missing.json'] }); expect(migrateWorkspace().warnings).toHaveLength(1); expect(fs.existsSync(path.join(project, 'packages/a/.oxlintrc.json'))).toBe(true); - expect(fs.existsSync(path.join(project, 'packages/a/.oxlintrc.ts'))).toBe(false); + expect(fs.existsSync(path.join(project, 'packages/a/.oxlintrc.ts'))).toBe(true); write('packages/b/missing.json', { rules: { 'no-console': 'warn' } }); const report = createMigrationReport(); @@ -734,14 +736,68 @@ describe('Oxlint JSON extends migration', () => { for (const name of ['a', 'b']) { expect(fs.existsSync(path.join(project, `packages/${name}/.oxlintrc.json`))).toBe(false); } - expect((await loadConfig(path.join(project, 'packages/a'))).lint!.rules!['no-debugger']).toBe( - 'error', - ); + expect( + (await loadConfig(path.join(project, 'packages/a'))).lint!.extends![0].rules!['no-debugger'], + ).toBe('error'); expect( (await loadConfig(path.join(project, 'packages/b'))).lint!.extends![0].rules!['no-console'], ).toBe('warn'); }); + it.each(['missing source', 'output conflict'])( + 'retries an isolated config after repairing a %s', + async (failure) => { + write('.oxlintrc.json', { extends: ['./base.json'] }); + if (failure === 'output conflict') { + write('base.json', { rules: { 'no-debugger': 'error' } }); + write('base.ts', 'export default {};'); + } + const firstReport = createMigrationReport(); + mergeViteConfigFiles(project, true, firstReport); + expect(firstReport.warnings).toHaveLength(1); + if (failure === 'missing source') { + const pendingReport = createMigrationReport(); + expect(retryPreservedOxlintConfigs(project, true, pendingReport)).toBe(false); + expect(pendingReport.warnings).toHaveLength(1); + write('base.json', { rules: { 'no-debugger': 'error' } }); + } else { + fs.unlinkSync(path.join(project, 'base.ts')); + } + + const report = createMigrationReport(); + expect(retryPreservedOxlintConfigs(project, true, report)).toBe(true); + + expect(report.warnings).toEqual([]); + expect(fs.existsSync(path.join(project, '.oxlintrc.json'))).toBe(false); + expect(fs.readFileSync(path.join(project, 'vite.config.mjs'), 'utf8')).not.toContain( + 'vp migrate: pending', + ); + expect((await loadConfig()).lint!.extends![0].rules!['no-debugger']).toBe('error'); + }, + ); + + it('keeps newly discovered shared owners live after repairing an unreadable graph', async () => { + createWorkspace(['a']); + write('.oxlintrc.json', { rules: { 'no-debugger': 'warn' } }); + write('packages/a/.oxlintrc.json', { extends: ['./missing.json'] }); + expect(migrateWorkspace(['a']).warnings).toHaveLength(1); + write('packages/a/missing.json', { extends: ['../../.oxlintrc.json'] }); + + const report = createMigrationReport(); + expect( + retryPreservedOxlintConfigs(project, true, report, [{ name: 'a', path: 'packages/a' }]), + ).toBe(true); + expect(report.warnings).toEqual([]); + const modulePath = path.join(project, '.oxlintrc.ts'); + fs.writeFileSync(modulePath, fs.readFileSync(modulePath, 'utf8').replace('"warn"', '"error"')); + expect((await loadConfig()).lint!.extends![0].rules!['no-debugger']).toBe('error'); + expect( + (await loadConfig(path.join(project, 'packages/a'))).lint!.extends![0].extends![0].rules![ + 'no-debugger' + ], + ).toBe('error'); + }); + it('does not reuse a generated module for a different JSON source', () => { write('base.json', { rules: { 'no-debugger': 'warn' } }); write('.oxlintrc.json', { extends: ['./base.json'] }); @@ -915,10 +971,12 @@ describe('Oxlint JSON extends migration', () => { expect(fs.readFileSync(source, 'utf8')).toBe(original); expect(fs.readFileSync(path.join(project, 'vite.config.mjs'), 'utf8')).toBe( - 'export default {};', + 'export default {};\n// vp migrate: pending .oxlintrc.json.\n', ); expect(report.warnings).toHaveLength(1); - expect(report.warnings[0]).toContain('vp lint -c .oxlintrc.json'); + expect(report.warnings[0]).toContain( + `vp lint -c ${path.relative(process.cwd(), source).replaceAll('\\', '/')}`, + ); expect(report.mergedConfigCount).toBe(0); }); diff --git a/packages/cli/src/migration/migrator/oxlint-config.ts b/packages/cli/src/migration/migrator/oxlint-config.ts index 92322f65d0..30aeb5b41e 100644 --- a/packages/cli/src/migration/migrator/oxlint-config.ts +++ b/packages/cli/src/migration/migrator/oxlint-config.ts @@ -109,6 +109,15 @@ export function prepareOxlintConfigs(projectPaths: string[]): PreparedOxlintConf .filter((file) => file.shared) .map((file) => fs.realpathSync(file.filename)), ); + // An unreadable consumer can still refer to any workspace's own config. + // Keep those owners in modules so a later repair cannot create an inline copy. + if ([...configs.values()].some((migration) => migration instanceof Error)) { + for (const [filename, migration] of configs) { + if (!(migration instanceof Error)) { + sharedSources.add(fs.realpathSync(filename)); + } + } + } for (const file of cache.files.values()) { if (sharedSources.has(fs.realpathSync(file.filename))) { file.shared = true; @@ -255,7 +264,7 @@ function relativeImport(from: string, to: string): string { } // Vite and TypeScript resolve .js imports to .ts source files. // This also works with NodeNext without changing allowImportingTsExtensions. - const specifier = /\.m?ts$/.test(from) ? relative.replace(/\.ts$/, '.js') : relative; + const specifier = relative.replace(/\.ts$/, '.js'); return specifier.startsWith('../') ? specifier : `./${specifier}`; } diff --git a/packages/cli/src/migration/migrator/vite-config.ts b/packages/cli/src/migration/migrator/vite-config.ts index 4c6898a64d..ef9e64f033 100644 --- a/packages/cli/src/migration/migrator/vite-config.ts +++ b/packages/cli/src/migration/migrator/vite-config.ts @@ -291,6 +291,10 @@ export function mergeViteConfigFiles( } } +function pendingOxlintMigrationMarker(filename: string): string { + return `// vp migrate: pending ${encodeURIComponent(path.basename(filename))}.`; +} + /** Resume partial migrations without applying lint setup to other existing Vite+ projects. */ export function retryPreservedOxlintConfigs( workspaceRoot: string, @@ -302,34 +306,26 @@ export function retryPreservedOxlintConfigs( workspaceRoot, ...(packages ?? []).map((pkg) => path.join(workspaceRoot, pkg.path)), ]); - const pending: Array<{ - filename: string; - migration: OxlintConfigMigration; - configs: ConfigFiles; - }> = []; - let hasCompletedMerge = false; + let updated = false; for (const [filename, migration] of prepared) { - if (migration instanceof Error) { - continue; - } const projectPath = path.dirname(filename); const configs = detectConfigs(projectPath); const existingViteConfig = configs.viteConfig && path.join(projectPath, configs.viteConfig); + const source = existingViteConfig ? fs.readFileSync(existingViteConfig, 'utf8') : ''; + const lines = source.split(/\r?\n/); if (existingViteConfig && hasConfigKey(existingViteConfig, 'lint')) { - const source = fs.readFileSync(existingViteConfig, 'utf8'); - const marker = oxlintMigrationMarker(filename, migration.file, workspaceRoot); - migration.migrated = source.split(/\r?\n/).includes(marker); - hasCompletedMerge ||= migration.migrated; + if (!(migration instanceof Error)) { + const marker = oxlintMigrationMarker(filename, migration.file, workspaceRoot); + migration.migrated = lines.includes(marker); + } continue; } - pending.push({ filename, migration, configs }); - } - let updated = false; - for (const { filename, migration, configs } of pending) { - if (!hasCompletedMerge && !hasGeneratedOxlintConfig(migration.file)) { + if ( + !lines.includes(pendingOxlintMigrationMarker(filename)) && + (migration instanceof Error || !hasGeneratedOxlintConfig(migration.file)) + ) { continue; } - const projectPath = path.dirname(filename); const viteConfig = ensureViteConfig(projectPath, configs, silent, report); mergeOxlintConfigFile( projectPath, @@ -341,7 +337,7 @@ export function retryPreservedOxlintConfigs( workspaceRoot, prepared, ); - updated ||= migration.migrated === true; + updated ||= !(migration instanceof Error) && migration.migrated === true; } return removeMigratedOxlintConfigs(prepared) || updated; } @@ -357,9 +353,25 @@ function mergeOxlintConfigFile( preparedOxlintConfigs?: PreparedOxlintConfigs, ): void { const fullOxlintPath = path.join(projectPath, oxlintConfig); + function recordPendingMigration(): void { + const filename = path.join(projectPath, viteConfig); + const marker = pendingOxlintMigrationMarker(oxlintConfig); + try { + const source = fs.readFileSync(filename, 'utf8'); + if (!source.split(/\r?\n/).includes(marker)) { + fs.appendFileSync(filename, `${source.endsWith('\n') ? '' : '\n'}${marker}\n`); + } + } catch (error) { + warnMigration( + `Could not record pending migration in ${displayRelative(filename)}: ${String(error)}`, + report, + ); + } + } function warnPreservedConfig(error: unknown): void { + recordPendingMigration(); warnMigration( - `Could not migrate ${displayRelative(fullOxlintPath)}: ${String(error)}. The original config was preserved; use vp lint -c ${oxlintConfig} until it is migrated manually.`, + `Could not migrate ${displayRelative(fullOxlintPath)}: ${String(error)}. The original config was preserved; use vp lint -c ${displayRelative(fullOxlintPath)} until it is migrated manually.`, report, ); } @@ -476,6 +488,9 @@ function mergeOxlintConfigFile( plan, preparedOxlintConfigs !== undefined, ); + if (!migration.migrated) { + recordPendingMigration(); + } } catch (error) { warnPreservedConfig(error); } @@ -654,6 +669,13 @@ function mergeAndRemoveJsonConfig( } } let content = result.content; + if (configKey === 'lint') { + const pendingMarker = pendingOxlintMigrationMarker(jsonConfigPath); + content = content + .split(/\r?\n/) + .filter((line) => line !== pendingMarker) + .join('\n'); + } if (modulePlan && (modulePlan.imports.length || modulePlan.completionMarker)) { const offset = content.startsWith('#!') ? content.indexOf('\n') + 1 : 0; const prefix = [modulePlan.completionMarker, ...modulePlan.imports] From 4c8158e2bf940077a45a15fabc48707c8e222d89 Mon Sep 17 00:00:00 2001 From: MK Date: Tue, 20 Oct 2026 00:13:39 +0800 Subject: [PATCH 20/24] fix(migrate): retain config pragmas and retry lint conflicts --- .../migration_oxlint_extends/snapshots.toml | 36 ++++ ...tion_oxlint_extends_lint_conflict_retry.md | 155 ++++++++++++++++++ ...igration_oxlint_extends_ts_check_pragma.md | 105 ++++++++++++ docs/guide/migrate-rules.md | 4 +- .../__tests__/oxlint-extends.spec.ts | 53 +++++- .../cli/src/migration/migrator/vite-config.ts | 42 ++++- 6 files changed, 389 insertions(+), 6 deletions(-) create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends/snapshots/migration_oxlint_extends_lint_conflict_retry.md create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends/snapshots/migration_oxlint_extends_ts_check_pragma.md diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends/snapshots.toml index 796ed27c61..6902cd39f1 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends/snapshots.toml @@ -212,3 +212,39 @@ steps = [ { argv = ["vpt", "print-file", "vite.config.ts"], comment = "The successful retry replaces the pending marker with the lint config" }, { argv = ["vp", "lint", "--threads=1", "index.js"], comment = "The migrated config retains the same diagnostic and exit code", continue-on-failure = true }, ] + +[[case]] +name = "migration_oxlint_extends_lint_conflict_retry" +vp = "local" +local-registry = true +unset-env = ["VP_SKIP_INSTALL"] +steps = [ + { argv = ["vpt", "write-file", "vite.config.mjs", "export default { lint: {} };\n"], snapshot = false }, + { argv = ["vp", "lint", "--threads=1", "-c", ".oxlintrc.json", "index.js"], comment = "The original JSON inheritance is selected explicitly", continue-on-failure = true }, + { argv = ["vp", "migrate", "--no-interactive", "--no-agent", "--no-editor", "--no-hooks"] }, + { argv = ["vpt", "print-file", "vite.config.mjs"], comment = "The lint conflict records a pending marker" }, + { argv = ["vpt", "replace-file-content", "vite.config.mjs", "lint: {},", ""], comment = "Remove the conflicting lint block" }, + { argv = ["vp", "migrate", "--no-interactive", "--no-agent", "--no-editor", "--no-hooks"] }, + { argv = ["vpt", "stat-file", ".oxlintrc.json", "--assert", "missing"] }, + { argv = ["vpt", "print-file", "vite.config.mjs"] }, + { argv = ["vp", "lint", "--threads=1", "index.js"], comment = "The retried migration retains the original diagnostics and exit code", continue-on-failure = true }, +] + +[[case]] +name = "migration_oxlint_extends_ts_check_pragma" +vp = "local" +local-registry = true +unset-env = ["VP_SKIP_INSTALL"] +steps = [ + { argv = ["vpt", "write-file", "vite.config.mjs", "// @ts-check\n/** @type {string} */\nexport const value = 1;\nexport default {};\n"], snapshot = false }, + { argv = ["vpt", "json-edit", "tsconfig.json", "compilerOptions.allowJs", "true"], snapshot = false }, + { argv = ["vpt", "json-edit", "tsconfig.json", "include", '["vite.config.mjs"]'], snapshot = false }, + { argv = ["vp", "install"], snapshot = false }, + { argv = ["vp", "exec", "tsc", "--noEmit"], comment = "The file-level pragma enables the original type diagnostic", continue-on-failure = true }, + { argv = ["vp", "migrate", "--no-interactive", "--no-agent", "--no-editor", "--no-hooks"] }, + { argv = ["vpt", "print-file", "vite.config.mjs"], comment = "Generated imports follow the file-level pragma and precede the statement's type annotation" }, + { argv = ["vp", "exec", "tsc", "--noEmit"], comment = "The pragma still enables the same type diagnostic after migration", continue-on-failure = true }, + { argv = ["vpt", "replace-file-content", "vite.config.mjs", "@ts-check", "@ts-nocheck"] }, + { argv = ["vpt", "json-edit", "tsconfig.json", "compilerOptions.checkJs", "true"] }, + { argv = ["vp", "exec", "tsc", "--noEmit"], comment = "A leading ts-nocheck pragma can still disable project-level JavaScript checking" }, +] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends/snapshots/migration_oxlint_extends_lint_conflict_retry.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends/snapshots/migration_oxlint_extends_lint_conflict_retry.md new file mode 100644 index 0000000000..766e9d3219 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends/snapshots/migration_oxlint_extends_lint_conflict_retry.md @@ -0,0 +1,155 @@ +# migration_oxlint_extends_lint_conflict_retry + +## `vpt write-file vite.config.mjs 'export default { lint: {} }; +'` + + +## `vp lint --threads=1 -c .oxlintrc.json index.js` + +The original JSON inheritance is selected explicitly + +**Exit code:** 1 + +``` + + × eslint(no-debugger): `debugger` statement is not allowed + ╭─[index.js:1:1] + 1 │ debugger; + · ───────── + 2 │ console.log("test"); + ╰──── + help: Remove the debugger statement + + ⚠ eslint(no-console): Unexpected console statement. + ╭─[index.js:2:1] + 1 │ debugger; + 2 │ console.log("test"); + · ─────────── + 3 │ alert("allowed"); + ╰──── + help: Delete this console statement. + + × local(no-foo): Do not use foo. + ╭─[index.js:4:14] + 3 │ alert("allowed"); + 4 │ export const foo = 1; + · ─── + 5 │ export const bar = 1; + ╰──── + +Found 1 warning and 2 errors. +Finished in on 1 file with rules using threads. +``` + +## `vp migrate --no-interactive --no-agent --no-editor --no-hooks` + +``` +VITE+ - The Unified Toolchain for the Web + +● Formatting code... +● Code formatted +◇ Migrated . to Vite+ +• Node pnpm +✓ Dependencies installed in +! Warnings: + - Preserved .oxlintrc.json because vite.config.mjs already has a lint config. Merge its inherited rules manually. +``` + +## `vpt print-file vite.config.mjs` + +The lint conflict records a pending marker + +``` +export default { + fmt: {}, + lint: {}, +}; +// vp migrate: pending .oxlintrc.json. +``` + +## `vpt replace-file-content vite.config.mjs 'lint: {},' ` + +Remove the conflicting lint block + +``` +``` + +## `vp migrate --no-interactive --no-agent --no-editor --no-hooks` + +``` +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. +● Formatting code... +● Code formatted +◇ Updated . to Vite+ +• Node pnpm +• Dependencies: + vite → +• 1 config update applied +``` + +## `vpt stat-file .oxlintrc.json --assert missing` + +``` +.oxlintrc.json: missing +``` + +## `vpt print-file vite.config.mjs` + +``` +// vp migrate: merged .oxlintrc.json (6e95c926c620f96efc4952a2de4580b0). +import oxlintConfig1 from "./shared/nested/first.js"; +import oxlintConfig2 from "./shared/second.js"; +export default { + lint: { + extends: [oxlintConfig1, oxlintConfig2], + rules: { + "no-alert": "off", + "local/no-foo": "error", + "vite-plus/prefer-vite-plus-imports": "error", + }, + ignorePatterns: ["shared/**", "vite.config.ts"], + options: {}, + jsPlugins: [{ name: "vite-plus", specifier: "vite-plus/oxlint-plugin" }], + }, + fmt: {}, +}; +``` + +## `vp lint --threads=1 index.js` + +The retried migration retains the original diagnostics and exit code + +**Exit code:** 1 + +``` + + × eslint(no-debugger): `debugger` statement is not allowed + ╭─[index.js:1:1] + 1 │ debugger; + · ───────── + 2 │ console.log("test"); + ╰──── + help: Remove the debugger statement + + ⚠ eslint(no-console): Unexpected console statement. + ╭─[index.js:2:1] + 1 │ debugger; + 2 │ console.log("test"); + · ─────────── + 3 │ alert("allowed"); + ╰──── + help: Delete this console statement. + + × local(no-foo): Do not use foo. + ╭─[index.js:4:14] + 3 │ alert("allowed"); + 4 │ export const foo = 1; + · ─── + 5 │ export const bar = 1; + ╰──── + +Found 1 warning and 2 errors. +Finished in on 1 file with rules using threads. +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends/snapshots/migration_oxlint_extends_ts_check_pragma.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends/snapshots/migration_oxlint_extends_ts_check_pragma.md new file mode 100644 index 0000000000..69886e9f1f --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends/snapshots/migration_oxlint_extends_ts_check_pragma.md @@ -0,0 +1,105 @@ +# migration_oxlint_extends_ts_check_pragma + +## `vpt write-file vite.config.mjs '// @ts-check +/** @type {string} */ +export const value = 1; +export default {}; +'` + + +## `vpt json-edit tsconfig.json compilerOptions.allowJs true` + + +## `vpt json-edit tsconfig.json include '["vite.config.mjs"]'` + + +## `vp install` + + +## `vp exec tsc --noEmit` + +The file-level pragma enables the original type diagnostic + +**Exit code:** 2 + +``` +vite.config.mjs:3:14 - error TS2322: Type 'number' is not assignable to type 'string'. + +3 export const value = 1; + ~~~~~ + +Found 1 error in vite.config.mjs:3 +``` + +## `vp migrate --no-interactive --no-agent --no-editor --no-hooks` + +``` +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. +● Formatting code... +● Code formatted +◇ Migrated . to Vite+ +• Node pnpm +✓ Dependencies installed in +• 1 config update applied +``` + +## `vpt print-file vite.config.mjs` + +Generated imports follow the file-level pragma and precede the statement's type annotation + +``` +// @ts-check +import oxlintConfig1 from "./shared/nested/first.js"; +import oxlintConfig2 from "./shared/second.js"; + +/** @type {string} */ +export const value = 1; +export default { + fmt: {}, + lint: { + extends: [oxlintConfig1, oxlintConfig2], + rules: { + "no-alert": "off", + "local/no-foo": "error", + "vite-plus/prefer-vite-plus-imports": "error", + }, + ignorePatterns: ["shared/**", "vite.config.ts"], + options: {}, + jsPlugins: [{ name: "vite-plus", specifier: "vite-plus/oxlint-plugin" }], + }, +}; +``` + +## `vp exec tsc --noEmit` + +The pragma still enables the same type diagnostic after migration + +**Exit code:** 2 + +``` +vite.config.mjs:6:14 - error TS2322: Type 'number' is not assignable to type 'string'. + +6 export const value = 1; + ~~~~~ + +Found 1 error in vite.config.mjs:6 +``` + +## `vpt replace-file-content vite.config.mjs @ts-check @ts-nocheck` + +``` +``` + +## `vpt json-edit tsconfig.json compilerOptions.checkJs true` + +``` +``` + +## `vp exec tsc --noEmit` + +A leading ts-nocheck pragma can still disable project-level JavaScript checking + +``` +``` diff --git a/docs/guide/migrate-rules.md b/docs/guide/migrate-rules.md index 1171368514..7f46795dcf 100644 --- a/docs/guide/migrate-rules.md +++ b/docs/guide/migrate-rules.md @@ -27,7 +27,7 @@ When a default upgrade skips setup actions that would apply, it prints a hint to When `.oxlintrc.json` or `.oxlintrc.jsonc` uses `extends`, `vp migrate` resolves each file path relative to the config that declares it. It creates shared TypeScript configs and imports them through `lint.extends`, including nested inheritance. Oxlint applies the existing merge order, rule severities, and overrides. Plain `vp lint` uses the migrated rules without a `-c` flag. -Migration assumes ESM projects. Shared configs become sibling `.ts` files. Imports from JavaScript and TypeScript configs use `.js` specifiers, which Vite and TypeScript resolve to the generated source files without changing compiler settings. Workspace consumers reuse the same generated modules, including the workspace that owns a shared config. Migration defaults stay in each workspace's local `lint` block. Edit these TypeScript files to update shared settings. Original shared JSON and JSONC files remain available to other consumers; edits to those originals do not update the migrated configs. +Migration assumes ESM projects. Shared configs become sibling `.ts` files. Imports from JavaScript and TypeScript configs use `.js` specifiers, which Vite and TypeScript resolve to the generated source files without changing compiler settings. File-level TypeScript pragmas stay before generated imports. Workspace consumers reuse the same generated modules, including the workspace that owns a shared config. Migration defaults stay in each workspace's local `lint` block. Edit these TypeScript files to update shared settings. Original shared JSON and JSONC files remain available to other consumers; edits to those originals do not update the migrated configs. JS-plugin declarations stay in their original config or override. Relative plugin paths become absolute paths at runtime through `fileURLToPath(new URL(..., import.meta.url))`. Explicit inherited `typeAware` and `typeCheck` settings take precedence over migration defaults. @@ -37,7 +37,7 @@ A shared config's owner keeps `env`, `globals`, `settings`, and `ignorePatterns` Generated modules include a source marker. A later migration attempt reuses these modules without replacing user edits, so a preserved consumer can migrate after its conflict is resolved. Existing modules without a matching marker remain conflicts. -Failed merges record a pending marker in the Vite config, so an isolated failure can be retried after repair. A successful merge removes this marker. Completed workspace merges record a completion marker, including configs merged inline. After fixing an unreadable config, run `vp migrate` again to migrate the remaining consumers and remove JSON files that are no longer needed. +Failed merges and existing `lint` blocks record a pending marker in the Vite config, so an isolated failure can be retried after the conflict is resolved. A successful merge removes this marker. Completed workspace merges record a completion marker, including configs merged inline. After fixing an unreadable config, run `vp migrate` again to migrate the remaining consumers and remove JSON files that are no longer needed. Shared config imports use literal paths so Vite and TypeScript can resolve filenames containing `#` or `%`. A relative import path containing `?` or a literal backslash requires a manual migration: Vite treats `?` as a query delimiter, and TypeScript treats backslashes as path separators. Migration preserves the JSON config and reports a warning in these cases. diff --git a/packages/cli/src/migration/__tests__/oxlint-extends.spec.ts b/packages/cli/src/migration/__tests__/oxlint-extends.spec.ts index f52be18aa4..121575d227 100644 --- a/packages/cli/src/migration/__tests__/oxlint-extends.spec.ts +++ b/packages/cli/src/migration/__tests__/oxlint-extends.spec.ts @@ -996,7 +996,7 @@ describe('Oxlint JSON extends migration', () => { expect(fs.readdirSync(project).some((file) => file.startsWith('.vite-plus-lint-'))).toBe(false); }); - it('preserves an inheritance tree when a lint config already exists', () => { + it('retries a preserved inheritance tree after removing a conflicting lint block', async () => { write('base.json', { rules: { 'no-debugger': 'error' } }); const source = write('.oxlintrc.json', { extends: ['./base.json'] }); const original = fs.readFileSync(source, 'utf8'); @@ -1007,8 +1007,57 @@ describe('Oxlint JSON extends migration', () => { mergeViteConfigFiles(project, true, report); expect(fs.readFileSync(source, 'utf8')).toBe(original); - expect(fs.readFileSync(path.join(project, 'vite.config.mjs'), 'utf8')).toBe(viteConfig); + const configPath = path.join(project, 'vite.config.mjs'); + expect(fs.readFileSync(configPath, 'utf8')).toBe( + `${viteConfig}\n// vp migrate: pending .oxlintrc.json.\n`, + ); expect(report.warnings[0]).toContain('already has a lint config'); + + fs.writeFileSync( + configPath, + fs.readFileSync(configPath, 'utf8').replace(viteConfig, 'export default {};'), + ); + const retryReport = createMigrationReport(); + expect(retryPreservedOxlintConfigs(project, true, retryReport)).toBe(true); + expect(retryReport.warnings).toEqual([]); + expect(fs.existsSync(source)).toBe(false); + expect((await loadConfig()).lint!.extends![0].rules!['no-debugger']).toBe('error'); + }); + + it.each([ + '// @ts-check', + '// @ts-nocheck', + '/// ', + '/** @jsxImportSource react */', + ])( + 'keeps %s before generated imports and statement directives on their original statement', + (pragma) => { + write('base.json', { rules: { 'no-debugger': 'error' } }); + write('.oxlintrc.json', { extends: ['./base.json'] }); + const header = `#!/usr/bin/env node\n${pragma}\n`; + write('vite.config.mjs', `${header}// @ts-expect-error\nexport default {};`); + + mergeViteConfigFiles(project, true); + + const source = fs.readFileSync(path.join(project, 'vite.config.mjs'), 'utf8'); + expect(source).toContain(`${header}import oxlintConfig1`); + expect(source).toContain('// @ts-expect-error\nexport default'); + }, + ); + + it('keeps shebangs and directive prologues before imports without moving triple-slash statement directives', () => { + write('base.json', { rules: { 'no-debugger': 'error' } }); + write('.oxlintrc.json', { extends: ['./base.json'] }); + write( + 'vite.config.mjs', + '#!/usr/bin/env node\n"";\n"use client";\n/// @ts-expect-error\nexport default {};', + ); + + mergeViteConfigFiles(project, true); + + const source = fs.readFileSync(path.join(project, 'vite.config.mjs'), 'utf8'); + expect(source).toMatch(/^#!\/usr\/bin\/env node\n["']{2};\n["']use client["'];\nimport /); + expect(source).toContain('/// @ts-expect-error\nexport default'); }); it('creates no shared modules when the config merger does not support the export shape', () => { diff --git a/packages/cli/src/migration/migrator/vite-config.ts b/packages/cli/src/migration/migrator/vite-config.ts index ef9e64f033..5060f38dae 100644 --- a/packages/cli/src/migration/migrator/vite-config.ts +++ b/packages/cli/src/migration/migrator/vite-config.ts @@ -1,11 +1,13 @@ import fs from 'node:fs'; import path from 'node:path'; +import type { Program } from '@oxc-project/types'; import * as prompts from '@voidzero-dev/vite-plus-prompts'; import { braceExpand } from 'minimatch'; import { type OxlintConfig } from 'oxlint'; import { + analyzeMigrationSource, hasConfigKey, mergeJsonConfig, mergeTsdownConfig, @@ -405,6 +407,7 @@ function mergeOxlintConfigFile( } // Never discard an inheritance tree when a lint block already exists. if (oxlintJson.extends?.length && hasConfigKey(path.join(projectPath, viteConfig), 'lint')) { + recordPendingMigration(); warnMigration( `Preserved ${displayRelative(fullOxlintPath)} because ${viteConfig} already has a lint config. Merge its inherited rules manually.`, report, @@ -599,6 +602,35 @@ function injectConfigDefaults( } } +function configImportOffset(filename: string, source: string): number { + const { ast, comments } = JSON.parse(analyzeMigrationSource(filename, source)) as { + ast: { node: Program }; + comments: Array<{ start: number; end: number }>; + }; + let offset = ast.node.hashbang?.end ?? 0; + for (const statement of ast.node.body) { + if (statement.type !== 'ExpressionStatement' || typeof statement.directive !== 'string') { + break; + } + offset = statement.end; + } + const firstStatement = ast.node.body[0]?.start ?? source.length; + for (const comment of comments) { + if (comment.end > firstStatement) { + break; + } + const text = source.slice(comment.start, comment.end); + // File pragmas must precede imports; next-statement directives must stay in place. + if ( + /^\/\/\/\s* Date: Tue, 20 Oct 2026 00:33:03 +0800 Subject: [PATCH 21/24] fix(migrate): avoid an intermediate config path array --- packages/cli/src/migration/migrator/oxlint-config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/src/migration/migrator/oxlint-config.ts b/packages/cli/src/migration/migrator/oxlint-config.ts index 30aeb5b41e..948deb9a24 100644 --- a/packages/cli/src/migration/migrator/oxlint-config.ts +++ b/packages/cli/src/migration/migrator/oxlint-config.ts @@ -211,7 +211,7 @@ export function removeMigratedOxlintConfigs(configs: PreparedOxlintConfigs): boo removableSources.add(fs.realpathSync(filename)); } } - const removablePaths = new Set([...removalCandidates].map(configPathInRealDirectory)); + const removablePaths = new Set(Array.from(removalCandidates, configPathInRealDirectory)); const cache = createConfigCache(); try { for (const file of files) { From ff52cb4a8681f225159e0c2058acd39acee09520 Mon Sep 17 00:00:00 2001 From: MK Date: Tue, 20 Oct 2026 12:19:18 +0800 Subject: [PATCH 22/24] refactor(migrate): simplify Oxlint module plan handling --- .../src/migration/migrator/oxlint-config.ts | 27 ++++++++++--------- .../cli/src/migration/migrator/vite-config.ts | 6 ++--- 2 files changed, 16 insertions(+), 17 deletions(-) diff --git a/packages/cli/src/migration/migrator/oxlint-config.ts b/packages/cli/src/migration/migrator/oxlint-config.ts index 948deb9a24..c3e3f18fa9 100644 --- a/packages/cli/src/migration/migrator/oxlint-config.ts +++ b/packages/cli/src/migration/migrator/oxlint-config.ts @@ -320,20 +320,22 @@ function moduleProvenance(file: OxlintConfigFile): string { return `// Generated by vp migrate from ${encodeURIComponent(relative)}.`; } -function reusableModulePath(file: OxlintConfigFile): string | undefined { - const filename = fs.realpathSync(file.filename).replace(/\.jsonc?$/i, '') + '.ts'; - if ( +function generatedModulePath(file: OxlintConfigFile): string { + return fs.realpathSync(file.filename).replace(/\.jsonc?$/i, '') + '.ts'; +} + +function isReusableModule(file: OxlintConfigFile, filename: string): boolean { + return ( !fs.existsSync(filename.replace(/\.ts$/, '.js')) && fs.existsSync(filename) && fs.readFileSync(filename, 'utf8').split(/\r?\n/, 1)[0] === moduleProvenance(file) - ) { - return filename; - } - return undefined; + ); } export function hasGeneratedOxlintConfig(file: OxlintConfigFile): boolean { - return reusableModulePath(file) !== undefined || file.extends.some(hasGeneratedOxlintConfig); + return ( + isReusableModule(file, generatedModulePath(file)) || file.extends.some(hasGeneratedOxlintConfig) + ); } /** Generate portable shared modules, leaving their plugins in their original layers. */ @@ -404,7 +406,7 @@ export function prepareOxlintConfigModules( } function generate(file: OxlintConfigFile): string { - const filename = fs.realpathSync(file.filename).replace(/\.jsonc?$/i, '') + '.ts'; + const filename = generatedModulePath(file); const existing = modules.get(filename); if (existing && moduleProvenance(existing.file) !== moduleProvenance(file)) { throw new Error(`Multiple Oxlint configs would create ${filename}`); @@ -416,11 +418,10 @@ export function prepareOxlintConfigModules( return filename; } const runtimePath = filename.replace(/\.ts$/, '.js'); - const reusable = reusableModulePath(file); - if (reusable) { + if (isReusableModule(file, filename)) { // Reuse earlier output, including user edits, without rewriting it. - file.modulePath = reusable; - return reusable; + file.modulePath = filename; + return filename; } if (fs.existsSync(filename) || fs.existsSync(runtimePath)) { throw new Error( diff --git a/packages/cli/src/migration/migrator/vite-config.ts b/packages/cli/src/migration/migrator/vite-config.ts index 5060f38dae..56623e6880 100644 --- a/packages/cli/src/migration/migrator/vite-config.ts +++ b/packages/cli/src/migration/migrator/vite-config.ts @@ -487,7 +487,6 @@ function mergeOxlintConfigFile( 'lint', silent, report, - plan.configContent, plan, preparedOxlintConfigs !== undefined, ); @@ -638,7 +637,6 @@ function mergeAndRemoveJsonConfig( configKey: string, silent = false, report?: MigrationReport, - configContent?: string, modulePlan?: OxlintModulePlan, deferRemoval = false, ): boolean { @@ -664,13 +662,13 @@ function mergeAndRemoveJsonConfig( // Only delete the original JSON after a successful merge. Resolved lint // configs contain object extends, which JSON Oxlint configs do not accept. let result; - if (configContent === undefined) { + if (!modulePlan) { result = mergeJsonConfig(fullViteConfigPath, fullJsonConfigPath, configKey); } else { const tempDir = fs.mkdtempSync(path.join(projectPath, '.vite-plus-lint-')); try { const tempConfig = path.join(tempDir, 'config.json'); - fs.writeFileSync(tempConfig, configContent); + fs.writeFileSync(tempConfig, modulePlan.configContent); result = mergeJsonConfig(fullViteConfigPath, tempConfig, configKey); } finally { fs.rmSync(tempDir, { recursive: true, force: true }); From ea097402c1b8a522c296aaaeda0f22411a72ffc5 Mon Sep 17 00:00:00 2001 From: MK Date: Tue, 20 Oct 2026 18:10:12 +0800 Subject: [PATCH 23/24] fix(migrate): sanitize inherited plugins and scope config retries --- .../migration_oxlint_extends/snapshots.toml | 15 ++ ...migration_oxlint_extends_missing_plugin.md | 150 ++++++++++++++++++ .../snapshots.toml | 24 ++- ...n_oxlint_extends_workspace_new_consumer.md | 109 +++++++++++++ ..._oxlint_extends_workspace_symlink_retry.md | 4 +- .../src/migration/__tests__/migrator.spec.ts | 49 ++++++ .../__tests__/oxlint-extends.spec.ts | 98 +++++++++++- packages/cli/src/migration/migrator/eslint.ts | 86 ++++++++-- .../src/migration/migrator/oxlint-config.ts | 16 +- .../cli/src/migration/migrator/vite-config.ts | 17 +- 10 files changed, 536 insertions(+), 32 deletions(-) create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends/snapshots/migration_oxlint_extends_missing_plugin.md create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/snapshots/migration_oxlint_extends_workspace_new_consumer.md diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends/snapshots.toml index 6902cd39f1..762a835758 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends/snapshots.toml @@ -248,3 +248,18 @@ steps = [ { argv = ["vpt", "json-edit", "tsconfig.json", "compilerOptions.checkJs", "true"] }, { argv = ["vp", "exec", "tsc", "--noEmit"], comment = "A leading ts-nocheck pragma can still disable project-level JavaScript checking" }, ] + +[[case]] +name = "migration_oxlint_extends_missing_plugin" +vp = "local" +local-registry = true +unset-env = ["VP_SKIP_INSTALL"] +steps = [ + { argv = ["vp", "lint", "--threads=1", "-c", ".oxlintrc.json", "index.js"], comment = "Original inherited diagnostics before a missing generated plugin is added", continue-on-failure = true }, + { argv = ["vpt", "json-edit", ".oxlintrc.json", "jsPlugins", '["eslint-plugin-vp-missing"]'], snapshot = false }, + { argv = ["vpt", "json-edit", ".oxlintrc.json", "rules.vp-missing/no-foo", '"error"'], snapshot = false }, + { argv = ["vp", "migrate", "--no-interactive", "--no-agent", "--no-editor", "--no-hooks"], comment = "Sanitize missing generated package references without discarding inheritance" }, + { argv = ["vpt", "print-file", "vite.config.ts"], comment = "The missing plugin and its rule are removed while inherited plugin rules remain" }, + { argv = ["vp", "lint", "--threads=1", "index.js"], comment = "Lint loads successfully and reports the inherited diagnostics", continue-on-failure = true }, + { argv = ["vp", "lint", "--threads=1", "example.test.js"], comment = "Inherited override plugins still run", continue-on-failure = true }, +] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends/snapshots/migration_oxlint_extends_missing_plugin.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends/snapshots/migration_oxlint_extends_missing_plugin.md new file mode 100644 index 0000000000..313371e36f --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends/snapshots/migration_oxlint_extends_missing_plugin.md @@ -0,0 +1,150 @@ +# migration_oxlint_extends_missing_plugin + +## `vp lint --threads=1 -c .oxlintrc.json index.js` + +Original inherited diagnostics before a missing generated plugin is added + +**Exit code:** 1 + +``` + + × eslint(no-debugger): `debugger` statement is not allowed + ╭─[index.js:1:1] + 1 │ debugger; + · ───────── + 2 │ console.log("test"); + ╰──── + help: Remove the debugger statement + + ⚠ eslint(no-console): Unexpected console statement. + ╭─[index.js:2:1] + 1 │ debugger; + 2 │ console.log("test"); + · ─────────── + 3 │ alert("allowed"); + ╰──── + help: Delete this console statement. + + × local(no-foo): Do not use foo. + ╭─[index.js:4:14] + 3 │ alert("allowed"); + 4 │ export const foo = 1; + · ─── + 5 │ export const bar = 1; + ╰──── + +Found 1 warning and 2 errors. +Finished in on 1 file with rules using threads. +``` + +## `vpt json-edit .oxlintrc.json jsPlugins '["eslint-plugin-vp-missing"]'` + + +## `vpt json-edit .oxlintrc.json rules.vp-missing/no-foo '"error"'` + + +## `vp migrate --no-interactive --no-agent --no-editor --no-hooks` + +Sanitize missing generated package references without discarding inheritance + +``` +VITE+ - The Unified Toolchain for the Web + +● Formatting code... +● Code formatted +◇ Migrated . to Vite+ +• Node pnpm +✓ Dependencies installed in +• 2 config updates applied +! Warnings: + - Stripped JS plugin reference(s) from the generated lint config: eslint-plugin-vp-missing. No matching package is present in this workspace, so loading them at lint time would fail. If you want their Oxlint coverage back, install each package (e.g. `vp install `) and add its name back to `lint.jsPlugins` in vite.config.ts. +``` + +## `vpt print-file vite.config.ts` + +The missing plugin and its rule are removed while inherited plugin rules remain + +``` +import oxlintConfig1 from "./shared/nested/first.js"; +import oxlintConfig2 from "./shared/second.js"; +import { defineConfig } from "vite-plus"; + +export default defineConfig({ + fmt: {}, + lint: { + extends: [oxlintConfig1, oxlintConfig2], + ignorePatterns: ["shared/**", "vite.config.ts"], + jsPlugins: [{ name: "vite-plus", specifier: "vite-plus/oxlint-plugin" }], + rules: { + "local/no-foo": "error", + "no-alert": "off", + "vite-plus/prefer-vite-plus-imports": "error", + }, + options: {}, + }, +}); +``` + +## `vp lint --threads=1 index.js` + +Lint loads successfully and reports the inherited diagnostics + +**Exit code:** 1 + +``` + + × eslint(no-debugger): `debugger` statement is not allowed + ╭─[index.js:1:1] + 1 │ debugger; + · ───────── + 2 │ console.log("test"); + ╰──── + help: Remove the debugger statement + + ⚠ eslint(no-console): Unexpected console statement. + ╭─[index.js:2:1] + 1 │ debugger; + 2 │ console.log("test"); + · ─────────── + 3 │ alert("allowed"); + ╰──── + help: Delete this console statement. + + × local(no-foo): Do not use foo. + ╭─[index.js:4:14] + 3 │ alert("allowed"); + 4 │ export const foo = 1; + · ─── + 5 │ export const bar = 1; + ╰──── + +Found 1 warning and 2 errors. +Finished in on 1 file with rules using threads. +``` + +## `vp lint --threads=1 example.test.js` + +Inherited override plugins still run + +**Exit code:** 1 + +``` + + ⚠ eslint(no-debugger): `debugger` statement is not allowed + ╭─[example.test.js:1:1] + 1 │ debugger; + · ───────── + 2 │ export const bar = 1; + ╰──── + help: Remove the debugger statement + + × override(no-bar): Do not use bar. + ╭─[example.test.js:2:14] + 1 │ debugger; + 2 │ export const bar = 1; + · ─── + ╰──── + +Found 1 warning and 1 error. +Finished in on 1 file with rules using threads. +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/snapshots.toml index b741a5bf14..8d46c5bc61 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/snapshots.toml @@ -180,7 +180,7 @@ steps = [ { argv = ["vpt", "stat-file", "packages/b/.oxlintrc.json", "--assert", "symlink"], comment = "The failed consumer retains its own alias" }, { argv = ["vp", "lint", "--threads=1", "index.js"], cwd = "packages/a", comment = "The migrated consumer keeps the same diagnostic", continue-on-failure = true }, { argv = ["vp", "lint", "--threads=1", "-c", ".oxlintrc.json", "index.js"], cwd = "packages/b", comment = "The preserved consumer keeps a working JSON fallback", continue-on-failure = true }, - { argv = ["vpt", "write-file", "packages/b/vite.config.mjs", "export default {};"], comment = "Fix the unsupported export before retrying" }, + { argv = ["vpt", "replace-file-content", "packages/b/vite.config.mjs", 'export { default } from "./another-config.mjs";', "export default {};"], comment = "Fix the unsupported export while retaining the workspace's pending marker" }, { argv = ["vp", "migrate", "--no-interactive", "--no-agent", "--no-editor", "--no-hooks"] }, { argv = ["vpt", "stat-file", "packages/b/.oxlintrc.json", "--assert", "missing"], comment = "The repaired alias is removed after its own migration completes" }, { argv = ["vpt", "stat-file", "shared/lint.json", "--assert", "file"], comment = "Unlinking both consumers preserves the shared source" }, @@ -291,3 +291,25 @@ steps = [ { argv = ["vp", "lint", "--threads=1", "index.js"], comment = "The owner follows the shared module edit", continue-on-failure = true }, { argv = ["vp", "lint", "--threads=1", "index.js"], cwd = "packages/a", comment = "The repaired consumer follows the same edit", continue-on-failure = true }, ] + +[[case]] +name = "migration_oxlint_extends_workspace_new_consumer" +vp = "local" +local-registry = true +unset-env = ["VP_SKIP_INSTALL"] +steps = [ + { argv = ["vpt", "rm", "packages/b/extra.ts"], snapshot = false }, + { argv = ["vpt", "write-file", "shared/base.json", '{"rules":{"no-console":"error"},"options":{"typeAware":false,"typeCheck":false}}'], snapshot = false }, + { argv = ["vpt", "json-edit", ".oxlintrc.json", "extends", '["./shared/base.json"]'], snapshot = false }, + { argv = ["vp", "migrate", "--no-interactive", "--no-agent", "--no-editor", "--no-hooks"] }, + { argv = ["vpt", "stat-file", "shared/base.ts", "--assert", "file"] }, + { argv = ["vpt", "write-file", "packages/c/package.json", '{"name":"new-consumer","type":"module"}'], snapshot = false }, + { argv = ["vpt", "write-file", "packages/c/vite.config.mjs", "export default {};\n"], snapshot = false }, + { argv = ["vpt", "write-file", "packages/c/.oxlintrc.json", '{"extends":["../../shared/base.json"]}'], snapshot = false }, + { argv = ["vpt", "write-file", "packages/c/index.js", 'console.log("hello");'], snapshot = false }, + { argv = ["vp", "lint", "--threads=1", "-c", ".oxlintrc.json", "index.js"], cwd = "packages/c", comment = "A new workspace uses the retained shared JSON", continue-on-failure = true }, + { argv = ["vp", "migrate", "--no-interactive", "--no-agent", "--no-editor", "--no-hooks"], comment = "An upgrade does not treat shared modules as a pending migration for the new workspace" }, + { argv = ["vpt", "print-file", "packages/c/vite.config.mjs"], comment = "The unmarked workspace keeps its original Vite config" }, + { argv = ["vpt", "print-file", "packages/c/.oxlintrc.json"], comment = "Its newly authored JSON config remains intact" }, + { argv = ["vp", "lint", "--threads=1", "-c", ".oxlintrc.json", "index.js"], cwd = "packages/c", comment = "The JSON config retains the same lint output", continue-on-failure = true }, +] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/snapshots/migration_oxlint_extends_workspace_new_consumer.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/snapshots/migration_oxlint_extends_workspace_new_consumer.md new file mode 100644 index 0000000000..0fd8c5438f --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/snapshots/migration_oxlint_extends_workspace_new_consumer.md @@ -0,0 +1,109 @@ +# migration_oxlint_extends_workspace_new_consumer + +## `vpt rm packages/b/extra.ts` + + +## `vpt write-file shared/base.json '{"rules":{"no-console":"error"},"options":{"typeAware":false,"typeCheck":false}}'` + + +## `vpt json-edit .oxlintrc.json extends '["./shared/base.json"]'` + + +## `vp migrate --no-interactive --no-agent --no-editor --no-hooks` + +``` +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. +◆ ✔ Merged .oxlintrc.json into vite.config.mjs +● Formatting code... +● Code formatted +◇ Migrated . to Vite+ +• Node pnpm +✓ Dependencies installed in +• 2 config updates applied +``` + +## `vpt stat-file shared/base.ts --assert file` + +``` +shared/base.ts: file +``` + +## `vpt write-file packages/c/package.json '{"name":"new-consumer","type":"module"}'` + + +## `vpt write-file packages/c/vite.config.mjs 'export default {}; +'` + + +## `vpt write-file packages/c/.oxlintrc.json '{"extends":["../../shared/base.json"]}'` + + +## `vpt write-file packages/c/index.js 'console.log("hello");'` + + +## `cd packages/c && vp lint --threads=1 -c .oxlintrc.json index.js` + +A new workspace uses the retained shared JSON + +**Exit code:** 1 + +``` + + × eslint(no-console): Unexpected console statement. + ╭─[index.js:1:1] + 1 │ console.log("hello"); + · ─────────── + ╰──── + help: Delete this console statement. + +Found 0 warnings and 1 error. +Finished in on 1 file with rules using threads. +``` + +## `vp migrate --no-interactive --no-agent --no-editor --no-hooks` + +An upgrade does not treat shared modules as a pending migration for the new workspace + +``` +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. +This project is already using Vite+! Happy coding! +``` + +## `vpt print-file packages/c/vite.config.mjs` + +The unmarked workspace keeps its original Vite config + +``` +export default {}; +``` + +## `vpt print-file packages/c/.oxlintrc.json` + +Its newly authored JSON config remains intact + +``` +{"extends":["../../shared/base.json"]} +``` + +## `cd packages/c && vp lint --threads=1 -c .oxlintrc.json index.js` + +The JSON config retains the same lint output + +**Exit code:** 1 + +``` + + × eslint(no-console): Unexpected console statement. + ╭─[index.js:1:1] + 1 │ console.log("hello"); + · ─────────── + ╰──── + help: Delete this console statement. + +Found 0 warnings and 1 error. +Finished in on 1 file with rules using threads. +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/snapshots/migration_oxlint_extends_workspace_symlink_retry.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/snapshots/migration_oxlint_extends_workspace_symlink_retry.md index e870d02a9c..c4f9530c43 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/snapshots/migration_oxlint_extends_workspace_symlink_retry.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/snapshots/migration_oxlint_extends_workspace_symlink_retry.md @@ -133,9 +133,9 @@ Found 0 warnings and 1 error. Finished in on 1 file with rules using threads. ``` -## `vpt write-file packages/b/vite.config.mjs 'export default {};'` +## `vpt replace-file-content packages/b/vite.config.mjs 'export { default } from "./another-config.mjs";' 'export default {};'` -Fix the unsupported export before retrying +Fix the unsupported export while retaining the workspace's pending marker ``` ``` diff --git a/packages/cli/src/migration/__tests__/migrator.spec.ts b/packages/cli/src/migration/__tests__/migrator.spec.ts index f8b1490549..bd868a769a 100644 --- a/packages/cli/src/migration/__tests__/migrator.spec.ts +++ b/packages/cli/src/migration/__tests__/migrator.spec.ts @@ -55,6 +55,7 @@ const { rewriteEslintPackageJson, collectInstalledPackageNames, sanitizeMigratedOxlintConfig, + sanitizeInheritedJsPlugins, detectIncompatibleEslintIntegration, hasExistingViteHooksPolicy, installGitHooks, @@ -1174,6 +1175,54 @@ describe('collectInstalledPackageNames', () => { }); describe('sanitizeMigratedOxlintConfig', () => { + it('removes missing generated plugins without discarding inherited or local plugin rules', () => { + const inherited = { jsPlugins: ['./base-plugin.mjs'] }; + const config: import('oxlint').OxlintConfig = { + extends: [inherited], + jsPlugins: ['eslint-plugin-missing', './local-plugin.mjs', 'eslint-plugin-installed/subpath'], + rules: { + 'missing/custom': 'error', + 'base/custom': 'warn', + 'local/custom': 'error', + 'installed/custom': 'error', + 'no-debugger': 'error', + }, + overrides: [ + { + files: ['**/*.test.js'], + jsPlugins: ['eslint-plugin-override-missing'], + rules: { + 'missing/custom': 'warn', + 'override-missing/custom': 'error', + 'base/custom': 'off', + }, + }, + { + files: ['**/*.local.js'], + jsPlugins: [{ name: 'missing', specifier: './replacement.mjs' }], + rules: { 'missing/custom': 'error' }, + }, + ], + }; + const report = createMigrationReport(); + + sanitizeInheritedJsPlugins(config, new Set(['eslint-plugin-installed']), os.tmpdir(), report); + + expect(config.extends).toEqual([inherited]); + expect(config.jsPlugins).toEqual(['./local-plugin.mjs', 'eslint-plugin-installed/subpath']); + expect(config.rules).toEqual({ + 'base/custom': 'warn', + 'local/custom': 'error', + 'installed/custom': 'error', + 'no-debugger': 'error', + }); + expect(config.overrides![0].jsPlugins).toEqual([]); + expect(config.overrides![0].rules).toEqual({ 'base/custom': 'off' }); + expect(config.overrides![1].rules).toEqual({ 'missing/custom': 'error' }); + expect(report.warnings).toHaveLength(1); + expect(report.warnings[0]).toContain('eslint-plugin-missing, eslint-plugin-override-missing'); + }); + it('removes unsupported rules', () => { const config: import('oxlint').OxlintConfig = { rules: { diff --git a/packages/cli/src/migration/__tests__/oxlint-extends.spec.ts b/packages/cli/src/migration/__tests__/oxlint-extends.spec.ts index 121575d227..4f083d1ef1 100644 --- a/packages/cli/src/migration/__tests__/oxlint-extends.spec.ts +++ b/packages/cli/src/migration/__tests__/oxlint-extends.spec.ts @@ -9,6 +9,7 @@ import { PackageManager } from '../../types/index.ts'; import { injectLintTypeCheckDefaults, mergeViteConfigFiles, + prepareOxlintConfigs, resolveOxlintConfigExtends, retryPreservedOxlintConfigs, rewriteMonorepo, @@ -154,6 +155,47 @@ describe('Oxlint JSON extends migration', () => { } }); + it.each([false, true])( + 'sanitizes missing root plugins with a shared owner: %s', + async (shared) => { + write('base.json', { + jsPlugins: ['./local-plugin.mjs'], + rules: { 'base/custom': 'error' }, + }); + write('.oxlintrc.json', { + extends: ['./base.json'], + jsPlugins: ['eslint-plugin-vp-missing'], + rules: { 'vp-missing/custom': 'error', 'base/custom': 'warn' }, + }); + const report = createMigrationReport(); + if (shared) { + createWorkspace(['a']); + write('packages/a/.oxlintrc.json', { extends: ['../../.oxlintrc.json'] }); + const packagePath = path.join(project, 'packages/a'); + const packages = [{ name: 'a', path: 'packages/a' }]; + const prepared = prepareOxlintConfigs([project, packagePath]); + mergeViteConfigFiles(project, true, report, packages, project, prepared); + mergeViteConfigFiles(packagePath, true, report, packages, project, prepared); + } else { + mergeViteConfigFiles(project, true, report); + } + + expect(report.warnings).toHaveLength(1); + expect(report.warnings[0]).toContain('eslint-plugin-vp-missing'); + const { lint } = await loadConfig(); + const migrated = shared ? lint!.extends![0] : lint!; + expect(migrated.jsPlugins).not.toContain('eslint-plugin-vp-missing'); + expect(migrated.rules).not.toHaveProperty('vp-missing/custom'); + expect(migrated.rules).toHaveProperty('base/custom', 'warn'); + expect(migrated.extends![0].rules).toHaveProperty('base/custom', 'error'); + if (shared) { + expect((await loadConfig(path.join(project, 'packages/a'))).lint!.extends![0]).toEqual( + migrated, + ); + } + }, + ); + it.each(['base.ts', 'base.js'])('preserves configs when %s already exists', (filename) => { write('base.json', { rules: { 'no-debugger': 'warn' } }); const original = 'export default { rules: { "no-debugger": "off" } };'; @@ -444,7 +486,10 @@ describe('Oxlint JSON extends migration', () => { 'off', ); - write('packages/b/vite.config.mjs', 'export default {};'); + fs.writeFileSync( + viteConfig, + fs.readFileSync(viteConfig, 'utf8').replace(original, 'export default {};'), + ); const retryReport = createMigrationReport(); expect( retryPreservedOxlintConfigs( @@ -651,13 +696,17 @@ describe('Oxlint JSON extends migration', () => { write('.oxlintrc.json', { rules: { 'no-debugger': 'warn' } }); write('packages/a/.oxlintrc.json', { extends: ['../../.oxlintrc.json'] }); write('packages/b/.oxlintrc.json', { extends: ['../a/.oxlintrc.json'] }); - write('packages/b/vite.config.mjs', "export { default } from './other.mjs';"); + const original = "export { default } from './other.mjs';"; + const viteConfig = write('packages/b/vite.config.mjs', original); expect(migrateWorkspace().warnings).toHaveLength(1); const modulePath = path.join(project, '.oxlintrc.ts'); const edited = fs.readFileSync(modulePath, 'utf8').replace('"warn"', '"error"'); fs.writeFileSync(modulePath, edited); - write('packages/b/vite.config.mjs', 'export default {};'); + fs.writeFileSync( + viteConfig, + fs.readFileSync(viteConfig, 'utf8').replace(original, 'export default {};'), + ); const report = createMigrationReport(); expect( @@ -713,6 +762,27 @@ describe('Oxlint JSON extends migration', () => { ); }); + it('does not retry a new workspace config that references a previously generated module', () => { + write('base.json', { rules: { 'no-debugger': 'error' } }); + write('.oxlintrc.json', { extends: ['./base.json'] }); + mergeViteConfigFiles(project, true); + createWorkspace(['a']); + const source = write('packages/a/.oxlintrc.json', { extends: ['../../base.json'] }); + const original = fs.readFileSync(source, 'utf8'); + const report = createMigrationReport(); + + expect( + retryPreservedOxlintConfigs(project, true, report, [{ name: 'a', path: 'packages/a' }]), + ).toBe(false); + + expect(report.warnings).toEqual([]); + expect(fs.readFileSync(source, 'utf8')).toBe(original); + expect(fs.readFileSync(path.join(project, 'packages/a/vite.config.mjs'), 'utf8')).toBe( + 'export default {};', + ); + expect(fs.existsSync(path.join(project, 'base.ts'))).toBe(true); + }); + it('resumes completed workspace merges after an unreadable graph is repaired', async () => { createWorkspace(); write('packages/a/.oxlintrc.json', { rules: { 'no-debugger': 'error' } }); @@ -910,6 +980,28 @@ describe('Oxlint JSON extends migration', () => { expect(report.warnings[0]).toContain('resolves differently'); }); + it('retains an inherited root plugin that resolves on disk without a direct dependency', async () => { + write('base.json', { rules: { 'no-debugger': 'error' } }); + write('node_modules/eslint-plugin-test/package.json', { + name: 'eslint-plugin-test', + main: 'index.js', + }); + write('node_modules/eslint-plugin-test/index.js', 'module.exports = {};'); + write('.oxlintrc.json', { + extends: ['./base.json'], + jsPlugins: ['eslint-plugin-test'], + rules: { 'test/custom': 'error' }, + }); + const report = createMigrationReport(); + + mergeViteConfigFiles(project, true, report); + + expect(report.warnings).toEqual([]); + const { lint } = await loadConfig(); + expect(lint!.jsPlugins).toContain('eslint-plugin-test'); + expect(lint!.rules).toHaveProperty('test/custom', 'error'); + }); + it('does not override an inherited opt-out of the default import rule', async () => { write('base.json', { rules: { 'vite-plus/prefer-vite-plus-imports': 'off' } }); write('.oxlintrc.json', { extends: ['./base.json'] }); diff --git a/packages/cli/src/migration/migrator/eslint.ts b/packages/cli/src/migration/migrator/eslint.ts index e5e8a69903..992a6e7e5f 100644 --- a/packages/cli/src/migration/migrator/eslint.ts +++ b/packages/cli/src/migration/migrator/eslint.ts @@ -7,7 +7,7 @@ import * as prompts from '@voidzero-dev/vite-plus-prompts'; import { type OxlintConfig } from 'oxlint'; import { compareReversed, isLessThanOrEqual, normalize } from 'verkit'; -import { rewriteEslint } from '../../../binding/index.js'; +import { ResolverFactory, rewriteEslint } from '../../../binding/index.js'; import { type WorkspacePackage } from '../../types/index.ts'; import { runCommandSilently } from '../../utils/command.ts'; import { editJsonFile, isJsonFile, readJsonFile } from '../../utils/json.ts'; @@ -764,6 +764,81 @@ function jsPluginsToNamespaces(entries: NonNullable): return ns; } +function warnMissingJsPlugins(plugins: Set, report?: MigrationReport): void { + if (plugins.size > 0) { + warnMigration( + `Stripped JS plugin reference(s) from the generated lint config: ${[...plugins].join(', ')}. ` + + 'No matching package is present in this workspace, so loading them at lint time would fail. ' + + 'If you want their Oxlint coverage back, install each package (e.g. `vp install `) and add its name back to `lint.jsPlugins` in vite.config.ts.', + report, + ); + } +} + +/** Only remove known missing references: inherited plugins can supply other namespaces at runtime. */ +export function sanitizeInheritedJsPlugins( + config: OxlintConfig, + availablePackages: Set, + configDir: string, + report?: MigrationReport, +): void { + const availableSpecifiers = new Set(availablePackages); + let resolver: ResolverFactory | undefined; + for (const layer of [config, ...(config.overrides ?? [])]) { + for (const entry of layer.jsPlugins ?? []) { + if (typeof entry !== 'string' || availableSpecifiers.has(entry)) { + continue; + } + const packageName = entry + .split('/') + .slice(0, entry.startsWith('@') ? 2 : 1) + .join('/'); + if ( + availablePackages.has(packageName) || + /^\.{1,2}[/\\]/.test(entry) || + path.win32.isAbsolute(entry) + ) { + availableSpecifiers.add(entry); + continue; + } + resolver ??= new ResolverFactory({ conditionNames: ['module-sync', 'node', 'import'] }); + if (resolver.sync(configDir, entry).path) { + availableSpecifiers.add(entry); + } + } + } + const droppedPlugins = new Set(); + function sanitizeLayer(layer: OxlintConfig, inheritedDropped = new Set()): Set { + const { kept, dropped } = partitionJsPlugins(layer.jsPlugins ?? [], availableSpecifiers); + if (dropped.length > 0) { + layer.jsPlugins = kept; + for (const plugin of dropped) { + droppedPlugins.add(plugin); + } + } + const droppedNamespaces = new Set([...inheritedDropped, ...jsPluginsToNamespaces(dropped)]); + for (const namespace of jsPluginsToNamespaces(kept)) { + droppedNamespaces.delete(namespace); + } + if (layer.plugins) { + layer.plugins = layer.plugins.filter( + (plugin) => !droppedNamespaces.has(plugin) || OXLINT_NATIVE_PLUGINS.has(plugin), + ); + } + for (const rule of Object.keys(layer.rules ?? {})) { + if (ruleKeyMatchesNamespace(rule, droppedNamespaces) && !isBuiltinOxlintRule(rule)) { + delete layer.rules![rule]; + } + } + return droppedNamespaces; + } + const droppedNamespaces = sanitizeLayer(config); + for (const override of config.overrides ?? []) { + sanitizeLayer(override, droppedNamespaces); + } + warnMissingJsPlugins(droppedPlugins, report); +} + /** * Sanitize the `.oxlintrc.json` produced by `@oxlint/migrate` (in-place) * before it gets merged into `vite.config.ts`. Drop references that @@ -908,14 +983,7 @@ export function sanitizeMigratedOxlintConfig( // phantom-reference case (e.g. `@unocss/eslint-config` translating // into `eslint-plugin-unocss` even though the user never had it). // A single accurate message covers both paths. - if (allDroppedJsPlugins.size > 0) { - warnMigration( - `Stripped JS plugin reference(s) from the generated lint config: ${[...allDroppedJsPlugins].join(', ')}. ` + - 'No matching package is present in this workspace, so loading them at lint time would fail. ' + - 'If you want their Oxlint coverage back, install each package (e.g. `vp install `) and add its name back to `lint.jsPlugins` in vite.config.ts.', - report, - ); - } + warnMissingJsPlugins(allDroppedJsPlugins, report); if (allDroppedPlugins.size > 0) { warnMigration( `Stripped unknown plugin reference(s) from the generated lint config: ${[...allDroppedPlugins].join(', ')}. ` + diff --git a/packages/cli/src/migration/migrator/oxlint-config.ts b/packages/cli/src/migration/migrator/oxlint-config.ts index c3e3f18fa9..b971d3fd49 100644 --- a/packages/cli/src/migration/migrator/oxlint-config.ts +++ b/packages/cli/src/migration/migrator/oxlint-config.ts @@ -332,12 +332,6 @@ function isReusableModule(file: OxlintConfigFile, filename: string): boolean { ); } -export function hasGeneratedOxlintConfig(file: OxlintConfigFile): boolean { - return ( - isReusableModule(file, generatedModulePath(file)) || file.extends.some(hasGeneratedOxlintConfig) - ); -} - /** Generate portable shared modules, leaving their plugins in their original layers. */ export function prepareOxlintConfigModules( root: OxlintConfigFile, @@ -405,7 +399,7 @@ export function prepareOxlintConfigModules( } } - function generate(file: OxlintConfigFile): string { + function generate(file: OxlintConfigFile, sourceConfig: OxlintConfig): string { const filename = generatedModulePath(file); const existing = modules.get(filename); if (existing && moduleProvenance(existing.file) !== moduleProvenance(file)) { @@ -434,7 +428,7 @@ export function prepareOxlintConfigModules( const inherited = file.extends.map((child, index) => { const name = `oxlintConfig${index + 1}`; imports.push( - `import ${name} from ${JSON.stringify(relativeImport(filename, generate(child)))};`, + `import ${name} from ${JSON.stringify(relativeImport(filename, generate(child, sourceConfig.extends![index])))};`, ); return name; }); @@ -450,7 +444,7 @@ export function prepareOxlintConfigModules( const url = encoded.startsWith('../') ? encoded : `./${encoded}`; return `fileURLToPath(new URL(${JSON.stringify(url)}, import.meta.url))`; } - const config = structuredClone(file.config); + const config = structuredClone(sourceConfig); ensureSvelteRuneGlobals(config); const body = printConfig(config, inherited, { pluginExpression }); if (needsFileUrl) { @@ -462,14 +456,14 @@ export function prepareOxlintConfigModules( const imports: string[] = []; let nextName = 1; - const inherited = importedFiles.map((file) => { + const inherited = importedFiles.map((file, index) => { let name: string; do { name = `oxlintConfig${nextName++}`; } while (names.has(name)); names.add(name); imports.push( - `import ${name} from ${JSON.stringify(relativeImport(viteConfigPath, generate(file)))};`, + `import ${name} from ${JSON.stringify(relativeImport(viteConfigPath, generate(file, config.extends![index])))};`, ); return name; }); diff --git a/packages/cli/src/migration/migrator/vite-config.ts b/packages/cli/src/migration/migrator/vite-config.ts index 56623e6880..96e6e6d46f 100644 --- a/packages/cli/src/migration/migrator/vite-config.ts +++ b/packages/cli/src/migration/migrator/vite-config.ts @@ -30,7 +30,6 @@ import { hasBaseUrlInTsconfig } from '../../utils/tsconfig.ts'; import { detectConfigs, type ConfigFiles } from '../detector.ts'; import { collectInstalledPackageNames, - hasGeneratedOxlintConfig, oxlintMigrationMarker, prepareOxlintConfigs, readRulesYaml, @@ -38,6 +37,7 @@ import { resolveOxlintConfigExtends, prepareOxlintConfigModules, sanitizeMigratedOxlintConfig, + sanitizeInheritedJsPlugins, } from '../migrator.ts'; import { type MigrationReport } from '../report.ts'; import { @@ -322,10 +322,7 @@ export function retryPreservedOxlintConfigs( } continue; } - if ( - !lines.includes(pendingOxlintMigrationMarker(filename)) && - (migration instanceof Error || !hasGeneratedOxlintConfig(migration.file)) - ) { + if (!lines.includes(pendingOxlintMigrationMarker(filename))) { continue; } const viteConfig = ensureViteConfig(projectPath, configs, silent, report); @@ -394,7 +391,8 @@ function mergeOxlintConfigFile( const { file } = migration; // Keep shared rules in one module, including for the workspace that owns it. // Fields that Oxlint does not inherit must also remain in the owner's local block. - let oxlintJson: OxlintConfig = structuredClone(file.config); + const sourceConfig: OxlintConfig = structuredClone(file.config); + let oxlintJson = sourceConfig; if (file.shared) { oxlintJson = { extends: [oxlintJson], @@ -447,6 +445,13 @@ function mergeOxlintConfigFile( collectInstalledPackageNames(workspaceRoot ?? projectPath, packages), report, ); + } else { + sanitizeInheritedJsPlugins( + sourceConfig, + collectInstalledPackageNames(workspaceRoot ?? projectPath, packages), + path.dirname(file.filename), + report, + ); } for (const layer of layers) { ensureSvelteRuneGlobals(layer); From 55cec9913510f20009d932ac1e9a85b98817867d Mon Sep 17 00:00:00 2001 From: MK Date: Tue, 20 Oct 2026 18:25:01 +0800 Subject: [PATCH 24/24] fix(migrate): retain workspace JSON during full setup --- .../snapshots.toml | 18 +++ ...ion_oxlint_extends_workspace_full_setup.md | 126 ++++++++++++++++++ packages/cli/src/migration/bin.ts | 17 ++- 3 files changed, 160 insertions(+), 1 deletion(-) create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/snapshots/migration_oxlint_extends_workspace_full_setup.md diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/snapshots.toml index 8d46c5bc61..f814be252f 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/snapshots.toml @@ -313,3 +313,21 @@ steps = [ { argv = ["vpt", "print-file", "packages/c/.oxlintrc.json"], comment = "Its newly authored JSON config remains intact" }, { argv = ["vp", "lint", "--threads=1", "-c", ".oxlintrc.json", "index.js"], cwd = "packages/c", comment = "The JSON config retains the same lint output", continue-on-failure = true }, ] + +[[case]] +name = "migration_oxlint_extends_workspace_full_setup" +vp = "local" +local-registry = true +unset-env = ["VP_SKIP_INSTALL"] +steps = [ + { argv = ["vp", "add", "-D", "vite-plus", "prettier@3.6.2"], snapshot = false }, + { argv = ["vpt", "write-file", ".prettierrc.json", '{"singleQuote":true}'], snapshot = false }, + { argv = ["vp", "lint", "--threads=1", "-c", ".oxlintrc.json", "index.js"], cwd = "packages/a", comment = "The existing workspace consumer inherits the root JSON rule" }, + { argv = ["vp", "migrate", "--full", "--no-interactive", "--no-agent", "--no-editor", "--no-hooks"], comment = "Prettier migration merges the root configs without migrating package configs" }, + { argv = ["vpt", "print-file", "vite.config.mjs"], comment = "The root uses its shared module" }, + { argv = ["vpt", "stat-file", ".oxlintrc.json", "--assert", "file"], comment = "The root JSON remains available to untouched consumers" }, + { argv = ["vpt", "stat-file", ".oxlintrc.ts", "--assert", "file"] }, + { argv = ["vpt", "print-file", "packages/a/vite.config.mjs"], comment = "The package Vite config remains unchanged" }, + { argv = ["vp", "lint", "--threads=1", "-c", ".oxlintrc.json", "index.js"], cwd = "packages/a", comment = "The preserved package JSON still reports the same diagnostic" }, + { argv = ["vp", "lint", "--threads=1", "index.js"], comment = "The migrated root enforces the same rule" }, +] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/snapshots/migration_oxlint_extends_workspace_full_setup.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/snapshots/migration_oxlint_extends_workspace_full_setup.md new file mode 100644 index 0000000000..8ce5b6ecc2 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extends_workspace/snapshots/migration_oxlint_extends_workspace_full_setup.md @@ -0,0 +1,126 @@ +# migration_oxlint_extends_workspace_full_setup + +## `vp add -D vite-plus prettier@3.6.2` + + +## `vpt write-file .prettierrc.json '{"singleQuote":true}'` + + +## `cd packages/a && vp lint --threads=1 -c .oxlintrc.json index.js` + +The existing workspace consumer inherits the root JSON rule + +``` + + ⚠ eslint(no-debugger): `debugger` statement is not allowed + ╭─[index.js:1:1] + 1 │ debugger; + · ───────── + ╰──── + help: Remove the debugger statement + +Found 1 warning and 0 errors. +Finished in on 1 file with rules using threads. +``` + +## `vp migrate --full --no-interactive --no-agent --no-editor --no-hooks` + +Prettier migration merges the root configs without migrating package configs + +``` +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. +● Prettier configuration detected. Auto-migrating to Oxfmt... +● Formatting code... +● Code formatted +◇ Updated . to Vite+ +• Node pnpm +• Dependencies: + vite → +✓ Dependencies installed in +• 2 config updates applied +• Prettier migrated to Oxfmt +• Package manager settings configured +``` + +## `vpt print-file vite.config.mjs` + +The root uses its shared module + +``` +// vp migrate: merged .oxlintrc.json (cca48660331feaa20cf74e4f147c8e10). +import oxlintConfig1 from './.oxlintrc.js'; +export default { + lint: { + extends: [oxlintConfig1], + options: {}, + jsPlugins: [{ name: 'vite-plus', specifier: 'vite-plus/oxlint-plugin' }], + rules: { + 'vite-plus/prefer-vite-plus-imports': 'error', + }, + }, + fmt: { + singleQuote: true, + printWidth: 80, + sortPackageJson: false, + ignorePatterns: [], + }, +}; +``` + +## `vpt stat-file .oxlintrc.json --assert file` + +The root JSON remains available to untouched consumers + +``` +.oxlintrc.json: file +``` + +## `vpt stat-file .oxlintrc.ts --assert file` + +``` +.oxlintrc.ts: file +``` + +## `vpt print-file packages/a/vite.config.mjs` + +The package Vite config remains unchanged + +``` +export default {}; +``` + +## `cd packages/a && vp lint --threads=1 -c .oxlintrc.json index.js` + +The preserved package JSON still reports the same diagnostic + +``` + + ⚠ eslint(no-debugger): `debugger` statement is not allowed + ╭─[index.js:1:1] + 1 │ debugger; + · ───────── + ╰──── + help: Remove the debugger statement + +Found 1 warning and 0 errors. +Finished in on 1 file with rules using threads. +``` + +## `vp lint --threads=1 index.js` + +The migrated root enforces the same rule + +``` + + ⚠ eslint(no-debugger): `debugger` statement is not allowed + ╭─[index.js:1:1] + 1 │ debugger; + · ───────── + ╰──── + help: Remove the debugger statement + +Found 1 warning and 0 errors. +Finished in on 1 file with rules using threads. +``` diff --git a/packages/cli/src/migration/bin.ts b/packages/cli/src/migration/bin.ts index 79a360981b..a699b6d2be 100644 --- a/packages/cli/src/migration/bin.ts +++ b/packages/cli/src/migration/bin.ts @@ -78,6 +78,8 @@ import { planVitestV5Migration, refreshVitestV5Migration, retryPreservedOxlintConfigs, + prepareOxlintConfigs, + removeMigratedOxlintConfigs, shouldSkipStagedMigrationForHooks, warnPackageLevelPrettier, vitestV5NeedsMigration, @@ -1464,12 +1466,25 @@ async function main() { // Merge configs and reinstall once if any tool or bootstrap migration happened if (eslintMigrated || prettierMigrated || tsupMigrated) { updateMigrationProgress('Rewriting configs'); + const rootDir = workspaceInfoOptional.rootDir; + // Full setup only merges the root here; untouched packages still need their JSON dependencies. + const oxlintConfigs = workspaceInfoOptional.isMonorepo + ? prepareOxlintConfigs([ + rootDir, + ...workspaceInfoOptional.packages.map((pkg) => path.join(rootDir, pkg.path)), + ]) + : undefined; mergeViteConfigFiles( - workspaceInfoOptional.rootDir, + rootDir, true, report, workspaceInfoOptional.packages, + rootDir, + oxlintConfigs, ); + if (oxlintConfigs) { + removeMigratedOxlintConfigs(oxlintConfigs); + } if (tsupMigrated) { mergeTsdownConfigFile(workspaceInfoOptional.rootDir, true, report); for (const pkg of workspaceInfoOptional.packages ?? []) {