Skip to content

Commit 9f1d497

Browse files
fengmk2claude
andcommitted
feat(migration): support monorepo peerDependencies detection
Find the nearest package.json for each source file instead of only checking the root. This allows monorepos to have different packages with different peerDependencies - a vite plugin package can have vite in peerDependencies (imports preserved) while an app package without peerDependencies will have imports rewritten. - Add find_nearest_package_json() to walk up directories - Cache package.json lookups for performance - Add monorepo unit tests and snap-test 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 7cf7d15 commit 9f1d497

11 files changed

Lines changed: 286 additions & 25 deletions

File tree

.github/workflows/e2e-test.yml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,10 @@ on:
99
- main
1010
paths-ignore:
1111
- '**/*.md'
12+
pull_request:
13+
paths:
14+
- 'ecosystem-ci/**'
15+
- '.github/workflows/e2e-test.yml'
1216

1317
concurrency:
1418
group: ${{ github.workflow }}-${{ github.sha }}
@@ -88,6 +92,14 @@ jobs:
8892
vite run lint
8993
vite run type
9094
vite run test -- --coverage
95+
- name: vite-plugin-react
96+
node-version: 22
97+
command: |
98+
vite run format
99+
vite run lint -- --fix
100+
# TODO(fengmk2): run all builds and tests after tsdown version upgrade
101+
vite run @vitejs/plugin-rsc#build
102+
vite run @vitejs/plugin-rsc#test
91103
92104
steps:
93105
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2

crates/vite_migration/src/import_rewriter.rs

Lines changed: 171 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
1-
use std::path::{Path, PathBuf};
1+
use std::{
2+
collections::HashMap,
3+
path::{Path, PathBuf},
4+
};
25

36
use vite_error::Error;
47

@@ -289,12 +292,33 @@ impl SkipPackages {
289292
}
290293
}
291294

292-
/// Parse package.json at the root and check which packages are in peerDependencies.
293-
/// Returns default (no skipping) if package.json doesn't exist or can't be parsed.
294-
fn get_skip_packages_from_root(root: &Path) -> SkipPackages {
295-
let package_json_path = root.join("package.json");
295+
/// Find the nearest package.json by walking up from the file's directory.
296+
/// Stops at the root directory.
297+
fn find_nearest_package_json(file_path: &Path, root: &Path) -> Option<PathBuf> {
298+
let mut current = file_path.parent()?;
299+
300+
loop {
301+
let package_json = current.join("package.json");
302+
if package_json.exists() {
303+
return Some(package_json);
304+
}
305+
306+
// Stop if we've reached the root
307+
if current == root {
308+
break;
309+
}
296310

297-
let content = match std::fs::read_to_string(&package_json_path) {
311+
// Move to parent directory
312+
current = current.parent()?;
313+
}
314+
315+
None
316+
}
317+
318+
/// Parse package.json and check which packages are in peerDependencies.
319+
/// Returns default (no skipping) if package.json doesn't exist or can't be parsed.
320+
fn get_skip_packages_from_package_json(package_json_path: &Path) -> SkipPackages {
321+
let content = match std::fs::read_to_string(package_json_path) {
298322
Ok(c) => c,
299323
Err(_) => return SkipPackages::default(),
300324
};
@@ -374,16 +398,27 @@ pub fn rewrite_imports_in_directory(root: &Path) -> Result<BatchRewriteResult, E
374398
errors: Vec::new(),
375399
};
376400

377-
// Check package.json at root for peerDependencies
378-
let skip_packages = get_skip_packages_from_root(root);
379-
380-
// If all packages are in peerDeps, skip all files
381-
if skip_packages.all_skipped() {
382-
result.unchanged_files = walk_result.files;
383-
return Ok(result);
384-
}
401+
// Cache package.json lookups to avoid re-reading the same file
402+
let mut skip_packages_cache: HashMap<PathBuf, SkipPackages> = HashMap::new();
385403

386404
for file_path in walk_result.files {
405+
// Find the nearest package.json for this file
406+
let skip_packages =
407+
if let Some(package_json_path) = find_nearest_package_json(&file_path, root) {
408+
skip_packages_cache
409+
.entry(package_json_path.clone())
410+
.or_insert_with(|| get_skip_packages_from_package_json(&package_json_path))
411+
.clone()
412+
} else {
413+
SkipPackages::default()
414+
};
415+
416+
// If all packages are in peerDeps for this file's package, skip it
417+
if skip_packages.all_skipped() {
418+
result.unchanged_files.push(file_path);
419+
continue;
420+
}
421+
387422
match rewrite_import(&file_path, &skip_packages) {
388423
Ok(rewrite_result) => {
389424
if rewrite_result.updated {
@@ -1675,7 +1710,7 @@ export default defineConfig({});"#;
16751710
}
16761711

16771712
#[test]
1678-
fn test_get_skip_packages_from_root_with_vite_peer_dep() {
1713+
fn test_get_skip_packages_from_package_json_with_vite_peer_dep() {
16791714
use std::fs;
16801715

16811716
let temp = tempdir().unwrap();
@@ -1687,16 +1722,17 @@ export default defineConfig({});"#;
16871722
"vite": "^5.0.0"
16881723
}
16891724
}"#;
1690-
fs::write(temp.path().join("package.json"), pkg_json).unwrap();
1725+
let package_json_path = temp.path().join("package.json");
1726+
fs::write(&package_json_path, pkg_json).unwrap();
16911727

1692-
let skip = get_skip_packages_from_root(temp.path());
1728+
let skip = get_skip_packages_from_package_json(&package_json_path);
16931729
assert!(skip.skip_vite);
16941730
assert!(!skip.skip_vitest);
16951731
assert!(!skip.skip_tsdown);
16961732
}
16971733

16981734
#[test]
1699-
fn test_get_skip_packages_from_root_with_all_peer_deps() {
1735+
fn test_get_skip_packages_from_package_json_with_all_peer_deps() {
17001736
use std::fs;
17011737

17021738
let temp = tempdir().unwrap();
@@ -1709,17 +1745,18 @@ export default defineConfig({});"#;
17091745
"tsdown": "^1.0.0"
17101746
}
17111747
}"#;
1712-
fs::write(temp.path().join("package.json"), pkg_json).unwrap();
1748+
let package_json_path = temp.path().join("package.json");
1749+
fs::write(&package_json_path, pkg_json).unwrap();
17131750

1714-
let skip = get_skip_packages_from_root(temp.path());
1751+
let skip = get_skip_packages_from_package_json(&package_json_path);
17151752
assert!(skip.skip_vite);
17161753
assert!(skip.skip_vitest);
17171754
assert!(skip.skip_tsdown);
17181755
assert!(skip.all_skipped());
17191756
}
17201757

17211758
#[test]
1722-
fn test_get_skip_packages_from_root_no_peer_deps() {
1759+
fn test_get_skip_packages_from_package_json_no_peer_deps() {
17231760
use std::fs;
17241761

17251762
let temp = tempdir().unwrap();
@@ -1730,20 +1767,22 @@ export default defineConfig({});"#;
17301767
"vite": "^5.0.0"
17311768
}
17321769
}"#;
1733-
fs::write(temp.path().join("package.json"), pkg_json).unwrap();
1770+
let package_json_path = temp.path().join("package.json");
1771+
fs::write(&package_json_path, pkg_json).unwrap();
17341772

1735-
let skip = get_skip_packages_from_root(temp.path());
1773+
let skip = get_skip_packages_from_package_json(&package_json_path);
17361774
assert!(!skip.skip_vite);
17371775
assert!(!skip.skip_vitest);
17381776
assert!(!skip.skip_tsdown);
17391777
}
17401778

17411779
#[test]
1742-
fn test_get_skip_packages_from_root_no_package_json() {
1780+
fn test_get_skip_packages_from_package_json_no_file() {
17431781
let temp = tempdir().unwrap();
17441782

17451783
// No package.json created - should return default (no skipping)
1746-
let skip = get_skip_packages_from_root(temp.path());
1784+
let package_json_path = temp.path().join("package.json");
1785+
let skip = get_skip_packages_from_package_json(&package_json_path);
17471786
assert!(!skip.skip_vite);
17481787
assert!(!skip.skip_vitest);
17491788
assert!(!skip.skip_tsdown);
@@ -1826,4 +1865,111 @@ import { build } from 'tsdown';"#;
18261865
let content = fs::read_to_string(temp.path().join("index.ts")).unwrap();
18271866
assert_eq!(content, original_content);
18281867
}
1868+
1869+
#[test]
1870+
fn test_find_nearest_package_json() {
1871+
use std::fs;
1872+
1873+
let temp = tempdir().unwrap();
1874+
1875+
// Create monorepo structure
1876+
fs::create_dir_all(temp.path().join("packages/vite-plugin/src")).unwrap();
1877+
fs::create_dir_all(temp.path().join("packages/app/src")).unwrap();
1878+
1879+
// Root package.json (no peerDeps)
1880+
fs::write(temp.path().join("package.json"), r#"{"name": "monorepo"}"#).unwrap();
1881+
1882+
// vite-plugin package.json (has vite in peerDeps)
1883+
fs::write(
1884+
temp.path().join("packages/vite-plugin/package.json"),
1885+
r#"{"name": "vite-plugin", "peerDependencies": {"vite": "^5.0.0"}}"#,
1886+
)
1887+
.unwrap();
1888+
1889+
// app package.json (no peerDeps)
1890+
fs::write(temp.path().join("packages/app/package.json"), r#"{"name": "app"}"#).unwrap();
1891+
1892+
// Test finding package.json from vite-plugin/src/index.ts
1893+
let file_path = temp.path().join("packages/vite-plugin/src/index.ts");
1894+
let result = find_nearest_package_json(&file_path, temp.path());
1895+
assert_eq!(result, Some(temp.path().join("packages/vite-plugin/package.json")));
1896+
1897+
// Test finding package.json from app/src/index.ts
1898+
let file_path = temp.path().join("packages/app/src/index.ts");
1899+
let result = find_nearest_package_json(&file_path, temp.path());
1900+
assert_eq!(result, Some(temp.path().join("packages/app/package.json")));
1901+
1902+
// Test finding package.json from root level file
1903+
let file_path = temp.path().join("vite.config.ts");
1904+
let result = find_nearest_package_json(&file_path, temp.path());
1905+
assert_eq!(result, Some(temp.path().join("package.json")));
1906+
}
1907+
1908+
#[test]
1909+
fn test_rewrite_imports_monorepo_different_peer_deps() {
1910+
use std::fs;
1911+
1912+
let temp = tempdir().unwrap();
1913+
1914+
// Create monorepo structure
1915+
fs::create_dir_all(temp.path().join("packages/vite-plugin/src")).unwrap();
1916+
fs::create_dir_all(temp.path().join("packages/app/src")).unwrap();
1917+
1918+
// Root package.json (no peerDeps)
1919+
fs::write(temp.path().join("package.json"), r#"{"name": "monorepo"}"#).unwrap();
1920+
1921+
// vite-plugin package.json (has vite in peerDeps)
1922+
fs::write(
1923+
temp.path().join("packages/vite-plugin/package.json"),
1924+
r#"{"name": "vite-plugin", "peerDependencies": {"vite": "^5.0.0"}}"#,
1925+
)
1926+
.unwrap();
1927+
1928+
// app package.json (no peerDeps)
1929+
fs::write(temp.path().join("packages/app/package.json"), r#"{"name": "app"}"#).unwrap();
1930+
1931+
// vite-plugin source file with vite and vitest imports
1932+
fs::write(
1933+
temp.path().join("packages/vite-plugin/src/index.ts"),
1934+
r#"import { defineConfig } from 'vite';
1935+
import { describe } from 'vitest';
1936+
export default defineConfig({});"#,
1937+
)
1938+
.unwrap();
1939+
1940+
// app source file with vite and vitest imports
1941+
fs::write(
1942+
temp.path().join("packages/app/src/index.ts"),
1943+
r#"import { defineConfig } from 'vite';
1944+
import { describe } from 'vitest';
1945+
export default defineConfig({});"#,
1946+
)
1947+
.unwrap();
1948+
1949+
// Run the batch rewrite
1950+
let result = rewrite_imports_in_directory(temp.path()).unwrap();
1951+
1952+
// Both files should be modified
1953+
assert_eq!(result.modified_files.len(), 2);
1954+
1955+
// vite-plugin: vite NOT rewritten (has peerDep), vitest IS rewritten
1956+
let vite_plugin_content =
1957+
fs::read_to_string(temp.path().join("packages/vite-plugin/src/index.ts")).unwrap();
1958+
assert_eq!(
1959+
vite_plugin_content,
1960+
r#"import { defineConfig } from 'vite';
1961+
import { describe } from '@voidzero-dev/vite-plus/test';
1962+
export default defineConfig({});"#
1963+
);
1964+
1965+
// app: vite IS rewritten (no peerDep), vitest IS rewritten
1966+
let app_content =
1967+
fs::read_to_string(temp.path().join("packages/app/src/index.ts")).unwrap();
1968+
assert_eq!(
1969+
app_content,
1970+
r#"import { defineConfig } from '@voidzero-dev/vite-plus';
1971+
import { describe } from '@voidzero-dev/vite-plus/test';
1972+
export default defineConfig({});"#
1973+
);
1974+
}
18291975
}

ecosystem-ci/.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,4 @@ skeleton
33
rollipop
44
frm-stack
55
vue-mini
6+
vite-plugin-react

ecosystem-ci/repo.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,5 +23,10 @@
2323
"repository": "https://github.com/vue-mini/vue-mini.git",
2424
"branch": "master",
2525
"hash": "c51332662993dde44f665822bdea94cd0abf368b"
26+
},
27+
"vite-plugin-react": {
28+
"repository": "https://github.com/vitejs/vite-plugin-react.git",
29+
"branch": "main",
30+
"hash": "0d3912b73d3aa1dc8f64619c82b3dacb0769e49e"
2631
}
2732
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
{
2+
"name": "migration-monorepo-skip-vite-peer-dependency"
3+
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
{
2+
"name": "my-vite-plugin",
3+
"peerDependencies": {
4+
"vite": "^6.0.0"
5+
}
6+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { defineConfig, type Plugin } from 'vite';
2+
import { describe, it, expect } from 'vitest';
3+
4+
export function myVitePlugin(): Plugin {
5+
return {
6+
name: 'my-vite-plugin',
7+
configResolved(config) {
8+
console.log(config);
9+
},
10+
};
11+
}
12+
13+
describe('myVitePlugin', () => {
14+
it('should work', () => {
15+
expect(myVitePlugin()).toBeDefined();
16+
});
17+
});
18+
19+
export default defineConfig({
20+
plugins: [myVitePlugin()],
21+
});
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
packages:
2+
- packages/*

0 commit comments

Comments
 (0)