Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 66 additions & 8 deletions crates/vite_migration/src/vite_config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<bool, Error> {
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,
Comment thread
fengmk2 marked this conversation as resolved.
Comment thread
fengmk2 marked this conversation as resolved.
Comment thread
fengmk2 marked this conversation as resolved.
_ => continue,
};
if !matches_key {
continue;
}
let Some(parent_object) = node.parent() else { continue };
Expand Down Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"name": "local-template-monorepo-fixture",
"private": true,
"packageManager": "pnpm@10.0.0"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
#!/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}`);
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"name": "starter-template",
"version": "0.0.0",
"private": true,
"description": "A local starter template that wires fmt/lint via shorthand.",
"bin": "./bin/index.mjs",
"type": "module"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
packages:
- packages/*
Original file line number Diff line number Diff line change
@@ -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 <cwd>/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 <semver> pnpm <semver>
→ 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
Original file line number Diff line number Diff line change
@@ -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"
]
}
Original file line number Diff line number Diff line change
@@ -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',
},
],
},
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"name": "migration-inline-config-shorthand",
"version": "0.0.0",
"private": true,
"devDependencies": {
"oxfmt": "1",
"oxlint": "1"
}
}
22 changes: 22 additions & 0 deletions packages/cli/snap-tests/migration-inline-config-shorthand/snap.txt
Original file line number Diff line number Diff line change
@@ -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 <semver> pnpm <semver>

> 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
Original file line number Diff line number Diff line change
@@ -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"
]
}
Original file line number Diff line number Diff line change
@@ -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,
};
});
60 changes: 60 additions & 0 deletions packages/cli/src/migration/__tests__/migrator.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ const {
hasFrameworkShim,
addFrameworkShim,
injectCreateDefaultTemplate,
injectFmtDefaults,
injectLintTypeCheckDefaults,
rewriteEslintPackageJson,
detectIncompatibleEslintIntegration,
preflightGitHooksSetup,
Expand Down Expand Up @@ -4722,6 +4724,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;

Expand Down
Loading