From 42a571f3b1b9674ea5af2bab99f2781023c2f5e9 Mon Sep 17 00:00:00 2001 From: MK Date: Mon, 15 Jun 2026 20:11:57 +0800 Subject: [PATCH 1/2] fix(create): preserve shorthand fmt/lint config keys vp create and vp migrate inject default fmt/lint blocks into the project's vite.config.ts, guarded by has_config_key. That check only matched `key: value` pairs, so a template that declares fmt/lint via shorthand properties (`fmt,` / `lint,`) was treated as not having them and got a duplicate inline key. Detect shorthand_property_identifier nodes in has_config_key so a shorthand-declared key counts as present. Closes #1836 --- crates/vite_migration/src/vite_config.rs | 74 +++++++++++++++++-- .../package.json | 5 ++ .../packages/starter-template/bin/index.mjs | 46 ++++++++++++ .../packages/starter-template/package.json | 8 ++ .../pnpm-workspace.yaml | 2 + .../snap.txt | 36 +++++++++ .../steps.json | 8 ++ .../vite.config.ts | 13 ++++ .../package.json | 9 +++ .../snap.txt | 22 ++++++ .../steps.json | 8 ++ .../vite.config.ts | 14 ++++ .../src/migration/__tests__/migrator.spec.ts | 60 +++++++++++++++ 13 files changed, 297 insertions(+), 8 deletions(-) create mode 100644 packages/cli/snap-tests/create-monorepo-local-template-shorthand/package.json create mode 100644 packages/cli/snap-tests/create-monorepo-local-template-shorthand/packages/starter-template/bin/index.mjs create mode 100644 packages/cli/snap-tests/create-monorepo-local-template-shorthand/packages/starter-template/package.json create mode 100644 packages/cli/snap-tests/create-monorepo-local-template-shorthand/pnpm-workspace.yaml create mode 100644 packages/cli/snap-tests/create-monorepo-local-template-shorthand/snap.txt create mode 100644 packages/cli/snap-tests/create-monorepo-local-template-shorthand/steps.json create mode 100644 packages/cli/snap-tests/create-monorepo-local-template-shorthand/vite.config.ts create mode 100644 packages/cli/snap-tests/migration-inline-config-shorthand/package.json create mode 100644 packages/cli/snap-tests/migration-inline-config-shorthand/snap.txt create mode 100644 packages/cli/snap-tests/migration-inline-config-shorthand/steps.json create mode 100644 packages/cli/snap-tests/migration-inline-config-shorthand/vite.config.ts diff --git a/crates/vite_migration/src/vite_config.rs b/crates/vite_migration/src/vite_config.rs index dd10b6062b..8d413861fe 100644 --- a/crates/vite_migration/src/vite_config.rs +++ b/crates/vite_migration/src/vite_config.rs @@ -291,19 +291,25 @@ fn strip_schema_property(config: &str) -> Cow<'_, str> { /// duplicate keys are resolved at runtime by JS spread semantics. /// /// Returns `true` only when the key appears as a **direct** member of one of -/// those recognized object literals. Comments, string occurrences, nested -/// keys (e.g. `plugins: [{ fmt: ... }]`), and unrelated objects are all -/// ignored correctly. +/// those recognized object literals, either as a `key: value` pair or as a +/// `{ key }` shorthand property (e.g. a template wiring in tooling config with +/// `fmt,` / `lint,`). Comments, string occurrences, nested keys (e.g. +/// `plugins: [{ fmt: ... }]`), and unrelated objects are all ignored correctly. pub fn has_config_key(vite_config_content: &str, config_key: &str) -> Result { let grep = SupportLang::TypeScript.ast_grep(vite_config_content); let root = grep.root(); for node in root.dfs() { - if node.kind() != "pair" { - continue; - } - let Some(key_node) = node.field("key") else { continue }; - if !pair_key_matches(&key_node, config_key) { + // Match both `key: value` pairs and `{ key }` shorthand properties. A + // custom template that wires tooling config in via shorthand (`fmt,` / + // `lint,`) still declares the key, so it must not get a duplicate + // inline key injected by `vp create` / `vp lint --init`. See #1836. + let matches_key = match node.kind().as_ref() { + "pair" => node.field("key").is_some_and(|key| pair_key_matches(&key, config_key)), + "shorthand_property_identifier" => node.text() == config_key, + _ => continue, + }; + if !matches_key { continue; } let Some(parent_object) = node.parent() else { continue }; @@ -1062,6 +1068,58 @@ export default () => assert!(has_config_key(cfg, "fmt").unwrap()); } + #[test] + fn test_has_config_key_shorthand_property() { + // A custom template that keeps tooling config in separate modules wires + // them in with shorthand properties (`fmt,` / `lint,`). The key is + // present even though there is no explicit value, so `vp create` / + // `vp lint --init` must not inject a duplicate inline key. See #1836. + let cfg = r#"import { defineConfig } from 'vite-plus'; + +import { fmt } from './tooling/format'; +import { lint } from './tooling/lint'; + +export default defineConfig(({ mode }) => { + return { + server: { port: 3000 }, + fmt, + lint, + }; +}); +"#; + assert!(has_config_key(cfg, "fmt").unwrap()); + assert!(has_config_key(cfg, "lint").unwrap()); + assert!(!has_config_key(cfg, "pack").unwrap()); + assert!(!has_config_key(cfg, "staged").unwrap()); + } + + #[test] + fn test_has_config_key_shorthand_object_export() { + let cfg = r#"import { defineConfig } from 'vite-plus'; + +const fmt = { singleQuote: true }; + +export default defineConfig({ + fmt, +}); +"#; + assert!(has_config_key(cfg, "fmt").unwrap()); + assert!(!has_config_key(cfg, "lint").unwrap()); + } + + #[test] + fn test_has_config_key_ignores_nested_shorthand() { + // `fmt` shorthand is nested inside a plugin's options object, not a + // top-level config key, so it must not count as present. + let cfg = r#"import { defineConfig } from 'vite-plus'; + +export default defineConfig({ + plugins: [somePlugin({ fmt })], +}); +"#; + assert!(!has_config_key(cfg, "fmt").unwrap()); + } + #[test] fn test_has_config_key_fate_template_shape() { // Mirrors create-fate's drizzle template — the bug that motivated this fix. diff --git a/packages/cli/snap-tests/create-monorepo-local-template-shorthand/package.json b/packages/cli/snap-tests/create-monorepo-local-template-shorthand/package.json new file mode 100644 index 0000000000..3a31cc819d --- /dev/null +++ b/packages/cli/snap-tests/create-monorepo-local-template-shorthand/package.json @@ -0,0 +1,5 @@ +{ + "name": "local-template-monorepo-fixture", + "private": true, + "packageManager": "pnpm@10.0.0" +} diff --git a/packages/cli/snap-tests/create-monorepo-local-template-shorthand/packages/starter-template/bin/index.mjs b/packages/cli/snap-tests/create-monorepo-local-template-shorthand/packages/starter-template/bin/index.mjs new file mode 100644 index 0000000000..a5957ebafe --- /dev/null +++ b/packages/cli/snap-tests/create-monorepo-local-template-shorthand/packages/starter-template/bin/index.mjs @@ -0,0 +1,46 @@ +#!/usr/bin/env node +// Minimal local template generator. Writes a package that declares its `fmt` +// and `lint` config via shorthand properties, plus standalone Oxlint/Oxfmt +// config files. `vp create` must merge-skip the standalone files instead of +// injecting duplicate inline `fmt:`/`lint:` blocks into vite.config.ts (#1836). +import { mkdirSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; + +const args = process.argv.slice(2); +const dirFlag = args.indexOf('--directory'); +const dir = dirFlag !== -1 && args[dirFlag + 1] ? args[dirFlag + 1] : 'starter-app'; + +mkdirSync(dir, { recursive: true }); + +const write = (name, content) => writeFileSync(path.join(dir, name), content); + +write( + 'package.json', + `${JSON.stringify({ name: path.basename(dir), version: '0.0.0', private: true }, null, 2)}\n`, +); + +write( + 'vite.config.ts', + `import { defineConfig } from 'vite-plus'; + +import { fmt } from './tooling/format'; +import { lint } from './tooling/lint'; + +export default defineConfig(({ mode }) => { + return { + server: { port: 3000 }, + fmt, + lint, + }; +}); +`, +); + +write('.oxlintrc.json', `${JSON.stringify({ rules: {} }, null, 2)}\n`); +write('.oxfmtrc.json', `${JSON.stringify({}, null, 2)}\n`); + +mkdirSync(path.join(dir, 'tooling'), { recursive: true }); +writeFileSync(path.join(dir, 'tooling', 'format.ts'), 'export const fmt = { ignorePatterns: [] };\n'); +writeFileSync(path.join(dir, 'tooling', 'lint.ts'), 'export const lint = { rules: {} };\n'); + +console.log(`cloned starter-template to ${dir}`); diff --git a/packages/cli/snap-tests/create-monorepo-local-template-shorthand/packages/starter-template/package.json b/packages/cli/snap-tests/create-monorepo-local-template-shorthand/packages/starter-template/package.json new file mode 100644 index 0000000000..d1cf1ee8c0 --- /dev/null +++ b/packages/cli/snap-tests/create-monorepo-local-template-shorthand/packages/starter-template/package.json @@ -0,0 +1,8 @@ +{ + "name": "starter-template", + "version": "0.0.0", + "private": true, + "description": "A local starter template that wires fmt/lint via shorthand.", + "type": "module", + "bin": "./bin/index.mjs" +} diff --git a/packages/cli/snap-tests/create-monorepo-local-template-shorthand/pnpm-workspace.yaml b/packages/cli/snap-tests/create-monorepo-local-template-shorthand/pnpm-workspace.yaml new file mode 100644 index 0000000000..924b55f42e --- /dev/null +++ b/packages/cli/snap-tests/create-monorepo-local-template-shorthand/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +packages: + - packages/* diff --git a/packages/cli/snap-tests/create-monorepo-local-template-shorthand/snap.txt b/packages/cli/snap-tests/create-monorepo-local-template-shorthand/snap.txt new file mode 100644 index 0000000000..be01777355 --- /dev/null +++ b/packages/cli/snap-tests/create-monorepo-local-template-shorthand/snap.txt @@ -0,0 +1,36 @@ +> vp create starter --no-interactive --no-agent -- --directory my-app # run the local create.templates entry; generated pkg declares fmt/lint via shorthand + +Generating project… + +Running: node /packages/starter-template/bin/index.mjs --directory my-app +cloned starter-template to my-app + +Monorepo integration... + +lint config already present in packages/my-app/vite.config.ts — removed redundant packages/my-app/.oxlintrc.json + +fmt config already present in packages/my-app/vite.config.ts — removed redundant packages/my-app/.oxfmtrc.json + +Formatting code... + +Code formatted +◇ Scaffolded packages/my-app +• Node pnpm +→ Next: cd packages/my-app && vp run + +> cat packages/my-app/vite.config.ts # fmt/lint stay shorthand only, no injected duplicate inline fmt:/lint: blocks (#1836) +import { defineConfig } from "vite-plus"; + +import { fmt } from "./tooling/format"; +import { lint } from "./tooling/lint"; + +export default defineConfig(({ mode }) => { + return { + server: { port: 3000 }, + fmt, + lint, + }; +}); + +> test ! -f packages/my-app/.oxlintrc.json # standalone lint config merge-skipped and removed +> test ! -f packages/my-app/.oxfmtrc.json # standalone fmt config merge-skipped and removed \ No newline at end of file diff --git a/packages/cli/snap-tests/create-monorepo-local-template-shorthand/steps.json b/packages/cli/snap-tests/create-monorepo-local-template-shorthand/steps.json new file mode 100644 index 0000000000..8ff5b63451 --- /dev/null +++ b/packages/cli/snap-tests/create-monorepo-local-template-shorthand/steps.json @@ -0,0 +1,8 @@ +{ + "commands": [ + "vp create starter --no-interactive --no-agent -- --directory my-app # run the local create.templates entry; generated pkg declares fmt/lint via shorthand", + "cat packages/my-app/vite.config.ts # fmt/lint stay shorthand only, no injected duplicate inline fmt:/lint: blocks (#1836)", + "test ! -f packages/my-app/.oxlintrc.json # standalone lint config merge-skipped and removed", + "test ! -f packages/my-app/.oxfmtrc.json # standalone fmt config merge-skipped and removed" + ] +} diff --git a/packages/cli/snap-tests/create-monorepo-local-template-shorthand/vite.config.ts b/packages/cli/snap-tests/create-monorepo-local-template-shorthand/vite.config.ts new file mode 100644 index 0000000000..e503dd8a6d --- /dev/null +++ b/packages/cli/snap-tests/create-monorepo-local-template-shorthand/vite.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from 'vite-plus'; + +export default defineConfig({ + create: { + templates: [ + { + name: 'starter', + description: 'A local starter template that wires fmt/lint via shorthand.', + template: './packages/starter-template', + }, + ], + }, +}); diff --git a/packages/cli/snap-tests/migration-inline-config-shorthand/package.json b/packages/cli/snap-tests/migration-inline-config-shorthand/package.json new file mode 100644 index 0000000000..f8effa758b --- /dev/null +++ b/packages/cli/snap-tests/migration-inline-config-shorthand/package.json @@ -0,0 +1,9 @@ +{ + "name": "migration-inline-config-shorthand", + "version": "0.0.0", + "private": true, + "devDependencies": { + "oxfmt": "1", + "oxlint": "1" + } +} diff --git a/packages/cli/snap-tests/migration-inline-config-shorthand/snap.txt b/packages/cli/snap-tests/migration-inline-config-shorthand/snap.txt new file mode 100644 index 0000000000..339db5a196 --- /dev/null +++ b/packages/cli/snap-tests/migration-inline-config-shorthand/snap.txt @@ -0,0 +1,22 @@ +> vp migrate --no-interactive --no-hooks 2>&1 # must NOT duplicate fmt/lint already declared as shorthand properties (#1836) +◇ Migrated . to Vite+ +• Node pnpm + +> cat vite.config.ts # fmt/lint stay as shorthand only, no injected inline fmt:/lint: blocks +import { defineConfig } from 'vite-plus'; + +// Mirrors a custom template that keeps tooling config in separate modules and +// wires them in with shorthand properties (`fmt,` / `lint,`). See #1836. +const fmt = { ignorePatterns: [] }; +const lint = { rules: {} }; + +export default defineConfig(({ mode }) => { + return { + server: { port: 3000 }, + fmt, + lint, + }; +}); + +> test ! -f .oxlintrc.json # no standalone lint config generated +> test ! -f .oxfmtrc.json # no standalone fmt config generated \ No newline at end of file diff --git a/packages/cli/snap-tests/migration-inline-config-shorthand/steps.json b/packages/cli/snap-tests/migration-inline-config-shorthand/steps.json new file mode 100644 index 0000000000..299732385a --- /dev/null +++ b/packages/cli/snap-tests/migration-inline-config-shorthand/steps.json @@ -0,0 +1,8 @@ +{ + "commands": [ + "vp migrate --no-interactive --no-hooks 2>&1 # must NOT duplicate fmt/lint already declared as shorthand properties (#1836)", + "cat vite.config.ts # fmt/lint stay as shorthand only, no injected inline fmt:/lint: blocks", + "test ! -f .oxlintrc.json # no standalone lint config generated", + "test ! -f .oxfmtrc.json # no standalone fmt config generated" + ] +} diff --git a/packages/cli/snap-tests/migration-inline-config-shorthand/vite.config.ts b/packages/cli/snap-tests/migration-inline-config-shorthand/vite.config.ts new file mode 100644 index 0000000000..dd1d182a33 --- /dev/null +++ b/packages/cli/snap-tests/migration-inline-config-shorthand/vite.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from 'vite-plus'; + +// Mirrors a custom template that keeps tooling config in separate modules and +// wires them in with shorthand properties (`fmt,` / `lint,`). See #1836. +const fmt = { ignorePatterns: [] }; +const lint = { rules: {} }; + +export default defineConfig(({ mode }) => { + return { + server: { port: 3000 }, + fmt, + lint, + }; +}); diff --git a/packages/cli/src/migration/__tests__/migrator.spec.ts b/packages/cli/src/migration/__tests__/migrator.spec.ts index 3963fc91bc..f74c700414 100644 --- a/packages/cli/src/migration/__tests__/migrator.spec.ts +++ b/packages/cli/src/migration/__tests__/migrator.spec.ts @@ -32,6 +32,8 @@ const { hasFrameworkShim, addFrameworkShim, injectCreateDefaultTemplate, + injectFmtDefaults, + injectLintTypeCheckDefaults, rewriteEslintPackageJson, detectIncompatibleEslintIntegration, preflightGitHooksSetup, @@ -2197,6 +2199,64 @@ describe('framework shim', () => { }); }); +// `vp create` / `vp migrate` inject default `lint`/`fmt` blocks into the +// scaffolded vite.config.ts. A custom template that already declares these +// keys via shorthand properties (`fmt,` / `lint,`, e.g. wiring in tooling +// modules) must be preserved verbatim, not get a duplicate inline key. See #1836. +describe('inject defaults — shorthand config keys', () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'vp-migrator-inject-shorthand-')); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + function writeShorthandViteConfig(): void { + fs.writeFileSync( + path.join(tmpDir, 'vite.config.ts'), + `import { defineConfig } from 'vite-plus'; + +import { fmt } from './tooling/format'; +import { lint } from './tooling/lint'; + +export default defineConfig(({ mode }) => { + return { + server: { port: 3000 }, + fmt, + lint, + }; +}); +`, + ); + } + + it('does not inject a duplicate `fmt` key when one exists as a shorthand property', () => { + writeShorthandViteConfig(); + const before = fs.readFileSync(path.join(tmpDir, 'vite.config.ts'), 'utf-8'); + + injectFmtDefaults(tmpDir, true); + + const after = fs.readFileSync(path.join(tmpDir, 'vite.config.ts'), 'utf-8'); + expect(after).toBe(before); + expect(after).not.toContain('fmt: {'); + }); + + it('does not inject a duplicate `lint` key when one exists as a shorthand property', () => { + writeShorthandViteConfig(); + const before = fs.readFileSync(path.join(tmpDir, 'vite.config.ts'), 'utf-8'); + + injectLintTypeCheckDefaults(tmpDir, true); + + const after = fs.readFileSync(path.join(tmpDir, 'vite.config.ts'), 'utf-8'); + expect(after).toBe(before); + expect(after).not.toContain('jsPlugins'); + expect(after).not.toContain('prefer-vite-plus-imports'); + }); +}); + describe('rewriteStandaloneProject — lazy plugin wrapping', () => { let tmpDir: string; From b16b9f3341a1165aff94ba066c172c3009fa8bab Mon Sep 17 00:00:00 2001 From: MK Date: Mon, 15 Jun 2026 20:45:42 +0800 Subject: [PATCH 2/2] chore(snap): format starter-template fixture files The pre-commit lint-staged glob does not cover .mjs/.json, so these two files in the new create-monorepo snap fixture were committed unformatted and failed `vp check`. --- .../packages/starter-template/bin/index.mjs | 5 ++++- .../packages/starter-template/package.json | 4 ++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/cli/snap-tests/create-monorepo-local-template-shorthand/packages/starter-template/bin/index.mjs b/packages/cli/snap-tests/create-monorepo-local-template-shorthand/packages/starter-template/bin/index.mjs index a5957ebafe..a71d230495 100644 --- a/packages/cli/snap-tests/create-monorepo-local-template-shorthand/packages/starter-template/bin/index.mjs +++ b/packages/cli/snap-tests/create-monorepo-local-template-shorthand/packages/starter-template/bin/index.mjs @@ -40,7 +40,10 @@ write('.oxlintrc.json', `${JSON.stringify({ rules: {} }, null, 2)}\n`); write('.oxfmtrc.json', `${JSON.stringify({}, null, 2)}\n`); mkdirSync(path.join(dir, 'tooling'), { recursive: true }); -writeFileSync(path.join(dir, 'tooling', 'format.ts'), 'export const fmt = { ignorePatterns: [] };\n'); +writeFileSync( + path.join(dir, 'tooling', 'format.ts'), + 'export const fmt = { ignorePatterns: [] };\n', +); writeFileSync(path.join(dir, 'tooling', 'lint.ts'), 'export const lint = { rules: {} };\n'); console.log(`cloned starter-template to ${dir}`); diff --git a/packages/cli/snap-tests/create-monorepo-local-template-shorthand/packages/starter-template/package.json b/packages/cli/snap-tests/create-monorepo-local-template-shorthand/packages/starter-template/package.json index d1cf1ee8c0..a13c9661b3 100644 --- a/packages/cli/snap-tests/create-monorepo-local-template-shorthand/packages/starter-template/package.json +++ b/packages/cli/snap-tests/create-monorepo-local-template-shorthand/packages/starter-template/package.json @@ -3,6 +3,6 @@ "version": "0.0.0", "private": true, "description": "A local starter template that wires fmt/lint via shorthand.", - "type": "module", - "bin": "./bin/index.mjs" + "bin": "./bin/index.mjs", + "type": "module" }