Skip to content

vp migrate adds a second vite-plus import when it rewrites a vite import #3430

Closed

Description

@fengmk2

Describe the bug

When vp migrate rewrites an import from vite to vite-plus, it does not merge it into an existing vite-plus import in the same file. The file then has two vite-plus imports, and import/no-duplicates reports an error.

Reproduction

package.json:

{
  "private": true,
  "type": "module",
  "devDependencies": {
    "vite": "npm:@voidzero-dev/vite-plus-core@1.5.0",
    "vite-plus": "1.5.0"
  }
}

vite.config.ts:

import type { Plugin } from 'vite'
import { defineConfig } from 'vite-plus'

const noop = (): Plugin => ({ name: 'noop' })

export default defineConfig({
  plugins: [noop()],
})

Run vp migrate --no-interactive with vite-plus 1.5.1.

Result

import type { Plugin } from "vite-plus";
import { defineConfig } from "vite-plus";

Expected

vp migrate merges the rewritten import into the existing one:

import { defineConfig, type Plugin } from "vite-plus";

Notes

The 1.6.0 release candidate behaves the same. In ecosystem testing, a project's vp check failed after the upgrade with import(no-duplicates): Module 'vite-plus' is imported more than once in this file.

Activity

  1. self-assigned this
    on Nov 11, 2026
  2. voidzero-bot commented on Nov 12, 2026

    @voidzero-bot
    Contributor
    • Reproduced: Yes
    • Exploration: Yes — View branch
    • Unit Test: Yes — crates/vp_migration/src/import_merge.rs and crates/vp_migration/src/import_rewriter.rs
    • Priority: P3: minor bug — This only affects files that already import from vite-plus alongside a vite import that migration rewrites. It leaves an import/no-duplicates lint failure, but users can manually merge the imports or rely on a linter autofix as a short-term workaround.

    The migration's import rewriter applies local ast-grep rules that replace one import specifier at a time. After rewriting import type { Plugin } from 'vite' to vite-plus, no later pass detects that the file now has two imports from vite-plus, so the declarations stay separate.

    I found a potential fix for this issue. The branch adds an oxc-based post-processing pass in crates/vp_migration/src/import_rewriter.rs that collapses adjacent duplicate imports after the ast-grep rewrites run, merging value and type-only named imports and emitting inline type specifiers when needed. It skips namespace imports, side-effect imports, imports with attributes, and conflicting bindings. The regression test uses the exact reproducer from the issue.

    Full Triage Report

    Triage Report for Issue #3430

    Reproduce

    Reproduced: yes

    After vp migrate --no-interactive, vite.config.ts contains two vite-plus imports instead of one merged import.

    Actual result:

    import type { Plugin } from "vite-plus";
    import { defineConfig } from "vite-plus";

    Expected result:

    import { defineConfig, type Plugin } from "vite-plus";

    Diagnose

    Code path

    The migration's import rewriting entry point is in crates/vp_migration/src/import_rewriter.rs:

    • rewrite_imports_in_directory_with_options (line 2284) walks all TS/JS files.
    • For each file it calls rewrite_import (line 2399).
    • rewrite_import calls rewrite_import_content_full (line 2480) which applies pre-loaded ast-grep rules via ast_grep::apply_loaded_rules.
    • For vite imports, the rewrite-vite-import rule (defined around line 21 of import_rewriter.rs) matches the string literal inside any import_statement whose value is exactly 'vite' or "vite" and replaces the specifier with vite-plus.

    Why duplicates are produced

    ast-grep rules are local transformations: they only see and rewrite one match at a time.
    They have no notion of other import statements in the same file.

    Given:

    import type { Plugin } from 'vite'
    import { defineConfig } from 'vite-plus'

    rewrite-vite-import rewrites the first string to "vite-plus", producing:

    import type { Plugin } from "vite-plus"
    import { defineConfig } from "vite-plus"

    There is no later pass that detects two imports from the same module and merges their named specifiers. vp fmt/oxfmt formats the code but does not merge duplicate import declarations, so the import/no-duplicates lint error remains.

    Suggested fix

    Add a post-processing step after the ast-grep rules have run (inside
    rewrite_import_content_full or just before returning from rewrite_import) that:

    1. Parses the rewritten source with an AST parser (e.g., oxc).
    2. Finds groups of import declarations that share the same source string.
    3. For each group, merges compatible imports:
      • Combine named imports into one declaration.
      • Preserve a default import if only one default exists per source.
      • Preserve side-effect imports separately.
      • Preserve namespace imports separately (do not merge with named imports).
      • When merging a pure type-only import with a value import, emit inline type specifiers (e.g., import { defineConfig, type Plugin } from "vite-plus").

    Edge cases

    • Separate import type + value imports from the same module should collapse into one value import with inline type specifiers.
    • Default imports, namespace imports (import * as ...), and side-effect imports should not be merged with named imports.
    • Comments attached to the removed import declarations should ideally be preserved (attached to the merged import or kept as a leading comment).
    • The same problem can happen for vitest → vite-plus/test rewrites if both vitest and vite-plus/test imports exist in one file.
    • The problem can happen for tsdown → vite-plus/pack rewrites as well.

    Confidence

    high

    Verify

    Claim

    Observed: vp migrate rewrites import type { Plugin } from 'vite' to
    import type { Plugin } from "vite-plus" in a file that already contains
    import { defineConfig } from "vite-plus", leaving two imports from the same
    module.

    Expected: the migrated file has a single merged import such as
    import { defineConfig, type Plugin } from "vite-plus".

    Research

    • Documentation (docs/guide/migrate.md) says migration "Rewrites imports where needed" but does not state that duplicate imports are intentionally left unmerged or that they will be merged.
    • crates/vp_migration/src/import_rewriter.rs uses local ast-grep rules that replace a single specifier string per match. There is no post-processing pass that merges import declarations.
    • No tests in crates/vp_migration/src/import_rewriter.rs cover a pre-existing vite-plus import alongside a vite import.
    • packages/cli/src/oxlint-plugin.ts mirrors the migration's specifier rewrites but also does not merge duplicate declarations.
    • vp fmt/oxfmt formats the file but does not merge duplicate imports.
    • A GitHub search for "duplicate import" / "no-duplicates" did not find prior maintainers stating that this behavior is intentional.

    Verdict

    bug

    The migration produces code that violates common lint rules (import/no-duplicates) which the recommended post-migration workflow (vp check) can run against. There is no documented or tested intent to leave such duplicates unmerged, and the merge is semantically safe (import type { Plugin } ≡ inline type Plugin).

    Confidence

    high

    Fix

    Changed files

    • crates/vp_migration/src/import_merge.rs (new)

      • Post-processes rewritten source to merge adjacent import declarations that now reference the same module specifier.
      • Uses the oxc parser already available in the crate.
      • Handles named imports (including inline type specifiers) and a single default import.
      • Skips namespace imports, side-effect imports, import attributes, multiple default imports, and conflicting local/imported bindings.
    • crates/vp_migration/src/import_rewriter.rs

      • Calls import_merge::merge_duplicate_imports after the ast-grep rewrites.
      • Only runs the merge step when the ast-grep rules actually changed the file.
      • Updated two existing oxlint-plugin rewrite tests whose expected outputs now reflect the merged result.
      • Added regression test test_rewrite_import_content_vite_plus_duplicate_merge using the exact input from the issue.
    • crates/vp_migration/src/lib.rs

      • Declared the new import_merge module.

    Rationale

    The ast-grep rules are intentionally local: each rule matches one occurrence of a string literal and rewrites it. Adding import merging to ast-grep rules is not practical. A small oxc-based post-processing pass keeps the migration's actual rewrites unchanged while collapsing the duplicate import statements those rewrites sometimes produce.

    Validation

    • cargo test -p vp_migration — all 417 tests pass.
    • cargo fmt -p vp_migration -- --check — clean.
    • cargo clippy -p vp_migration --all-targets — clean.
    • NAPI binding smoke test calling rewriteImportsInDirectory on the reproduction file produced:
    import { defineConfig, type Plugin } from 'vite-plus';
    
    const noop = (): Plugin => ({ name: 'noop' })
    
    export default defineConfig({
      plugins: [noop()],
    })

    Regression tests

    New tests added directly exercise the merge pass:

    • import_merge::tests::merges_value_and_type_only_vite_plus_imports
    • import_merge::tests::merges_two_value_imports_from_same_source
    • import_merge::tests::merges_two_type_only_imports
    • import_merge::tests::does_not_merge_namespace_imports
    • import_merge::tests::does_not_merge_default_with_type_only_decl
    • import_merge::tests::does_not_merge_different_sources
    • import_merge::tests::does_not_merge_conflicting_local_bindings

    End-to-end regression test in import_rewriter::tests:

    • test_rewrite_import_content_vite_plus_duplicate_merge

    Skipped checks and notes

    • The full CLI vp migrate reproduction could not be completed because the workspace node_modules are missing oxfmt/oxlint in this environment, causing the migration's format/editor step to fail before/after the fix. The bug and fix are verified at the Rust unit-test level and through the NAPI binding smoke test.
    • pnpm -F vite-plus build could not be run because pnpm-lock.yaml is out of sync with package.json in this environment; the binding was rebuilt directly via cargo build --features rolldown,oxc-node in packages/cli/binding.

    This report was made by an LLM. The analysis may be wrong, and the potential fix might not work, but is intended as a starting point for exploring the issue.

    I've opened a pull request with this fix: #3439

  3. added a commit that references this issue on Nov 12, 2026
    fc32837
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

Type

No type

Fields

Priority

None yet

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions