Skip to content

Commit 31163c5

Browse files
committed
fix(migrate): treat any two Oxc configs in a directory as a conflict
The conflict guard fired only when a directory held a JSON *and* a dynamic config for the same tool. Running the pinned binaries shows the rule both tools enforce is one config per directory, not one config form: oxlint 1.78.0 and oxfmt 0.63.0 each fail with "Both '<a>' and '<b>' found in <dir>" for `.oxlintrc.json` + `.oxlintrc.jsonc` and for `oxlint.config.ts` + `oxlint.config.mts`, exactly as they do for the mixed pair. Both of those shapes previously migrated through, consuming one config and leaving the other on disk unreferenced. Detection now flags any tool with more than one config present, and the conflict carries a single `configs` list in the tool's candidate order instead of the JSON/dynamic split. The rendered line is unchanged for the two-config case and gains comma separation beyond it.
1 parent 13a11a9 commit 31163c5

2 files changed

Lines changed: 57 additions & 65 deletions

File tree

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

Lines changed: 32 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -84,28 +84,25 @@ describe('detectOxcConfigConflicts', () => {
8484
expect(detectOxcConfigConflicts(tmpDir)).toEqual([]);
8585
});
8686

87-
// Two JSON forms are deliberately NOT a conflict here: the ambiguity this
88-
// guard exists for is JSON-vs-dynamic, which is the combination oxlint itself
89-
// rejects and the one that leaves an unreferenced config shadowing the
90-
// inlined block after migration.
91-
it('reports no conflict for two JSON forms of the same tool', () => {
92-
write('.oxlintrc.json');
93-
write('.oxlintrc.jsonc');
94-
95-
expect(detectOxcConfigConflicts(tmpDir)).toEqual([]);
96-
});
97-
87+
// The rule both tools enforce is one config per directory, not one config
88+
// *form*: `.oxlintrc.json` + `.oxlintrc.jsonc` and `oxlint.config.ts` +
89+
// `oxlint.config.mts` fail the same way the mixed pair does, so every
90+
// two-config shape below is a conflict.
9891
it.each([
9992
['oxlint', '.oxlintrc.json', 'oxlint.config.ts'],
10093
['oxlint', '.oxlintrc.jsonc', 'oxlint.config.mts'],
94+
['oxlint', '.oxlintrc.json', '.oxlintrc.jsonc'],
95+
['oxlint', 'oxlint.config.ts', 'oxlint.config.mts'],
10196
['oxfmt', '.oxfmtrc.json', 'oxfmt.config.ts'],
10297
['oxfmt', '.oxfmtrc.jsonc', 'oxfmt.config.mts'],
103-
] as const)('flags %s when %s and %s coexist', (tool, jsonConfig, dynamicConfig) => {
104-
write(jsonConfig);
105-
write(dynamicConfig);
98+
['oxfmt', '.oxfmtrc.json', '.oxfmtrc.jsonc'],
99+
['oxfmt', 'oxfmt.config.ts', 'oxfmt.config.mts'],
100+
] as const)('flags %s when %s and %s coexist', (tool, firstConfig, secondConfig) => {
101+
write(firstConfig);
102+
write(secondConfig);
106103

107104
expect(detectOxcConfigConflicts(tmpDir)).toEqual([
108-
{ tool, dir: '.', jsonConfigs: [jsonConfig], dynamicConfigs: [dynamicConfig] },
105+
{ tool, dir: '.', configs: [firstConfig, secondConfig] },
109106
]);
110107
});
111108

@@ -121,18 +118,17 @@ describe('detectOxcConfigConflicts', () => {
121118
]);
122119
});
123120

124-
it('lists every present form on both sides of a conflict', () => {
125-
write('.oxlintrc.json');
121+
it('lists every config present, in the tool candidate order', () => {
122+
write('oxlint.config.mts');
126123
write('.oxlintrc.jsonc');
127124
write('oxlint.config.ts');
128-
write('oxlint.config.mts');
125+
write('.oxlintrc.json');
129126

130127
expect(detectOxcConfigConflicts(tmpDir)).toEqual([
131128
{
132129
tool: 'oxlint',
133130
dir: '.',
134-
jsonConfigs: ['.oxlintrc.json', '.oxlintrc.jsonc'],
135-
dynamicConfigs: ['oxlint.config.ts', 'oxlint.config.mts'],
131+
configs: ['.oxlintrc.json', '.oxlintrc.jsonc', 'oxlint.config.ts', 'oxlint.config.mts'],
136132
},
137133
]);
138134
});
@@ -145,8 +141,7 @@ describe('detectOxcConfigConflicts', () => {
145141
{
146142
tool: 'oxlint',
147143
dir: 'packages/app',
148-
jsonConfigs: ['.oxlintrc.json'],
149-
dynamicConfigs: ['oxlint.config.ts'],
144+
configs: ['.oxlintrc.json', 'oxlint.config.ts'],
150145
},
151146
]);
152147
});
@@ -188,8 +183,7 @@ describe('collectOxcConfigConflicts', () => {
188183
{
189184
tool: 'oxlint',
190185
dir: 'packages/app',
191-
jsonConfigs: ['.oxlintrc.json'],
192-
dynamicConfigs: ['oxlint.config.ts'],
186+
configs: ['.oxlintrc.json', 'oxlint.config.ts'],
193187
},
194188
]);
195189
});
@@ -225,8 +219,7 @@ describe('formatOxcConfigConflict', () => {
225219
formatOxcConfigConflict({
226220
tool: 'oxlint',
227221
dir: '.',
228-
jsonConfigs: ['.oxlintrc.json'],
229-
dynamicConfigs: ['oxlint.config.ts'],
222+
configs: ['.oxlintrc.json', 'oxlint.config.ts'],
230223
}),
231224
).toBe(
232225
'the project root has `.oxlintrc.json` and `oxlint.config.ts` — oxlint allows only one config per directory.',
@@ -238,11 +231,22 @@ describe('formatOxcConfigConflict', () => {
238231
formatOxcConfigConflict({
239232
tool: 'oxfmt',
240233
dir: 'packages/app',
241-
jsonConfigs: ['.oxfmtrc.json'],
242-
dynamicConfigs: ['oxfmt.config.mts'],
234+
configs: ['.oxfmtrc.json', 'oxfmt.config.mts'],
243235
}),
244236
).toBe(
245237
'packages/app has `.oxfmtrc.json` and `oxfmt.config.mts` — oxfmt allows only one config per directory.',
246238
);
247239
});
240+
241+
it('separates three or more configs with commas', () => {
242+
expect(
243+
formatOxcConfigConflict({
244+
tool: 'oxlint',
245+
dir: '.',
246+
configs: ['.oxlintrc.json', '.oxlintrc.jsonc', 'oxlint.config.ts'],
247+
}),
248+
).toBe(
249+
'the project root has `.oxlintrc.json`, `.oxlintrc.jsonc` and `oxlint.config.ts` — oxlint allows only one config per directory.',
250+
);
251+
});
248252
});

packages/cli/src/migration/detector.ts

Lines changed: 25 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -47,8 +47,8 @@ export const PRETTIER_CONFIG_FILES = [
4747
// The JSON forms are inlined into `vite.config.*` during migration and deleted;
4848
// the dynamic forms are preserved and imported instead. Detection takes the
4949
// first match in each list, so the two forms are ordered JSON-first only to keep
50-
// the historical precedence — `detectOxcConfigConflicts` rejects the ambiguous
51-
// state before that precedence can matter.
50+
// the historical precedence — `detectOxcConfigConflicts` rejects any directory
51+
// holding more than one of these before that precedence can matter.
5252
// https://oxc.rs/docs/guide/usage/linter/config.html#configuration-file-format
5353
export const OXLINT_JSON_CONFIG_FILES = ['.oxlintrc.json', '.oxlintrc.jsonc'] as const;
5454
export const OXLINT_DYNAMIC_CONFIG_FILES = ['oxlint.config.ts', 'oxlint.config.mts'] as const;
@@ -68,25 +68,25 @@ export const OXFMT_CONFIG_FILES = [
6868
export interface OxcConfigConflict {
6969
/** `oxlint` or `oxfmt` — the tool whose config is ambiguous. */
7070
tool: 'oxlint' | 'oxfmt';
71-
/** Directory holding both forms, relative to the workspace root ('.' for the root). */
71+
/** Directory holding the competing configs, relative to the workspace root ('.' for the root). */
7272
dir: string;
73-
/** Every JSON-form config present in `dir`. */
74-
jsonConfigs: string[];
75-
/** Every dynamic-form config present in `dir`. */
76-
dynamicConfigs: string[];
73+
/** Every config for `tool` present in `dir`, in the tool's own candidate order. */
74+
configs: string[];
7775
}
7876

7977
/**
80-
* Detect directories that hold both a JSON and a dynamic config for the same Oxc
81-
* tool.
78+
* Detect directories that hold more than one config for the same Oxc tool.
8279
*
83-
* Oxlint itself refuses to run in that state ("Only one of `.oxlintrc.json` and
84-
* `oxlint.config.ts` is allowed per directory"), so such a project is already
85-
* broken before migration sees it. Migration cannot repair it either: first-match
86-
* detection would inline and delete the JSON config and leave the dynamic one on
87-
* disk unreferenced, where it then silently shadows the freshly inlined `lint`
88-
* block for direct `oxlint` invocations. Erroring out and letting the user pick a
89-
* single config first is the only outcome that does not quietly lose settings.
80+
* Both tools refuse to run in that state — `oxlint` and `oxfmt` each fail with
81+
* "Both '<a>' and '<b>' found in <dir>" — so such a project is already broken
82+
* before migration sees it. The rule is one config per directory, not one config
83+
* *form*: two JSON forms (`.oxlintrc.json` + `.oxlintrc.jsonc`) and two dynamic
84+
* forms (`oxlint.config.ts` + `oxlint.config.mts`) are rejected exactly like the
85+
* mixed pair. Migration cannot repair any of them either: first-match detection
86+
* would consume one config and leave the rest on disk unreferenced, where they
87+
* then silently shadow the freshly inlined `lint` block for direct `oxlint`
88+
* invocations. Erroring out and letting the user pick a single config first is
89+
* the only outcome that does not quietly lose settings.
9090
*
9191
* `dir` is `'.'` for the workspace root; other values are workspace-relative
9292
* package paths with forward slashes.
@@ -98,27 +98,15 @@ export function detectOxcConfigConflicts(
9898
const conflicts: OxcConfigConflict[] = [];
9999

100100
const tools = [
101-
{
102-
tool: 'oxlint',
103-
jsonForms: OXLINT_JSON_CONFIG_FILES,
104-
dynamicForms: OXLINT_DYNAMIC_CONFIG_FILES,
105-
},
106-
{
107-
tool: 'oxfmt',
108-
jsonForms: OXFMT_JSON_CONFIG_FILES,
109-
dynamicForms: OXFMT_DYNAMIC_CONFIG_FILES,
110-
},
101+
{ tool: 'oxlint', configFiles: OXLINT_CONFIG_FILES },
102+
{ tool: 'oxfmt', configFiles: OXFMT_CONFIG_FILES },
111103
] as const;
112104

113-
for (const { tool, jsonForms, dynamicForms } of tools) {
114-
const present = (candidates: readonly string[]) =>
115-
candidates.filter((config) => fs.existsSync(path.join(projectPath, config)));
105+
for (const { tool, configFiles } of tools) {
106+
const configs = configFiles.filter((config) => fs.existsSync(path.join(projectPath, config)));
116107

117-
const jsonConfigs = present(jsonForms);
118-
const dynamicConfigs = present(dynamicForms);
119-
120-
if (jsonConfigs.length > 0 && dynamicConfigs.length > 0) {
121-
conflicts.push({ tool, dir: relativeDir, jsonConfigs, dynamicConfigs });
108+
if (configs.length > 1) {
109+
conflicts.push({ tool, dir: relativeDir, configs });
122110
}
123111
}
124112

@@ -146,9 +134,9 @@ export function collectOxcConfigConflicts(
146134
/** Render one conflict as a user-facing line for the migration abort message. */
147135
export function formatOxcConfigConflict(conflict: OxcConfigConflict): string {
148136
const location = conflict.dir === '.' ? 'the project root' : conflict.dir;
149-
const files = [...conflict.jsonConfigs, ...conflict.dynamicConfigs]
150-
.map((file) => `\`${file}\``)
151-
.join(' and ');
137+
const quoted = conflict.configs.map((file) => `\`${file}\``);
138+
// `a and b` for the common pair, `a, b and c` once a directory holds more.
139+
const files = [quoted.slice(0, -1).join(', '), quoted.at(-1)].filter(Boolean).join(' and ');
152140
return `${location} has ${files}${conflict.tool} allows only one config per directory.`;
153141
}
154142

0 commit comments

Comments
 (0)