Skip to content

Commit b9624d4

Browse files
authored
feat: add migration command for global cli (#313)
### TL;DR Refactored the package script migration system to be more flexible and support additional use cases like lint-staged configurations. ### What changed? - Renamed `rewritePackageJsonScripts` to `rewriteScripts` with a more flexible API - Now accepts script content as a string instead of requiring file paths - Returns the updated content instead of writing to files directly - Returns `null` when no changes are needed - Added support for migrating lint-staged configurations in package.json and .lintstagedrc.json - Removed tokio dependency from vite_migration crate - Added comprehensive test coverage for the new functionality - Created a new migration command infrastructure in the global package - Added snap tests to verify lint-staged migration works correctly ### How to test? 1. Test with a project that has lint-staged configuration: ```bash # Create a project with lint-staged config mkdir test-project && cd test-project echo '{"lint-staged":{"*.js":["oxlint --fix"]}}' > package.json # Run migration vite migration ``` 2. Test with a standalone .lintstagedrc.json file: ```bash # Create a .lintstagedrc.json file echo '{"*.js":"oxlint --fix"}' > .lintstagedrc.json # Run migration vite migration ``` 3. Verify that: - The lint-staged configurations are properly updated - Both array and string formats are handled correctly - The migration preserves the original structure ### Why make this change? The previous implementation was limited to only migrating scripts in package.json and required file system access. This refactoring makes the script migration system more flexible and reusable across different contexts. By supporting lint-staged configurations, we ensure that pre-commit hooks are properly migrated to use the unified vite-plus commands. This is particularly important for maintaining code quality workflows during migration. The new design also follows better separation of concerns - the core migration logic doesn't need to know about file paths or perform I/O operations, making it easier to test and more composable.
1 parent 027dcff commit b9624d4

70 files changed

Lines changed: 2174 additions & 971 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

Cargo.lock

Lines changed: 0 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/vite_migration/Cargo.toml

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,7 @@ ast-grep-config = { workspace = true }
1111
ast-grep-core = { workspace = true }
1212
ast-grep-language = { workspace = true }
1313
serde_json = { workspace = true, features = ["preserve_order"] }
14-
tokio = { workspace = true, features = ["fs"] }
1514
vite_error = { workspace = true }
1615

17-
[dev-dependencies]
18-
tokio = { workspace = true, features = ["macros", "rt"] }
19-
2016
[lints]
2117
workspace = true

crates/vite_migration/src/lib.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
mod package;
22

3-
pub use package::rewrite_package_json_scripts;
3+
pub use package::rewrite_scripts;

crates/vite_migration/src/package.rs

Lines changed: 199 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,34 @@
1-
use std::path::Path;
2-
31
use ast_grep_config::{GlobalRules, RuleConfig, from_yaml_string};
42
use ast_grep_core::replacer::Replacer;
53
use ast_grep_language::{LanguageExt, SupportLang};
6-
use serde_json::Value;
7-
use tokio::fs;
4+
use serde_json::{Map, Value};
85
use vite_error::Error;
96

107
/// load script rules from yaml file
11-
async fn load_ast_grep_rules(yaml_path: &Path) -> Result<Vec<RuleConfig<SupportLang>>, Error> {
12-
let yaml = fs::read_to_string(yaml_path).await?;
8+
fn load_ast_grep_rules(yaml: &str) -> Result<Vec<RuleConfig<SupportLang>>, Error> {
139
let globals = GlobalRules::default();
1410
let rules: Vec<RuleConfig<SupportLang>> = from_yaml_string::<SupportLang>(&yaml, &globals)?;
1511
Ok(rules)
1612
}
1713

14+
// Marker to replace "cross-env " before ast-grep processing
15+
// Using a fake env var assignment that won't match our rules
16+
const CROSS_ENV_MARKER: &str = "__CROSS_ENV__=1 ";
17+
const CROSS_ENV_REPLACEMENT: &str = "cross-env ";
18+
1819
/// rewrite a single script command string using rules
1920
fn rewrite_script(script: &str, rules: &[RuleConfig<SupportLang>]) -> String {
20-
// current stores the current script text, and update it when the rule matches
21-
let mut current = script.to_string();
21+
// Only handle cross-env replacement if it's present in the script
22+
let has_cross_env = script.contains(CROSS_ENV_REPLACEMENT);
2223

24+
// Step 1: Replace "cross-env " with marker so ast-grep can see the actual commands
25+
let mut current = if has_cross_env {
26+
script.replace(CROSS_ENV_REPLACEMENT, CROSS_ENV_MARKER)
27+
} else {
28+
script.to_string()
29+
};
30+
31+
// Step 2: Process with ast-grep
2332
for rule in rules {
2433
// only handle bash rules
2534
if rule.language != SupportLang::Bash {
@@ -56,102 +65,100 @@ fn rewrite_script(script: &str, rules: &[RuleConfig<SupportLang>]) -> String {
5665
}
5766
}
5867

59-
current
68+
// Step 3: Replace marker back with "cross-env " (only if we replaced it)
69+
if has_cross_env { current.replace(CROSS_ENV_MARKER, CROSS_ENV_REPLACEMENT) } else { current }
6070
}
6171

62-
/// rewrite scripts in package.json using rules from rules_yaml_path
63-
pub async fn rewrite_package_json_scripts(
64-
package_json_path: &Path,
65-
rules_yaml_path: &Path,
66-
) -> Result<bool, Error> {
67-
let content = fs::read_to_string(package_json_path).await?;
68-
let mut json: Value = serde_json::from_str(&content)?;
69-
let rules = load_ast_grep_rules(rules_yaml_path).await?;
72+
/// rewrite scripts json content using rules from rules_yaml
73+
pub fn rewrite_scripts(scripts_json: &str, rules_yaml: &str) -> Result<Option<String>, Error> {
74+
let mut scripts: Map<String, Value> = serde_json::from_str(scripts_json)?;
75+
let rules = load_ast_grep_rules(rules_yaml)?;
7076

7177
let mut updated = false;
7278
// get scripts field (object)
73-
if let Some(scripts) = json.get_mut("scripts").and_then(Value::as_object_mut) {
74-
let keys: Vec<String> = scripts.keys().cloned().collect();
75-
for key in keys {
76-
if let Some(Value::String(script)) = scripts.get(&key) {
77-
let new_script = rewrite_script(script, &rules);
78-
if new_script != *script {
79+
80+
for value in scripts.values_mut() {
81+
if value.is_array() {
82+
// lint-staged scripts can be an array of strings
83+
// https://github.com/lint-staged/lint-staged?tab=readme-ov-file#packagejson-example
84+
if let Some(sub_scripts) = value.as_array_mut() {
85+
for sub_script in sub_scripts.iter_mut() {
86+
if sub_script.is_string()
87+
&& let Some(raw_script) = sub_script.as_str()
88+
{
89+
let new_script = rewrite_script(raw_script, &rules);
90+
if new_script != raw_script {
91+
updated = true;
92+
*sub_script = Value::String(new_script);
93+
}
94+
}
95+
}
96+
}
97+
} else if value.is_string() {
98+
if let Some(raw_script) = value.as_str() {
99+
let new_script = rewrite_script(raw_script, &rules);
100+
if new_script != raw_script {
79101
updated = true;
80-
scripts.insert(key.clone(), Value::String(new_script));
102+
*value = Value::String(new_script);
81103
}
82104
}
83105
}
84106
}
85107

86108
if updated {
87-
// write back to file
88-
let new_content = serde_json::to_string_pretty(&json)?;
89-
fs::write(package_json_path, new_content).await?;
109+
let new_content = serde_json::to_string_pretty(&scripts)?;
110+
Ok(Some(new_content))
111+
} else {
112+
Ok(None)
90113
}
91-
92-
Ok(updated)
93114
}
94115

95116
#[cfg(test)]
96117
mod tests {
97118
use super::*;
98119

99-
#[test]
100-
fn test_rewrite_script() {
101-
let yaml = r#"
102-
# vite => vite dev
120+
const RULES_YAML: &str = r#"
121+
# vite => vite dev (handles all cases: with/without env var prefix and flag args)
122+
# Match command_name to preserve env var prefix and arguments
123+
# Excludes subcommands like "vite build", "vite test", etc.
103124
---
104-
id: replace-vite-alone
125+
id: replace-vite
105126
language: bash
106127
rule:
107-
kind: command
108-
has:
109-
kind: command_name
110-
regex: '^vite$'
111-
not:
112-
has:
113-
kind: word
114-
field: argument
128+
kind: command_name
129+
regex: '^vite$'
130+
inside:
131+
kind: command
132+
not:
133+
# ignore non-flag arguments (subcommands like build, test, etc.)
134+
regex: 'vite\s+[^-]'
115135
fix: vite dev
116136
117-
# vite [OPTIONS] => vite dev [OPTIONS]
137+
# oxlint => vite lint (handles all cases: with/without env var prefix and args)
138+
# Match command_name to preserve env var prefix and arguments
118139
---
119-
id: replace-vite-with-args
140+
id: replace-oxlint
120141
language: bash
121-
severity: info
122142
rule:
123-
pattern: vite $$$ARGS
124-
not:
125-
# ignore non-flag arguments
126-
regex: 'vite\s+[^-]'
127-
fix: vite dev $$$ARGS
128-
129-
# oxlint => vite lint
130-
---
131-
id: replace-oxlint-alone
132-
language: bash
133-
rule:
134-
kind: command
135-
has:
136-
kind: command_name
137-
regex: '^oxlint$'
138-
not:
139-
has:
140-
kind: word
141-
field: argument
143+
kind: command_name
144+
regex: '^oxlint$'
142145
fix: vite lint
143146
144-
# oxlint [OPTIONS] => vite lint [OPTIONS]
147+
# vitest => vite test
145148
---
146-
id: replace-oxlint-with-args
149+
id: replace-vitest
147150
language: bash
148151
rule:
149-
pattern: oxlint $$$ARGS
150-
fix: vite lint $$$ARGS
151-
"#;
152+
kind: command_name
153+
regex: '^vitest$'
154+
fix: vite test
155+
"#;
156+
157+
#[test]
158+
fn test_rewrite_script() {
152159
let globals = GlobalRules::default();
153160
let rules: Vec<RuleConfig<SupportLang>> =
154-
from_yaml_string::<SupportLang>(&yaml, &globals).unwrap();
161+
from_yaml_string::<SupportLang>(&RULES_YAML, &globals).unwrap();
155162
// vite commands
156163
assert_eq!(rewrite_script("vite", &rules), "vite dev");
157164
assert_eq!(rewrite_script("vite dev", &rules), "vite dev");
@@ -217,6 +224,21 @@ fix: vite lint $$$ARGS
217224
),
218225
"if [ -f file.txt ]; then vite dev --port 3000 && npm run lint; fi"
219226
);
227+
// env variable commands
228+
assert_eq!(
229+
rewrite_script("NODE_ENV=test VITE_CJS_IGNORE_WARNING=true vite", &rules),
230+
"NODE_ENV=test VITE_CJS_IGNORE_WARNING=true vite dev"
231+
);
232+
assert_eq!(
233+
rewrite_script("FOO=bar vite --port 3000", &rules),
234+
"FOO=bar vite dev --port 3000"
235+
);
236+
// env variable with oxlint commands
237+
assert_eq!(rewrite_script("DEBUG=1 oxlint", &rules), "DEBUG=1 vite lint");
238+
assert_eq!(
239+
rewrite_script("NODE_ENV=test oxlint --type-aware", &rules),
240+
"NODE_ENV=test vite lint --type-aware"
241+
);
220242
// oxlint commands
221243
assert_eq!(rewrite_script("oxlint", &rules), "vite lint");
222244
assert_eq!(rewrite_script("oxlint --type-aware", &rules), "vite lint --type-aware");
@@ -230,4 +252,111 @@ fix: vite lint $$$ARGS
230252
"npm run type-check && vite lint --type-aware"
231253
);
232254
}
255+
256+
#[test]
257+
fn test_rewrite_package_json_scripts_success() {
258+
let package_json_scripts = r#"
259+
{
260+
"dev": "vite"
261+
}
262+
"#;
263+
let updated = rewrite_scripts(package_json_scripts, &RULES_YAML)
264+
.expect("failed to rewrite package.json scripts");
265+
assert!(updated.is_some());
266+
assert_eq!(
267+
updated.unwrap(),
268+
r#"
269+
{
270+
"dev": "vite dev"
271+
}
272+
"#
273+
.trim()
274+
);
275+
}
276+
277+
#[test]
278+
fn test_rewrite_package_json_scripts_with_env_variable_success() {
279+
let package_json_scripts = r#"
280+
{
281+
"dev:cjs": "VITE_CJS_IGNORE_WARNING=true vite",
282+
"lint": "VITE_CJS_IGNORE_WARNING=true FOO=bar oxlint --fix"
283+
}
284+
"#;
285+
let updated = rewrite_scripts(package_json_scripts, &RULES_YAML)
286+
.expect("failed to rewrite package.json scripts");
287+
assert!(updated.is_some());
288+
assert_eq!(
289+
updated.unwrap(),
290+
r#"
291+
{
292+
"dev:cjs": "VITE_CJS_IGNORE_WARNING=true vite dev",
293+
"lint": "VITE_CJS_IGNORE_WARNING=true FOO=bar vite lint --fix"
294+
}
295+
"#
296+
.trim()
297+
);
298+
}
299+
300+
#[test]
301+
fn test_rewrite_package_json_scripts_using_cross_env() {
302+
let package_json_scripts = r#"
303+
{
304+
"dev:cjs": "cross-env VITE_CJS_IGNORE_WARNING=true vite && cross-env FOO=bar vitest run",
305+
"lint": "cross-env VITE_CJS_IGNORE_WARNING=true FOO=bar oxlint --fix",
306+
"test": "vite build && cross-env FOO=bar vitest run && echo ' cross-env test done ' || echo ' cross-env test failed '"
307+
}
308+
"#;
309+
let updated = rewrite_scripts(package_json_scripts, &RULES_YAML)
310+
.expect("failed to rewrite package.json scripts");
311+
assert!(updated.is_some());
312+
assert_eq!(
313+
updated.unwrap(),
314+
r#"
315+
{
316+
"dev:cjs": "cross-env VITE_CJS_IGNORE_WARNING=true vite dev && cross-env FOO=bar vite test run",
317+
"lint": "cross-env VITE_CJS_IGNORE_WARNING=true FOO=bar vite lint --fix",
318+
"test": "vite build && cross-env FOO=bar vite test run && echo ' cross-env test done ' || echo ' cross-env test failed '"
319+
}
320+
"#
321+
.trim()
322+
);
323+
}
324+
325+
#[test]
326+
fn test_rewrite_package_json_scripts_lint_staged() {
327+
let package_json_scripts = r#"
328+
{
329+
"*.js": ["oxlint --fix --type-aware", "oxfmt --fix"],
330+
"*.ts": "oxfmt --fix"
331+
}
332+
"#;
333+
let updated = rewrite_scripts(package_json_scripts, &RULES_YAML)
334+
.expect("failed to rewrite package.json scripts");
335+
assert!(updated.is_some());
336+
assert_eq!(
337+
updated.unwrap(),
338+
r#"
339+
{
340+
"*.js": [
341+
"vite lint --fix --type-aware",
342+
"oxfmt --fix"
343+
],
344+
"*.ts": "oxfmt --fix"
345+
}
346+
"#
347+
.trim()
348+
);
349+
}
350+
351+
#[test]
352+
fn test_rewrite_package_json_scripts_no_update() {
353+
let package_json_scripts = r#"
354+
{
355+
"foo": "bar"
356+
}
357+
"#;
358+
let updated = rewrite_scripts(package_json_scripts, &RULES_YAML)
359+
.expect("failed to rewrite package.json scripts");
360+
assert!(updated.is_none());
361+
}
233362
}

packages/cli/binding/index.d.ts

Lines changed: 6 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -138,28 +138,25 @@ export interface PathAccess {
138138
}
139139

140140
/**
141-
* Rewrite package.json scripts using rules from rules_yaml_path
141+
* Rewrite scripts json content using rules from rules_yaml
142142
*
143143
* # Arguments
144144
*
145-
* * `package_json_path` - The path to the package.json file
146-
* * `rules_yaml_path` - The path to the ast-grep rules.yaml file
145+
* * `scripts_json` - The scripts section of the package.json file as a JSON string
146+
* * `rules_yaml` - The ast-grep rules.yaml as a YAML string
147147
*
148148
* # Returns
149149
*
150-
* * `updated` - Whether the package.json scripts were updated
150+
* * `updated` - The updated scripts section of the package.json file as a JSON string, or `null` if no updates were made
151151
*
152152
* # Example
153153
*
154154
* ```javascript
155-
* const updated = await rewritePackageJsonScripts("package.json", "rules.yaml");
155+
* const updated = rewriteScripts("scripts section json content here", "ast-grep rules yaml content here");
156156
* console.log(`Updated: ${updated}`);
157157
* ```
158158
*/
159-
export declare function rewritePackageJsonScripts(
160-
packageJsonPath: string,
161-
rulesYamlPath: string,
162-
): Promise<boolean>;
159+
export declare function rewriteScripts(scriptsJson: string, rulesYaml: string): string | null;
163160

164161
/**
165162
* Main entry point for the CLI, called from JavaScript.

0 commit comments

Comments
 (0)