From 38290fbcd8cfce1ccb7730ad198e0549abb327d6 Mon Sep 17 00:00:00 2001 From: Aaron Reisman Date: Fri, 2 Oct 2026 14:40:02 +0700 Subject: [PATCH] feat(plan): let synthesized commands declare a tool entry script Built-in tools are planned with the absolute path of their entry script in args, and args go into the cache key as is. Moving the workspace, or using a second checkout, changes that path and misses the cache. SyntheticPlanRequest gets an optional tool_entry. When the entry is inside the workspace, the planner passes it relative to the task's cwd, so the command and the cache key stay the same after a move. Other paths and all other args are left alone. --- crates/vt_bin/src/lib.rs | 1 + crates/vt_plan/src/error.rs | 3 + crates/vt_plan/src/lib.rs | 1 + crates/vt_plan/src/plan.rs | 10 +- crates/vt_plan/src/plan_request.rs | 11 +- crates/vt_plan/src/tool_entry.rs | 37 ++++++ crates/vt_plan/tests/tool_entry.rs | 191 +++++++++++++++++++++++++++++ 7 files changed, 252 insertions(+), 2 deletions(-) create mode 100644 crates/vt_plan/src/tool_entry.rs create mode 100644 crates/vt_plan/tests/tool_entry.rs diff --git a/crates/vt_bin/src/lib.rs b/crates/vt_bin/src/lib.rs index ebe44c02a..deb853694 100644 --- a/crates/vt_bin/src/lib.rs +++ b/crates/vt_bin/src/lib.rs @@ -93,6 +93,7 @@ impl vt::CommandHandler for CommandHandler { let program = find_executable(get_path_env(&command.envs), &command.cwd, "vtt")?; Ok(HandledCommand::Synthesized(SyntheticPlanRequest { program, + tool_entry: None, args: args.into_iter().filter(|a| a.as_str() != "--").collect(), cache_config: UserCacheConfig::with_config(EnabledCacheConfig { env: None, diff --git a/crates/vt_plan/src/error.rs b/crates/vt_plan/src/error.rs index 39c5f87fe..5ed6a6ea0 100644 --- a/crates/vt_plan/src/error.rs +++ b/crates/vt_plan/src/error.rs @@ -89,6 +89,9 @@ pub struct PathFingerprintError { /// Errors that can occur when planning a specific execution from a task. #[derive(Debug, thiserror::Error)] pub enum Error { + #[error("Tool entry path is not valid UTF-8: {path}")] + NonUtf8ToolEntry { path: Arc }, + #[error("Failed to plan tasks from `{command}` in task {task_display}")] NestPlan { task_display: TaskDisplay, diff --git a/crates/vt_plan/src/lib.rs b/crates/vt_plan/src/lib.rs index e58884c94..ad1435db6 100644 --- a/crates/vt_plan/src/lib.rs +++ b/crates/vt_plan/src/lib.rs @@ -9,6 +9,7 @@ mod plan; pub mod plan_request; mod ps1_shim; pub mod remote_cache; +mod tool_entry; use std::{collections::BTreeMap, ffi::OsStr, fmt::Debug, sync::Arc}; diff --git a/crates/vt_plan/src/plan.rs b/crates/vt_plan/src/plan.rs index c76ba93f8..95836d38a 100644 --- a/crates/vt_plan/src/plan.rs +++ b/crates/vt_plan/src/plan.rs @@ -561,7 +561,15 @@ pub fn plan_synthetic_request( parent_cache_config: ParentCacheConfig, resolved_remote_cache: Option<&ResolvedRemoteCacheConfig>, ) -> Result { - let SyntheticPlanRequest { program, args, cache_config, envs } = synthetic_plan_request; + let SyntheticPlanRequest { program, args, tool_entry, cache_config, envs } = + synthetic_plan_request; + let args = if let Some(entry) = tool_entry { + std::iter::once(crate::tool_entry::entry_argument(&entry, cwd, workspace_path)?) + .chain(args.iter().cloned()) + .collect() + } else { + args + }; let program_path = which(&program, &envs, cwd)?; let (program_path, args) = diff --git a/crates/vt_plan/src/plan_request.rs b/crates/vt_plan/src/plan_request.rs index dd876555d..78dc75e4c 100644 --- a/crates/vt_plan/src/plan_request.rs +++ b/crates/vt_plan/src/plan_request.rs @@ -26,6 +26,7 @@ impl ScriptCommand { SyntheticPlanRequest { program: Arc::from(OsStr::new(&self.program)), args: self.args.clone(), + tool_entry: None, cache_config, envs: self.envs.clone(), } @@ -84,9 +85,17 @@ pub struct SyntheticPlanRequest { /// The program to execute pub program: Arc, - /// The arguments to pass to the program + /// The arguments to pass after `tool_entry`, or directly to the program when absent. pub args: Arc<[Str]>, + /// A known executable script loaded by `program`, inserted before `args`. + /// + /// Producers supply the absolute entry path, not a duplicate in `args`. The planner + /// makes an in-workspace entry relative to the actual task cwd for both execution + /// and caching. External paths and paths containing parent traversal stay absolute. + /// `None` preserves ordinary argument handling; arguments are never inspected for paths. + pub tool_entry: Option>, + /// The cache config as if it's defined in `vite.config.*` pub cache_config: UserCacheConfig, diff --git a/crates/vt_plan/src/tool_entry.rs b/crates/vt_plan/src/tool_entry.rs new file mode 100644 index 000000000..bfc5a5770 --- /dev/null +++ b/crates/vt_plan/src/tool_entry.rs @@ -0,0 +1,37 @@ +use std::{path::Component, sync::Arc}; + +use vt_path::{AbsolutePath, RelativePathBuf}; +use vt_str::Str; + +use crate::Error; + +pub fn entry_argument( + entry: &Arc, + cwd: &AbsolutePath, + workspace: &AbsolutePath, +) -> Result { + let absolute = entry + .as_path() + .to_str() + .ok_or_else(|| Error::NonUtf8ToolEntry { path: Arc::clone(entry) })?; + // Do not collapse `link/..`: lexical normalization can change the executed file. + if [entry.as_ref(), cwd, workspace] + .iter() + .any(|path| path.as_path().components().any(|component| component == Component::ParentDir)) + { + return Ok(Str::from(absolute)); + } + if let (Ok(Some(entry_relative)), Ok(Some(cwd_relative))) = + (entry.strip_prefix(workspace), cwd.strip_prefix(workspace)) + && let Some(relative) = + pathdiff::diff_paths(entry_relative.as_path(), cwd_relative.as_path()) + && let Ok(relative) = RelativePathBuf::new(relative) + { + // A leading `./` keeps an entry named `-tool.js` from being read as an option. + if relative.as_str().starts_with("..") { + return Ok(Str::from(relative.as_str())); + } + return Ok(vt_str::format!("./{}", relative.as_str())); + } + Ok(Str::from(absolute)) +} diff --git a/crates/vt_plan/tests/tool_entry.rs b/crates/vt_plan/tests/tool_entry.rs new file mode 100644 index 000000000..937d8d2d8 --- /dev/null +++ b/crates/vt_plan/tests/tool_entry.rs @@ -0,0 +1,191 @@ +use std::{ffi::OsStr, sync::Arc}; + +use vt_graph::config::UserCacheConfig; +use vt_path::{AbsolutePath, AbsolutePathBuf}; +use vt_plan::{SpawnExecution, plan_request::SyntheticPlanRequest, plan_synthetic}; +use vt_str::Str; + +fn root() -> Arc { + AbsolutePathBuf::new(std::env::temp_dir()).unwrap().join("tool-entry-workspace").into() +} + +fn plan( + workspace: &Arc, + cwd: &Arc, + entry: Option>, + args: Arc<[Str]>, +) -> SpawnExecution { + let program = std::env::current_exe().unwrap().into_os_string().into(); + plan_synthetic( + workspace, + cwd, + SyntheticPlanRequest { + program, + args, + tool_entry: entry, + cache_config: UserCacheConfig::Bool(true), + envs: Arc::default(), + }, + Arc::from([Str::from("tool")]), + ) + .unwrap() +} + +#[test] +fn tool_entry_uses_task_cwd_and_identical_spawn_and_cache_arguments() { + let temp = tempfile::tempdir().unwrap(); + let workspace: Arc = + AbsolutePathBuf::new(temp.path().to_path_buf()).unwrap().into(); + let entry: Arc = workspace.join("node_modules/tool/bin.js").into(); + std::fs::create_dir_all(entry.parent().unwrap()).unwrap(); + std::fs::create_dir_all(workspace.join("packages/nested")).unwrap(); + std::fs::write(entry.as_path(), "tool entry").unwrap(); + for (cwd, expected) in [ + (Arc::clone(&workspace), "./node_modules/tool/bin.js"), + (workspace.join("packages/nested").into(), "../../node_modules/tool/bin.js"), + ] { + let args: Arc<[Str]> = Arc::from([ + Str::from("--config"), + Str::from(entry.as_path().to_str().unwrap()), + Str::from("../untouched"), + ]); + let planned = plan(&workspace, &cwd, Some(Arc::clone(&entry)), Arc::clone(&args)); + assert_eq!(planned.spawn_command.cwd, cwd); + assert_eq!(planned.spawn_command.args[0], expected); + assert_eq!( + std::fs::read_to_string(cwd.join(planned.spawn_command.args[0].as_str())).unwrap(), + "tool entry" + ); + assert_eq!(&planned.spawn_command.args[1..], args.as_ref()); + assert_eq!( + planned.cache_metadata.unwrap().spawn_fingerprint.args().as_ref(), + planned.spawn_command.args.as_ref() + ); + } +} + +#[test] +fn tool_entry_relocation_has_the_same_spawn_fingerprint() { + let a = root(); + let b: Arc = a.parent().unwrap().join("other-workspace").into(); + let fingerprints: Vec<_> = [Arc::clone(&a), b] + .iter() + .map(|workspace| { + plan( + workspace, + &workspace.join("packages/nested").into(), + Some(workspace.join("node_modules/tool/bin.js").into()), + Arc::from([Str::from("run")]), + ) + .cache_metadata + .unwrap() + .spawn_fingerprint + }) + .collect(); + assert_eq!(fingerprints[0], fingerprints[1]); + for (entry, args) in [ + ("node_modules/other/bin.js", Arc::from([Str::from("run")])), + ("node_modules/tool/bin.js", Arc::from([Str::from("build")])), + ] { + let changed = plan(&a, &a.join("packages/nested").into(), Some(a.join(entry).into()), args); + assert_ne!(fingerprints[0], changed.cache_metadata.unwrap().spawn_fingerprint); + } +} + +#[test] +fn tool_entry_preserves_external_and_traversal_paths() { + let workspace = root(); + for entry in [ + workspace.parent().unwrap().join("tool-entry-workspace-other/bin.js"), + workspace.join("../external/bin.js"), + workspace.join("node_modules/link/../bin.js"), + ] { + let entry: Arc = entry.into(); + let planned = plan(&workspace, &workspace, Some(Arc::clone(&entry)), Arc::default()); + assert_eq!(OsStr::new(planned.spawn_command.args[0].as_str()), entry.as_path()); + } +} + +#[test] +fn tool_entry_absent_preserves_all_arguments() { + let workspace = root(); + let args: Arc<[Str]> = Arc::from([ + Str::from(workspace.join("node_modules/tool/bin.js").as_path().to_str().unwrap()), + Str::from("--entry=/absolute/opaque.js"), + ]); + let planned = plan(&workspace, &workspace, None, Arc::clone(&args)); + assert_eq!(planned.spawn_command.args, args); +} + +#[test] +fn tool_entry_option_like_filename_stays_a_path() { + let workspace = root(); + let planned = + plan(&workspace, &workspace, Some(workspace.join("-tool.js").into()), Arc::default()); + assert_eq!(planned.spawn_command.args[0], "./-tool.js"); +} + +#[test] +fn ordinary_script_conversion_does_not_declare_a_tool_entry() { + let command = vt_plan::plan_request::ScriptCommand { + program: Str::from("node"), + args: Arc::from([Str::from("/opaque/script.js")]), + envs: Arc::default(), + cwd: root(), + }; + let request = command.to_synthetic_plan_request(UserCacheConfig::Bool(true)); + assert!(request.tool_entry.is_none()); + assert_eq!(request.args, command.args); +} + +#[test] +fn non_entry_absolute_arguments_remain_location_sensitive() { + let workspace = root(); + let fingerprints: Vec<_> = ["first", "second"] + .iter() + .map(|name| { + let entry: Arc = workspace.join(name).join("bin.js").into(); + plan( + &workspace, + &workspace, + None, + Arc::from([Str::from(entry.as_path().to_str().unwrap())]), + ) + .cache_metadata + .unwrap() + .spawn_fingerprint + }) + .collect(); + assert_ne!(fingerprints[0], fingerprints[1]); +} + +#[test] +fn tool_entry_rejects_non_utf8_without_lossy_conversion() { + #[cfg(unix)] + let invalid = { + use std::os::unix::ffi::OsStringExt as _; + std::ffi::OsString::from_vec(vec![0xff]) + }; + #[cfg(windows)] + let invalid = { + use std::os::windows::ffi::OsStringExt as _; + std::ffi::OsString::from_wide(&[0xd800]) + }; + let workspace = root(); + let entry: Arc = workspace.join(invalid).into(); + for enabled in [false, true] { + let result = plan_synthetic( + &workspace, + &workspace, + SyntheticPlanRequest { + program: std::env::current_exe().unwrap().into_os_string().into(), + args: Arc::default(), + tool_entry: Some(Arc::clone(&entry)), + cache_config: UserCacheConfig::Bool(enabled), + envs: Arc::default(), + }, + Arc::from([Str::from("tool")]), + ); + assert!(matches!(result, Err(vt_plan::Error::NonUtf8ToolEntry { path }) if path == entry)); + } +}