diff --git a/CHANGELOG.md b/CHANGELOG.md index a0d93dcf2..ee064fc7b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,6 @@ # Changelog +- **Fixed** `vp run` now preserves pathname expansion from Unix package-script shells, so unquoted patterns such as `packages/*/src` expand before they are passed to the underlying command ([#573](https://github.com/voidzero-dev/vite-task/issues/573)). - **Fixed** `vp run` no longer hangs or fails when a task leaves a process running behind it, such as a dev server or a background helper, or when one of a task's processes is killed. The run finishes as soon as the task itself does, and the files the task used are still recorded ([#544](https://github.com/voidzero-dev/vite-task/issues/544), [#675](https://github.com/voidzero-dev/vite-task/pull/675)). - **Fixed** A task that reads or writes an unusually large number of files now runs to the end instead of being killed partway through. Vite+ reports the run as not cached, because it could not record every file the task used ([#533](https://github.com/voidzero-dev/vite-task/issues/533), [#675](https://github.com/voidzero-dev/vite-task/pull/675)). - **Fixed** Vite+ diagnostics now display individual paths and working directories without Rust debug formatting such as quoted paths or escaped Windows backslashes ([#534](https://github.com/voidzero-dev/vite-task/pull/534)). diff --git a/crates/vt_bin/tests/e2e_snapshots/fixtures/package_script_glob/package.json b/crates/vt_bin/tests/e2e_snapshots/fixtures/package_script_glob/package.json new file mode 100644 index 000000000..e09ca3028 --- /dev/null +++ b/crates/vt_bin/tests/e2e_snapshots/fixtures/package_script_glob/package.json @@ -0,0 +1,14 @@ +{ + "name": "package-script-glob", + "private": true, + "scripts": { + "unquoted-glob": "vtt print packages/*/src", + "and-glob": "vtt print before && vtt print packages/*/src", + "quoted-glob": "vtt print \"packages/*/src\"", + "nested-vt-glob": "vt run print-paths prefix packages/*/src", + "nested-vt-quoted-glob": "vt run print-paths prefix \"packages/*/src\"", + "nested-vt-glob-fails": "vt run fail-with-code packages/*/src", + "print-paths": "vtt print", + "fail-with-code": "vtt exit 23" + } +} diff --git a/crates/vt_bin/tests/e2e_snapshots/fixtures/package_script_glob/packages/a/src/input.txt b/crates/vt_bin/tests/e2e_snapshots/fixtures/package_script_glob/packages/a/src/input.txt new file mode 100644 index 000000000..789819226 --- /dev/null +++ b/crates/vt_bin/tests/e2e_snapshots/fixtures/package_script_glob/packages/a/src/input.txt @@ -0,0 +1 @@ +a diff --git a/crates/vt_bin/tests/e2e_snapshots/fixtures/package_script_glob/packages/b/src/input.txt b/crates/vt_bin/tests/e2e_snapshots/fixtures/package_script_glob/packages/b/src/input.txt new file mode 100644 index 000000000..617807982 --- /dev/null +++ b/crates/vt_bin/tests/e2e_snapshots/fixtures/package_script_glob/packages/b/src/input.txt @@ -0,0 +1 @@ +b diff --git a/crates/vt_bin/tests/e2e_snapshots/fixtures/package_script_glob/snapshots.toml b/crates/vt_bin/tests/e2e_snapshots/fixtures/package_script_glob/snapshots.toml new file mode 100644 index 000000000..c4638beb8 --- /dev/null +++ b/crates/vt_bin/tests/e2e_snapshots/fixtures/package_script_glob/snapshots.toml @@ -0,0 +1,93 @@ +[[e2e]] +name = "unquoted_glob_expands_on_unix" +cfg = "unix" +comment = """ +Unquoted pathname patterns in package scripts are executed by the platform shell, matching package-manager script semantics on Unix. +""" +steps = [["vt", "run", "unquoted-glob"]] + +[[e2e]] +name = "unquoted_glob_stays_static_on_windows" +cfg = "windows" +comment = """ +Because `cmd.exe` does not perform pathname expansion, an unquoted pattern stays on the static execution path and is passed to the program literally. +""" +steps = [["vt", "run", "unquoted-glob"]] + +[[e2e]] +name = "unquoted_glob_preserves_extra_args_on_unix" +cfg = "unix" +comment = """ +Extra arguments passed to `vt run` are shell-escaped and appended after the package script. Shell operators inside one argument must remain literal after Unix shell expansion. +""" +steps = [["vt", "run", "unquoted-glob", "tail && vtt exit 31"]] + +[[e2e]] +name = "unquoted_glob_preserves_extra_args_on_windows" +cfg = "windows" +comment = """ +The static Windows execution path preserves extra argument boundaries without routing them through `cmd.exe`. Shell operators inside one argument must remain literal. +""" +steps = [["vt", "run", "unquoted-glob", "tail && vtt exit 31"]] + +[[e2e]] +name = "and_list_with_glob_uses_one_shell_on_unix" +cfg = "unix" +comment = """ +If any command in an `&&` list needs pathname expansion, the complete list is kept intact for the Unix shell so ordering and short-circuit semantics are preserved. +""" +steps = [["vt", "run", "and-glob"]] + +[[e2e]] +name = "and_list_with_glob_stays_static_on_windows" +cfg = "windows" +comment = """ +Since the pathname pattern has no special meaning to `cmd.exe`, the existing static `&&` planning remains available on Windows while preserving ordering and literal arguments. +""" +steps = [["vt", "run", "and-glob"]] + +[[e2e]] +name = "quoted_glob_stays_literal" +comment = """ +Quoted pathname patterns remain literal for the child command to interpret on every platform. +""" +steps = [["vt", "run", "quoted-glob"]] + +[[e2e]] +name = "nested_vt_quoted_glob_stays_command_owned" +comment = """ +A quoted pathname pattern remains one literal argument when passed to a nested Vite+ command, so the command can implement portable glob semantics itself. +""" +steps = [["vt", "run", "nested-vt-quoted-glob"]] + +[[e2e]] +name = "nested_vt_receives_expanded_glob_on_unix" +cfg = "unix" +comment = """ +An unquoted pathname pattern in a package script that invokes nested `vt run` is expanded by the shell before the new `vt` process starts. This verifies PATH lookup and preserves both preceding extra arguments and expanded paths. +""" +steps = [["vt", "run", "nested-vt-glob"]] + +[[e2e]] +name = "nested_vt_receives_literal_glob_on_windows" +cfg = "windows" +comment = """ +On Windows, the static planner keeps nested `vt run` in process and forwards the pattern literally while preserving the preceding extra argument. +""" +steps = [["vt", "run", "nested-vt-glob"]] + +[[e2e]] +name = "nested_vt_propagates_exit_code_on_unix" +cfg = "unix" +comment = """ +The shell fallback used for an unquoted pathname pattern preserves a nested `vt run` failure exit code after Unix shell expansion. +""" +steps = [["vt", "run", "nested-vt-glob-fails"]] + +[[e2e]] +name = "nested_vt_propagates_exit_code_on_windows" +cfg = "windows" +comment = """ +The static Windows path for an unquoted pathname pattern preserves a nested `vt run` failure exit code. +""" +steps = [["vt", "run", "nested-vt-glob-fails"]] diff --git a/crates/vt_bin/tests/e2e_snapshots/fixtures/package_script_glob/snapshots/and_list_with_glob_stays_static_on_windows.md b/crates/vt_bin/tests/e2e_snapshots/fixtures/package_script_glob/snapshots/and_list_with_glob_stays_static_on_windows.md new file mode 100644 index 000000000..7f9d15808 --- /dev/null +++ b/crates/vt_bin/tests/e2e_snapshots/fixtures/package_script_glob/snapshots/and_list_with_glob_stays_static_on_windows.md @@ -0,0 +1,11 @@ +# and_list_with_glob_stays_static_on_windows + +Since the pathname pattern has no special meaning to `cmd.exe`, the existing static `&&` planning remains available on Windows while preserving ordering and literal arguments. + +## `vt run and-glob` + +``` +$ vtt print before && vtt print packages/*/src ⊘ cache disabled +before +packages/*/src +``` diff --git a/crates/vt_bin/tests/e2e_snapshots/fixtures/package_script_glob/snapshots/and_list_with_glob_uses_one_shell_on_unix.md b/crates/vt_bin/tests/e2e_snapshots/fixtures/package_script_glob/snapshots/and_list_with_glob_uses_one_shell_on_unix.md new file mode 100644 index 000000000..fc5cd863f --- /dev/null +++ b/crates/vt_bin/tests/e2e_snapshots/fixtures/package_script_glob/snapshots/and_list_with_glob_uses_one_shell_on_unix.md @@ -0,0 +1,11 @@ +# and_list_with_glob_uses_one_shell_on_unix + +If any command in an `&&` list needs pathname expansion, the complete list is kept intact for the Unix shell so ordering and short-circuit semantics are preserved. + +## `vt run and-glob` + +``` +$ vtt print before && vtt print packages/*/src ⊘ cache disabled +before +packages/a/src packages/b/src +``` diff --git a/crates/vt_bin/tests/e2e_snapshots/fixtures/package_script_glob/snapshots/nested_vt_propagates_exit_code_on_unix.md b/crates/vt_bin/tests/e2e_snapshots/fixtures/package_script_glob/snapshots/nested_vt_propagates_exit_code_on_unix.md new file mode 100644 index 000000000..fce209752 --- /dev/null +++ b/crates/vt_bin/tests/e2e_snapshots/fixtures/package_script_glob/snapshots/nested_vt_propagates_exit_code_on_unix.md @@ -0,0 +1,12 @@ +# nested_vt_propagates_exit_code_on_unix + +The shell fallback used for an unquoted pathname pattern preserves a nested `vt run` failure exit code after Unix shell expansion. + +## `vt run nested-vt-glob-fails` + +**Exit code:** 23 + +``` +$ vt run fail-with-code packages/*/src ⊘ cache disabled +$ vtt exit 23 packages/a/src packages/b/src ⊘ cache disabled +``` diff --git a/crates/vt_bin/tests/e2e_snapshots/fixtures/package_script_glob/snapshots/nested_vt_propagates_exit_code_on_windows.md b/crates/vt_bin/tests/e2e_snapshots/fixtures/package_script_glob/snapshots/nested_vt_propagates_exit_code_on_windows.md new file mode 100644 index 000000000..0cf22366f --- /dev/null +++ b/crates/vt_bin/tests/e2e_snapshots/fixtures/package_script_glob/snapshots/nested_vt_propagates_exit_code_on_windows.md @@ -0,0 +1,12 @@ +# nested_vt_propagates_exit_code_on_windows + +The static Windows path for an unquoted pathname pattern preserves a nested `vt run` failure exit code. + +## `vt run nested-vt-glob-fails` + +**Exit code:** 23 + +``` +$ vt run fail-with-code packages/*/src ⊘ cache disabled +$ vtt exit 23 packages/*/src ⊘ cache disabled +``` diff --git a/crates/vt_bin/tests/e2e_snapshots/fixtures/package_script_glob/snapshots/nested_vt_quoted_glob_stays_command_owned.md b/crates/vt_bin/tests/e2e_snapshots/fixtures/package_script_glob/snapshots/nested_vt_quoted_glob_stays_command_owned.md new file mode 100644 index 000000000..182f3282b --- /dev/null +++ b/crates/vt_bin/tests/e2e_snapshots/fixtures/package_script_glob/snapshots/nested_vt_quoted_glob_stays_command_owned.md @@ -0,0 +1,10 @@ +# nested_vt_quoted_glob_stays_command_owned + +A quoted pathname pattern remains one literal argument when passed to a nested Vite+ command, so the command can implement portable glob semantics itself. + +## `vt run nested-vt-quoted-glob` + +``` +$ vtt print prefix 'packages/*/src' ⊘ cache disabled +prefix packages/*/src +``` diff --git a/crates/vt_bin/tests/e2e_snapshots/fixtures/package_script_glob/snapshots/nested_vt_receives_expanded_glob_on_unix.md b/crates/vt_bin/tests/e2e_snapshots/fixtures/package_script_glob/snapshots/nested_vt_receives_expanded_glob_on_unix.md new file mode 100644 index 000000000..f51133636 --- /dev/null +++ b/crates/vt_bin/tests/e2e_snapshots/fixtures/package_script_glob/snapshots/nested_vt_receives_expanded_glob_on_unix.md @@ -0,0 +1,11 @@ +# nested_vt_receives_expanded_glob_on_unix + +An unquoted pathname pattern in a package script that invokes nested `vt run` is expanded by the shell before the new `vt` process starts. This verifies PATH lookup and preserves both preceding extra arguments and expanded paths. + +## `vt run nested-vt-glob` + +``` +$ vt run print-paths prefix packages/*/src ⊘ cache disabled +$ vtt print prefix packages/a/src packages/b/src ⊘ cache disabled +prefix packages/a/src packages/b/src +``` diff --git a/crates/vt_bin/tests/e2e_snapshots/fixtures/package_script_glob/snapshots/nested_vt_receives_literal_glob_on_windows.md b/crates/vt_bin/tests/e2e_snapshots/fixtures/package_script_glob/snapshots/nested_vt_receives_literal_glob_on_windows.md new file mode 100644 index 000000000..bb721d308 --- /dev/null +++ b/crates/vt_bin/tests/e2e_snapshots/fixtures/package_script_glob/snapshots/nested_vt_receives_literal_glob_on_windows.md @@ -0,0 +1,11 @@ +# nested_vt_receives_literal_glob_on_windows + +On Windows, the static planner keeps nested `vt run` in process and forwards the pattern literally while preserving the preceding extra argument. + +## `vt run nested-vt-glob` + +``` +$ vt run print-paths prefix packages/*/src ⊘ cache disabled +$ vtt print prefix packages/*/src ⊘ cache disabled +prefix packages/*/src +``` diff --git a/crates/vt_bin/tests/e2e_snapshots/fixtures/package_script_glob/snapshots/quoted_glob_stays_literal.md b/crates/vt_bin/tests/e2e_snapshots/fixtures/package_script_glob/snapshots/quoted_glob_stays_literal.md new file mode 100644 index 000000000..cf10c1da9 --- /dev/null +++ b/crates/vt_bin/tests/e2e_snapshots/fixtures/package_script_glob/snapshots/quoted_glob_stays_literal.md @@ -0,0 +1,10 @@ +# quoted_glob_stays_literal + +Quoted pathname patterns remain literal for the child command to interpret on every platform. + +## `vt run quoted-glob` + +``` +$ vtt print "packages/*/src" ⊘ cache disabled +packages/*/src +``` diff --git a/crates/vt_bin/tests/e2e_snapshots/fixtures/package_script_glob/snapshots/unquoted_glob_expands_on_unix.md b/crates/vt_bin/tests/e2e_snapshots/fixtures/package_script_glob/snapshots/unquoted_glob_expands_on_unix.md new file mode 100644 index 000000000..d707e410f --- /dev/null +++ b/crates/vt_bin/tests/e2e_snapshots/fixtures/package_script_glob/snapshots/unquoted_glob_expands_on_unix.md @@ -0,0 +1,10 @@ +# unquoted_glob_expands_on_unix + +Unquoted pathname patterns in package scripts are executed by the platform shell, matching package-manager script semantics on Unix. + +## `vt run unquoted-glob` + +``` +$ vtt print packages/*/src ⊘ cache disabled +packages/a/src packages/b/src +``` diff --git a/crates/vt_bin/tests/e2e_snapshots/fixtures/package_script_glob/snapshots/unquoted_glob_preserves_extra_args_on_unix.md b/crates/vt_bin/tests/e2e_snapshots/fixtures/package_script_glob/snapshots/unquoted_glob_preserves_extra_args_on_unix.md new file mode 100644 index 000000000..61a2f80f8 --- /dev/null +++ b/crates/vt_bin/tests/e2e_snapshots/fixtures/package_script_glob/snapshots/unquoted_glob_preserves_extra_args_on_unix.md @@ -0,0 +1,10 @@ +# unquoted_glob_preserves_extra_args_on_unix + +Extra arguments passed to `vt run` are shell-escaped and appended after the package script. Shell operators inside one argument must remain literal after Unix shell expansion. + +## `vt run unquoted-glob 'tail && vtt exit 31'` + +``` +$ vtt print packages/*/src ⊘ cache disabled +packages/a/src packages/b/src tail && vtt exit 31 +``` diff --git a/crates/vt_bin/tests/e2e_snapshots/fixtures/package_script_glob/snapshots/unquoted_glob_preserves_extra_args_on_windows.md b/crates/vt_bin/tests/e2e_snapshots/fixtures/package_script_glob/snapshots/unquoted_glob_preserves_extra_args_on_windows.md new file mode 100644 index 000000000..5be6facc8 --- /dev/null +++ b/crates/vt_bin/tests/e2e_snapshots/fixtures/package_script_glob/snapshots/unquoted_glob_preserves_extra_args_on_windows.md @@ -0,0 +1,10 @@ +# unquoted_glob_preserves_extra_args_on_windows + +The static Windows execution path preserves extra argument boundaries without routing them through `cmd.exe`. Shell operators inside one argument must remain literal. + +## `vt run unquoted-glob "tail && vtt exit 31"` + +``` +$ vtt print packages/*/src ⊘ cache disabled +packages/*/src tail && vtt exit 31 +``` diff --git a/crates/vt_bin/tests/e2e_snapshots/fixtures/package_script_glob/snapshots/unquoted_glob_stays_static_on_windows.md b/crates/vt_bin/tests/e2e_snapshots/fixtures/package_script_glob/snapshots/unquoted_glob_stays_static_on_windows.md new file mode 100644 index 000000000..7e1a7dad6 --- /dev/null +++ b/crates/vt_bin/tests/e2e_snapshots/fixtures/package_script_glob/snapshots/unquoted_glob_stays_static_on_windows.md @@ -0,0 +1,10 @@ +# unquoted_glob_stays_static_on_windows + +Because `cmd.exe` does not perform pathname expansion, an unquoted pattern stays on the static execution path and is passed to the program literally. + +## `vt run unquoted-glob` + +``` +$ vtt print packages/*/src ⊘ cache disabled +packages/*/src +``` diff --git a/crates/vt_plan/src/plan.rs b/crates/vt_plan/src/plan.rs index 06860b8fd..a364df491 100644 --- a/crates/vt_plan/src/plan.rs +++ b/crates/vt_plan/src/plan.rs @@ -132,7 +132,9 @@ async fn plan_task_as_execution_node( let command_str = command.as_str(); let is_last_command = command_item_index + 1 == commands.len(); // Try to parse the command string as a list of subcommands separated by `&&` - if let Some(parsed_subcommands) = try_parse_as_and_list(command_str) { + // `cmd.exe` passes wildcard characters through literally. Only shells with + // POSIX pathname expansion need to preserve unquoted patterns for shell execution. + if let Some(parsed_subcommands) = try_parse_as_and_list(command_str, cfg!(unix)) { let and_item_count = parsed_subcommands.len(); for (and_item_index, (and_item, add_item_span)) in parsed_subcommands.into_iter().enumerate() diff --git a/crates/vt_plan/tests/plan_snapshots/fixtures/shell_fallback/package.json b/crates/vt_plan/tests/plan_snapshots/fixtures/shell_fallback/package.json index 094121094..1c9f006b7 100644 --- a/crates/vt_plan/tests/plan_snapshots/fixtures/shell_fallback/package.json +++ b/crates/vt_plan/tests/plan_snapshots/fixtures/shell_fallback/package.json @@ -1,5 +1,7 @@ { "scripts": { - "pipe-test": "echo hello | node -e \"process.stdin.pipe(process.stdout)\"" + "pipe-test": "echo hello | node -e \"process.stdin.pipe(process.stdout)\"", + "unquoted-glob": "vtt print packages/*/src", + "quoted-glob": "vtt print \"packages/*/src\"" } } diff --git a/crates/vt_plan/tests/plan_snapshots/fixtures/shell_fallback/snapshots.toml b/crates/vt_plan/tests/plan_snapshots/fixtures/shell_fallback/snapshots.toml index 9cbaffec8..fa4ab7ac4 100644 --- a/crates/vt_plan/tests/plan_snapshots/fixtures/shell_fallback/snapshots.toml +++ b/crates/vt_plan/tests/plan_snapshots/fixtures/shell_fallback/snapshots.toml @@ -3,3 +3,12 @@ [[plan]] name = "shell_fallback_for_pipe_command" args = ["run", "pipe-test"] + +[[plan]] +name = "shell_fallback_for_unquoted_glob" +args = ["run", "unquoted-glob"] +platform = "unix" + +[[plan]] +name = "direct_spawn_for_quoted_glob" +args = ["run", "quoted-glob"] diff --git a/crates/vt_plan/tests/plan_snapshots/fixtures/shell_fallback/snapshots/query_direct_spawn_for_quoted_glob.jsonc b/crates/vt_plan/tests/plan_snapshots/fixtures/shell_fallback/snapshots/query_direct_spawn_for_quoted_glob.jsonc new file mode 100644 index 000000000..d4401af03 --- /dev/null +++ b/crates/vt_plan/tests/plan_snapshots/fixtures/shell_fallback/snapshots/query_direct_spawn_for_quoted_glob.jsonc @@ -0,0 +1,91 @@ +// run quoted-glob +{ + "graph": [ + { + "key": [ + "/", + "quoted-glob" + ], + "node": { + "task_display": { + "package_name": "", + "task_name": "quoted-glob", + "package_path": "/" + }, + "items": [ + { + "execution_item_display": { + "task_display": { + "package_name": "", + "task_name": "quoted-glob", + "package_path": "/" + }, + "command": "vtt print \"packages/*/src\"", + "cwd": "/" + }, + "kind": { + "Leaf": { + "Spawn": { + "cache_metadata": { + "spawn_fingerprint": { + "cwd": "", + "program_fingerprint": { + "OutsideWorkspace": { + "program_name": "vtt" + } + }, + "args": [ + "print", + "packages/*/src" + ], + "env_fingerprints": { + "fingerprinted_envs": {}, + "untracked_env_config": [ + "" + ] + } + }, + "execution_cache_key": { + "UserTask": { + "task_name": "quoted-glob", + "command_item_index": 0, + "and_item_index": 0, + "extra_args": [], + "package_path": "" + } + }, + "input_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] + }, + "output_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] + } + }, + "spawn_command": { + "program_path": "/vtt", + "args": [ + "print", + "packages/*/src" + ], + "spawn_envs": { + "FORCE_COLOR": "1", + "PATH": "/node_modules/.bin:", + "VP_RUN": "1" + }, + "cwd": "/" + } + } + } + } + } + ] + }, + "neighbors": [] + } + ], + "concurrency_limit": 4 +} diff --git a/crates/vt_plan/tests/plan_snapshots/fixtures/shell_fallback/snapshots/query_shell_fallback_for_unquoted_glob.jsonc b/crates/vt_plan/tests/plan_snapshots/fixtures/shell_fallback/snapshots/query_shell_fallback_for_unquoted_glob.jsonc new file mode 100644 index 000000000..a9e0f9e8f --- /dev/null +++ b/crates/vt_plan/tests/plan_snapshots/fixtures/shell_fallback/snapshots/query_shell_fallback_for_unquoted_glob.jsonc @@ -0,0 +1,91 @@ +// run unquoted-glob +{ + "graph": [ + { + "key": [ + "/", + "unquoted-glob" + ], + "node": { + "task_display": { + "package_name": "", + "task_name": "unquoted-glob", + "package_path": "/" + }, + "items": [ + { + "execution_item_display": { + "task_display": { + "package_name": "", + "task_name": "unquoted-glob", + "package_path": "/" + }, + "command": "vtt print packages/*/src", + "cwd": "/" + }, + "kind": { + "Leaf": { + "Spawn": { + "cache_metadata": { + "spawn_fingerprint": { + "cwd": "", + "program_fingerprint": { + "OutsideWorkspace": { + "program_name": "" + } + }, + "args": [ + "", + "vtt print packages/*/src" + ], + "env_fingerprints": { + "fingerprinted_envs": {}, + "untracked_env_config": [ + "" + ] + } + }, + "execution_cache_key": { + "UserTask": { + "task_name": "unquoted-glob", + "command_item_index": 0, + "and_item_index": 0, + "extra_args": [], + "package_path": "" + } + }, + "input_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] + }, + "output_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] + } + }, + "spawn_command": { + "program_path": "", + "args": [ + "", + "vtt print packages/*/src" + ], + "spawn_envs": { + "FORCE_COLOR": "1", + "PATH": "/node_modules/.bin:", + "VP_RUN": "1" + }, + "cwd": "/" + } + } + } + } + } + ] + }, + "neighbors": [] + } + ], + "concurrency_limit": 4 +} diff --git a/crates/vt_plan/tests/plan_snapshots/fixtures/shell_fallback/snapshots/task_graph.md b/crates/vt_plan/tests/plan_snapshots/fixtures/shell_fallback/snapshots/task_graph.md index 5bf34186e..fc42c75e8 100644 --- a/crates/vt_plan/tests/plan_snapshots/fixtures/shell_fallback/snapshots/task_graph.md +++ b/crates/vt_plan/tests/plan_snapshots/fixtures/shell_fallback/snapshots/task_graph.md @@ -3,6 +3,8 @@ ```mermaid flowchart TD task_0["/#pipe-test"] + task_1["/#quoted-glob"] + task_2["/#unquoted-glob"] ``` ## `/#pipe-test` @@ -44,3 +46,81 @@ flowchart TD } ``` +## `/#quoted-glob` + +```json +{ + "task_display": { + "package_name": "", + "task_name": "quoted-glob", + "package_path": "/" + }, + "resolved_config": { + "commands": [ + "vtt print \"packages/*/src\"" + ], + "resolved_options": { + "cwd": "/", + "cache_config": { + "env_config": { + "fingerprinted_envs": [], + "untracked_env": [ + "" + ] + }, + "input_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] + }, + "output_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] + } + } + } + }, + "source": "PackageJsonScript" +} +``` + +## `/#unquoted-glob` + +```json +{ + "task_display": { + "package_name": "", + "task_name": "unquoted-glob", + "package_path": "/" + }, + "resolved_config": { + "commands": [ + "vtt print packages/*/src" + ], + "resolved_options": { + "cwd": "/", + "cache_config": { + "env_config": { + "fingerprinted_envs": [], + "untracked_env": [ + "" + ] + }, + "input_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] + }, + "output_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] + } + } + } + }, + "source": "PackageJsonScript" +} +``` + diff --git a/crates/vt_plan/tests/plan_snapshots/main.rs b/crates/vt_plan/tests/plan_snapshots/main.rs index 0d23f0a93..deb744dbc 100644 --- a/crates/vt_plan/tests/plan_snapshots/main.rs +++ b/crates/vt_plan/tests/plan_snapshots/main.rs @@ -35,6 +35,8 @@ struct Plan { pub name: Str, pub args: Vec, #[serde(default)] + pub platform: Option, + #[serde(default)] pub cwd: RelativePathBuf, #[serde(default)] pub compact: bool, @@ -234,6 +236,9 @@ fn run_case_inner( snapshots.check_snapshot("task_graph.md", task_graph_markdown.as_str())?; for plan in cases_file.plan_cases { + if !should_run_on_this_platform(plan.platform.as_ref()) { + continue; + } assert_identifier_like("plan case name", plan.name.as_str()); let snapshot_base = vt_str::format!("query_{}", plan.name); let compact = plan.compact; diff --git a/crates/vt_shell/src/lib.rs b/crates/vt_shell/src/lib.rs index 5077a78e4..ca9d2e5bd 100644 --- a/crates/vt_shell/src/lib.rs +++ b/crates/vt_shell/src/lib.rs @@ -57,15 +57,100 @@ const PARSER_OPTIONS: ParserOptions = ParserOptions { /// Uses `brush_parser::word::parse` to properly handle nested quoting /// (e.g. single quotes inside double quotes are preserved as literal characters). /// Returns `None` if the word contains expansions that cannot be statically resolved -/// (parameter expansion, command substitution, arithmetic). -fn unquote(word: &Word) -> Option { +/// (pathname expansion when enabled, parameter expansion, command substitution, arithmetic). +fn unquote(word: &Word, pathname_expansion_enabled: bool) -> Option { let Word { value, loc: _ } = word; let pieces = brush_parser::word::parse(value.as_str(), &PARSER_OPTIONS).ok()?; + if pathname_expansion_enabled && contains_pathname_expansion(&pieces) { + return None; + } let mut result = Str::with_capacity(value.len()); flatten_pieces(&pieces, &mut result)?; Some(result) } +#[derive(Default)] +struct PathnameExpansionDetector { + bracket_expression: Option, +} + +#[derive(Default)] +struct BracketExpression { + has_member: bool, + can_negate: bool, +} + +impl PathnameExpansionDetector { + fn push(&mut self, value: &str, pattern_syntax_enabled: bool) -> bool { + for char in value.chars() { + if char == '/' { + // A bracket expression cannot cross a pathname component boundary. + self.bracket_expression = None; + continue; + } + + let Some(bracket_expression) = &mut self.bracket_expression else { + if pattern_syntax_enabled { + if matches!(char, '*' | '?') { + return true; + } + if char == '[' { + self.bracket_expression = + Some(BracketExpression { has_member: false, can_negate: true }); + } + } + continue; + }; + + if pattern_syntax_enabled && char == ']' { + if bracket_expression.has_member { + return true; + } + // `]` is a literal member when it is the first character after `[` or + // an optional negation character. A later unquoted `]` must still close it. + bracket_expression.has_member = true; + } else if pattern_syntax_enabled + && bracket_expression.can_negate + && matches!(char, '!' | '^') + { + } else { + bracket_expression.has_member = true; + } + bracket_expression.can_negate = false; + } + false + } +} + +fn contains_pathname_expansion(pieces: &[WordPieceWithSource]) -> bool { + fn visit( + pieces: &[WordPieceWithSource], + detector: &mut PathnameExpansionDetector, + pattern_syntax_enabled: bool, + ) -> bool { + for piece in pieces { + let found = match &piece.piece { + WordPiece::Text(s) => detector.push(s, pattern_syntax_enabled), + WordPiece::SingleQuotedText(s) | WordPiece::AnsiCQuotedText(s) => { + detector.push(s, false) + } + WordPiece::EscapeSequence(s) => { + detector.push(s.strip_prefix('\\').unwrap_or(s), false) + } + WordPiece::DoubleQuotedSequence(inner) + | WordPiece::GettextDoubleQuotedSequence(inner) => visit(inner, detector, false), + _ => false, + }; + if found { + return true; + } + } + false + } + + visit(pieces, &mut PathnameExpansionDetector::default(), true) +} + /// Recursively extract literal text from parsed word pieces. /// /// Returns `None` if any piece requires runtime expansion. @@ -92,7 +177,10 @@ fn flatten_pieces(pieces: &[WordPieceWithSource], result: &mut Str) -> Option<() Some(()) } -fn pipeline_to_command(pipeline: &Pipeline) -> Option<(TaskParsedCommand, Range)> { +fn pipeline_to_command( + pipeline: &Pipeline, + pathname_expansion_enabled: bool, +) -> Option<(TaskParsedCommand, Range)> { let location = pipeline.location()?; let range = location.start.index..location.end.index; @@ -122,7 +210,8 @@ fn pipeline_to_command(pipeline: &Pipeline) -> Option<(TaskParsedCommand, Range< let AssignmentValue::Scalar(value) = value else { return None; }; - envs.insert(name.as_str().into(), unquote(value)?); + // Assignment values are not subject to pathname expansion. + envs.insert(name.as_str().into(), unquote(value, false)?); } } let mut args = Vec::::new(); @@ -131,14 +220,24 @@ fn pipeline_to_command(pipeline: &Pipeline) -> Option<(TaskParsedCommand, Range< let CommandPrefixOrSuffixItem::Word(word) = suffix_item else { return None; }; - args.push(unquote(word)?); + args.push(unquote(word, pathname_expansion_enabled)?); } } - Some((TaskParsedCommand { envs, program: unquote(program)?, args }, range)) + Some(( + TaskParsedCommand { envs, program: unquote(program, pathname_expansion_enabled)?, args }, + range, + )) } +/// Parses commands that can be executed without a shell. +/// +/// Set `pathname_expansion_enabled` when the target shell treats unquoted patterns as pathname +/// expansions. Such patterns make the command ineligible for static execution. #[must_use] -pub fn try_parse_as_and_list(cmd: &str) -> Option)>> { +pub fn try_parse_as_and_list( + cmd: &str, + pathname_expansion_enabled: bool, +) -> Option)>> { let mut parser = Parser::new(cmd.as_bytes(), &PARSER_OPTIONS); let Program { complete_commands } = parser.parse_program().ok()?; let [compound_list] = complete_commands.as_slice() else { @@ -150,12 +249,12 @@ pub fn try_parse_as_and_list(cmd: &str) -> Option)>::new(); - commands.push(pipeline_to_command(&and_or_list.first)?); + commands.push(pipeline_to_command(&and_or_list.first, pathname_expansion_enabled)?); for and_or in &and_or_list.additional { let AndOr::And(pipeline) = and_or else { return None; }; - commands.push(pipeline_to_command(pipeline)?); + commands.push(pipeline_to_command(pipeline, pathname_expansion_enabled)?); } Some(commands) } @@ -164,10 +263,14 @@ pub fn try_parse_as_and_list(cmd: &str) -> Option Option)>> { + try_parse_as_and_list(cmd, true) + } + #[test] fn test_parse_single_command() { let source = r"A=B hello world"; - let list = try_parse_as_and_list(source).unwrap(); + let list = parse(source).unwrap(); assert_eq!(list.len(), 1); let (cmd, range) = &list[0]; assert_eq!(&source[range.clone()], source); @@ -184,7 +287,7 @@ mod tests { #[test] fn test_parse_command() { let source = r#"A=B hello world && FOO="BE\"R" program "arg1" "arg\"2" && zzz"#; - let list = try_parse_as_and_list(source).unwrap(); + let list = parse(source).unwrap(); let commands = list.iter().map(|(cmd, _)| cmd).collect::>(); assert_eq!( @@ -242,22 +345,22 @@ mod tests { fn test_unquote_preserves_nested_quotes() { // Single quotes inside double quotes are preserved let cmd = r#"echo "hello 'world'""#; - let list = try_parse_as_and_list(cmd).unwrap(); + let list = parse(cmd).unwrap(); assert_eq!(list[0].0.args[0].as_str(), "hello 'world'"); // Double quotes inside single quotes are preserved let cmd = r#"echo 'hello "world"'"#; - let list = try_parse_as_and_list(cmd).unwrap(); + let list = parse(cmd).unwrap(); assert_eq!(list[0].0.args[0].as_str(), "hello \"world\""); // Backslash escaping in double quotes let cmd = r#"echo "hello\"world""#; - let list = try_parse_as_and_list(cmd).unwrap(); + let list = parse(cmd).unwrap(); assert_eq!(list[0].0.args[0].as_str(), "hello\"world"); // Backslash escaping outside quotes let cmd = r"echo hello\ world"; - let list = try_parse_as_and_list(cmd).unwrap(); + let list = parse(cmd).unwrap(); assert_eq!(list[0].0.args[0].as_str(), "hello world"); } @@ -286,10 +389,97 @@ mod tests { assert!(parse_and_flatten(r#""hello $(cmd)""#).is_none()); } + #[test] + fn test_unquoted_pathname_expansion_uses_shell_fallback() { + for cmd in [ + "tool packages/*/src", + "tool packages/?/src", + "tool packages/[ab]/src", + "tool packages/[!ab]/src", + "tool packages/[]a]/src", + "tool packages/[[:alpha:]]/src", + "packages/*/bin --help", + "tool --pattern=*", + "tool https://example.test/items?limit=1", + "tool expression[ab]", + ] { + assert!(parse(cmd).is_none(), "{cmd}"); + } + } + + #[test] + fn test_pathname_expansion_fallback_is_not_path_heuristic() { + // Shell expansion is determined by unquoted word syntax, not by whether a word looks + // like a filesystem path. These non-path words must still use the shell so their + // behavior matches package-manager scripts. + for cmd in ["tool --include=*", "tool key?value", "tool selector[ab]"] { + assert!(parse(cmd).is_none(), "{cmd}"); + } + } + + #[test] + fn test_shell_without_pathname_expansion_keeps_patterns_on_static_path() { + let parsed = + try_parse_as_and_list("tool packages/*/src && tool packages/[ab]/src", false).unwrap(); + + assert_eq!(parsed.len(), 2); + assert_eq!(parsed[0].0.args[0], "packages/*/src"); + assert_eq!(parsed[1].0.args[0], "packages/[ab]/src"); + } + + #[test] + fn test_unmatched_bracket_stays_on_static_path() { + // An unmatched `[` is an ordinary character rather than a bracket expression. A slash + // also terminates the pathname component before a later `]` can close the expression. + for (cmd, expected) in [ + ("tool selector[abc", "selector[abc"), + ("tool packages/[abc/src]", "packages/[abc/src]"), + ("tool selector[]", "selector[]"), + ("tool selector[!]", "selector[!]"), + ] { + let parsed = parse(cmd).unwrap(); + assert_eq!(parsed[0].0.args[0], expected); + } + } + + #[test] + fn test_glob_in_and_list_falls_back_as_one_shell_script() { + assert!(parse("tool before && tool packages/*/src").is_none()); + assert!(parse("tool packages/*/src && tool after").is_none()); + + let parsed = parse("tool before && tool after").unwrap(); + assert_eq!(parsed.len(), 2); + } + + #[test] + fn test_invalid_shell_syntax_uses_shell_fallback() { + assert!(parse("tool 'unterminated").is_none()); + } + + #[test] + fn test_quoted_or_escaped_pathname_patterns_stay_literal() { + for (cmd, expected) in [ + (r#"tool "packages/*/src""#, "packages/*/src"), + ("tool 'packages/?/src'", "packages/?/src"), + (r"tool packages/\[ab\]/src", "packages/[ab]/src"), + (r#"tool "https://example.test/items?limit=1""#, "https://example.test/items?limit=1"), + (r"tool --pattern=\*", "--pattern=*"), + ] { + let parsed = parse(cmd).unwrap(); + assert_eq!(parsed[0].0.args[0], expected); + } + } + + #[test] + fn test_assignment_value_pathname_patterns_stay_literal() { + let parsed = parse("PATTERN=* tool").unwrap(); + assert_eq!(parsed[0].0.envs["PATTERN"], "*"); + } + #[test] fn test_parse_urllib_prepare() { let cmd = r#"node -e "const v = parseInt(process.versions.node, 10); if (v >= 20) require('child_process').execSync('vp config', {stdio: 'inherit'});""#; - let result = try_parse_as_and_list(cmd); + let result = parse(cmd); let (parsed, _) = &result.as_ref().unwrap()[0]; // Single quotes inside double quotes must be preserved as literal characters assert_eq!(