From a60caf4e8b7d60264df91384d8b06e882b42411b Mon Sep 17 00:00:00 2001 From: MK Date: Tue, 20 Oct 2026 14:42:16 +0800 Subject: [PATCH 1/3] refactor(migrate): reuse native literal shell command parsing --- crates/vp_migration/src/lib.rs | 2 + crates/vp_migration/src/shell_command.rs | 217 ++++++++++++++++++ packages/cli/binding/index.cjs | 1 + packages/cli/binding/index.d.cts | 17 ++ packages/cli/binding/src/migration.rs | 43 ++++ .../__tests__/setup-plan-git-hooks.spec.ts | 4 + .../vitest-v5-benchmark-output.spec.ts | 9 + .../src/utils/__tests__/shell-command.spec.ts | 88 +++++++ packages/cli/src/utils/shell-command.ts | 107 +-------- 9 files changed, 388 insertions(+), 100 deletions(-) create mode 100644 crates/vp_migration/src/shell_command.rs create mode 100644 packages/cli/src/utils/__tests__/shell-command.spec.ts diff --git a/crates/vp_migration/src/lib.rs b/crates/vp_migration/src/lib.rs index 5b350c8644..250147e70f 100644 --- a/crates/vp_migration/src/lib.rs +++ b/crates/vp_migration/src/lib.rs @@ -14,6 +14,7 @@ mod pack_config; mod package; mod prettier; mod script_rewrite; +mod shell_command; mod source_analysis; mod task_cache; mod vite_config; @@ -24,6 +25,7 @@ pub use import_rewriter::{ rewrite_imports_in_directory_with_options, }; pub use package::{rewrite_eslint, rewrite_prettier, rewrite_scripts}; +pub use shell_command::{LiteralCommandToken, parse_literal_commands}; pub use source_analysis::analyze_migration_source; pub use task_cache::{TaskCacheMigrationResult, migrate_task_cache_config}; pub use vite_config::{ diff --git a/crates/vp_migration/src/shell_command.rs b/crates/vp_migration/src/shell_command.rs new file mode 100644 index 0000000000..dee41fffca --- /dev/null +++ b/crates/vp_migration/src/shell_command.rs @@ -0,0 +1,217 @@ +use std::ops::Range; + +use ast_grep_core::{Node, tree_sitter::StrDoc}; +use ast_grep_language::{LanguageExt, SupportLang}; + +/// A decoded shell word and its original UTF-8 byte range, including quotes. +#[derive(Debug, PartialEq, Eq)] +pub struct LiteralCommandToken { + pub value: String, + pub range: Range, +} + +type BashNode<'a> = Node<'a, StrDoc>; + +/// Read the restricted command syntax used by migrations, without evaluating +/// shell state. Assignments and package-manager wrappers remain in argv so each +/// caller can apply its own policy. +/// +/// Use the same Bash AST as source-range script rewrites. Unlike brush-parser's +/// command formatter, this retains source spelling and byte ranges directly. +/// Only simple commands separated by `&&`, `||`, or `;` are supported when +/// `allow_lists` is true. More Bash syntax must not imply more rewrite authority. +pub fn parse_literal_commands( + source: &str, + allow_lists: bool, +) -> Option>> { + // Preserve the existing restriction on escapes, even in single quotes. + if source.contains('\\') { + return None; + } + let ast = SupportLang::Bash.ast_grep(source); + let root = ast.root(); + if root.dfs().any(|node| node.is_error() || node.is_missing()) { + return None; + } + let mut nodes = Vec::new(); + collect_commands(root, &mut nodes)?; + if nodes.is_empty() { + return (!allow_lists && is_spacing(source)).then(|| vec![vec![]]); + } + if !allow_lists && nodes.len() != 1 { + return None; + } + let mut commands = Vec::new(); + let mut end = 0; + for node in nodes { + let range = node.range(); + let gap = &source[end..range.start]; + if commands.is_empty() { + if !is_spacing(gap) { + return None; + } + } else if !matches!(gap.trim_matches([' ', '\t']), "&&" | "||" | ";") { + return None; + } + let mut tokens = Vec::new(); + let mut word_end = range.start; + let words = if node.kind() == "variable_assignment" { + vec![node.clone()] + } else { + node.children().collect() + }; + for word in words { + let range = word.range(); + if !is_spacing(&source[word_end..range.start]) { + return None; + } + let value = literal_value(&word, allow_lists)?; + word_end = range.end; + tokens.push(LiteralCommandToken { value, range }); + } + if tokens.is_empty() { + return None; + } + commands.push(tokens); + end = range.end; + } + // Reject comments, newlines and trailing separators, including a single + // command followed by `;`, even when the parser accepts them. + is_spacing(&source[end..]).then_some(commands) +} + +fn is_spacing(source: &str) -> bool { + source.chars().all(|c| matches!(c, ' ' | '\t')) +} + +fn collect_commands<'a>(node: BashNode<'a>, commands: &mut Vec>) -> Option<()> { + match node.kind().as_ref() { + "program" | "list" => { + for child in node.children() { + collect_commands(child, commands)?; + } + } + // An assignment without an executable is still returned to the caller, + // which decides whether it can reason about the resulting shell state. + "command" | "variable_assignments" | "variable_assignment" => commands.push(node), + "&&" | "||" | ";" => {} + _ => return None, + } + Some(()) +} + +fn literal_value(node: &BashNode<'_>, reject_hash: bool) -> Option { + let text = node.text(); + match node.kind().as_ref() { + "word" | "number" | "variable_name" | "=" => { + if text.contains(['$', '`', '\r', '\n']) || (reject_hash && text.contains('#')) { + return None; + } + Some(text.into_owned()) + } + "raw_string" => Some(text.strip_prefix('\'')?.strip_suffix('\'')?.to_owned()), + "string" => { + // Read the original interior: AST string_content nodes can omit + // literal newlines. Expansions inside double quotes remain unsafe. + if node + .children() + .any(|child| !matches!(child.kind().as_ref(), "\"" | "string_content")) + || text.contains(['$', '`']) + { + return None; + } + Some(text.strip_prefix('"')?.strip_suffix('"')?.to_owned()) + } + "command_name" | "concatenation" | "variable_assignment" => { + let mut value = String::new(); + for child in node.children() { + value.push_str(&literal_value(&child, reject_hash)?); + } + Some(value) + } + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::parse_literal_commands; + + #[test] + fn reads_literals_assignments_and_lists_with_original_ranges() { + let source = " echo '你好🦀; done' && MODE=\"unit && extra\" pnpm exec v\"itest\" --project='unit*'; vp test"; + let commands = parse_literal_commands(source, true).unwrap(); + let values: Vec> = commands + .iter() + .map(|command| command.iter().map(|token| token.value.as_str()).collect()) + .collect(); + assert_eq!( + values, + [ + vec!["echo", "你好🦀; done"], + vec!["MODE=unit && extra", "pnpm", "exec", "vitest", "--project=unit*"], + vec!["vp", "test"], + ] + ); + assert_eq!(&source[commands[0][1].range.clone()], "'你好🦀; done'"); + assert_eq!(&source[commands[1][3].range.clone()], "v\"itest\""); + assert_eq!(&source[commands[1][4].range.clone()], "--project='unit*'"); + assert_eq!(&source[commands[2][0].range.clone()], "vp"); + } + + #[test] + fn preserves_literal_quoted_text_and_empty_arguments() { + let source = "vp\t'config' \"\" '$HOME `cmd` && # hi' \"one\ntwo\""; + let commands = parse_literal_commands(source, false).unwrap(); + let values: Vec<&str> = commands[0].iter().map(|token| token.value.as_str()).collect(); + assert_eq!(values, ["vp", "config", "", "$HOME `cmd` && # hi", "one\ntwo"]); + } + + #[test] + fn rejects_unsupported_shell_syntax() { + for source in [ + "vp config | tee log", + "vp config & echo done", + "vp config > log", + "vp config < input", + "vp config <; } +/** A literal argument with its original JavaScript UTF-16 source range. */ +export interface LiteralCommandToken { + value: string; + start: number; + end: number; +} + /** * Merge JSON configuration file into vite config file * @@ -3849,6 +3856,16 @@ export type ParseHooksArgsOutcome = | { status: 'exit'; code: number } | { status: 'error'; error: CliParseError }; +/** + * Read simple literal commands, optionally separated by `&&`, `||`, or `;`. + * Returns null for unsupported syntax. Assignments and wrappers stay in argv; + * callers must decide whether a command is safe to rewrite. + */ +export declare function parseLiteralCommands( + source: string, + allowLists: boolean, +): Array> | null; + export declare function parseMigrateArgs(argv: Array): ParseMigrateArgsOutcome; export type ParseMigrateArgsOutcome = diff --git a/packages/cli/binding/src/migration.rs b/packages/cli/binding/src/migration.rs index 8578a9cd0b..aa0901e775 100644 --- a/packages/cli/binding/src/migration.rs +++ b/packages/cli/binding/src/migration.rs @@ -3,6 +3,49 @@ use std::path::Path; use napi::{anyhow, bindgen_prelude::*}; use napi_derive::napi; +/// A literal argument with its original JavaScript UTF-16 source range. +#[napi(object)] +pub struct LiteralCommandToken { + pub value: String, + pub start: u32, + pub end: u32, +} + +/// Read simple literal commands, optionally separated by `&&`, `||`, or `;`. +/// Returns null for unsupported syntax. Assignments and wrappers stay in argv; +/// callers must decide whether a command is safe to rewrite. +#[napi] +pub fn parse_literal_commands( + source: String, + allow_lists: bool, +) -> Option>> { + let commands = vp_migration::parse_literal_commands(&source, allow_lists)?; + let mut byte_offset = 0; + let mut utf16_offset = 0; + // Ranges arrive in source order. Convert incrementally so non-ASCII scripts + // take linear time, including characters represented by surrogate pairs. + let mut to_utf16 = |offset| { + utf16_offset += source[byte_offset..offset].encode_utf16().count(); + byte_offset = offset; + u32::try_from(utf16_offset).ok() + }; + commands + .into_iter() + .map(|command| { + command + .into_iter() + .map(|token| { + Some(LiteralCommandToken { + value: token.value, + start: to_utf16(token.range.start)?, + end: to_utf16(token.range.end)?, + }) + }) + .collect() + }) + .collect() +} + /// Parse source and resolve lexical bindings for the TypeScript migration rules. #[napi] pub fn analyze_migration_source(filename: String, source: String) -> Result { diff --git a/packages/cli/src/migration/__tests__/setup-plan-git-hooks.spec.ts b/packages/cli/src/migration/__tests__/setup-plan-git-hooks.spec.ts index d4a98ae2c9..f3f0340042 100644 --- a/packages/cli/src/migration/__tests__/setup-plan-git-hooks.spec.ts +++ b/packages/cli/src/migration/__tests__/setup-plan-git-hooks.spec.ts @@ -325,6 +325,10 @@ describe('collectMigrationSetupPlan existing Vite+ hooks', () => { it.each([ ['vp config --no-hooks', 'vp config'], ['tsc -b && vp config --no-hooks', 'tsc -b && vp config'], + [ + 'echo "你好🦀" && pnpm exec vp config "--no-hooks" && echo "café"', + 'echo "你好🦀" && pnpm exec vp config && echo "café"', + ], [ 'echo "build && --no-hooks" && vp "config" "--no-hooks" && echo --no-hooks', 'echo "build && --no-hooks" && vp "config" && echo --no-hooks', diff --git a/packages/cli/src/migration/__tests__/vitest-v5-benchmark-output.spec.ts b/packages/cli/src/migration/__tests__/vitest-v5-benchmark-output.spec.ts index 26535e36f5..27926dd1ba 100644 --- a/packages/cli/src/migration/__tests__/vitest-v5-benchmark-output.spec.ts +++ b/packages/cli/src/migration/__tests__/vitest-v5-benchmark-output.spec.ts @@ -201,6 +201,15 @@ describe('Vitest v5 benchmark JSON command migration', () => { expect(result.findings).toEqual([]); }); + it('preserves UTF-16 positions around non-ASCII benchmark arguments', () => { + const input = `vitest bench '性能🦀' --outputJson "报告🦀.json" --run`; + const result = migrateVitestV5Command('package.json', input, true); + expect(result.content).toBe( + `vitest bench '性能🦀' --reporter=default --reporter=json --outputFile="报告🦀.json" --run`, + ); + expect(result.findings).toEqual([]); + }); + it.each([ 'vitest bench --outputJson=$OUTPUT', 'vitest bench --outputJson *.json', diff --git a/packages/cli/src/utils/__tests__/shell-command.spec.ts b/packages/cli/src/utils/__tests__/shell-command.spec.ts new file mode 100644 index 0000000000..e2c21c2374 --- /dev/null +++ b/packages/cli/src/utils/__tests__/shell-command.spec.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from 'vitest'; + +import { parseLiteralCommands } from '../../../binding/index.js'; +import { getCommandStartIndex, literalArgv, literalCommands } from '../shell-command.ts'; + +describe('native literal command analysis', () => { + it('returns UTF-16 ranges into the original source, including quoted words', () => { + const source = ` echo '你好🦀' && MODE="café" pnpm exec v"p" config "--no-hooks" || echo 'e\u0301'`; + const commands = parseLiteralCommands(source, true)!; + expect(commands.map((tokens) => tokens.map(({ value }) => value))).toEqual([ + ['echo', '你好🦀'], + ['MODE=café', 'pnpm', 'exec', 'vp', 'config', '--no-hooks'], + ['echo', 'e\u0301'], + ]); + expect( + commands.map((tokens) => tokens.map(({ start, end }) => source.slice(start, end))), + ).toEqual([ + ['echo', "'你好🦀'"], + ['MODE="café"', 'pnpm', 'exec', 'v"p"', 'config', '"--no-hooks"'], + ['echo', "'e\u0301'"], + ]); + const flag = commands[1].at(-1)!; + expect(source.slice(0, flag.start) + '--hooks' + source.slice(flag.end)).toBe( + ` echo '你好🦀' && MODE="café" pnpm exec v"p" config --hooks || echo 'e\u0301'`, + ); + }); + + it.each(['pnpm exec', 'npm exec --', 'yarn', 'bun x', 'npx', 'bunx'])( + 'keeps the %s wrapper policy in the caller', + (wrapper) => { + const tokens = literalArgv(`${wrapper} vp config`)!; + const values = tokens.map(({ value }) => value); + expect(values.slice(getCommandStartIndex(values))).toEqual(['vp', 'config']); + }, + ); + + it('keeps environment assignments and stateful commands for caller policies', () => { + expect( + literalCommands(`MODE='unit && extra' vitest; cd child && vp test`)?.map((tokens) => + tokens.map(({ value }) => value), + ), + ).toEqual([ + ['MODE=unit && extra', 'vitest'], + ['cd', 'child'], + ['vp', 'test'], + ]); + }); + + it('does not interpret command names inside arguments', () => { + expect(literalArgv('echo vp config --no-hooks')?.map(({ value }) => value)).toEqual([ + 'echo', + 'vp', + 'config', + '--no-hooks', + ]); + expect(literalArgv(`vp config "" '$FLAGS; && # literal'`)?.map(({ value }) => value)).toEqual([ + 'vp', + 'config', + '', + '$FLAGS; && # literal', + ]); + }); + + it.each([ + 'vp config "$FLAGS"', + 'MODE=$(node env.js) vp config', + 'vp config | tee log', + 'vp config > log', + 'vp config &', + 'vp config # comment', + 'vp config\nvp config', + 'vp config &&', + 'vp config;', + 'if true; then vp config; fi', + 'vp config "unterminated', + ])('rejects unsupported syntax in %s', (source) => { + expect(parseLiteralCommands(source, true)).toBeNull(); + expect(literalCommands(source)).toBeUndefined(); + expect(literalArgv(source)).toBeUndefined(); + }); + + it('keeps single-command and command-list contracts distinct', () => { + expect(literalArgv(' \t')).toEqual([]); + expect(literalCommands(' \t')).toBeUndefined(); + expect(literalArgv('vp config && echo done')).toBeUndefined(); + expect(literalCommands('vp config && echo done')).toHaveLength(2); + }); +}); diff --git a/packages/cli/src/utils/shell-command.ts b/packages/cli/src/utils/shell-command.ts index 13697a409c..fd264c3909 100644 --- a/packages/cli/src/utils/shell-command.ts +++ b/packages/cli/src/utils/shell-command.ts @@ -1,8 +1,6 @@ -export interface CommandToken { - value: string; - start: number; - end: number; -} +import { parseLiteralCommands, type LiteralCommandToken } from '../../binding/index.js'; + +export type CommandToken = LiteralCommandToken; /** Locate the executable after a supported package-manager wrapper. */ export function getCommandStartIndex(argv: readonly string[]): number { @@ -19,103 +17,12 @@ export function getCommandStartIndex(argv: readonly string[]): number { return start; } -/** Parse only a single literal argv command. Shell expansion, pipelines, and - * compound commands need review; treating them as argv could change behavior. */ +/** Parse only a single literal argv command. */ export function literalArgv(command: string): CommandToken[] | undefined { - const tokens: CommandToken[] = []; - let value = ''; - let start = 0; - let quote = ''; - let active = false; - for (let i = 0; i < command.length; i++) { - const character = command[i]; - if (quote) { - if (character === quote) { - quote = ''; - } else if ( - character === '\\' || - (quote === '"' && (character === '$' || character === '`')) - ) { - return undefined; - } else { - value += character; - } - } else if (character === "'" || character === '"') { - if (!active) { - start = i; - } - quote = character; - active = true; - } else if (character === '\n' || character === '\r') { - return undefined; - } else if (/\s/.test(character)) { - if (active) { - tokens.push({ value, start, end: i }); - } - active = false; - value = ''; - } else if (/[|&;<>($`\\)]/.test(character)) { - return undefined; - } else { - if (!active) { - start = i; - } - value += character; - active = true; - } - } - if (quote) { - return undefined; - } - if (active) { - tokens.push({ value, start, end: command.length }); - } - return tokens; + return parseLiteralCommands(command, false)?.[0]; } -/** Read literal command lists without executing a shell. Token offsets refer - * to the original script so callers can preserve its quoting and spacing. */ +/** Read literal command lists with offsets in the original JavaScript string. */ export function literalCommands(command: string): CommandToken[][] | undefined { - const commands: CommandToken[][] = []; - let quote = ''; - let start = 0; - function appendCommand(end: number): boolean { - const tokens = literalArgv(command.slice(start, end)); - if (!tokens?.length) { - return false; - } - for (const token of tokens) { - token.start += start; - token.end += start; - } - commands.push(tokens); - return true; - } - for (let index = 0; index < command.length; index++) { - const char = command[index]; - if (char === '\\') { - return undefined; - } - if (quote) { - if (char === quote) { - quote = ''; - } - } else if (char === '"' || char === "'") { - quote = char; - } else if (char === '#') { - return undefined; - } else if (char === ';' || command.startsWith('&&', index) || command.startsWith('||', index)) { - if (!appendCommand(index)) { - return undefined; - } - if (char !== ';') { - index++; - } - start = index + 1; - } - } - if (quote || !appendCommand(command.length)) { - return undefined; - } - return commands; + return parseLiteralCommands(command, true) ?? undefined; } From 83cdf1e3673aca78a925959ccc19fd86ebf51b85 Mon Sep 17 00:00:00 2001 From: MK Date: Tue, 20 Oct 2026 15:01:42 +0800 Subject: [PATCH 2/3] refactor(migrate): call native shell analysis directly --- .../__tests__/shell-command.spec.ts | 39 ++++++++----------- .../cli/src/migration/migrator/git-hooks.ts | 27 +++++++------ packages/cli/src/migration/migrator/shared.ts | 28 +++++++++++++ .../cli/src/migration/vitest-v5/commands.ts | 14 +++---- packages/cli/src/utils/shell-command.ts | 28 ------------- 5 files changed, 64 insertions(+), 72 deletions(-) rename packages/cli/src/{utils => migration}/__tests__/shell-command.spec.ts (69%) delete mode 100644 packages/cli/src/utils/shell-command.ts diff --git a/packages/cli/src/utils/__tests__/shell-command.spec.ts b/packages/cli/src/migration/__tests__/shell-command.spec.ts similarity index 69% rename from packages/cli/src/utils/__tests__/shell-command.spec.ts rename to packages/cli/src/migration/__tests__/shell-command.spec.ts index e2c21c2374..67d83ee7f9 100644 --- a/packages/cli/src/utils/__tests__/shell-command.spec.ts +++ b/packages/cli/src/migration/__tests__/shell-command.spec.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest'; import { parseLiteralCommands } from '../../../binding/index.js'; -import { getCommandStartIndex, literalArgv, literalCommands } from '../shell-command.ts'; +import { getCommandStartIndex } from '../migrator/shared.ts'; describe('native literal command analysis', () => { it('returns UTF-16 ranges into the original source, including quoted words', () => { @@ -28,7 +28,7 @@ describe('native literal command analysis', () => { it.each(['pnpm exec', 'npm exec --', 'yarn', 'bun x', 'npx', 'bunx'])( 'keeps the %s wrapper policy in the caller', (wrapper) => { - const tokens = literalArgv(`${wrapper} vp config`)!; + const tokens = parseLiteralCommands(`${wrapper} vp config`, false)![0]; const values = tokens.map(({ value }) => value); expect(values.slice(getCommandStartIndex(values))).toEqual(['vp', 'config']); }, @@ -36,8 +36,8 @@ describe('native literal command analysis', () => { it('keeps environment assignments and stateful commands for caller policies', () => { expect( - literalCommands(`MODE='unit && extra' vitest; cd child && vp test`)?.map((tokens) => - tokens.map(({ value }) => value), + parseLiteralCommands(`MODE='unit && extra' vitest; cd child && vp test`, true)?.map( + (tokens) => tokens.map(({ value }) => value), ), ).toEqual([ ['MODE=unit && extra', 'vitest'], @@ -47,18 +47,14 @@ describe('native literal command analysis', () => { }); it('does not interpret command names inside arguments', () => { - expect(literalArgv('echo vp config --no-hooks')?.map(({ value }) => value)).toEqual([ - 'echo', - 'vp', - 'config', - '--no-hooks', - ]); - expect(literalArgv(`vp config "" '$FLAGS; && # literal'`)?.map(({ value }) => value)).toEqual([ - 'vp', - 'config', - '', - '$FLAGS; && # literal', - ]); + expect( + parseLiteralCommands('echo vp config --no-hooks', false)?.[0].map(({ value }) => value), + ).toEqual(['echo', 'vp', 'config', '--no-hooks']); + expect( + parseLiteralCommands(`vp config "" '$FLAGS; && # literal'`, false)?.[0].map( + ({ value }) => value, + ), + ).toEqual(['vp', 'config', '', '$FLAGS; && # literal']); }); it.each([ @@ -75,14 +71,13 @@ describe('native literal command analysis', () => { 'vp config "unterminated', ])('rejects unsupported syntax in %s', (source) => { expect(parseLiteralCommands(source, true)).toBeNull(); - expect(literalCommands(source)).toBeUndefined(); - expect(literalArgv(source)).toBeUndefined(); + expect(parseLiteralCommands(source, false)).toBeNull(); }); it('keeps single-command and command-list contracts distinct', () => { - expect(literalArgv(' \t')).toEqual([]); - expect(literalCommands(' \t')).toBeUndefined(); - expect(literalArgv('vp config && echo done')).toBeUndefined(); - expect(literalCommands('vp config && echo done')).toHaveLength(2); + expect(parseLiteralCommands(' \t', false)).toEqual([[]]); + expect(parseLiteralCommands(' \t', true)).toBeNull(); + expect(parseLiteralCommands('vp config && echo done', false)).toBeNull(); + expect(parseLiteralCommands('vp config && echo done', true)).toHaveLength(2); }); }); diff --git a/packages/cli/src/migration/migrator/git-hooks.ts b/packages/cli/src/migration/migrator/git-hooks.ts index 02560157c3..9c71499a46 100644 --- a/packages/cli/src/migration/migrator/git-hooks.ts +++ b/packages/cli/src/migration/migrator/git-hooks.ts @@ -4,7 +4,12 @@ import path from 'node:path'; import * as prompts from '@voidzero-dev/vite-plus-prompts'; import spawn from 'cross-spawn'; -import { parseConfigArgs, rewriteScripts } from '../../../binding/index.js'; +import { + type LiteralCommandToken, + parseConfigArgs, + parseLiteralCommands, + rewriteScripts, +} from '../../../binding/index.js'; import { findUnsafeHookInstallPath, HOOK_STUB_SCRIPT, @@ -15,12 +20,6 @@ import type { PackageManager, WorkspacePackage } from '../../types/index.ts'; import { documentationUrl } from '../../utils/documentation.ts'; import { findGitRoot } from '../../utils/git.ts'; import { editJsonFile, isJsonFile, readJsonFile } from '../../utils/json.ts'; -import { - type CommandToken, - getCommandStartIndex, - literalArgv, - literalCommands, -} from '../../utils/shell-command.ts'; import { hasStagedConfigInViteConfig, mergeStagedConfigToViteConfig, @@ -29,8 +28,10 @@ import { } from '../migrator.ts'; import { type MigrationReport } from '../report.ts'; import { + getCommandStartIndex, LINT_STAGED_ALL_CONFIG_FILES, LINT_STAGED_OTHER_CONFIG_FILES, + SHELL_STATE_COMMANDS, warnMigration, } from './shared.ts'; @@ -158,7 +159,7 @@ function hasVpConfigLifecycleScript(scripts: Record | undefined) } interface VpConfigHookCommand { - args: CommandToken[]; + args: LiteralCommandToken[]; hooksEnabled: boolean; } @@ -168,13 +169,13 @@ function vpConfigHookCommands(script: string | undefined): VpConfigHookCommand[] if (!script) { return []; } - let commands = literalCommands(script); + let commands = parseLiteralCommands(script, true); // Preserve a leading refresh even when later commands need shell evaluation. // setupGitHooks prepends this form when it cannot rewrite an existing script. const separator = script.indexOf('&&'); if (!commands && separator !== -1) { - const first = literalArgv(script.slice(0, separator)); - commands = first?.length ? [first] : undefined; + const first = parseLiteralCommands(script.slice(0, separator), false)?.[0]; + commands = first?.length ? [first] : null; } if (!commands) { return []; @@ -186,9 +187,7 @@ function vpConfigHookCommands(script: string | undefined): VpConfigHookCommand[] // Environment overrides can disable hooks or redirect Git operations. return result; } - if ( - ['cd', 'pushd', 'popd', '.', 'source', 'eval', 'export', 'set', 'unset'].includes(values[0]) - ) { + if (SHELL_STATE_COMMANDS.includes(values[0])) { return result; } const start = getCommandStartIndex(values); diff --git a/packages/cli/src/migration/migrator/shared.ts b/packages/cli/src/migration/migrator/shared.ts index c5af65cef2..23a2a02d67 100644 --- a/packages/cli/src/migration/migrator/shared.ts +++ b/packages/cli/src/migration/migrator/shared.ts @@ -10,6 +10,34 @@ import { detectPackageMetadata } from '../../utils/package.ts'; import { displayRelative } from '../../utils/path.ts'; import { addManualStep, addMigrationWarning, type MigrationReport } from '../report.ts'; +// Commands whose effects prevent literal analysis of later invocations. +export const SHELL_STATE_COMMANDS: readonly string[] = [ + 'cd', + 'pushd', + 'popd', + '.', + 'source', + 'eval', + 'export', + 'set', + 'unset', +]; + +/** Locate the executable after a supported package-manager wrapper. */ +export function getCommandStartIndex(argv: readonly string[]): number { + if (!['pnpm', 'npm', 'yarn', 'bun', 'npx', 'bunx'].includes(argv[0])) { + return 0; + } + let start = 1; + if (argv[start] === 'exec' || argv[start] === 'x') { + start++; + } + if (argv[start] === '--') { + start++; + } + return start; +} + // All known lint-staged config file names. // JSON-parseable ones come first so rewriteLintStagedConfigFile can rewrite them. export const LINT_STAGED_JSON_CONFIG_FILES = ['.lintstagedrc.json', '.lintstagedrc'] as const; diff --git a/packages/cli/src/migration/vitest-v5/commands.ts b/packages/cli/src/migration/vitest-v5/commands.ts index 475cf7e335..2955214cf1 100644 --- a/packages/cli/src/migration/vitest-v5/commands.ts +++ b/packages/cli/src/migration/vitest-v5/commands.ts @@ -1,9 +1,10 @@ -import { getCommandStartIndex, literalArgv, literalCommands } from '../../utils/shell-command.ts'; +import { parseLiteralCommands } from '../../../binding/index.js'; +import { getCommandStartIndex, SHELL_STATE_COMMANDS } from '../migrator/shared.ts'; import type { RewriteResult, VitestV5Finding } from './ast.ts'; /** Read test commands without evaluating shell state or directory changes. */ export function literalTestCommands(command: string): string[][] | undefined { - const commands = literalCommands(command); + const commands = parseLiteralCommands(command, true); if (!commands) { return undefined; } @@ -13,10 +14,7 @@ export function literalTestCommands(command: string): string[][] | undefined { while (/^[A-Za-z_]\w*=/.test(values[0] ?? '')) { values.shift(); } - if ( - !values.length || - ['cd', 'pushd', 'popd', '.', 'source', 'eval', 'export', 'set', 'unset'].includes(values[0]) - ) { + if (!values.length || SHELL_STATE_COMMANDS.includes(values[0])) { return undefined; } result.push(values); @@ -37,7 +35,7 @@ export function vitestCommandArgsStart(values: readonly string[]): number { } function migrateBenchmarkOutput(command: string): string { - const argv = literalArgv(command); + const argv = parseLiteralCommands(command, false)?.[0]; if (!argv) { return command; } @@ -139,7 +137,7 @@ export function migrateVitestV5Command( command = migrateBenchmarkOutput(command); // The bench subcommand still exists in v5. Only its removed flags block // migration; benchmark source changes are handled by the source pass. - const argv = literalArgv(command); + const argv = parseLiteralCommands(command, false)?.[0]; const values = argv?.map(({ value }) => value); const runner = values ? vitestCommandArgsStart(values) : -1; let removedFlag = /(?:^|\s)(?:--compare|--outputJson)(?:\s|=|$)/.test(command); diff --git a/packages/cli/src/utils/shell-command.ts b/packages/cli/src/utils/shell-command.ts deleted file mode 100644 index fd264c3909..0000000000 --- a/packages/cli/src/utils/shell-command.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { parseLiteralCommands, type LiteralCommandToken } from '../../binding/index.js'; - -export type CommandToken = LiteralCommandToken; - -/** Locate the executable after a supported package-manager wrapper. */ -export function getCommandStartIndex(argv: readonly string[]): number { - if (!['pnpm', 'npm', 'yarn', 'bun', 'npx', 'bunx'].includes(argv[0])) { - return 0; - } - let start = 1; - if (argv[start] === 'exec' || argv[start] === 'x') { - start++; - } - if (argv[start] === '--') { - start++; - } - return start; -} - -/** Parse only a single literal argv command. */ -export function literalArgv(command: string): CommandToken[] | undefined { - return parseLiteralCommands(command, false)?.[0]; -} - -/** Read literal command lists with offsets in the original JavaScript string. */ -export function literalCommands(command: string): CommandToken[][] | undefined { - return parseLiteralCommands(command, true) ?? undefined; -} From d2cdc9cf4d923bd839d9b7f90170b334e151fdd3 Mon Sep 17 00:00:00 2001 From: MK Date: Tue, 20 Oct 2026 15:02:44 +0800 Subject: [PATCH 3/3] refactor(migrate): share native shell rewrite flow --- crates/vp_migration/src/script_rewrite.rs | 58 +++++++++-------------- 1 file changed, 22 insertions(+), 36 deletions(-) diff --git a/crates/vp_migration/src/script_rewrite.rs b/crates/vp_migration/src/script_rewrite.rs index 55bc22db3c..20a7c19acb 100644 --- a/crates/vp_migration/src/script_rewrite.rs +++ b/crates/vp_migration/src/script_rewrite.rs @@ -45,10 +45,7 @@ pub(crate) fn rewrite_pack_flags(script: &str) -> String { } fn rewrite_direct_pack_flags(script: &str) -> String { - let mut parser = - brush_parser::Parser::new(script.as_bytes(), &brush_parser::ParserOptions::default()); - let Ok(mut program) = parser.parse_program() else { return script.to_owned() }; - let changed = visit_simple_commands(&mut program, &mut |cmd| { + rewrite_simple_commands(script, |cmd| { let words = collect_command_words(cmd); let start = match words.first().map(|word| word.value.as_str()) { Some("vp") => 0, @@ -87,30 +84,32 @@ fn rewrite_direct_pack_flags(script: &str) -> String { } } changed - }); - if changed { - collapse_newlines(&normalize_pipe_spacing(&program.to_string())) - } else { - script.to_owned() - } + }) } fn rewrite_direct_script(script: &str, config: &ScriptRewriteConfig) -> String { - let mut parser = - brush_parser::Parser::new(script.as_bytes(), &brush_parser::ParserOptions::default()); - let Ok(mut program) = parser.parse_program() else { + rewrite_simple_commands(script, |cmd| rewrite_in_simple_command(cmd, config)) +} + +fn parse_program(script: &str) -> Option { + brush_parser::Parser::new(script.as_bytes(), &brush_parser::ParserOptions::default()) + .parse_program() + .ok() +} + +/// Keep parse failures and unchanged scripts intact. Every rewrite uses the +/// same command traversal and formatting repairs for brush-parser's output. +fn rewrite_simple_commands( + script: &str, + mut visitor: impl FnMut(&mut ast::SimpleCommand) -> bool, +) -> String { + let Some(mut program) = parse_program(script) else { return script.to_owned(); }; - - if !rewrite_in_program(&mut program, config) { + if !visit_simple_commands(&mut program, &mut visitor) { return script.to_owned(); } - let output = normalize_pipe_spacing(&program.to_string()); - collapse_newlines(&output) -} - -fn rewrite_in_program(program: &mut ast::Program, config: &ScriptRewriteConfig) -> bool { - visit_simple_commands(program, &mut |cmd| rewrite_in_simple_command(cmd, config)) + collapse_newlines(&normalize_pipe_spacing(&program.to_string())) } fn visit_simple_commands( @@ -369,9 +368,7 @@ fn find_bunx_invocations(cmd: &ast::SimpleCommand) -> Vec { } fn parse_single_simple_command(script: &str) -> Option { - let mut parser = - brush_parser::Parser::new(script.as_bytes(), &brush_parser::ParserOptions::default()); - let mut program = parser.parse_program().ok()?; + let mut program = parse_program(script)?; if program.complete_commands.len() != 1 { return None; } @@ -440,18 +437,7 @@ pub(crate) fn rewrite_bunx_commands( script: &str, mut rewrite_inner: impl FnMut(&str) -> String, ) -> String { - let mut parser = - brush_parser::Parser::new(script.as_bytes(), &brush_parser::ParserOptions::default()); - let Ok(mut program) = parser.parse_program() else { - return script.to_owned(); - }; - if !visit_simple_commands(&mut program, &mut |cmd| { - rewrite_bunx_in_simple_command(cmd, &mut rewrite_inner) - }) { - return script.to_owned(); - } - - collapse_newlines(&normalize_pipe_spacing(&program.to_string())) + rewrite_simple_commands(script, |cmd| rewrite_bunx_in_simple_command(cmd, &mut rewrite_inner)) } fn make_suffix_word(value: &str) -> ast::CommandPrefixOrSuffixItem {