Skip to content

Commit 20b4ca0

Browse files
committed
feat(migrate): preserve packages referenced via lint.jsPlugins
Before this commit, `rewriteEslintPackageJson` stripped every package matching `isEslintEcosystemDep` — including ones `@oxlint/migrate` had just referenced via `lint.jsPlugins`. Result: the generated config listed `jsPlugins: ["eslint-plugin-foo"]` but the package was no longer in `package.json`, so the sanitizer (or Oxlint at runtime) would strip / fail to load it. Two passes in a row, two opposite decisions about the same package. Now the cleanup honors what `@oxlint/migrate` actually wrote: 1. After `@oxlint/migrate` produces `.oxlintrc.json`, collect every string-form package name appearing in `lint.jsPlugins[]` (and in each `overrides[].jsPlugins[]`), skipping local-path specifiers (`./X`, `../X`, `/X`) which don't map to a `package.json` entry. 2. Pass that set to `rewriteEslintPackageJson` as `preserveJsPlugins`. 3. The cleanup loop skips removal for any name in that set — even when it matches `isEslintEcosystemDep`'s named / prefix / scope / scoped-regex patterns. Net effect: - User had `eslint-plugin-vue` in devDeps AND imported it from `eslint.config.mjs` → `@oxlint/migrate` lists it in jsPlugins → cleanup keeps it → sanitizer keeps it → `vp lint` actually loads the plugin and runs its rules. - User had `eslint-plugin-vue` in devDeps but didn't import it → `@oxlint/migrate` doesn't list it → cleanup removes it as before (no regression). - `@oxlint/migrate` invented a reference to a package the user never installed (the WeakAuras `eslint-plugin-unocss` case) → not in devDeps, nothing to preserve → sanitizer still strips the orphan reference + warns (no regression). New snap-test `migration-eslint-jsplugins-preserve` exercises the preserve path end-to-end: a fixture with `eslint-plugin-survives` in devDeps + an inline `survives` plugin in `eslint.config.mjs`. After migration, the package is still in devDeps, the jsPlugins reference is intact in `vite.config.ts`, and the `survives/no-fiction` rule survived in `lint.rules` — all without firing any "stripped" warning. Three unit tests cover the API surface: - preserveJsPlugins keeps named jsPlugins through cleanup - preserveJsPlugins overrides every branch of isEslintEcosystemDep (named / prefix / scope / scoped regex) - empty preserveJsPlugins set behaves identically to no argument (default-compatible, can't accidentally weaken cleanup for existing callers) No existing snap-test drifted.
1 parent 21d581a commit 20b4ca0

6 files changed

Lines changed: 247 additions & 2 deletions

File tree

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
// Inline-defined `survives` plugin — @oxlint/migrate translates it into
2+
// `lint.jsPlugins: ["eslint-plugin-survives"]`. The package is listed
3+
// in this fixture's package.json devDependencies, so:
4+
// 1. The cleanup step should NOT delete `eslint-plugin-survives`
5+
// from package.json (it's referenced by the generated jsPlugins
6+
// array — removing it would invalidate the lint config we just
7+
// generated).
8+
// 2. The sanitizer should NOT strip the jsPlugins entry (the
9+
// package is present in the workspace).
10+
// 3. The `survives/no-fiction` rule should survive in the merged
11+
// `lint.rules` (the `survives` namespace is backed by the kept
12+
// jsPlugin).
13+
export default [
14+
{
15+
plugins: {
16+
survives: {
17+
rules: {
18+
'no-fiction': {
19+
meta: { type: 'problem' },
20+
create() {
21+
return {};
22+
},
23+
},
24+
},
25+
},
26+
},
27+
rules: {
28+
'survives/no-fiction': 'warn',
29+
},
30+
},
31+
];
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
{
2+
"name": "migration-eslint-jsplugins-preserve",
3+
"scripts": {
4+
"lint": "eslint ."
5+
},
6+
"devDependencies": {
7+
"eslint": "^9.0.0",
8+
"eslint-plugin-survives": "^1.0.0",
9+
"vite": "^7.0.0"
10+
}
11+
}
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
> vp migrate --no-interactive # plugin referenced via lint.jsPlugins must be preserved through cleanup AND sanitization
2+
◇ Migrated . to Vite+
3+
• Node <semver> pnpm <semver>
4+
• 4 config updates applied
5+
• ESLint rules migrated to Oxlint
6+
7+
> cat package.json # eslint-plugin-survives stays in devDependencies (eslint itself is removed)
8+
{
9+
"name": "migration-eslint-jsplugins-preserve",
10+
"scripts": {
11+
"lint": "vp lint .",
12+
"prepare": "vp config"
13+
},
14+
"devDependencies": {
15+
"eslint-plugin-survives": "^1.0.0",
16+
"vite": "catalog:",
17+
"vite-plus": "catalog:"
18+
},
19+
"packageManager": "pnpm@<semver>"
20+
}
21+
22+
> cat vite.config.ts # lint.jsPlugins keeps `eslint-plugin-survives`; lint.rules keeps `survives/no-fiction`
23+
import { defineConfig } from 'vite-plus';
24+
25+
export default defineConfig({
26+
staged: {
27+
"*": "vp check --fix"
28+
},
29+
fmt: {},
30+
lint: {
31+
"plugins": [
32+
"oxc",
33+
"typescript",
34+
"unicorn",
35+
"react"
36+
],
37+
"jsPlugins": [
38+
"eslint-plugin-survives",
39+
{
40+
"name": "vite-plus",
41+
"specifier": "vite-plus/oxlint-plugin"
42+
}
43+
],
44+
"categories": {
45+
"correctness": "warn"
46+
},
47+
"env": {
48+
"builtin": true
49+
},
50+
"rules": {
51+
"survives/no-fiction": "warn",
52+
"vite-plus/prefer-vite-plus-imports": "error"
53+
},
54+
"options": {
55+
"typeAware": true,
56+
"typeCheck": true
57+
}
58+
},
59+
});
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"commands": [
3+
"vp migrate --no-interactive # plugin referenced via lint.jsPlugins must be preserved through cleanup AND sanitization",
4+
"cat package.json # eslint-plugin-survives stays in devDependencies (eslint itself is removed)",
5+
"cat vite.config.ts # lint.jsPlugins keeps `eslint-plugin-survives`; lint.rules keeps `survives/no-fiction`"
6+
]
7+
}

packages/cli/src/migration/__tests__/migrator.spec.ts

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -543,6 +543,84 @@ describe('rewriteEslintPackageJson', () => {
543543
const after = fs.readFileSync(pkgPath, 'utf8');
544544
expect(after).toBe(before);
545545
});
546+
547+
it('preserves packages referenced in lint.jsPlugins (so the generated config still loads)', () => {
548+
// When @oxlint/migrate translates a real ESLint plugin into a
549+
// lint.jsPlugins reference, Oxlint will `import()` the package at
550+
// lint time. If we strip it from package.json the lint config we
551+
// just generated is invalidated. The preserveJsPlugins set guards
552+
// against that.
553+
const pkgPath = writePkg({
554+
devDependencies: {
555+
eslint: '^9.0.0',
556+
'eslint-plugin-vue': '^10.0.0',
557+
'eslint-plugin-import-x': '^4.0.0',
558+
'eslint-plugin-react': '^7.37.0',
559+
'@stylistic/eslint-plugin': '^2.0.0',
560+
'@typescript-eslint/parser': '^8.0.0',
561+
vite: '^7.0.0',
562+
},
563+
});
564+
rewriteEslintPackageJson(
565+
pkgPath,
566+
new Set(['eslint-plugin-vue', 'eslint-plugin-import-x', '@stylistic/eslint-plugin']),
567+
);
568+
const pkg = readJson(pkgPath);
569+
expect(pkg.devDependencies).toEqual({
570+
// Preserved (in jsPlugins set, so Oxlint will load them):
571+
'eslint-plugin-vue': '^10.0.0',
572+
'eslint-plugin-import-x': '^4.0.0',
573+
'@stylistic/eslint-plugin': '^2.0.0',
574+
// Removed (no jsPlugins reference, normal cleanup):
575+
// 'eslint': stripped
576+
// 'eslint-plugin-react': stripped
577+
// '@typescript-eslint/parser': stripped
578+
vite: '^7.0.0',
579+
});
580+
});
581+
582+
it('preserveJsPlugins overrides every cleanup pattern (named, prefix, scope, regex)', () => {
583+
// Stress-test each branch of isEslintEcosystemDep against the
584+
// preserve set so a future contributor adding a new cleanup branch
585+
// can't accidentally bypass the carve-out.
586+
const pkgPath = writePkg({
587+
devDependencies: {
588+
eslint: '^9.0.0', // named match in ESLINT_ECOSYSTEM_NAMES
589+
'eslint-plugin-foo': '^1.0.0', // prefix match
590+
'@eslint/js': '^9.0.0', // scope match
591+
'@scope/eslint-plugin-bar': '^1.0.0', // scoped regex match
592+
keepme: '^1.0.0',
593+
},
594+
});
595+
rewriteEslintPackageJson(
596+
pkgPath,
597+
new Set(['eslint', 'eslint-plugin-foo', '@eslint/js', '@scope/eslint-plugin-bar']),
598+
);
599+
const pkg = readJson(pkgPath);
600+
expect(pkg.devDependencies).toEqual({
601+
eslint: '^9.0.0',
602+
'eslint-plugin-foo': '^1.0.0',
603+
'@eslint/js': '^9.0.0',
604+
'@scope/eslint-plugin-bar': '^1.0.0',
605+
keepme: '^1.0.0',
606+
});
607+
});
608+
609+
it('does not invent preserveJsPlugins entries — only what the caller asked for', () => {
610+
// Sanity: an empty preserve set behaves identically to the default
611+
// (no carve-out), so the new parameter can't accidentally weaken
612+
// the cleanup for existing callers.
613+
const pkgPath = writePkg({
614+
devDependencies: {
615+
eslint: '^9.0.0',
616+
'eslint-plugin-foo': '^1.0.0',
617+
vite: '^7.0.0',
618+
},
619+
});
620+
rewriteEslintPackageJson(pkgPath, new Set());
621+
const pkg = readJson(pkgPath);
622+
expect(pkg.devDependencies).toEqual({ vite: '^7.0.0' });
623+
});
546624
});
547625

548626
function writePkgAt(dir: string, pkg: object): void {

packages/cli/src/migration/migrator.ts

Lines changed: 61 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -329,6 +329,15 @@ export async function migrateEslintToOxlint(
329329
options.report.eslintMigrated = true;
330330
}
331331

332+
// Read the generated `.oxlintrc.json` to find any packages it references
333+
// in `lint.jsPlugins`. Those packages need to stay in `package.json` so
334+
// Oxlint can actually `import()` them at lint time — without this carve-out,
335+
// the next step would strip them via `isEslintEcosystemDep` and we'd
336+
// immediately invalidate the config we just generated. Local-path
337+
// specifiers (`./X`, `../X`, `/X`) are skipped — they're paths, not
338+
// package names, and have no `package.json` entry to preserve.
339+
const preserveJsPlugins = collectJsPluginPackageNames(projectPath);
340+
332341
// Step 3-5: Cleanup runs uniformly across the root and every workspace
333342
// package — delete eslint config files, scrub ESLint-ecosystem deps from
334343
// package.json, and rewrite eslint references in any local lint-staged
@@ -346,13 +355,52 @@ export async function migrateEslintToOxlint(
346355
continue;
347356
}
348357
deleteEslintConfigFiles(target, options?.report, options?.silent);
349-
rewriteEslintPackageJson(path.join(target, 'package.json'));
358+
rewriteEslintPackageJson(path.join(target, 'package.json'), preserveJsPlugins);
350359
rewriteEslintLintStagedConfigFiles(target, options?.report);
351360
}
352361

353362
return true;
354363
}
355364

365+
/**
366+
* Read `<projectPath>/.oxlintrc.json` (if any) and collect the package
367+
* names referenced via `lint.jsPlugins[]` string entries. Object-form
368+
* entries (`{ name, specifier }`) and local-path specifiers (`./X`,
369+
* `../X`, `/X`) are excluded — neither maps to a `package.json` entry
370+
* we'd accidentally strip.
371+
*/
372+
function collectJsPluginPackageNames(projectPath: string): Set<string> {
373+
const out = new Set<string>();
374+
const oxlintConfigPath = path.join(projectPath, '.oxlintrc.json');
375+
if (!fs.existsSync(oxlintConfigPath)) {
376+
return out;
377+
}
378+
let config: OxlintConfig;
379+
try {
380+
config = readJsonFile(oxlintConfigPath, true) as OxlintConfig;
381+
} catch {
382+
return out;
383+
}
384+
const collectFrom = (jsPlugins: OxlintConfig['jsPlugins']): void => {
385+
for (const entry of jsPlugins ?? []) {
386+
if (typeof entry !== 'string') {
387+
continue;
388+
}
389+
if (entry.startsWith('./') || entry.startsWith('../') || entry.startsWith('/')) {
390+
continue;
391+
}
392+
out.add(entry);
393+
}
394+
};
395+
collectFrom(config.jsPlugins);
396+
if (Array.isArray(config.overrides)) {
397+
for (const override of config.overrides) {
398+
collectFrom(override.jsPlugins);
399+
}
400+
}
401+
return out;
402+
}
403+
356404
function deleteEslintConfigFiles(basePath: string, report?: MigrationReport, silent = false): void {
357405
const configs = detectConfigs(basePath);
358406
for (const file of [configs.eslintConfig, configs.eslintLegacyConfig]) {
@@ -442,8 +490,16 @@ function isEslintEcosystemDep(name: string): boolean {
442490
* in scope for adoption, so a half-cleanup at the workspace level would
443491
* be inconsistent with the rest of the flow (which already replaces
444492
* vite-related overrides and adds vite-plus across all packages).
493+
*
494+
* `preserveJsPlugins` names packages that `@oxlint/migrate` referenced
495+
* via `lint.jsPlugins` and that Oxlint will need to `import()` at lint
496+
* time. They override `isEslintEcosystemDep` so the generated config
497+
* isn't immediately invalidated by the cleanup step.
445498
*/
446-
export function rewriteEslintPackageJson(packageJsonPath: string): void {
499+
export function rewriteEslintPackageJson(
500+
packageJsonPath: string,
501+
preserveJsPlugins: ReadonlySet<string> = new Set(),
502+
): void {
447503
editJsonFile<{
448504
devDependencies?: Record<string, string>;
449505
dependencies?: Record<string, string>;
@@ -465,6 +521,9 @@ export function rewriteEslintPackageJson(packageJsonPath: string): void {
465521
}
466522
let removedAny = false;
467523
for (const name of Object.keys(deps)) {
524+
if (preserveJsPlugins.has(name)) {
525+
continue;
526+
}
468527
if (isEslintEcosystemDep(name)) {
469528
delete deps[name];
470529
changed = true;

0 commit comments

Comments
 (0)