Skip to content

Commit af44ac9

Browse files
committed
fix(migrate): report lint rules dropped during oxlint sanitization
`sanitizeMigratedOxlintConfig` removes rules whose namespace no surviving plugin contributes. That removal is necessary — Oxlint refuses to start on a rule naming a plugin it cannot resolve ("Plugin 'x' not found") — but it happened silently, while dropped plugins and jsPlugins already warned. A local jsPlugin makes this visible. Its real namespace comes from the plugin's `meta.name`, which cannot be derived from a path specifier such as `./lint/kumo.js`, so every `kumo/*` rule was filtered out of the migrated config with no output at all. Collect the removed rule keys and warn with them, pointing at the object form (`{ name, specifier }`) that lets a user name the namespace explicitly. Refs #2231
1 parent a583efa commit af44ac9

2 files changed

Lines changed: 89 additions & 3 deletions

File tree

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

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1187,6 +1187,66 @@ describe('collectInstalledPackageNames', () => {
11871187
sanitizeMigratedOxlintConfig(config, available);
11881188
expect(config.jsPlugins).toEqual([]);
11891189
});
1190+
1191+
// #2231: a local jsPlugin's real namespace comes from its `meta.name`,
1192+
// which can't be derived from the path, so its rules get filtered out.
1193+
// Dropping them is currently required (Oxlint refuses to start on a rule
1194+
// whose plugin it can't resolve), but it must not happen silently.
1195+
it('warns which rules it dropped when a local jsPlugin namespace is unresolvable', () => {
1196+
writeRootPkg({ devDependencies: { vite: '^7.0.0' } });
1197+
const available = collectInstalledPackageNames(tmpDir);
1198+
const report = createMigrationReport();
1199+
const config: import('oxlint').OxlintConfig = {
1200+
jsPlugins: ['./lint/kumo.js'],
1201+
rules: { 'kumo/no-foo': 'error', 'no-debugger': 'error' },
1202+
};
1203+
1204+
sanitizeMigratedOxlintConfig(config, available, report);
1205+
1206+
// The unbacked rule still has to go, and the native one stays.
1207+
expect(config.rules).toEqual({ 'no-debugger': 'error' });
1208+
// The local plugin itself is preserved — Oxlint resolves it by path.
1209+
expect(config.jsPlugins).toEqual(['./lint/kumo.js']);
1210+
// The drop is reported, names the rule, and points at the fix.
1211+
const warning = report.warnings.find((w) => w.includes('Stripped lint rule(s)'));
1212+
expect(warning).toBeDefined();
1213+
expect(warning).toContain('kumo/no-foo');
1214+
expect(warning).not.toContain('no-debugger');
1215+
expect(warning).toContain('specifier');
1216+
});
1217+
1218+
it('reports rules dropped from overrides, not just base rules', () => {
1219+
writeRootPkg({ devDependencies: { vite: '^7.0.0' } });
1220+
const available = collectInstalledPackageNames(tmpDir);
1221+
const report = createMigrationReport();
1222+
const config: import('oxlint').OxlintConfig = {
1223+
overrides: [{ files: ['**/*.ts'], rules: { 'kumo/no-bar': 'warn' } }],
1224+
};
1225+
1226+
sanitizeMigratedOxlintConfig(config, available, report);
1227+
1228+
expect(config.overrides?.[0]?.rules).toEqual({});
1229+
const warning = report.warnings.find((w) => w.includes('Stripped lint rule(s)'));
1230+
expect(warning).toBeDefined();
1231+
expect(warning).toContain('kumo/no-bar');
1232+
});
1233+
1234+
it('does not warn about rules when nothing was dropped', () => {
1235+
writeRootPkg({ devDependencies: { vite: '^7.0.0' } });
1236+
const available = collectInstalledPackageNames(tmpDir);
1237+
const report = createMigrationReport();
1238+
const config: import('oxlint').OxlintConfig = {
1239+
rules: { 'no-debugger': 'error', 'typescript/no-explicit-any': 'error' },
1240+
};
1241+
1242+
sanitizeMigratedOxlintConfig(config, available, report);
1243+
1244+
expect(config.rules).toEqual({
1245+
'no-debugger': 'error',
1246+
'typescript/no-explicit-any': 'error',
1247+
});
1248+
expect(report.warnings.some((w) => w.includes('Stripped lint rule(s)'))).toBe(false);
1249+
});
11901250
});
11911251

11921252
describe('ensureSvelteRuneGlobals', () => {

packages/cli/src/migration/migrator/eslint.ts

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -541,15 +541,23 @@ function ruleKeyMatchesNamespace(key: string, namespaces: Set<string>): boolean
541541
return false;
542542
}
543543

544-
/** Filter a rules object to only entries whose namespace is recognized. */
544+
/**
545+
* Filter a rules object to only entries whose namespace is recognized.
546+
* Every removed key is recorded in `dropped` so the caller can tell the
547+
* user which rules it lost — silently discarding a user's custom rules
548+
* is the failure mode reported in #2231.
549+
*/
545550
function filterRulesAgainstNamespaces(
546551
rules: Record<string, unknown>,
547552
namespaces: Set<string>,
553+
dropped?: Set<string>,
548554
): Record<string, unknown> {
549555
const out: Record<string, unknown> = {};
550556
for (const [key, value] of Object.entries(rules)) {
551557
if (ruleKeyMatchesNamespace(key, namespaces)) {
552558
out[key] = value;
559+
} else {
560+
dropped?.add(key);
553561
}
554562
}
555563
return out;
@@ -634,6 +642,7 @@ export function sanitizeMigratedOxlintConfig(
634642
// Track everything we strip so we can warn the user.
635643
const allDroppedJsPlugins = new Set<string>();
636644
const allDroppedPlugins = new Set<string>();
645+
const allDroppedRules = new Set<string>();
637646

638647
// 1. Sanitize base-level jsPlugins.
639648
const baseSplit = partitionJsPlugins(config.jsPlugins ?? [], availablePackages);
@@ -670,7 +679,7 @@ export function sanitizeMigratedOxlintConfig(
670679
// `rules: undefined` property that would shift downstream key
671680
// emission in the merged vite.config.ts.
672681
if (config.rules) {
673-
const filtered = filterRulesAgainstNamespaces(config.rules, baseNamespaces);
682+
const filtered = filterRulesAgainstNamespaces(config.rules, baseNamespaces, allDroppedRules);
674683
if (Object.keys(filtered).length !== Object.keys(config.rules).length) {
675684
config.rules = filtered as typeof config.rules;
676685
}
@@ -720,7 +729,11 @@ export function sanitizeMigratedOxlintConfig(
720729

721730
// Override rules.
722731
if (override.rules) {
723-
const filtered = filterRulesAgainstNamespaces(override.rules, overrideNamespaces);
732+
const filtered = filterRulesAgainstNamespaces(
733+
override.rules,
734+
overrideNamespaces,
735+
allDroppedRules,
736+
);
724737
if (Object.keys(filtered).length !== Object.keys(override.rules).length) {
725738
override.rules = filtered as typeof override.rules;
726739
}
@@ -753,6 +766,19 @@ export function sanitizeMigratedOxlintConfig(
753766
report,
754767
);
755768
}
769+
// Rules have to go when nothing backs their namespace: Oxlint refuses to
770+
// start at all on a rule whose plugin it can't find ("Plugin 'x' not
771+
// found"), so keeping them would break `vp lint` outright rather than
772+
// degrade it. But losing a rule the user wrote should never be silent —
773+
// dropped plugins already warn, and rules used to disappear quietly.
774+
if (allDroppedRules.size > 0) {
775+
warnMigration(
776+
`Stripped lint rule(s) from the generated lint config: ${[...allDroppedRules].join(', ')}. ` +
777+
'No plugin in the migrated config contributes their namespace, and Oxlint fails to start when a rule names a plugin it cannot find. ' +
778+
"If a namespace comes from a local JS plugin, name it explicitly in `lint.jsPlugins` as `{ name: '<namespace>', specifier: './path/to/plugin.js' }` and add those rules back.",
779+
report,
780+
);
781+
}
756782
}
757783

758784
export function warnPackageLevelEslint() {

0 commit comments

Comments
 (0)