diff --git a/CHANGELOG.md b/CHANGELOG.md index 674434045..7a53d6610 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,6 @@ # Changelog +- **Added** Runner-aware env tracking: tools can ask the runner for env values via `getEnv`/`getEnvs`, and the served values (and glob match-sets) become part of the cache fingerprint, so changing them invalidates the cache with the env var named in the miss message ([#430](https://github.com/voidzero-dev/vite-task/pull/430)) - **Added** Runner-aware tools can now opt the current task run out of caching through the new IPC channel; Vite dev server integration uses this automatically ([#441](https://github.com/voidzero-dev/vite-task/pull/441)) - **Fixed** Prefix environment assignments like `PATH=... command` now affect executable lookup during task planning, so tools provided only by the prefixed `PATH` can be resolved correctly ([#440](https://github.com/voidzero-dev/vite-task/pull/440)) - **Changed** Cache misses caused by a tracked env var now name the env var inline, for example `cache miss: env 'NODE_ENV' changed`, instead of the generic `envs changed` message ([#438](https://github.com/voidzero-dev/vite-task/pull/438)) diff --git a/CLAUDE.md b/CLAUDE.md index b5b8405f3..bedde7d31 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -140,6 +140,10 @@ Enforced by `.clippy.toml`: - Only convert to std paths when interfacing with std library functions - Add necessary methods in `vite_path` instead of falling back to std path types +### Environment Variables + +`std::env::vars_os` is read exactly once — in `Session::init` — to bootstrap the session env snapshot (`Session.envs`). Everything downstream (planning, spawn env resolution, IPC `getEnv`/`getEnvs`, cache fingerprint validation) must use that snapshot or the plan's resolved env maps, never re-read the live process env. This keeps a run's behavior consistent with its plan and lets tests inject envs via `Session::init_with`. + ### Cross-Platform Requirements All code must work on both Unix and Windows without platform skipping: diff --git a/Cargo.lock b/Cargo.lock index ad37f767c..0262e79fe 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4267,6 +4267,7 @@ dependencies = [ "tracing", "twox-hash", "uuid", + "vite_glob", "vite_path", "vite_select", "vite_str", diff --git a/crates/fspy/build.rs b/crates/fspy/build.rs index 90b7bbcf4..1e62b304d 100644 --- a/crates/fspy/build.rs +++ b/crates/fspy/build.rs @@ -158,7 +158,6 @@ fn register_preload_cdylib() -> anyhow::Result<()> { } fn main() -> anyhow::Result<()> { - println!("cargo:rerun-if-changed=build.rs"); let out_dir = PathBuf::from(env::var_os("OUT_DIR").unwrap()); fetch_macos_binaries(&out_dir).context("Failed to fetch macOS binaries")?; register_preload_cdylib().context("Failed to register preload cdylib")?; diff --git a/crates/vite_task/Cargo.toml b/crates/vite_task/Cargo.toml index 2bc0ad7e3..77a9771ef 100644 --- a/crates/vite_task/Cargo.toml +++ b/crates/vite_task/Cargo.toml @@ -45,6 +45,7 @@ tracing = { workspace = true } twox-hash = { workspace = true } materialized_artifact = { workspace = true } uuid = { workspace = true, features = ["v4"] } +vite_glob = { workspace = true } vite_path = { workspace = true } vite_select = { workspace = true } vite_str = { workspace = true } diff --git a/crates/vite_task/docs/task-cache.md b/crates/vite_task/docs/task-cache.md index 440901ccd..d6c75a910 100644 --- a/crates/vite_task/docs/task-cache.md +++ b/crates/vite_task/docs/task-cache.md @@ -92,7 +92,7 @@ The cache entry key uniquely identifies a command execution context: ```rust pub struct CacheEntryKey { pub spawn_fingerprint: SpawnFingerprint, - pub input_config: ResolvedInputConfig, + pub input_config: ResolvedGlobConfig, } ``` @@ -303,7 +303,7 @@ Cache entries are serialized using `bincode` for efficient storage. │ ────────────────────── │ │ CacheEntryKey { │ │ spawn_fingerprint: SpawnFingerprint { ... }, │ -│ input_config: ResolvedInputConfig { ... }, │ +│ input_config: ResolvedGlobConfig { ... }, │ │ } │ │ ExecutionCacheKey::UserTask { │ │ task_name: "build", │ diff --git a/crates/vite_task/src/session/cache/display.rs b/crates/vite_task/src/session/cache/display.rs index 44b5031de..804b76449 100644 --- a/crates/vite_task/src/session/cache/display.rs +++ b/crates/vite_task/src/session/cache/display.rs @@ -120,8 +120,8 @@ pub fn detect_spawn_fingerprint_changes( } /// Names of the env vars involved in a set of spawn-fingerprint changes, in the -/// order detected. Only env changes are collected; untracked-env and non-env -/// changes are skipped. +/// order detected. Only the tracked-env kinds are collected; untracked-env and +/// non-env changes are skipped. fn env_change_names(changes: &[SpawnFingerprintChange]) -> Vec<&Str> { changes .iter() @@ -195,6 +195,13 @@ pub fn format_cache_status_inline(cache_status: &CacheStatus) -> Option { FingerprintMismatch::InputChanged { kind, path } => { format_input_change_str(*kind, path.as_str()) } + // Env changes reported by a runner-aware tool render the same as + // changes detected from a manual `env` config — both name the + // env var that changed. + FingerprintMismatch::TrackedEnvChanged(mismatch) + | FingerprintMismatch::TrackedEnvGlobChanged { mismatch, .. } => { + format_env_changed_inline(&[mismatch.name()]) + } }; Some(vite_str::format!("○ cache miss: {reason}, executing")) } diff --git a/crates/vite_task/src/session/cache/mod.rs b/crates/vite_task/src/session/cache/mod.rs index d760381b5..608748d28 100644 --- a/crates/vite_task/src/session/cache/mod.rs +++ b/crates/vite_task/src/session/cache/mod.rs @@ -3,7 +3,9 @@ pub mod archive; pub mod display; -use std::{collections::BTreeMap, fmt::Display, fs::File, io::Write, sync::Arc, time::Duration}; +use std::{ + collections::BTreeMap, ffi::OsStr, fmt::Display, fs::File, io::Write, sync::Arc, time::Duration, +}; // Re-export display functions for convenience pub use display::format_cache_status_inline; @@ -12,6 +14,7 @@ pub use display::{ format_spawn_change, }; use rusqlite::{Connection, OptionalExtension as _}; +use rustc_hash::FxHashMap; use serde::{Deserialize, Serialize}; use tokio::sync::Mutex; use vite_path::{AbsolutePath, RelativePathBuf}; @@ -142,9 +145,10 @@ pub enum InputChangeKind { /// A single env var difference between a stored fingerprint and the current /// environment. /// -/// The canonical shape for an env change wherever one is detected and -/// reported. The [`Display`] impl is the single source of the user-facing -/// wording. +/// Shared by the prerun (spawn fingerprint) and post-run (tool-tracked env / +/// env glob) mismatch paths, so every env change is reported and rendered the +/// same way regardless of where it was detected. The [`Display`] impl is the +/// single source of the user-facing wording. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub enum EnvMismatch { /// Set now, but absent from the stored fingerprint. @@ -165,6 +169,23 @@ impl EnvMismatch { } } } + + /// Compare a stored env value against the current one, returning the + /// mismatch if they differ. `None` on either side means the env is unset + /// there; two unset (or two equal) values are not a mismatch. + #[must_use] + pub fn compare(name: &Str, stored: Option<&Str>, current: Option<&Str>) -> Option { + match (stored, current) { + (None, Some(value)) => Some(Self::Added { name: name.clone(), value: value.clone() }), + (Some(value), None) => Some(Self::Removed { name: name.clone(), value: value.clone() }), + (Some(old_value), Some(new_value)) if old_value != new_value => Some(Self::Changed { + name: name.clone(), + old_value: old_value.clone(), + new_value: new_value.clone(), + }), + _ => None, + } + } } impl Display for EnvMismatch { @@ -198,6 +219,27 @@ pub enum FingerprintMismatch { kind: InputChangeKind, path: RelativePathBuf, }, + /// A tool-tracked env var changed between runs. + TrackedEnvChanged(EnvMismatch), + /// A tool-tracked env glob's match-set changed between runs. Carries the + /// first differing entry. + TrackedEnvGlobChanged { + pattern: Str, + mismatch: EnvMismatch, + }, +} + +impl From for FingerprintMismatch { + fn from(mismatch: crate::session::execute::fingerprint::PostRunMismatch) -> Self { + use crate::session::execute::fingerprint::PostRunMismatch; + match mismatch { + PostRunMismatch::InputChanged { kind, path } => Self::InputChanged { kind, path }, + PostRunMismatch::TrackedEnvChanged(mismatch) => Self::TrackedEnvChanged(mismatch), + PostRunMismatch::TrackedEnvGlobChanged { pattern, mismatch } => { + Self::TrackedEnvGlobChanged { pattern, mismatch } + } + } + } } impl Display for FingerprintMismatch { @@ -215,6 +257,10 @@ impl Display for FingerprintMismatch { Self::InputChanged { kind, path } => { write!(f, "{}", display::format_input_change_str(*kind, path.as_str())) } + Self::TrackedEnvChanged(mismatch) => write!(f, "tracked {mismatch}"), + Self::TrackedEnvGlobChanged { pattern, mismatch } => { + write!(f, "tracked env glob {:?}: {mismatch}", pattern.as_str()) + } } } } @@ -240,7 +286,7 @@ pub fn split_path(path: &str) -> (Option<&str>, &str) { /// its own cache warm across branch switches, and a cache from a different /// version is simply ignored (it lives in a directory this build never looks /// at) rather than aborting the run. Bumping the version starts a fresh cache. -const CACHE_SCHEMA_VERSION: u32 = 13; +const CACHE_SCHEMA_VERSION: u32 = 14; /// Name of the per-version subdirectory (e.g. `v13`) under the task-cache /// directory that holds the database and output archives for the current @@ -286,12 +332,16 @@ impl ExecutionCache { /// Try to hit cache by looking up the cache entry key and validating inputs. /// Returns `Ok(Ok(cache_value))` on cache hit, `Ok(Err(cache_miss))` on miss. + /// + /// `envs` is the session env snapshot the run's plan was bootstrapped from; + /// tracked-env validation resolves against it, never the live process env. #[tracing::instrument(level = "debug", skip_all)] pub async fn try_hit( &self, cache_metadata: &CacheMetadata, globbed_inputs: &BTreeMap, workspace_root: &AbsolutePath, + envs: &FxHashMap, Arc>, ) -> anyhow::Result> { let spawn_fingerprint = &cache_metadata.spawn_fingerprint; let execution_cache_key = &cache_metadata.execution_cache_key; @@ -307,11 +357,11 @@ impl ExecutionCache { return Ok(Err(CacheMiss::FingerprintMismatch(mismatch))); } - // Validate post-run fingerprint (inferred inputs from fspy) - if let Some((kind, path)) = cache_value.post_run_fingerprint.validate(workspace_root)? { - return Ok(Err(CacheMiss::FingerprintMismatch( - FingerprintMismatch::InputChanged { kind, path }, - ))); + // Validate post-run fingerprint (inferred inputs + tracked envs) + if let Some(mismatch) = + cache_value.post_run_fingerprint.validate(workspace_root, envs)? + { + return Ok(Err(CacheMiss::FingerprintMismatch(mismatch.into()))); } // Associate the execution key to the cache entry key if not already, // so that next time we can find it and report what changed diff --git a/crates/vite_task/src/session/execute/cache_update.rs b/crates/vite_task/src/session/execute/cache_update.rs index 45b679e52..4d0cdad13 100644 --- a/crates/vite_task/src/session/execute/cache_update.rs +++ b/crates/vite_task/src/session/execute/cache_update.rs @@ -1,8 +1,9 @@ //! Post-run cache update: decide whether a finished spawn may be cached and, //! if so, store its fingerprint, captured output, and output archive. -use std::{sync::Arc, time::Duration}; +use std::{collections::BTreeMap, sync::Arc, time::Duration}; +use rustc_hash::FxHashSet; use vite_path::{AbsolutePath, RelativePathBuf}; use vite_str::Str; use vite_task_plan::cache_metadata::CacheMetadata; @@ -27,6 +28,9 @@ use crate::{ /// value is only ever `Some` when tracking happened (see [`observe_fspy`]). struct TrackingOutcome { path_reads: HashMap, + /// All paths the task wrote to. Consumed by `collect_and_archive_outputs` + /// when `output_config.includes_auto` is set. + path_writes: FxHashSet, /// First path that was both read and written during execution, if any. /// A non-empty value means caching this task is unsound. read_write_overlap: Option, @@ -53,7 +57,7 @@ pub(super) async fn update_cache( cancelled: bool, ) -> (CacheUpdateStatus, Option) { let CacheState { metadata, globbed_inputs, std_outputs, tracking } = state; - let fspy_negatives = tracking.as_ref().map(|t| t.input_negative_globs.as_slice()); + let input_negative_globs = tracking.as_ref().map(|t| t.input_negative_globs.as_slice()); if let Some(reports) = reports && reports.cache_disabled @@ -63,6 +67,14 @@ pub(super) async fn update_cache( return (CacheUpdateStatus::NotUpdated(CacheNotUpdatedReason::ToolRequested), None); } + // `ignoreInput`/`ignoreOutput` are accepted over IPC but not yet + // applied — runner-aware output tracking (which consumes them) lands + // in a follow-up. Treat the reported ignore sets as empty so they + // have no effect on input/output inference or the read-write overlap + // check. + let ignored_input_rels: FxHashSet = FxHashSet::default(); + let ignored_output_rels: FxHashSet = FxHashSet::default(); + if cancelled { // Cancelled (Ctrl-C or sibling failure) — result is untrustworthy. return (CacheUpdateStatus::NotUpdated(CacheNotUpdatedReason::Cancelled), None); @@ -73,7 +85,14 @@ pub(super) async fn update_cache( return (CacheUpdateStatus::NotUpdated(CacheNotUpdatedReason::NonZeroExitStatus), None); } - let fspy_outcome = observe_fspy(outcome, fspy_negatives, workspace_root); + let fspy_outcome = observe_fspy( + outcome, + metadata, + input_negative_globs, + &ignored_input_rels, + &ignored_output_rels, + workspace_root, + ); if let Some(TrackingOutcome { read_write_overlap: Some(path), .. }) = &fspy_outcome { // fspy-inferred read-write overlap: the task wrote to a file it also @@ -89,30 +108,49 @@ pub(super) async fn update_cache( ); } - if fspy_outcome.is_none() && fspy_negatives.is_some() { + if fspy_outcome.is_none() && input_negative_globs.is_some() { // Task requested fspy auto-inference but this binary was built without // `cfg(fspy)`. Task ran, but we can't compute a valid cache entry // without tracked path accesses. return (CacheUpdateStatus::NotUpdated(CacheNotUpdatedReason::FspyUnsupported), None); } + // Collect tool-reported tracked envs for the post-run fingerprint. + // User-declared `env` wins — skip names that are already in the spawn + // fingerprint. + let tracked_envs = reports.map(|r| collect_tracked_envs(r, metadata)).unwrap_or_default(); + let tracked_env_globs = reports.map(collect_tracked_env_globs).unwrap_or_default(); + // Paths already in globbed_inputs are skipped: the overlap check above // guarantees no input modification, so the prerun hash is the correct // post-exec hash. let empty_path_reads = HashMap::default(); let path_reads = fspy_outcome.as_ref().map_or(&empty_path_reads, |o| &o.path_reads); - let post_run_fingerprint = - match PostRunFingerprint::create(path_reads, workspace_root, &globbed_inputs) { - Ok(fingerprint) => fingerprint, - Err(err) => { - return ( - CacheUpdateStatus::NotUpdated(CacheNotUpdatedReason::CacheDisabled), - Some(ExecutionError::PostRunFingerprint(err)), - ); - } - }; + let post_run_fingerprint = match PostRunFingerprint::create( + path_reads, + workspace_root, + &globbed_inputs, + tracked_envs, + tracked_env_globs, + ) { + Ok(fingerprint) => fingerprint, + Err(err) => { + return ( + CacheUpdateStatus::NotUpdated(CacheNotUpdatedReason::CacheDisabled), + Some(ExecutionError::PostRunFingerprint(err)), + ); + } + }; - let output_archive = match collect_and_archive_outputs(metadata, workspace_root, cache_dir) { + // Collect output files and create archive. Tool-reported `ignoreOutput` + // paths are excluded from archiving too. + let output_archive = match collect_and_archive_outputs( + metadata, + fspy_outcome.as_ref(), + &ignored_output_rels, + workspace_root, + cache_dir, + ) { Ok(archive) => archive, Err(err) => { return ( @@ -139,62 +177,197 @@ pub(super) async fn update_cache( } /// Summarize the run's fspy observations. `Some` iff tracking was both -/// requested (`fspy_negatives.is_some()`) and compiled in (`cfg(fspy)`). On a +/// requested (`tracking.is_some()`) and compiled in (`cfg(fspy)`). On a /// `cfg(not(fspy))` build this is always `None`, and [`update_cache`] /// short-circuits to `FspyUnsupported` when tracking was needed. +/// +/// `path_reads` is gated on `input_config.includes_auto`, filtered by +/// user-configured input negatives, and by tool-reported `ignoreInput` +/// paths. `path_writes` is NOT filtered here — output negatives and +/// `ignoreOutput` are applied later inside `collect_and_archive_outputs`. +/// Keeping the two sides separate avoids `input: ["!dist/**"]` accidentally +/// dropping writes to `dist/**`, which would break archive restoration. fn observe_fspy( outcome: &ChildOutcome, - fspy_negatives: Option<&[wax::Glob<'static>]>, + metadata: &CacheMetadata, + input_negative_globs: Option<&[wax::Glob<'static>]>, + ignored_input_rels: &FxHashSet, + ignored_output_rels: &FxHashSet, workspace_root: &AbsolutePath, ) -> Option { #[cfg(fspy)] { + use wax::Program as _; + use super::tracked_accesses::TrackedPathAccesses; - outcome.path_accesses.as_ref().zip(fspy_negatives).map(|(raw, negs)| { - let tracked = TrackedPathAccesses::from_raw(raw, workspace_root, negs); - let read_write_overlap = - tracked.path_reads.keys().find(|p| tracked.path_writes.contains(*p)).cloned(); - TrackingOutcome { path_reads: tracked.path_reads, read_write_overlap } + outcome.path_accesses.as_ref().map(|raw| { + let tracked = TrackedPathAccesses::from_raw(raw, workspace_root); + let path_reads: HashMap = + if metadata.input_config.includes_auto + && let Some(negatives) = input_negative_globs + { + tracked + .path_reads + .iter() + .filter(|(path, _)| { + !negatives.iter().any(|neg| neg.is_match(path.as_str())) + && !is_ignored(path, ignored_input_rels) + }) + .map(|(path, read)| (path.clone(), *read)) + .collect() + } else { + HashMap::default() + }; + let read_write_overlap = path_reads + .keys() + .find(|p| tracked.path_writes.contains(*p) && !is_ignored(p, ignored_output_rels)) + .cloned(); + TrackingOutcome { path_reads, path_writes: tracked.path_writes, read_write_overlap } }) } #[cfg(not(fspy))] { - let _ = (outcome, fspy_negatives, workspace_root); + let _ = ( + outcome, + metadata, + input_negative_globs, + ignored_input_rels, + ignored_output_rels, + workspace_root, + ); None } } -/// Collect output files matching the configured globs and create a tar.zst -/// archive in the cache directory. +/// Whether `path` is covered by any `ignored` entry. An ignored entry matches +/// itself (exact file) and everything under it (directory subtree). +fn is_ignored(path: &RelativePathBuf, ignored: &FxHashSet) -> bool { + if ignored.is_empty() { + return false; + } + if ignored.contains(path) { + return true; + } + ignored.iter().any(|ig| path.strip_prefix(ig).is_some()) +} + +/// Select tool-reported env records to embed in the post-run fingerprint. +/// Only `tracked: true` records are included, and names that the user already +/// declared as fingerprinted are skipped (their value is already in the cache +/// key via the spawn fingerprint). +fn collect_tracked_envs(reports: &Reports, metadata: &CacheMetadata) -> BTreeMap> { + let fingerprinted = &metadata.spawn_fingerprint.env_fingerprints().fingerprinted_envs; + reports + .env_records + .iter() + .filter(|(_, record)| record.tracked) + .filter_map(|(name, record)| { + let name_str = name.to_str()?; + if fingerprinted.contains_key(name_str) { + return None; + } + let value = record.value.as_ref().and_then(|v| v.to_str().map(Str::from)); + Some((Str::from(name_str), value)) + }) + .collect() +} + +/// Select tool-reported env-glob records to embed in the post-run +/// fingerprint. Only `tracked: true` records are included, and the full +/// match-set is stored as-is. +/// +/// Unlike [`collect_tracked_envs`], names already covered by the user's +/// declared `env` are *not* filtered out: lookup-time validation re-expands +/// the glob over the whole env context (see [`PostRunFingerprint::validate`]), +/// so a filtered match-set would always diff as having `added` entries and +/// miss the cache deterministically. Storing user-declared names here is +/// harmless — a change to their value already shifts the spawn fingerprint, +/// invalidating the cache key before the post-run fingerprint is consulted. +fn collect_tracked_env_globs(reports: &Reports) -> BTreeMap> { + reports + .env_glob_records + .iter() + .filter(|(_, record)| record.tracked) + .map(|(pattern, record)| { + let matches: BTreeMap = record + .matches + .iter() + .filter_map(|(name, value)| { + let name_str = name.to_str()?; + let value_str = value.to_str()?; + Some((Str::from(name_str), Str::from(value_str))) + }) + .collect(); + (Str::from(pattern.as_ref()), matches) + }) + .collect() +} + +/// Collect output files and create a tar.zst archive in the cache directory. +/// +/// Output files are determined by: +/// - fspy-tracked writes (when `output_config.includes_auto` is true) +/// - Positive output globs (always, if configured) +/// - Filtered by negative output globs +/// - Filtered by tool-reported `ignoreOutput` paths (auto writes only) /// -/// Returns `Some(archive_filename)` if files were archived, `None` if the -/// output config has no positive globs or no files matched. +/// Returns `Some(archive_filename)` if files were archived, `None` if no output files. fn collect_and_archive_outputs( cache_metadata: &CacheMetadata, + tracking: Option<&TrackingOutcome>, + ignored_output_rels: &FxHashSet, workspace_root: &AbsolutePath, cache_dir: &AbsolutePath, ) -> anyhow::Result> { + use wax::Program as _; + let output_config = &cache_metadata.output_config; - if output_config.positive_globs.is_empty() { - return Ok(None); + // Collect output files from auto-detection (fspy writes), excluding + // anything the tool reported via `ignoreOutput`. + let mut output_files: FxHashSet = FxHashSet::default(); + + if output_config.includes_auto + && let Some(t) = tracking + { + output_files + .extend(t.path_writes.iter().filter(|p| !is_ignored(p, ignored_output_rels)).cloned()); } - let output_files = glob::collect_glob_paths( - workspace_root, - &output_config.positive_globs, - &output_config.negative_globs, - )?; + // Collect output files from positive globs + if !output_config.positive_globs.is_empty() { + let glob_paths = glob::collect_glob_paths( + workspace_root, + &output_config.positive_globs, + &output_config.negative_globs, + )?; + output_files.extend(glob_paths); + } + + // Apply negative globs to auto-detected files + if output_config.includes_auto && !output_config.negative_globs.is_empty() { + let negatives: Vec> = output_config + .negative_globs + .iter() + .map(|p| Ok(wax::Glob::new(p.as_str())?.into_owned())) + .collect::>()?; + output_files.retain(|path| !negatives.iter().any(|neg| neg.is_match(path.as_str()))); + } if output_files.is_empty() { return Ok(None); } + // Sort for deterministic archive content + let mut sorted_files: Vec = output_files.into_iter().collect(); + sorted_files.sort(); + + // Create archive with UUID filename let archive_name: Str = vite_str::format!("{}.tar.zst", uuid::Uuid::new_v4()); let archive_path = cache_dir.join(archive_name.as_str()); - archive::create_output_archive(workspace_root, &output_files, &archive_path)?; + archive::create_output_archive(workspace_root, &sorted_files, &archive_path)?; Ok(Some(archive_name)) } diff --git a/crates/vite_task/src/session/execute/fingerprint.rs b/crates/vite_task/src/session/execute/fingerprint.rs index 7b7103d78..0cfdfc480 100644 --- a/crates/vite_task/src/session/execute/fingerprint.rs +++ b/crates/vite_task/src/session/execute/fingerprint.rs @@ -5,18 +5,23 @@ use std::{ collections::BTreeMap, + ffi::OsStr, fs::File, io::{self, BufRead}, sync::Arc, }; use rayon::iter::{IntoParallelRefIterator, ParallelIterator}; +use rustc_hash::FxHashMap; use serde::{Deserialize, Serialize}; use vite_path::{AbsolutePath, RelativePathBuf}; use vite_str::Str; use wincode::{SchemaRead, SchemaWrite}; -use crate::{collections::HashMap, session::cache::InputChangeKind}; +use crate::{ + collections::HashMap, + session::cache::{EnvMismatch, InputChangeKind}, +}; /// Path read access info #[derive(Debug, Clone, Copy)] @@ -31,6 +36,36 @@ pub struct PostRunFingerprint { /// Paths inferred from fspy during execution with their content fingerprints. /// Only populated when `input_config.includes_auto` is true. pub inferred_inputs: HashMap, + + /// Env vars observed via runner-aware IPC `getEnv` with `tracked: true`. + /// Key is the env name; value is the env value at execution time (or + /// `None` if unset). Validated at cache lookup by comparing against the + /// session env snapshot. + pub tracked_envs: BTreeMap>, + + /// Glob-pattern env queries (`getEnvs`) made with `tracked: true`. + /// Outer key is the glob pattern, inner map is the match-set at + /// execution time (name → value). Validated at cache lookup by + /// re-matching against the session env snapshot and comparing the + /// resulting set. + pub tracked_env_globs: BTreeMap>, +} + +/// A mismatch between the stored post-run fingerprint and the current state. +#[expect( + clippy::enum_variant_names, + reason = "all three variants describe different kinds of post-run changes; \ + dropping the `Changed` suffix on any one of them would be misleading" +)] +#[derive(Debug, Clone)] +pub enum PostRunMismatch { + /// An inferred input file or directory changed. + InputChanged { kind: InputChangeKind, path: RelativePathBuf }, + /// A tool-tracked env var changed value (or was added/removed). + TrackedEnvChanged(EnvMismatch), + /// A tool-tracked env glob's match-set changed between runs. Carries the + /// first differing entry (in env-name order). + TrackedEnvGlobChanged { pattern: Str, mismatch: EnvMismatch }, } /// Fingerprint for a single path (file or directory) @@ -69,11 +104,15 @@ impl PostRunFingerprint { /// * `inferred_path_reads` - Map of paths that were read during execution (from fspy) /// * `base_dir` - Workspace root for resolving relative paths /// * `globbed_inputs` - Prerun glob fingerprint; paths here are skipped + /// * `tracked_envs` - Tool-requested env vars (name → value), validated on lookup + /// * `tracked_env_globs` - Tool-requested env globs (pattern → matches), validated on lookup #[tracing::instrument(level = "debug", skip_all, name = "create_post_run_fingerprint")] pub fn create( inferred_path_reads: &HashMap, base_dir: &AbsolutePath, globbed_inputs: &BTreeMap, + tracked_envs: BTreeMap>, + tracked_env_globs: BTreeMap>, ) -> anyhow::Result { let inferred_inputs = inferred_path_reads .par_iter() @@ -85,16 +124,22 @@ impl PostRunFingerprint { }) .collect::>>()?; - Ok(Self { inferred_inputs }) + Ok(Self { inferred_inputs, tracked_envs, tracked_env_globs }) } - /// Validates the fingerprint against current filesystem state. - /// Returns `Some((kind, path))` if an input changed, `None` if all valid. + /// Validates the fingerprint against the current filesystem state and the + /// session env snapshot. Returns `Some(mismatch)` on the first divergence, + /// `None` if all valid. + /// + /// `envs` must be the same env map the run's plan was bootstrapped from + /// (`Session.envs`) — the map that served `getEnv`/`getEnvs` at record + /// time — never a fresh read of the process env. #[tracing::instrument(level = "debug", skip_all, name = "validate_post_run_fingerprint")] pub fn validate( &self, base_dir: &AbsolutePath, - ) -> anyhow::Result> { + envs: &FxHashMap, Arc>, + ) -> anyhow::Result> { let input_mismatch = self.inferred_inputs.par_iter().find_map_any( |(input_relative_path, path_fingerprint)| { let input_full_path = Arc::::from(base_dir.join(input_relative_path)); @@ -120,11 +165,106 @@ impl PostRunFingerprint { } else { input_relative_path.clone() }; - Some(Ok((kind, path))) + Some(Ok(PostRunMismatch::InputChanged { kind, path })) } }, ); - input_mismatch.transpose() + if let Some(result) = input_mismatch { + return result.map(Some); + } + + // Validate tracked envs against the session env snapshot. + for (name, stored_value) in &self.tracked_envs { + let current_value = + envs.get(OsStr::new(name.as_str())).and_then(|v| v.to_str().map(Str::from)); + if let Some(mismatch) = + EnvMismatch::compare(name, stored_value.as_ref(), current_value.as_ref()) + { + return Ok(Some(PostRunMismatch::TrackedEnvChanged(mismatch))); + } + } + + // Validate tracked env globs: re-expand each pattern over the session + // env snapshot and report the first entry that diverges from the + // stored match-set. + for (pattern, stored_matches) in &self.tracked_env_globs { + let current_matches = match_env_glob(pattern.as_str(), envs)?; + if let Some(mismatch) = first_env_glob_mismatch(stored_matches, ¤t_matches) { + return Ok(Some(PostRunMismatch::TrackedEnvGlobChanged { + pattern: pattern.clone(), + mismatch, + })); + } + } + + Ok(None) + } +} + +/// Build the current match-set for `pattern` by enumerating the given env +/// snapshot and keeping UTF-8 names whose representation matches the glob. +/// Mirrors the server-side match (see `vite_task_server::Recorder::get_envs`), +/// which resolved against the same map at record time. +fn match_env_glob( + pattern: &str, + envs: &FxHashMap, Arc>, +) -> anyhow::Result> { + let glob = vite_glob::env::EnvGlob::new(pattern)?; + Ok(envs + .iter() + .filter_map(|(name, value)| { + let name_str = name.to_str()?; + let value_str = value.to_str()?; + if glob.is_match(name_str) { + Some((Str::from(name_str), Str::from(value_str))) + } else { + None + } + }) + .collect()) +} + +/// Find the first difference between the stored and current match-sets of an +/// env glob. Both maps are `BTreeMap`, so the scan walks them in sorted +/// lockstep and the reported entry is deterministic (smallest diverging name). +fn first_env_glob_mismatch( + stored: &BTreeMap, + current: &BTreeMap, +) -> Option { + let mut stored_iter = stored.iter(); + let mut current_iter = current.iter(); + let mut s = stored_iter.next(); + let mut c = current_iter.next(); + + loop { + match (s, c) { + (None, None) => return None, + (Some((name, value)), None) => { + return Some(EnvMismatch::Removed { name: name.clone(), value: value.clone() }); + } + (None, Some((name, value))) => { + return Some(EnvMismatch::Added { name: name.clone(), value: value.clone() }); + } + (Some((sn, sv)), Some((cn, cv))) => match sn.cmp(cn) { + std::cmp::Ordering::Equal => { + if sv != cv { + return Some(EnvMismatch::Changed { + name: sn.clone(), + old_value: sv.clone(), + new_value: cv.clone(), + }); + } + s = stored_iter.next(); + c = current_iter.next(); + } + std::cmp::Ordering::Less => { + return Some(EnvMismatch::Removed { name: sn.clone(), value: sv.clone() }); + } + std::cmp::Ordering::Greater => { + return Some(EnvMismatch::Added { name: cn.clone(), value: cv.clone() }); + } + }, + } } } diff --git a/crates/vite_task/src/session/execute/mod.rs b/crates/vite_task/src/session/execute/mod.rs index 73ce91b98..15124ae08 100644 --- a/crates/vite_task/src/session/execute/mod.rs +++ b/crates/vite_task/src/session/execute/mod.rs @@ -18,6 +18,7 @@ use std::{ }; use futures_util::future::LocalBoxFuture; +use rustc_hash::FxHashMap; use tokio_util::sync::CancellationToken; use vite_path::{AbsolutePath, RelativePathBuf}; use vite_task_ipc_shared::NODE_CLIENT_PATH_ENV_NAME; @@ -88,7 +89,7 @@ struct CacheState<'a> { /// Captured stdout/stderr for cache replay. Written in place during drain; /// always present (possibly empty) once we reach the cache-update phase. std_outputs: Vec, - /// `Some` iff auto-input tracking is on (`input.includes_auto` + successful + /// `Some` iff auto-input tracking is on (`includes_auto` + successful /// IPC bind). Bundles fspy's input negative globs with the per-task IPC /// server that runner-aware tools talk to. Parts are borrowed in place /// during the wait/join; the struct is never moved out. @@ -142,6 +143,7 @@ impl<'a> ExecutionMode<'a> { cache_metadata: Option<&'a CacheMetadata>, stdio_config: StdioConfig, globbed_inputs: BTreeMap, + envs: &Arc, Arc>>, ) -> Result { let Some(metadata) = cache_metadata else { return Ok(Self::Uncached { @@ -163,11 +165,17 @@ impl<'a> ExecutionMode<'a> { // fspy + IPC are bundled. If binding the IPC server fails we abort // the execution — tools that rely on IPC would otherwise silently // diverge from the cache. - let (envs, ServerHandle { driver, stop_accepting }) = - serve(Recorder::new()).map_err(ExecutionError::IpcServerBind)?; + // + // The IPC `getEnv` endpoint serves values from the spawn's full + // env context (not the task's filtered `all_envs`), so a tool can + // ask for vars the user never declared and have them fingerprinted + // via the tool's `tracked: true` flag. The same map validates + // these envs at cache lookup. + let (ipc_envs, ServerHandle { driver, stop_accepting }) = + serve(Recorder::new(Arc::clone(envs))).map_err(ExecutionError::IpcServerBind)?; Some(Tracking { input_negative_globs: negatives, - ipc_envs: envs.collect(), + ipc_envs: ipc_envs.collect(), ipc_server_fut: driver, stop_accepting, }) @@ -333,12 +341,17 @@ async fn run( interrupt_token: CancellationToken, ) -> Result { let cache_metadata = spawn_execution.cache_metadata.as_ref(); + // The spawn's full env context from the plan (session envs plus prefix + // envs of this command and of enclosing nested runs). Resolves IPC + // `getEnv`/`getEnvs` and validates tracked envs — the live process env is + // never re-read during execution. + let envs = &spawn_execution.spawn_command.full_envs; // 1. Determine cache status FIRST by trying cache hit, so the reporter can // display cache status immediately when execution begins. On a lookup // error, `start()` is never called — there is no valid status to show. let (cache_status, cached_value, globbed_inputs) = - lookup_cache(cache_metadata, cache, workspace_root).await?; + lookup_cache(cache_metadata, cache, workspace_root, envs).await?; // 2. Report execution start with the determined cache status. // Returns StdioConfig with the reporter's suggestion and writers. @@ -357,7 +370,7 @@ async fn run( } // 4. Fold the cache/fspy/stdio decisions into the typed mode. - let mut mode = ExecutionMode::build(cache_metadata, stdio_config, globbed_inputs) + let mut mode = ExecutionMode::build(cache_metadata, stdio_config, globbed_inputs, envs) .map_err(Report::failed)?; // Measure end-to-end duration here — spawn() doesn't track time. @@ -464,6 +477,7 @@ async fn lookup_cache( cache_metadata: Option<&CacheMetadata>, cache: &ExecutionCache, workspace_root: &Arc, + envs: &Arc, Arc>>, ) -> Result<(CacheStatus, Option, BTreeMap), Report> { let Some(cache_metadata) = cache_metadata else { // No cache metadata provided — caching is disabled for this task. @@ -485,7 +499,7 @@ async fn lookup_cache( Report::failed(ExecutionError::Cache { kind: CacheErrorKind::Lookup, source: err }) })?; - match cache.try_hit(cache_metadata, &globbed_inputs, workspace_root).await { + match cache.try_hit(cache_metadata, &globbed_inputs, workspace_root, envs).await { // Cache hit — the caller replays the cached outputs. Ok(Ok(cached)) => Ok(( CacheStatus::Hit { replayed_duration: cached.duration }, diff --git a/crates/vite_task/src/session/execute/tracked_accesses.rs b/crates/vite_task/src/session/execute/tracked_accesses.rs index 83596cc7d..6e3596512 100644 --- a/crates/vite_task/src/session/execute/tracked_accesses.rs +++ b/crates/vite_task/src/session/execute/tracked_accesses.rs @@ -1,4 +1,8 @@ //! Normalize raw fspy path accesses into workspace-relative, filtered form. +//! +//! User-configured negative globs are NOT applied here. They are applied later, +//! separately for reads (input config) and writes (output config), since those +//! two configs are independent. #![cfg(fspy)] use std::collections::hash_map::Entry; @@ -21,22 +25,19 @@ pub struct TrackedPathAccesses { } impl TrackedPathAccesses { - /// Build from fspy's raw iterable by stripping the workspace prefix, - /// normalizing `..` components, and filtering against the negative globs. - pub fn from_raw( - raw: &PathAccessIterable, - workspace_root: &AbsolutePath, - resolved_negatives: &[wax::Glob<'static>], - ) -> Self { + /// Build from fspy's raw iterable by stripping the workspace prefix and + /// normalizing `..` components. `.git/*` paths are skipped. User-configured + /// negatives are applied by the caller (see module docs). + pub fn from_raw(raw: &PathAccessIterable, workspace_root: &AbsolutePath) -> Self { let mut accesses = Self::default(); for access in raw.iter() { - // Strip workspace root, clean `..` components, and filter in one pass. + // Strip workspace root and clean `..` components in one pass. // fspy may report paths like `packages/sub-pkg/../shared/dist/output.js`. let relative_path = access.path.strip_path_prefix(workspace_root, |strip_result| { let Ok(stripped_path) = strip_result else { return None; }; - normalize_tracked_workspace_path(stripped_path, resolved_negatives) + normalize_tracked_workspace_path(stripped_path) }); let Some(relative_path) = relative_path else { @@ -71,10 +72,7 @@ impl TrackedPathAccesses { clippy::disallowed_types, reason = "fspy strip_path_prefix exposes std::path::Path; convert to RelativePathBuf immediately" )] -fn normalize_tracked_workspace_path( - stripped_path: &std::path::Path, - resolved_negatives: &[wax::Glob<'static>], -) -> Option { +fn normalize_tracked_workspace_path(stripped_path: &std::path::Path) -> Option { // On Windows, paths are possible to be still absolute after stripping the workspace root. // For example: c:\workspace\subdir\c:\workspace\subdir // Just ignore those accesses. @@ -90,12 +88,6 @@ fn normalize_tracked_workspace_path( return None; } - if !resolved_negatives.is_empty() - && resolved_negatives.iter().any(|neg| wax::Program::is_match(neg, relative.as_str())) - { - return None; - } - Some(relative) } @@ -111,8 +103,7 @@ mod tests { clippy::disallowed_types, reason = "normalize_tracked_workspace_path requires std::path::Path for fspy strip_path_prefix output" )] - let relative_path = - normalize_tracked_workspace_path(std::path::Path::new(r"foo\C:\bar"), &[]); + let relative_path = normalize_tracked_workspace_path(std::path::Path::new(r"foo\C:\bar")); assert!(relative_path.is_none()); } } diff --git a/crates/vite_task/src/session/reporter/mod.rs b/crates/vite_task/src/session/reporter/mod.rs index 8f7bc37b4..1d6039791 100644 --- a/crates/vite_task/src/session/reporter/mod.rs +++ b/crates/vite_task/src/session/reporter/mod.rs @@ -481,6 +481,7 @@ pub mod test_fixtures { program_path: test_path(), args: Arc::from([]), all_envs: Arc::new(BTreeMap::new()), + full_envs: Arc::new(rustc_hash::FxHashMap::default()), cwd: test_path(), }, })), diff --git a/crates/vite_task/src/session/reporter/summary.rs b/crates/vite_task/src/session/reporter/summary.rs index 1a362b2fc..5a63fc8b9 100644 --- a/crates/vite_task/src/session/reporter/summary.rs +++ b/crates/vite_task/src/session/reporter/summary.rs @@ -17,7 +17,7 @@ use vite_str::Str; use super::{CACHE_MISS_STYLE, COMMAND_STYLE, ColorizeExt}; use crate::session::{ cache::{ - CacheMiss, FingerprintMismatch, InputChangeKind, SpawnFingerprintChange, + CacheMiss, EnvMismatch, FingerprintMismatch, InputChangeKind, SpawnFingerprintChange, detect_spawn_fingerprint_changes, format_input_change_str, format_spawn_change, }, event::{ @@ -135,6 +135,11 @@ pub enum SavedCacheMissReason { ConfigChanged, /// An input file or folder changed. InputChanged { kind: InputChangeKind, path: Str }, + /// A runner-aware tool reported a tracked env var that changed between runs. + TrackedEnvChanged(EnvMismatch), + /// A runner-aware tool reported a tracked env glob whose match-set changed + /// between runs. Carries the first differing entry. + TrackedEnvGlobChanged { pattern: Str, mismatch: EnvMismatch }, } /// An execution error, serializable for persistence. @@ -276,6 +281,15 @@ impl SavedCacheMissReason { FingerprintMismatch::InputChanged { kind, path } => { Self::InputChanged { kind: *kind, path: Str::from(path.as_str()) } } + FingerprintMismatch::TrackedEnvChanged(mismatch) => { + Self::TrackedEnvChanged(mismatch.clone()) + } + FingerprintMismatch::TrackedEnvGlobChanged { pattern, mismatch } => { + Self::TrackedEnvGlobChanged { + pattern: pattern.clone(), + mismatch: mismatch.clone(), + } + } }, } } @@ -565,6 +579,13 @@ impl TaskResult { let desc = format_input_change_str(*kind, path.as_str()); vite_str::format!("→ Cache miss: {desc}") } + // Env changes reported by a runner-aware tool render the same + // as changes from a manual `env` config — no "tracked" + // wording, just the actual env change. + SavedCacheMissReason::TrackedEnvChanged(mismatch) + | SavedCacheMissReason::TrackedEnvGlobChanged { mismatch, .. } => { + vite_str::format!("→ Cache miss: {mismatch}") + } }, }, } diff --git a/crates/vite_task_bin/src/vtt/grep_file.rs b/crates/vite_task_bin/src/vtt/grep_file.rs new file mode 100644 index 000000000..50b3281b3 --- /dev/null +++ b/crates/vite_task_bin/src/vtt/grep_file.rs @@ -0,0 +1,16 @@ +pub fn run(args: &[String]) { + let [path, pattern] = args else { + eprintln!("Usage: vtt grep-file "); + std::process::exit(2); + }; + match std::fs::read_to_string(path) { + Ok(content) => { + if content.contains(pattern.as_str()) { + println!("{path}: found {pattern:?}"); + } else { + println!("{path}: missing {pattern:?}"); + } + } + Err(_) => println!("{path}: not found"), + } +} diff --git a/crates/vite_task_bin/src/vtt/main.rs b/crates/vite_task_bin/src/vtt/main.rs index d6dcb1af7..c2d3e2156 100644 --- a/crates/vite_task_bin/src/vtt/main.rs +++ b/crates/vite_task_bin/src/vtt/main.rs @@ -11,6 +11,7 @@ mod check_tty; mod cp; mod exit; mod exit_on_ctrlc; +mod grep_file; mod list_dir; mod mkdir; mod pipe_stdin; @@ -22,6 +23,7 @@ mod print_file; mod read_stdin; mod replace_file_content; mod rm; +mod stat_file; mod touch_file; mod write_file; @@ -30,7 +32,7 @@ fn main() { if args.len() < 2 { eprintln!("Usage: vtt [args...]"); eprintln!( - "Subcommands: barrier, check-tty, cp, exit, exit-on-ctrlc, list-dir, mkdir, pipe-stdin, print, print-color, print-cwd, print-env, print-file, read-stdin, replace-file-content, rm, touch-file, write-file" + "Subcommands: barrier, check-tty, cp, exit, exit-on-ctrlc, grep-file, list-dir, mkdir, pipe-stdin, print, print-color, print-cwd, print-env, print-file, read-stdin, replace-file-content, rm, stat-file, touch-file, write-file" ); std::process::exit(1); } @@ -44,6 +46,10 @@ fn main() { "cp" => cp::run(&args[2..]), "exit" => exit::run(&args[2..]), "exit-on-ctrlc" => exit_on_ctrlc::run(), + "grep-file" => { + grep_file::run(&args[2..]); + Ok(()) + } "list-dir" => list_dir::run(&args[2..]), "mkdir" => mkdir::run(&args[2..]), "pipe-stdin" => pipe_stdin::run(&args[2..]), @@ -58,6 +64,10 @@ fn main() { "read-stdin" => read_stdin::run(), "replace-file-content" => replace_file_content::run(&args[2..]), "rm" => rm::run(&args[2..]), + "stat-file" => { + stat_file::run(&args[2..]); + Ok(()) + } "touch-file" => touch_file::run(&args[2..]), "write-file" => write_file::run(&args[2..]), other => { diff --git a/crates/vite_task_bin/src/vtt/stat_file.rs b/crates/vite_task_bin/src/vtt/stat_file.rs new file mode 100644 index 000000000..75fbebf4c --- /dev/null +++ b/crates/vite_task_bin/src/vtt/stat_file.rs @@ -0,0 +1,9 @@ +pub fn run(args: &[String]) { + for file in args { + if std::fs::metadata(file).is_ok() { + println!("{file}: exists"); + } else { + println!("{file}: missing"); + } + } +} diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/ipc_client_test/scripts/fetch_env.mjs b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/ipc_client_test/scripts/fetch_env.mjs new file mode 100644 index 000000000..b9b4204f0 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/ipc_client_test/scripts/fetch_env.mjs @@ -0,0 +1,10 @@ +import { getEnv } from '@voidzero-dev/vite-task-client'; +import { writeFileSync, mkdirSync } from 'node:fs'; + +// getEnv returns the env value from the runner and — with tracked: true — +// adds the env to the post-run fingerprint, so a change between runs +// invalidates the cache. +const value = getEnv('PROBE_ENV', { tracked: true }) ?? '(unset)'; + +mkdirSync('dist', { recursive: true }); +writeFileSync('dist/out.txt', 'PROBE_ENV=' + value + '\n'); diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/ipc_client_test/scripts/fetch_envs.mjs b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/ipc_client_test/scripts/fetch_envs.mjs new file mode 100644 index 000000000..b89f2cc0b --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/ipc_client_test/scripts/fetch_envs.mjs @@ -0,0 +1,14 @@ +import { getEnvs } from '@voidzero-dev/vite-task-client'; +import { writeFileSync, mkdirSync } from 'node:fs'; + +// getEnvs asks the runner for every env matching the glob. The glob (plus +// its match-set) becomes part of the post-run fingerprint, so adding, +// removing, or changing any matching env invalidates the cache on the next +// run. The non-matching UNRELATED envs set by some test steps must not +// contribute. +const matches = getEnvs('PROBE_*', { tracked: true }); + +mkdirSync('dist', { recursive: true }); +const sorted = Object.entries(matches).sort(([a], [b]) => a.localeCompare(b)); +const body = sorted.map(([k, v]) => `${k}=${v}`).join('\n'); +writeFileSync('dist/out.txt', body + '\n'); diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/ipc_client_test/scripts/fetch_intermediate_envs.mjs b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/ipc_client_test/scripts/fetch_intermediate_envs.mjs new file mode 100644 index 000000000..cda108aee --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/ipc_client_test/scripts/fetch_intermediate_envs.mjs @@ -0,0 +1,18 @@ +import { getEnv } from '@voidzero-dev/vite-task-client'; +import { writeFileSync, mkdirSync } from 'node:fs'; + +// Reached via `outer-prefixed` (`PREFIXED_A=a vt run inner-prefixed`): the +// nested run carries PREFIXED_A into the planning context, and our own +// command (`PREFIXED_B=b node ...`) prefixes PREFIXED_B. getEnv must serve +// both. Only PREFIXED_B reaches this process's env — PREFIXED_A is +// undeclared, so the spawn env filters it; the runner still knows it. +const servedA = getEnv('PREFIXED_A', { tracked: true }) ?? '(unset)'; +const servedB = getEnv('PREFIXED_B', { tracked: true }) ?? '(unset)'; +const ownA = process.env.PREFIXED_A ?? '(unset)'; +const ownB = process.env.PREFIXED_B ?? '(unset)'; + +mkdirSync('dist', { recursive: true }); +writeFileSync( + 'dist/out.txt', + `served A=${servedA} B=${servedB}\nprocess.env A=${ownA} B=${ownB}\n`, +); diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/ipc_client_test/scripts/fetch_prefixed_env.mjs b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/ipc_client_test/scripts/fetch_prefixed_env.mjs new file mode 100644 index 000000000..1487b3b37 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/ipc_client_test/scripts/fetch_prefixed_env.mjs @@ -0,0 +1,12 @@ +import { getEnv } from '@voidzero-dev/vite-task-client'; +import { writeFileSync, mkdirSync } from 'node:fs'; + +// The task command prefixes `PREFIXED_ENV=from-command` (see vite-task.json), +// so this process's own env carries that value. `getEnv` must serve the same +// value: the runner resolves from the spawn's env context (session env +// overlaid with the command's prefix envs), not the session env alone. +const served = getEnv('PREFIXED_ENV', { tracked: true }) ?? '(unset)'; +const own = process.env.PREFIXED_ENV ?? '(unset)'; + +mkdirSync('dist', { recursive: true }); +writeFileSync('dist/out.txt', `served=${served}\nprocess.env=${own}\n`); diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/ipc_client_test/scripts/ignore_input.mjs b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/ipc_client_test/scripts/ignore_input.mjs new file mode 100644 index 000000000..e368bb9c9 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/ipc_client_test/scripts/ignore_input.mjs @@ -0,0 +1,15 @@ +import { ignoreInput } from '@voidzero-dev/vite-task-client'; +import { writeFileSync, readFileSync } from 'node:fs'; +import { mkdirSync } from 'node:fs'; + +// The task reads from `cache_like/` (which we want the runner to IGNORE as +// an input), and writes to `dist/`. Without the ignore, the auto-input +// fingerprint would fluctuate with cache_like/ contents even though they're +// not semantic inputs. +mkdirSync('cache_like', { recursive: true }); +writeFileSync('cache_like/stale.txt', 'stale-' + Date.now() + '\n'); +ignoreInput('cache_like'); +readFileSync('cache_like/stale.txt', 'utf8'); + +mkdirSync('dist', { recursive: true }); +writeFileSync('dist/out.txt', 'ok\n'); diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/ipc_client_test/scripts/ignore_output.mjs b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/ipc_client_test/scripts/ignore_output.mjs new file mode 100644 index 000000000..efb1aa169 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/ipc_client_test/scripts/ignore_output.mjs @@ -0,0 +1,14 @@ +import { ignoreOutput } from '@voidzero-dev/vite-task-client'; +import { writeFileSync, readFileSync, mkdirSync } from 'node:fs'; + +// The task both reads and writes `sidecar/tmp.txt`. If the runner didn't +// treat `sidecar/` as an ignored output, the read-write overlap check would +// refuse to cache the task. `dist/out.txt` is the real output. +mkdirSync('sidecar', { recursive: true }); +writeFileSync('sidecar/tmp.txt', 'initial\n'); +readFileSync('sidecar/tmp.txt', 'utf8'); +writeFileSync('sidecar/tmp.txt', 'final\n'); +ignoreOutput('sidecar'); + +mkdirSync('dist', { recursive: true }); +writeFileSync('dist/out.txt', 'ok\n'); diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/ipc_client_test/snapshots.toml b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/ipc_client_test/snapshots.toml index 19e517c83..9a8ad219c 100644 --- a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/ipc_client_test/snapshots.toml +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/ipc_client_test/snapshots.toml @@ -17,3 +17,211 @@ steps = [ "disable-cache", ], comment = "cache miss (NotFound) because nothing was cached" }, ] + +[[e2e]] +name = "fetch_envs_tracks_glob_match_set" +comment = """ +Exercises `getEnvs(pattern, { tracked: true })`. The glob `PROBE_*` and +its match-set snapshot enter the post-run fingerprint: later runs diff the +current match-set against what was stored and miss on add / remove / change, +but hit when only non-matching envs differ. +""" +ignore = true +steps = [ + { argv = [ + "vt", + "run", + "fetch-envs", + ], envs = [ + [ + "PROBE_A", + "a", + ], + [ + "PROBE_B", + "b", + ], + ], comment = "populate: first run captures {PROBE_A, PROBE_B} under the glob" }, + { argv = [ + "vtt", + "print-file", + "dist/out.txt", + ], comment = "the tool observed both matching envs" }, + { argv = [ + "vt", + "run", + "fetch-envs", + ], envs = [ + [ + "PROBE_A", + "a", + ], + [ + "PROBE_B", + "b", + ], + ], comment = "unchanged: same match-set → cache hit" }, + { argv = [ + "vt", + "run", + "fetch-envs", + ], envs = [ + [ + "PROBE_A", + "changed", + ], + [ + "PROBE_B", + "b", + ], + ], comment = "change: PROBE_A value differs → cache miss (TrackedEnvGlobChanged / changed)" }, + { argv = [ + "vt", + "run", + "fetch-envs", + ], envs = [ + [ + "PROBE_A", + "changed", + ], + [ + "PROBE_B", + "b", + ], + [ + "PROBE_C", + "c", + ], + ], comment = "add: PROBE_C is new under the glob → cache miss (added)" }, + { argv = [ + "vt", + "run", + "fetch-envs", + ], envs = [ + [ + "PROBE_B", + "b", + ], + [ + "PROBE_C", + "c", + ], + ], comment = "remove: PROBE_A dropped from the match-set → cache miss (removed)" }, + { argv = [ + "vt", + "run", + "fetch-envs", + ], envs = [ + [ + "PROBE_B", + "b", + ], + [ + "PROBE_C", + "c", + ], + [ + "UNRELATED", + "noise", + ], + ], comment = "non-matching noise: UNRELATED doesn't match PROBE_* → cache hit" }, + { argv = [ + "vtt", + "print-file", + "dist/out.txt", + ], comment = "match-set unchanged from the previous successful run" }, +] + +[[e2e]] +name = "fetch_env_tracked_invalidates_on_change" +comment = """ +Exercises `getEnv(name, { tracked: true })`. The env value becomes part +of the post-run fingerprint: the same value still hits, a different value +misses with `envs changed`. +""" +ignore = true +steps = [ + { argv = [ + "vt", + "run", + "fetch-env", + ], envs = [ + [ + "PROBE_ENV", + "first", + ], + ], comment = "first run captures PROBE_ENV=first in the fingerprint" }, + { argv = [ + "vt", + "run", + "fetch-env", + ], envs = [ + [ + "PROBE_ENV", + "first", + ], + ], comment = "cache hit: PROBE_ENV unchanged" }, + { argv = [ + "vt", + "run", + "fetch-env", + ], envs = [ + [ + "PROBE_ENV", + "second", + ], + ], comment = "cache miss: envs changed (PROBE_ENV changed)" }, +] + +[[e2e]] +name = "fetch_env_sees_command_prefix_env" +comment = """ +A command-prefixed env (`PREFIXED_ENV=from-command node ...`) is part of the +spawn's env context: the child process sees it in `process.env`, so +`getEnv('PREFIXED_ENV')` must serve the same value. Today the runner resolves +`getEnv` against the session env snapshot only, so it answers `(unset)` while +the process env says `from-command`. +""" +ignore = true +steps = [ + { argv = [ + "vt", + "run", + "fetch-prefixed-env", + ], comment = "tool asks the runner for an env the command prefix sets" }, + { argv = [ + "vtt", + "print-file", + "dist/out.txt", + ], comment = "served value must match the process env value" }, +] + +[[e2e]] +name = "fetch_env_sees_intermediate_prefix_envs" +comment = """ +Prefix envs accumulate through nested runs: `outer-prefixed` is +`PREFIXED_A=a vt run inner-prefixed`, and `inner-prefixed` is +`PREFIXED_B=b node ...`. The inner tool's `getEnv` must see both — the +nested expansion threads PREFIXED_A into the inner spawn's env context even +though the (undeclared) var never reaches the inner process env. The second +run validates the tracked values against the same plan-derived context and +hits the cache. +""" +ignore = true +steps = [ + { argv = [ + "vt", + "run", + "outer-prefixed", + ], comment = "outer prefix env reaches the inner tool via the runner" }, + { argv = [ + "vtt", + "print-file", + "dist/out.txt", + ], comment = "both prefix envs served; only the inner one is in process.env" }, + { argv = [ + "vt", + "run", + "outer-prefixed", + ], comment = "cache hit: tracked values validate against the plan's env context" }, +] diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/ipc_client_test/snapshots/fetch_env_sees_command_prefix_env.md b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/ipc_client_test/snapshots/fetch_env_sees_command_prefix_env.md new file mode 100644 index 000000000..228047319 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/ipc_client_test/snapshots/fetch_env_sees_command_prefix_env.md @@ -0,0 +1,24 @@ +# fetch_env_sees_command_prefix_env + +A command-prefixed env (`PREFIXED_ENV=from-command node ...`) is part of the +spawn's env context: the child process sees it in `process.env`, so +`getEnv('PREFIXED_ENV')` must serve the same value. Today the runner resolves +`getEnv` against the session env snapshot only, so it answers `(unset)` while +the process env says `from-command`. + +## `vt run fetch-prefixed-env` + +tool asks the runner for an env the command prefix sets + +``` +$ PREFIXED_ENV=from-command node scripts/fetch_prefixed_env.mjs +``` + +## `vtt print-file dist/out.txt` + +served value must match the process env value + +``` +served=from-command +process.env=from-command +``` diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/ipc_client_test/snapshots/fetch_env_sees_intermediate_prefix_envs.md b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/ipc_client_test/snapshots/fetch_env_sees_intermediate_prefix_envs.md new file mode 100644 index 000000000..e6e3d4058 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/ipc_client_test/snapshots/fetch_env_sees_intermediate_prefix_envs.md @@ -0,0 +1,37 @@ +# fetch_env_sees_intermediate_prefix_envs + +Prefix envs accumulate through nested runs: `outer-prefixed` is +`PREFIXED_A=a vt run inner-prefixed`, and `inner-prefixed` is +`PREFIXED_B=b node ...`. The inner tool's `getEnv` must see both — the +nested expansion threads PREFIXED_A into the inner spawn's env context even +though the (undeclared) var never reaches the inner process env. The second +run validates the tracked values against the same plan-derived context and +hits the cache. + +## `vt run outer-prefixed` + +outer prefix env reaches the inner tool via the runner + +``` +$ PREFIXED_B=b node scripts/fetch_intermediate_envs.mjs +``` + +## `vtt print-file dist/out.txt` + +both prefix envs served; only the inner one is in process.env + +``` +served A=a B=b +process.env A=(unset) B=b +``` + +## `vt run outer-prefixed` + +cache hit: tracked values validate against the plan's env context + +``` +$ PREFIXED_B=b node scripts/fetch_intermediate_envs.mjs ◉ cache hit, replaying + +--- +vt run: cache hit. +``` diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/ipc_client_test/snapshots/fetch_env_tracked_invalidates_on_change.md b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/ipc_client_test/snapshots/fetch_env_tracked_invalidates_on_change.md new file mode 100644 index 000000000..2e9e7dd6c --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/ipc_client_test/snapshots/fetch_env_tracked_invalidates_on_change.md @@ -0,0 +1,32 @@ +# fetch_env_tracked_invalidates_on_change + +Exercises `getEnv(name, { tracked: true })`. The env value becomes part +of the post-run fingerprint: the same value still hits, a different value +misses with `envs changed`. + +## `PROBE_ENV=first vt run fetch-env` + +first run captures PROBE_ENV=first in the fingerprint + +``` +$ node scripts/fetch_env.mjs +``` + +## `PROBE_ENV=first vt run fetch-env` + +cache hit: PROBE_ENV unchanged + +``` +$ node scripts/fetch_env.mjs ◉ cache hit, replaying + +--- +vt run: cache hit. +``` + +## `PROBE_ENV=second vt run fetch-env` + +cache miss: envs changed (PROBE_ENV changed) + +``` +$ node scripts/fetch_env.mjs ○ cache miss: env 'PROBE_ENV' changed, executing +``` diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/ipc_client_test/snapshots/fetch_envs_tracks_glob_match_set.md b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/ipc_client_test/snapshots/fetch_envs_tracks_glob_match_set.md new file mode 100644 index 000000000..e339789a5 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/ipc_client_test/snapshots/fetch_envs_tracks_glob_match_set.md @@ -0,0 +1,78 @@ +# fetch_envs_tracks_glob_match_set + +Exercises `getEnvs(pattern, { tracked: true })`. The glob `PROBE_*` and +its match-set snapshot enter the post-run fingerprint: later runs diff the +current match-set against what was stored and miss on add / remove / change, +but hit when only non-matching envs differ. + +## `PROBE_A=a PROBE_B=b vt run fetch-envs` + +populate: first run captures {PROBE_A, PROBE_B} under the glob + +``` +$ node scripts/fetch_envs.mjs +``` + +## `vtt print-file dist/out.txt` + +the tool observed both matching envs + +``` +PROBE_A=a +PROBE_B=b +``` + +## `PROBE_A=a PROBE_B=b vt run fetch-envs` + +unchanged: same match-set → cache hit + +``` +$ node scripts/fetch_envs.mjs ◉ cache hit, replaying + +--- +vt run: cache hit. +``` + +## `PROBE_A=changed PROBE_B=b vt run fetch-envs` + +change: PROBE_A value differs → cache miss (TrackedEnvGlobChanged / changed) + +``` +$ node scripts/fetch_envs.mjs ○ cache miss: env 'PROBE_A' changed, executing +``` + +## `PROBE_A=changed PROBE_B=b PROBE_C=c vt run fetch-envs` + +add: PROBE_C is new under the glob → cache miss (added) + +``` +$ node scripts/fetch_envs.mjs ○ cache miss: env 'PROBE_C' changed, executing +``` + +## `PROBE_B=b PROBE_C=c vt run fetch-envs` + +remove: PROBE_A dropped from the match-set → cache miss (removed) + +``` +$ node scripts/fetch_envs.mjs ○ cache miss: env 'PROBE_A' changed, executing +``` + +## `PROBE_B=b PROBE_C=c UNRELATED=noise vt run fetch-envs` + +non-matching noise: UNRELATED doesn't match PROBE_* → cache hit + +``` +$ node scripts/fetch_envs.mjs ◉ cache hit, replaying + +--- +vt run: cache hit. +``` + +## `vtt print-file dist/out.txt` + +match-set unchanged from the previous successful run + +``` +PROBE_B=b +PROBE_C=c +``` diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/ipc_client_test/vite-task.json b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/ipc_client_test/vite-task.json index a91991012..dbf03f094 100644 --- a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/ipc_client_test/vite-task.json +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/ipc_client_test/vite-task.json @@ -1,8 +1,36 @@ { "tasks": { + "ignore-input": { + "command": "node scripts/ignore_input.mjs", + "cache": true + }, + "ignore-output": { + "command": "node scripts/ignore_output.mjs", + "cache": true + }, "disable-cache": { "command": "node scripts/disable_cache.mjs", "cache": true + }, + "fetch-env": { + "command": "node scripts/fetch_env.mjs", + "cache": true + }, + "fetch-envs": { + "command": "node scripts/fetch_envs.mjs", + "cache": true + }, + "fetch-prefixed-env": { + "command": "PREFIXED_ENV=from-command node scripts/fetch_prefixed_env.mjs", + "cache": true + }, + "outer-prefixed": { + "command": "PREFIXED_A=a vt run inner-prefixed", + "cache": true + }, + "inner-prefixed": { + "command": "PREFIXED_B=b node scripts/fetch_intermediate_envs.mjs", + "cache": true } } } diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/vite_build_cache/index.html b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/vite_build_cache/index.html new file mode 100644 index 000000000..20fc85a43 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/vite_build_cache/index.html @@ -0,0 +1,9 @@ + + + + vp-run-vite-cache + + + + + diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/vite_build_cache/package.json b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/vite_build_cache/package.json new file mode 100644 index 000000000..e4e3497f2 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/vite_build_cache/package.json @@ -0,0 +1,5 @@ +{ + "name": "vite-build-cache-fixture", + "private": true, + "type": "module" +} diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/vite_build_cache/snapshots.toml b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/vite_build_cache/snapshots.toml new file mode 100644 index 000000000..7123f6266 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/vite_build_cache/snapshots.toml @@ -0,0 +1,108 @@ +[[e2e]] +name = "vite_prefix_env_change_invalidates_cache" +comment = """ +`VITE_MODE` is picked up by Vite's patched `loadEnv`, which asks the runner for every `VITE_*` env via `getEnvs(pattern, { tracked: true })`. Flipping its value between runs must invalidate the cache AND change the build output — Vite's `define` plugin substitutes `import.meta.env.VITE_MODE` at build time, so dead-code elimination leaves only the branch matching the value. +""" +ignore = true +steps = [ + { argv = [ + "vt", + "run", + "--cache", + "build", + ], envs = [ + [ + "VITE_MODE", + "production", + ], + ], comment = "first run: production build" }, + { argv = [ + "vtt", + "grep-file", + "dist/assets/main.js", + "BUILD_MODE_PROD", + ], comment = "production build: PROD marker survived DCE" }, + { argv = [ + "vtt", + "grep-file", + "dist/assets/main.js", + "BUILD_MODE_DEV", + ], comment = "dev branch is gone" }, + { argv = [ + "vt", + "run", + "--cache", + "build", + ], envs = [ + [ + "VITE_MODE", + "production", + ], + ], comment = "cache hit: VITE_MODE unchanged" }, + { argv = [ + "vt", + "run", + "--cache", + "build", + ], envs = [ + [ + "VITE_MODE", + "development", + ], + ], comment = "cache miss: envs changed — VITE_MODE value changed" }, + { argv = [ + "vtt", + "grep-file", + "dist/assets/main.js", + "BUILD_MODE_PROD", + ], comment = "PROD marker gone after the dev rebuild" }, + { argv = [ + "vtt", + "grep-file", + "dist/assets/main.js", + "BUILD_MODE_DEV", + ], comment = "DEV marker now in the bundle" }, +] + +[[e2e]] +name = "vite_node_env_change_invalidates_cache" +comment = """ +`NODE_ENV` enters the build's cache fingerprint via Vite's `getEnv('NODE_ENV')` call in `resolveConfig`. Same value → cache hit; different value → cache miss with `envs changed`. +""" +ignore = true +steps = [ + { argv = [ + "vt", + "run", + "--cache", + "build", + ], envs = [ + [ + "NODE_ENV", + "production", + ], + ], comment = "first run: NODE_ENV=production" }, + { argv = [ + "vt", + "run", + "--cache", + "build", + ], envs = [ + [ + "NODE_ENV", + "production", + ], + ], comment = "cache hit: NODE_ENV unchanged" }, + { argv = [ + "vt", + "run", + "--cache", + "build", + ], envs = [ + [ + "NODE_ENV", + "development", + ], + ], comment = "cache miss: envs changed (NODE_ENV changed)" }, +] + diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/vite_build_cache/snapshots/vite_node_env_change_invalidates_cache.md b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/vite_build_cache/snapshots/vite_node_env_change_invalidates_cache.md new file mode 100644 index 000000000..116973efb --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/vite_build_cache/snapshots/vite_node_env_change_invalidates_cache.md @@ -0,0 +1,30 @@ +# vite_node_env_change_invalidates_cache + +`NODE_ENV` enters the build's cache fingerprint via Vite's `getEnv('NODE_ENV')` call in `resolveConfig`. Same value → cache hit; different value → cache miss with `envs changed`. + +## `NODE_ENV=production vt run --cache build` + +first run: NODE_ENV=production + +``` +$ vite build +``` + +## `NODE_ENV=production vt run --cache build` + +cache hit: NODE_ENV unchanged + +``` +$ vite build ◉ cache hit, replaying + +--- +vt run: cache hit. +``` + +## `NODE_ENV=development vt run --cache build` + +cache miss: envs changed (NODE_ENV changed) + +``` +$ vite build ○ cache miss: env 'NODE_ENV' changed, executing +``` diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/vite_build_cache/snapshots/vite_prefix_env_change_invalidates_cache.md b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/vite_build_cache/snapshots/vite_prefix_env_change_invalidates_cache.md new file mode 100644 index 000000000..380bd8f38 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/vite_build_cache/snapshots/vite_prefix_env_change_invalidates_cache.md @@ -0,0 +1,62 @@ +# vite_prefix_env_change_invalidates_cache + +`VITE_MODE` is picked up by Vite's patched `loadEnv`, which asks the runner for every `VITE_*` env via `getEnvs(pattern, { tracked: true })`. Flipping its value between runs must invalidate the cache AND change the build output — Vite's `define` plugin substitutes `import.meta.env.VITE_MODE` at build time, so dead-code elimination leaves only the branch matching the value. + +## `VITE_MODE=production vt run --cache build` + +first run: production build + +``` +$ vite build +``` + +## `vtt grep-file dist/assets/main.js BUILD_MODE_PROD` + +production build: PROD marker survived DCE + +``` +dist/assets/main.js: found "BUILD_MODE_PROD" +``` + +## `vtt grep-file dist/assets/main.js BUILD_MODE_DEV` + +dev branch is gone + +``` +dist/assets/main.js: missing "BUILD_MODE_DEV" +``` + +## `VITE_MODE=production vt run --cache build` + +cache hit: VITE_MODE unchanged + +``` +$ vite build ◉ cache hit, replaying + +--- +vt run: cache hit. +``` + +## `VITE_MODE=development vt run --cache build` + +cache miss: envs changed — VITE_MODE value changed + +``` +$ vite build ○ cache miss: env 'VITE_MODE' changed, executing +``` + +## `vtt grep-file dist/assets/main.js BUILD_MODE_PROD` + +PROD marker gone after the dev rebuild + +``` +dist/assets/main.js: missing "BUILD_MODE_PROD" +``` + +## `vtt grep-file dist/assets/main.js BUILD_MODE_DEV` + +DEV marker now in the bundle + +``` +dist/assets/main.js: found "BUILD_MODE_DEV" +``` diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/vite_build_cache/src/main.js b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/vite_build_cache/src/main.js new file mode 100644 index 000000000..541b3d7c8 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/vite_build_cache/src/main.js @@ -0,0 +1,9 @@ +// `import.meta.env.VITE_MODE` is replaced at build time from the value vite +// picks up for keys matching `envPrefix` (`VITE_` by default). The markers +// let the e2e test assert that flipping VITE_MODE actually changed what was +// built and that glob-tracking invalidates the cache. +if (import.meta.env.VITE_MODE === 'production') { + document.body.append('BUILD_MODE_PROD'); +} else { + document.body.append('BUILD_MODE_DEV'); +} diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/vite_build_cache/vite-task.json b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/vite_build_cache/vite-task.json new file mode 100644 index 000000000..7c10cb232 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/vite_build_cache/vite-task.json @@ -0,0 +1,20 @@ +{ + "tasks": { + "build": { + "command": "vite build", + // No `"env": [...]` — vite's patched `loadEnv` asks the runner for + // every `VITE_*` env via `getEnvs`, so the glob + match-set are + // fingerprinted automatically. + // + // Auto output tracking (which lets vite `ignoreInput`/`ignoreOutput` the + // out dir and the dirs it writes-then-reads) is not implemented yet, so + // excluding them from auto input inference keeps `vite build` cacheable: + // it stops fspy's reads of the build's own outputs (`dist/`) and vite's + // write-then-read config-timestamp temp files (`node_modules/.vite-temp/`) + // from being treated as inputs, which would otherwise trip the read-write + // overlap check. + "input": ["!dist/**", "!node_modules/.vite-temp/**", { "auto": true }], + "cache": true + } + } +} diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/vite_build_cache/vite.config.js b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/vite_build_cache/vite.config.js new file mode 100644 index 000000000..cc83efec1 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/vite_build_cache/vite.config.js @@ -0,0 +1,15 @@ +import { defineConfig } from 'vite'; + +export default defineConfig({ + logLevel: 'silent', + build: { + rollupOptions: { + output: { + // Stable filenames make cache behaviour deterministic across runs. + entryFileNames: 'assets/main.js', + chunkFileNames: 'assets/chunk.js', + assetFileNames: 'assets/[name][extname]', + }, + }, + }, +}); diff --git a/crates/vite_task_client_napi/src/lib.rs b/crates/vite_task_client_napi/src/lib.rs index 3307d755c..8bb9e7f14 100644 --- a/crates/vite_task_client_napi/src/lib.rs +++ b/crates/vite_task_client_napi/src/lib.rs @@ -122,7 +122,7 @@ impl RunnerClient { pub fn load() -> Result { #[expect( clippy::disallowed_methods, - reason = "client bootstrap reads the live process env to find runner IPC handoff" + reason = "client load needs the live process env to discover runner IPC connection vars" )] let client = Client::from_envs(std::env::vars_os()) .map_err(|err| { diff --git a/crates/vite_task_graph/run-config.ts b/crates/vite_task_graph/run-config.ts index 3a8477c6e..35bc91b64 100644 --- a/crates/vite_task_graph/run-config.ts +++ b/crates/vite_task_graph/run-config.ts @@ -57,14 +57,16 @@ untrackedEnv?: Array, */ input?: Array, /** - * Output files to archive after a successful run and restore on cache hit. + * Output files to archive and restore on cache hit. * - * - Omitted or `[]` (empty): no output archiving (default) + * - Omitted: automatically tracks which files the task writes + * - `[]` (empty): disables output restoration entirely * - Glob patterns (e.g. `"dist/**"`) select specific output files, relative to the package directory * - `{pattern: "...", base: "workspace" | "package"}` specifies a glob with an explicit base directory + * - `{auto: true}` enables automatic file tracking * - Negative patterns (e.g. `"!dist/cache/**"`) exclude matched files */ -output?: Array, } | { +output?: Array, } | { /** * Whether to cache the task */ diff --git a/crates/vite_task_graph/src/config/mod.rs b/crates/vite_task_graph/src/config/mod.rs index b455320b1..7a516f1d4 100644 --- a/crates/vite_task_graph/src/config/mod.rs +++ b/crates/vite_task_graph/src/config/mod.rs @@ -7,8 +7,8 @@ use rustc_hash::FxHashSet; use serde::Serialize; pub use user::{ AutoInput, Command, EnabledCacheConfig, GlobWithBase, InputBase, ResolvedGlobalCacheConfig, - UserCacheConfig, UserGlobalCacheConfig, UserInputEntry, UserInputsConfig, UserOutputEntry, - UserRunConfig, UserTaskConfig, UserTaskDefinition, + UserCacheConfig, UserGlobalCacheConfig, UserInputEntry, UserInputsConfig, UserRunConfig, + UserTaskConfig, UserTaskDefinition, }; use vite_path::AbsolutePath; use vite_str::Str; @@ -75,11 +75,15 @@ impl ResolvedTaskOptions { workspace_root, )?; - let output_config = ResolvedGlobConfig::from_user_output_config( - enabled_cache_config.output.as_ref(), - dir, - workspace_root, - )?; + // Auto output restoration is not implemented yet — it lands with + // runner-aware output tracking (which consumes `ignoreOutput`). + // Until then `output: None` defaults to disabled rather than + // auto-inference, so a cache hit never restores an unverified, + // fspy-inferred output set. + let output_config = match enabled_cache_config.output.as_ref() { + None => ResolvedGlobConfig::disabled(), + output => ResolvedGlobConfig::from_user_config(output, dir, workspace_root)?, + }; Some(CacheConfig { env_config: EnvConfig { @@ -140,6 +144,16 @@ impl ResolvedGlobConfig { } } + /// Disabled configuration: no auto-inference and no explicit patterns. + #[must_use] + pub const fn disabled() -> Self { + Self { + includes_auto: false, + positive_globs: BTreeSet::new(), + negative_globs: BTreeSet::new(), + } + } + /// Resolve from user configuration, making glob patterns workspace-root-relative. /// /// - `None`: defaults to auto-inference (`[{auto: true}]`) @@ -196,61 +210,6 @@ impl ResolvedGlobConfig { Ok(Self { includes_auto, positive_globs, negative_globs }) } - /// Resolve from user output configuration, making glob patterns workspace-root-relative. - /// - /// Unlike [`Self::from_user_config`], `None` and `Some([])` both produce an empty config - /// with `includes_auto = false` (no output archiving). - /// - /// TODO: remove this method once auto output inference lands; at that point - /// `output` becomes a `UserInputsConfig` and routes through - /// [`Self::from_user_config`] like inputs. - /// - /// # Errors - /// - /// Returns [`ResolveTaskConfigError`] if a glob pattern is invalid or resolves - /// outside the workspace root. - pub fn from_user_output_config( - user_outputs: Option<&Vec>, - package_dir: &AbsolutePath, - workspace_root: &AbsolutePath, - ) -> Result { - let mut positive_globs = BTreeSet::new(); - let mut negative_globs = BTreeSet::new(); - - let Some(entries) = user_outputs else { - return Ok(Self { includes_auto: false, positive_globs, negative_globs }); - }; - - for entry in entries { - match entry { - UserOutputEntry::Glob(pattern) => { - Self::insert_glob( - pattern.as_str(), - package_dir, - workspace_root, - &mut positive_globs, - &mut negative_globs, - )?; - } - UserOutputEntry::GlobWithBase(GlobWithBase { pattern, base }) => { - let base_dir = match base { - InputBase::Package => package_dir, - InputBase::Workspace => workspace_root, - }; - Self::insert_glob( - pattern.as_str(), - base_dir, - workspace_root, - &mut positive_globs, - &mut negative_globs, - )?; - } - } - } - - Ok(Self { includes_auto: false, positive_globs, negative_globs }) - } - /// Insert a glob pattern into the appropriate set (positive or negative), /// resolving it relative to the given base directory. fn insert_glob( diff --git a/crates/vite_task_graph/src/config/user.rs b/crates/vite_task_graph/src/config/user.rs index 86adcb214..6357b6d22 100644 --- a/crates/vite_task_graph/src/config/user.rs +++ b/crates/vite_task_graph/src/config/user.rs @@ -65,22 +65,6 @@ pub enum UserInputEntry { /// Default (when field omitted): `[{auto: true}]` - infer from file accesses. pub type UserInputsConfig = Vec; -/// A single output entry in the `output` array. -/// -/// Outputs can be: -/// - Glob patterns as strings (resolved relative to the package directory) -/// - Object form with explicit base: `{ "pattern": "...", "base": "workspace" | "package" }` -#[derive(Debug, Deserialize, PartialEq, Eq, Clone)] -// TS derive macro generates code using std types that clippy disallows; skip derive during linting -#[cfg_attr(all(test, not(clippy)), derive(TS))] -#[serde(untagged)] -pub enum UserOutputEntry { - /// Glob pattern (positive or negative starting with `!`), resolved relative to package dir - Glob(Str), - /// Glob pattern with explicit base directory - GlobWithBase(GlobWithBase), -} - /// Cache-related fields of a task defined by user in `vite.config.*` #[derive(Debug, Deserialize, PartialEq, Eq)] // TS derive macro generates code using std types that clippy disallows; skip derive during linting @@ -142,15 +126,17 @@ pub struct EnabledCacheConfig { #[cfg_attr(all(test, not(clippy)), ts(inline))] pub input: Option, - /// Output files to archive after a successful run and restore on cache hit. + /// Output files to archive and restore on cache hit. /// - /// - Omitted or `[]` (empty): no output archiving (default) + /// - Omitted: automatically tracks which files the task writes + /// - `[]` (empty): disables output restoration entirely /// - Glob patterns (e.g. `"dist/**"`) select specific output files, relative to the package directory /// - `{pattern: "...", base: "workspace" | "package"}` specifies a glob with an explicit base directory + /// - `{auto: true}` enables automatic file tracking /// - Negative patterns (e.g. `"!dist/cache/**"`) exclude matched files #[serde(default)] #[cfg_attr(all(test, not(clippy)), ts(inline))] - pub output: Option>, + pub output: Option, } /// Options for user-defined tasks in `vite.config.*`, excluding the command. diff --git a/crates/vite_task_plan/src/context.rs b/crates/vite_task_plan/src/context.rs index 8422128f8..1c7436a7c 100644 --- a/crates/vite_task_plan/src/context.rs +++ b/crates/vite_task_plan/src/context.rs @@ -30,10 +30,10 @@ pub struct PlanContext<'a> { /// The environment variables for the current execution context. /// /// `Arc`-shared with copy-on-write semantics: [`duplicate`](Self::duplicate) - /// and downstream consumers (`ScriptCommand::envs`) share the map; - /// mutations ([`add_envs`](Self::add_envs), - /// [`prepend_path`](Self::prepend_path)) clone only when the map is - /// currently shared. + /// and downstream consumers (`ScriptCommand::envs`, + /// `SpawnCommand::full_envs`) share the map; mutations + /// ([`add_envs`](Self::add_envs), [`prepend_path`](Self::prepend_path)) + /// clone only when the map is currently shared. envs: Arc, Arc>>, /// The callbacks for loading task graphs and parsing commands. diff --git a/crates/vite_task_plan/src/lib.rs b/crates/vite_task_plan/src/lib.rs index abd66861c..7251d451e 100644 --- a/crates/vite_task_plan/src/lib.rs +++ b/crates/vite_task_plan/src/lib.rs @@ -50,6 +50,18 @@ pub struct SpawnCommand { #[serde(serialize_with = "serialize_envs")] pub all_envs: Arc, Arc>>, + /// The command's full env context: the planning context's envs, which + /// accumulate the prefix envs of this command and of enclosing nested + /// `vp run` expansions on top of the session envs. Resolves runner-aware + /// `getEnv`/`getEnvs` requests and validates tracked envs at cache lookup. + /// + /// Not passed to the child process — that is the filtered + /// [`all_envs`](Self::all_envs). Skipped in serialized plans: it mirrors the ambient + /// environment, which is not part of the plan's identity and would bloat + /// snapshots nondeterministically. + #[serde(skip)] + pub full_envs: Arc, Arc>>, + /// Current working directory pub cwd: Arc, } diff --git a/crates/vite_task_plan/src/plan.rs b/crates/vite_task_plan/src/plan.rs index 12bf5c334..cc530735c 100644 --- a/crates/vite_task_plan/src/plan.rs +++ b/crates/vite_task_plan/src/plan.rs @@ -143,11 +143,11 @@ async fn plan_task_as_execution_node( // This command's env context: extend the planning context with // the command's prefix envs (`FOO=1 tool ...`). Everything that - // interprets the command reads this one context — program - // lookup, plan-request callbacks, and nested-run planning. The - // per-and-item duplicate above scopes the extension to this - // command, matching shell semantics (`FOO=1 a && b` does not - // set `FOO` for `b`). + // interprets the command reads this one context: program + // lookup, plan-request callbacks, nested-run planning, and the + // spawned process's `full_envs`. The per-and-item duplicate + // above scopes the extension to this command, matching shell + // semantics (`FOO=1 a && b` does not set `FOO` for `b`). context.add_envs(and_item.envs.iter()); let mut args = and_item.args; @@ -519,7 +519,7 @@ fn resolve_synthetic_cache_config( } if let Some(output) = output { - let synthetic_output = ResolvedGlobConfig::from_user_output_config( + let synthetic_output = ResolvedGlobConfig::from_user_config( Some(&output), package_dir, workspace_path, @@ -594,6 +594,14 @@ fn strip_prefix_for_cache( } } +/// Resolve a single spawned process from a command. +/// +/// Env contract: `envs` is the command's full env context — the planning +/// context's envs, already extended with this command's prefix envs by the +/// caller. It becomes `SpawnCommand::full_envs` verbatim. `prefix_envs` is +/// passed separately because two derivations must distinguish prefix envs +/// from the ambient context: they bypass the `env`/`untrackedEnv` declaration +/// filter into `all_envs` (the child env), and they are always fingerprinted. #[expect(clippy::result_large_err, reason = "Error is large for diagnostics")] #[expect( clippy::needless_pass_by_value, @@ -682,12 +690,20 @@ fn plan_spawn_execution( (OsStr::new(name.as_str()).into(), OsStr::new(value.as_str()).into()) })); + // The spawn's full env context for runner-aware `getEnv`/`getEnvs` is the + // command's env context as received — no merging here. The caller already + // extended the planning context with this command's prefix envs (see the + // `context.add_envs` call in the and-item loop), exactly as a nested run + // seeds its inner planning context. + let full_envs = Arc::clone(envs); + Ok(SpawnExecution { spawn_command: SpawnCommand { program_path, args: Arc::clone(&args), cwd, all_envs: Arc::new(all_envs.into_iter().collect()), + full_envs, }, cache_metadata: resolved_cache_metadata, }) @@ -942,11 +958,7 @@ mod tests { positive_globs: positive_globs.iter().map(|s| Str::from(*s)).collect(), negative_globs: BTreeSet::new(), }, - output_config: ResolvedGlobConfig { - includes_auto: false, - positive_globs: BTreeSet::new(), - negative_globs: BTreeSet::new(), - }, + output_config: ResolvedGlobConfig::default_auto(), } } diff --git a/crates/vite_task_server/src/lib.rs b/crates/vite_task_server/src/lib.rs index c3a7ae9ed..e0c82d03f 100644 --- a/crates/vite_task_server/src/lib.rs +++ b/crates/vite_task_server/src/lib.rs @@ -19,7 +19,8 @@ pub trait Handler { fn ignore_output(&mut self, path: &Arc); fn disable_cache(&mut self); fn get_env(&mut self, name: &OsStr, tracked: bool) -> Option>; - /// Returns the env names whose names match `pattern`. + /// Returns the subset of the env map whose names match `pattern` as a + /// glob, recording the match-set for the post-run fingerprint. /// /// # Errors /// @@ -61,11 +62,8 @@ pub struct InvalidGlob { pub source: vite_glob::env::EnvGlobError, } -/// A [`Handler`] that records every report. -/// -/// `get_env` and `get_envs` keep protocol compatibility but deliberately do -/// not serve runner env values yet; callers fall back to their own process env -/// until env tracking lands. +/// A [`Handler`] that records every report and resolves `get_env` against +/// a provided env map. /// /// Call [`Recorder::into_reports`] after the driver future completes to /// recover the collected [`Reports`]. @@ -75,6 +73,10 @@ pub struct Recorder { cache_disabled: bool, env_records: FxHashMap, EnvRecord>, env_glob_records: FxHashMap, EnvGlobRecord>, + /// The env map `get_env`/`get_envs` resolve against. The runner passes its + /// session env snapshot — the map its plan was bootstrapped from — shared + /// by `Arc`, never re-read from the live process env. + env_map: Arc, Arc>>, } /// A record of an env value requested via `get_env`. @@ -89,10 +91,9 @@ pub struct EnvRecord { /// A record of a glob-pattern env query made via `get_envs`. /// -/// `matches` is captured on the first call and reused on repeat queries. -/// In this slice the set is always empty; env value serving and fingerprint -/// tracking land in the cache layer follow-up. `tracked` is monotonic like -/// `EnvRecord::tracked`. +/// `matches` is captured on the first call and reused on repeat queries — +/// the server's `env_map` is immutable for the task's lifetime, so the set +/// is stable. `tracked` is monotonic like `EnvRecord::tracked`. /// /// Names and values are stored as `OsStr` here — the in-memory recording /// layer is byte-faithful. Conversion to `str` happens at the fingerprint @@ -116,13 +117,14 @@ pub struct Reports { impl Recorder { #[must_use] - pub fn new() -> Self { + pub fn new(env_map: Arc, Arc>>) -> Self { Self { ignored_inputs: FxHashSet::default(), ignored_outputs: FxHashSet::default(), cache_disabled: false, env_records: FxHashMap::default(), env_glob_records: FxHashMap::default(), + env_map, } } @@ -138,12 +140,6 @@ impl Recorder { } } -impl Default for Recorder { - fn default() -> Self { - Self::new() - } -} - impl Handler for Recorder { fn ignore_input(&mut self, path: &Arc) { self.ignored_inputs.insert(Arc::clone(path)); @@ -160,10 +156,11 @@ impl Handler for Recorder { fn get_env(&mut self, name: &OsStr, tracked: bool) -> Option> { if let Some(existing) = self.env_records.get_mut(name) { existing.tracked |= tracked; - return None; + return existing.value.clone(); } - self.env_records.insert(name.into(), EnvRecord { tracked, value: None }); - None + let value = self.env_map.get(name).cloned(); + self.env_records.insert(name.into(), EnvRecord { tracked, value: value.clone() }); + value } fn get_envs( @@ -173,12 +170,28 @@ impl Handler for Recorder { ) -> Result, Arc>, vite_glob::env::EnvGlobError> { if let Some(existing) = self.env_glob_records.get_mut(pattern) { existing.tracked |= tracked; - return Ok(FxHashMap::default()); + return Ok(existing.matches.clone()); } - let _glob = vite_glob::env::EnvGlob::new(pattern)?; + let glob = vite_glob::env::EnvGlob::new(pattern)?; + let matches: FxHashMap, Arc> = self + .env_map + .iter() + .filter_map(|(name, value)| { + // Env names that aren't valid UTF-8 can't be matched against a + // glob, so they're silently dropped. Values can still be + // non-UTF-8 — that conversion happens at the wire-encoding / + // fingerprinting boundary, not here. + let name_str = name.to_str()?; + if glob.is_match(name_str) { + Some((Arc::clone(name), Arc::clone(value))) + } else { + None + } + }) + .collect(); self.env_glob_records - .insert(Arc::from(pattern), EnvGlobRecord { tracked, matches: FxHashMap::default() }); - Ok(FxHashMap::default()) + .insert(Arc::from(pattern), EnvGlobRecord { tracked, matches: matches.clone() }); + Ok(matches) } } diff --git a/crates/vite_task_server/tests/integration.rs b/crates/vite_task_server/tests/integration.rs index 38f186574..9338b4c3c 100644 --- a/crates/vite_task_server/tests/integration.rs +++ b/crates/vite_task_server/tests/integration.rs @@ -25,13 +25,13 @@ fn env_map(pairs: &[(&str, &str)]) -> FxHashMap, Arc> { } fn run_with_server( - _envs: FxHashMap, Arc>, + envs: FxHashMap, Arc>, client_work: F, ) -> Result where F: FnOnce(Vec<(&'static OsStr, OsString)>) + Send + 'static, { - let recorder = Recorder::new(); + let recorder = Recorder::new(Arc::new(envs)); let rt = Builder::new_current_thread().enable_all().build().unwrap(); rt.block_on(async move { @@ -113,7 +113,7 @@ fn get_env_found_and_not_found() { let reports = run_with_server(env_map(&[("NODE_ENV", "production")]), |envs| { let client = connect(&envs); let present = client.get_env(OsStr::new("NODE_ENV"), true).unwrap(); - assert!(present.is_none()); + assert_eq!(present.as_deref(), Some(OsStr::new("production"))); let missing = client.get_env(OsStr::new("MISSING"), false).unwrap(); assert!(missing.is_none()); }) @@ -121,7 +121,7 @@ fn get_env_found_and_not_found() { let node = reports.env_records.get(OsStr::new("NODE_ENV")).expect("NODE_ENV recorded"); assert!(node.tracked); - assert!(node.value.is_none()); + assert_eq!(node.value.as_deref(), Some(OsStr::new("production"))); let missing = reports.env_records.get(OsStr::new("MISSING")).expect("MISSING recorded"); assert!(!missing.tracked); @@ -136,7 +136,7 @@ fn get_env_tracked_upgrade_is_monotonic() { let b = client.get_env(OsStr::new("NODE_ENV"), true).unwrap(); let c = client.get_env(OsStr::new("NODE_ENV"), false).unwrap(); for v in [a, b, c] { - assert!(v.is_none()); + assert_eq!(v.as_deref(), Some(OsStr::new("production"))); } }) .expect("driver returned error"); @@ -161,7 +161,7 @@ fn concurrent_clients() { let client = connect(&envs); client.ignore_input(OsStr::new(path)).unwrap(); let value = client.get_env(OsStr::new("SHARED"), true).unwrap(); - assert!(value.is_none()); + assert_eq!(value.as_deref(), Some(OsStr::new("value"))); }) }) .collect(); @@ -174,7 +174,7 @@ fn concurrent_clients() { assert_eq!(reports.ignored_inputs.len(), 4); let shared = reports.env_records.get(OsStr::new("SHARED")).expect("recorded"); assert!(shared.tracked); - assert!(shared.value.is_none()); + assert_eq!(shared.value.as_deref(), Some(OsStr::new("value"))); } #[test] @@ -217,20 +217,29 @@ fn server_returns_error_on_non_absolute_path() { } #[test] -fn get_envs_returns_empty_match_set() { +fn get_envs_returns_matching_entries() { let reports = run_with_server( env_map(&[("PROBE_A", "alpha"), ("PROBE_B", "beta"), ("UNRELATED", "noise")]), |envs| { let client = connect(&envs); let matches = client.get_envs("PROBE_*", true).unwrap(); - assert!(matches.is_empty()); + assert_eq!(matches.len(), 2); + assert_eq!( + matches.get(OsStr::new("PROBE_A")).map(AsRef::as_ref), + Some(OsStr::new("alpha")) + ); + assert_eq!( + matches.get(OsStr::new("PROBE_B")).map(AsRef::as_ref), + Some(OsStr::new("beta")) + ); + assert!(!matches.contains_key(OsStr::new("UNRELATED"))); }, ) .expect("driver returned error"); let glob = reports.env_glob_records.get("PROBE_*").expect("glob recorded"); assert!(glob.tracked); - assert!(glob.matches.is_empty()); + assert_eq!(glob.matches.len(), 2); } #[test] @@ -262,7 +271,7 @@ fn get_envs_tracked_upgrade_is_monotonic() { let glob = reports.env_glob_records.get("PROBE_*").expect("glob recorded"); assert!(glob.tracked, "tracked must stay true once set"); - assert!(glob.matches.is_empty()); + assert_eq!(glob.matches.len(), 1); } #[test] diff --git a/docs/runner-task-ipc/design-decisions.md b/docs/runner-task-ipc/design-decisions.md new file mode 100644 index 000000000..4cb6bbab9 --- /dev/null +++ b/docs/runner-task-ipc/design-decisions.md @@ -0,0 +1,15 @@ +## Why change code in tools instead of configuring in vite-plus + +- logic locality +- dynamic decision at runtime +- provide api to tools' plugins. + +## Why implement client in rust (instead of pure js) + +- Consumable by both rust and js (via napi) +- Easier to implement sync api + +## Why provide client at runtime (instead of bundling in the tools) + +- Makes IPC protocol a implementation detail. Allows us to evolve IPC implementation or data schema without breaking clients (as long as we maintain the client API contract) +- Easier for 3rd party client implementation in other languages (for example, esbuild can create a golang wrapper over the client ffi) diff --git a/docs/runner-task-ipc/index.md b/docs/runner-task-ipc/index.md new file mode 100644 index 000000000..362d70afc --- /dev/null +++ b/docs/runner-task-ipc/index.md @@ -0,0 +1,38 @@ +# runner-aware tools + +## Motivation + +Report information from the tools to the runner, to help runner cache results without needing user's manual configs. + +### What information vite-task knows without runner-awareness of tools? + +- All files that are read/written by the tools +- All directory that are read/written by the tools + +### What information vite-task doesn't know without runner-awareness of tools? + +- **Why** did the tool read/write the file/directory? (e.g. files in cache should not be considered as inputs even when they are read by the tool, and should not be considered as outputs even when they are written by the tool) +- **What** env variables are the tool interested in? (they are not available to the tool's process env if the user doesn't explicitly define them in `env` in the config) +- **Whether** the tool needs be cached at all? (e.g. dev server doesn't need to be cached, but build does) + +## Implementation + +Workflow: + +1. For each spawn execution, `vite_task` starts an IPC server via `vite_task_server::serve` and passes the server's connection info plus the path to a materialized node addon into the child's env. +2. The task process loads the addon through `@voidzero-dev/vite-task-client` and reports back over IPC: which reads/writes to ignore, which envs it needs (resolved from the spawn's full env context saved in the plan — session envs plus command prefix envs, including those of enclosing nested runs), and whether to disable caching. +3. When the task exits, the server drains and hands its collected reports back to `vite_task`, which feeds them into the cache layer. + +Crate / package responsibilities: + +- `vite_task_ipc_shared` — wincode-encoded request/response types and the env-var names used to hand connection info to children. +- `vite_task_server` — `Handler` trait, `serve` entry point, and a built-in `Recorder` handler that stores what the cache layer consumes. +- `vite_task_client` — sync blocking Rust client; `Client::from_envs` no-ops when the IPC env is absent. +- `vite_task_client_napi` — NAPI cdylib exposing the client's methods to Node. +- `materialized_artifact` — shared machinery for embedding the cdylib into `vp` and writing it to a temp file on first use (extracted from `fspy`). +- `@voidzero-dev/vite-task-client` — npm package with JSDoc-typed wrappers (`ignoreInput`, `ignoreOutput`, `disableCache`, `getEnv`, `getEnvs`). Lazy-loads the addon and silently no-ops when it can't connect, so tools don't break when run outside `vp`. + +Notes: + +- ignored input/output files reported from the runner are considered as part of `{ auto: true }`, which means if the user defines `input`/`output` without `auto: true`, in the config, the runner will only consider the files defined in the config as inputs/outputs, and ignore what's reported from the tools. +- envs requested from the tools are additional to the envs defined in the config. User config always wins: if an env is already defined in the config (e.g. as `untrackedEnv`), the tool cannot override it (e.g. upgrade it to tracked). diff --git a/docs/runner-task-ipc/plan.md b/docs/runner-task-ipc/plan.md new file mode 100644 index 000000000..c125bbcc9 --- /dev/null +++ b/docs/runner-task-ipc/plan.md @@ -0,0 +1,8 @@ +# Implementation Plan + +1. **Protocol** — `vite_task_ipc_shared`. Define message types and serialization. Everything else depends on this. ✅ +2. **Transport** — `vite_task_server` + `vite_task_client`. Build both sides, test them against each other directly in Rust. ✅ +3. **Extract artifact** — Pull `artifact.rs` out of fspy into a shared crate. Prerequisite for dylib embedding. ✅ +4. **JS bridge** — `vite_task_client_napi` (real impl) + `@voidzero-dev/vite-task-client` (JS wrapper with `getEnvs` logic). ✅ +5. **Runner integration** — Wire into `vite_task` spawn: start server per execution, embed/extract dylib, inject the IPC envs via `serve()`'s returned iterator. ✅ +6. **Cache integration** — Runner consumes the reported data (ignored inputs/outputs, requested envs, disable cache) and adjusts caching behavior. ✅ diff --git a/docs/runner-task-ipc/server-design.md b/docs/runner-task-ipc/server-design.md new file mode 100644 index 000000000..51e14267a --- /dev/null +++ b/docs/runner-task-ipc/server-design.md @@ -0,0 +1,147 @@ +# Server API & Lifecycle + +## Goal + +The IPC server runs per spawn execution **only when fspy is enabled**, letting tools report runtime-only facts to the runner (`ignoreInputs`, `ignoreOutputs`, `disableCache`, `getEnv`). The runner uses these reports alongside fspy's tracked accesses for cache correctness. + +## Key principles + +1. **Server doesn't take a cancellation token.** The caller signals "stop accepting" via `StopAccepting::signal()`. The server has no awareness of external cancellation. +2. **Handler is moved in, returned out.** The caller doesn't keep a reference. The driver owns the handler; on drain completion it returns it by value. No self-reference, no `&H` lifetime. +3. **`CancellationToken` is internal** — hidden from the public API (exposed only via `StopAccepting`). +4. **Driver is `!Send`**, lifetime bounded by `H`'s lifetime — if `H: 'static`, the driver is `'static`; if `H` borrows, the driver respects that. + +## Server API + +```rust +pub trait Handler { + fn ignore_input(&self, path: &Arc); + fn ignore_output(&self, path: &Arc); + fn disable_cache(&self); + fn get_env(&self, name: &str, tracked: bool) -> Option>; +} + +pub fn serve<'h, H: Handler + 'h>( + handler: H, +) -> io::Result<(impl Iterator, ServerHandle<'h, H>)>; + +pub struct ServerHandle<'h, H> { + pub driver: LocalBoxFuture<'h, H>, + pub stop_accepting: StopAccepting, +} + +pub struct StopAccepting { /* opaque */ } +impl StopAccepting { + pub fn signal(self); +} +``` + +## Driver semantics + +The driver future, when polled: + +1. **Accept phase** — accepts new clients and pumps per-client futures (`FuturesUnordered`) until `StopAccepting::signal()` fires. +2. **Listener teardown** — drops listener; Unix socket file auto-cleaned via `tempfile::NamedTempFile`. +3. **Drain phase** — waits for in-flight per-client futures to complete naturally (each ends on client EOF). +4. **Returns `H`** — the owned handler that was moved in at `serve()`. + +Dropping the driver before it resolves tears everything down immediately. Handler is dropped without being returned. + +## Lifecycle in `execute_spawn` + +### When to start + +Only when fspy is enabled (`cache_metadata.input_config.includes_auto`). No fspy → no IPC server. + +### Construction (at `ExecutionMode` build time) + +`serve()` yields an env-pair iterator that the caller chains directly into the spawn's envs. The specific env var(s) used for IPC handoff are an implementation detail between the server and client crates — the runner never has to know their names. + +```rust +let (ipc_envs, server) = serve(IpcRecorder::new(env_config))?; +let envs = cmd.all_envs.iter().map(|(k, v)| (&**k, &**v)).chain(ipc_envs); +let child = spawn(&cmd, envs, true, SpawnStdio::Piped, token).await?; +// After the child is spawned, nothing else needs the IPC envs. + +let fspy = FspyState { + negatives, + server, // ServerHandle<'h, IpcRecorder> +}; +``` + +### `FspyState` shape + +```rust +struct FspyState { + negatives: Vec>, + server: ServerHandle, +} +``` + +**Not stored:** + +- IPC env name/value — consumed once to build the spawn envs, dropped immediately. +- `handler` — lives inside `server.driver`'s async state; recovered by value when the driver resolves. + +### Driving the server during `pipe_stdio` / `child.wait` + +The driver is polled as an extra arm in the existing `tokio::select!` blocks. `LocalBoxFuture<'static, H>` is `Unpin`, so `&mut driver` is a valid select arm: + +```rust +tokio::select! { + r = &mut pipe_fut => r, + _recorder = &mut fspy_state.server.driver => { + unreachable!("driver resolved before stop_accepting.signal()") + } +} +``` + +The driver only resolves after `stop_accepting.signal()` + drain — neither happens during these phases, so the branch is unreachable. + +### Completion paths + +```rust +// Normal exit: +if !fast_fail_token.is_cancelled() && !interrupt_token.is_cancelled() { + if let Some(fspy_state) = fspy.take() { + fspy_state.server.stop_accepting.signal(); + let recorder = fspy_state.server.driver.await; + // recorder.into_reports() flows into cache-update + } +} + +// Cancellation: fspy dropped at scope end → driver dropped → teardown. +``` + +## Design-decision log + +### Why no `'static` bound on `H`? + +The driver future _owns_ the handler (via `Rc` internally). It doesn't need to outlive `H` — it just needs `H` to outlive the future. So the signature is `serve<'h, H: Handler + 'h>` and the returned `ServerHandle<'h, H>` carries the lifetime. If the caller's `H` is `'static`, the driver is `'static`; if `H` borrows, the driver respects that. + +If the caller wants to store `ServerHandle` in a struct without a lifetime parameter, they can use a `'static` handler (naturally satisfied by handlers that own all their state via `RefCell<...>` + cloned data). + +### Handler is owned by the driver, not shared via `Rc` + +The driver's async function owns `handler: H` as a local. Per-client futures borrow `&handler` from that same async state; Rust's async-fn state machine makes this self-borrow sound (the state is pinned and never moves). All per-client futures live inside `FuturesUnordered` which is also part of the same state — borrow scopes are contained. + +When drain completes and all per-client futures have been dropped, the outer async returns `handler` by move. No `Rc`, no `try_unwrap`, no panic possible. + +### Why return `H` from the driver? + +Caller doesn't keep the handler around separately. Avoids `Rc::try_unwrap` at the call site. Makes it impossible to forget recovering the state. + +### Why `StopAccepting::signal(self)` instead of exposing `CancellationToken`? + +- Hides the implementation (could swap `CancellationToken` for `oneshot` or `Notify` later). +- Reads as intent: "stop accepting" vs. "cancel". +- `self`-consuming method signals one-shot semantics. +- Keeps the public API free of `tokio_util` types. + +### Why not pass `shutdown: impl Future` or `CancellationToken`? + +Earlier direction: "server doesn't care about cancellation token; it simply stops accepting when the process exits." The caller doesn't have a token to pass — they have a moment (child exit) when they want to stop accepting. `StopAccepting::signal()` is that moment. + +### On `spawn()` changes (deferred) + +`spawn()` will need to accept extra envs (e.g. `envs: impl IntoIterator, impl AsRef)>`) so the caller can inject the IPC envs without cloning `Arc`. Not part of this step. diff --git a/docs/runner-task-ipc/todo.md b/docs/runner-task-ipc/todo.md new file mode 100644 index 000000000..6b31e6a11 --- /dev/null +++ b/docs/runner-task-ipc/todo.md @@ -0,0 +1,30 @@ +# TODO + +## Native addon should return `null` on connect failure + +**Status**: not started. + +**Decision**: when `Client::from_envs` returns `Ok(None)` (env missing) or `Err` (connect failed), the `.node` addon's `require()` must resolve to `null` instead of throwing. + +### Why + +- **Impossible to misuse** — a caller writes `const addon = require(path); if (!addon) return;` once. You can't call a method on `null`, so there's no silent-no-op trap. +- **Smallest API surface** — one null-check at load time replaces per-call `try/catch` or per-call readiness checks everywhere. +- **Reserves room to upgrade** — we can later promote truly unexpected errors (bugs, misconfiguration) to throw while keeping the expected "no runner / no socket" cases as `null`, without changing tools or the JS wrapper. Committing to "throw on failure" today closes that door forever. + +### Why not the alternatives + +- **Throw (today)** — forces every third-party consumer into `try/catch` forever. Forgetting it crashes the tool in non-runner contexts. +- **Readiness flag on an always-returned object** — pushes the no-op decision into the methods. Every call site either needs a guard or relies on silent no-op (the tool thinks it's reporting, but isn't). + +### How + +napi-rs 3.x has no hook to change what its `napi_register_module_v1` returns ([napi-3.8.5/src/bindgen_runtime/module_register.rs:487-545](https://) hard-codes `Ok(exports)`), so we have to bypass it: + +- Enable napi's `noop` feature on [crates/vite_task_client_napi/Cargo.toml](../../crates/vite_task_client_napi/Cargo.toml) — disables napi-rs's own `napi_register_module_v1` and avoids a duplicate-symbol linker error. +- Drop `napi-derive` and the `#[napi]` decorators. Rewrite [crates/vite_task_client_napi/src/lib.rs](../../crates/vite_task_client_napi/src/lib.rs) using raw `napi::sys::*`: one `#[unsafe(no_mangle)] extern "C" fn napi_register_module_v1`, four `extern "C"` callbacks, properties registered via `sys::napi_define_properties`. +- On `Ok(Some(client))`: store client in a `thread_local! { static CLIENT: OnceCell }` and populate `exports`. On any other outcome: return `sys::napi_get_null(env)`. +- Update [packages/vite-task-client/index.js](../../packages/vite-task-client/index.js) — drop the `try/catch` in `load()`; `require()`'s result is already `null` or an object. +- Add an e2e test that spawns Node without `VP_RUN_IPC_NAME` and asserts `require(addonPath) === null`. + +Estimated size: ~200 lines. We only depend on `sys::*`, which is the stable Node ABI, so napi-rs internal churn can't break us. diff --git a/docs/runner-task-ipc/transport.md b/docs/runner-task-ipc/transport.md new file mode 100644 index 000000000..fb2cc34bf --- /dev/null +++ b/docs/runner-task-ipc/transport.md @@ -0,0 +1,20 @@ +# IPC Transport + +Cross-platform IPC straight on top of OS primitives — no `interprocess` or other transport-abstraction crate in between: + +| Platform | Server | Client | +| ------------------ | -------------------------------------------------------------------- | ---------------------------------------------------------- | +| Unix (macOS/Linux) | `tokio::net::UnixListener` (path inside a `tempfile::NamedTempFile`) | `std::os::unix::net::UnixStream` | +| Windows | `tokio::net::windows::named_pipe::NamedPipeServer` (per-instance) | `std::fs::OpenOptions::open` on the `\\.\pipe\` path | + +The socket path or pipe name is passed to the task process via an env var shared between `vite_task_server` and `vite_task_client` (the specific name is an implementation detail). Clients check for its presence and skip IPC gracefully if absent. + +## Server Model + +One listener per task execution. The runner creates a new socket just before spawning the task and tears it down after the task exits. + +The listener runs an accept loop and handles multiple concurrent clients — build tools may spawn worker processes or threads that each connect independently. + +On Windows, each accepted connection consumes the pending `NamedPipeServer` instance, and the accept loop immediately creates a fresh instance for the next client. There is a brief window during the swap where a client can see `ERROR_PIPE_BUSY`; the client retries (bounded) until the new instance is ready. + +Platform differences are handled via `#[cfg(unix)]` / `#[cfg(windows)]`. diff --git a/docs/runner-task-ipc/vite-proposal.md b/docs/runner-task-ipc/vite-proposal.md new file mode 100644 index 000000000..2fc7ac5d5 --- /dev/null +++ b/docs/runner-task-ipc/vite-proposal.md @@ -0,0 +1,224 @@ +# Proposal: Let Vite Talk to Vite Task + +We'd like `vite build` to be cached by Vite Task **correctly** and **with zero user configuration**. This proposal adds a small dependency and a few calls in Vite's codebase to make that possible. + +## Background + +Vite Task (`vp run` in Vite+) caches a task by tracking three things: + +- the files it reads (**inputs**) +- the files it writes (**outputs**) +- the env vars it depends on (**envs**) + +Inputs and envs are **fingerprints** of the cache: any change in them triggers a cache miss and a re-run. + +Outputs are the main content of the cache: they are **restored on cache hits**. + +Vite Task discovers inputs and outputs automatically by **tracking reads and writes at the syscall level**. But envs have to be declared in the task config; Vite Task only passes declared envs through to the child process, so **undeclared envs are invisible to the task** and don't affect caching. + +A typical task config looks like this: + +```json +{ + "tasks": { + "build": { + "command": "build-script", + "cache": true, + // The user doesn't have to declare inputs and outputs. + // But they does have to declare envs. Vite Task can't track what envs a task reads. + "env": ["APP_*"] + } + } +} +``` + +## Motivation + +The goal of this proposal is to cache `vite build` **correctly**, and with **zero manual cache config**. + +Vite Task needs the Vite process to communicate two things: + +- Which file reads and writes are **internal** (the task's own cache) rather than real inputs or outputs; +- Which envs the task **actually needs** at runtime. + +## Cases && Proposed Changes + +We want to add a small dependency `@voidzero-dev/vite-task-client` to Vite that lets it talk to Vite Task at runtime. Vite calls into the client at the relevant code paths to declare "ignore this path as an input/output" or "fetch these envs from Vite Task". The calls **are no-ops when Vite runs outside Vite Task**. + +### 1. Reading envs according to `envPrefix` + +Vite's [`envPrefix`](https://vite.dev/config/shared-options#envprefix) exposes any env matching the prefix (`VITE_*` by default) to the client bundle via `import.meta.env`. + +**Current State**: For Vite Task to forward these envs to the build and fingerprint them, the user has to declare them in Vite Task config: + +```jsonc +// Vite Task config +{ "tasks": { "build": { "command": "vite build", "env": ["VITE_*"] } } } +``` + +This task config duplicates `envPrefix`. The two configs currently have to be maintained in sync by the user. Forgetting to do so silently produces incorrect bundles (because the `VITE_*` envs won't be passed to Vite). + +**Proposed Changes**: + +Add a call to `vite-task-client`'s `getEnvs` **before reading the envs matching `envPrefix`**. If Vite is running outside Vite Task, this calls is a no-op. If Vite is running inside Vite Task, it does two things: + +- It tells Vite Task which envs are relevant to the build, so they **become part of the cache fingerprint**. +- It **populates `process.env`** with those envs, so Vite can see them. + +```diff +--- a/packages/vite/src/node/env.ts ++++ b/packages/vite/src/node/env.ts +@@ loadEnv(mode, envDir, prefixes = 'VITE_') { + const parsed = Object.fromEntries(...); + for (const [key, value] of Object.entries(parsed)) + if (prefixes.some(p => key.startsWith(p))) env[key] = value; ++ for (const prefix of prefixes) getEnvs(`${prefix}*`, { tracked: true }); + for (const key in process.env) + if (prefixes.some(p => key.startsWith(p))) env[key] = process.env[key]; +``` + +### 2. Pre-bundling deps in `cacheDir` + +Vite's dep optimizer reads metadata from and writes pre-bundled deps into [`cacheDir`](https://vite.dev/config/shared-options#cachedir) (default `node_modules/.vite/`). + +**Current State**: Vite Task sees these reads and writes, and **treats them as the task's inputs and outputs**. But they're in fact neither: `cacheDir` is the dep optimizer's internal state. The user has to exclude the directory out of both lists manually: + +```jsonc +// Vite task config +{ + "input": [{ "auto": true }, { "pattern": "!node_modules/.vite/**", "base": "workspace" }], + "output": [{ "auto": true }, { "pattern": "!node_modules/.vite/**", "base": "workspace" }], +} +``` + +And `cacheDir` is user-configurable, so the path in this config has to match whatever the user set. + +**Proposed Changes**: + +Add two calls **right after resolving `depsCacheDir`**. If Vite is running outside Vite Task, these calls are no-ops. If Vite is running inside Vite Task, they do two things: + +- They tell Vite Task to **not treat reads in this directory as inputs**. +- They tell Vite Task to **not treat writes in this directory as outputs**. + +```diff +--- a/packages/vite/src/node/optimizer/index.ts ++++ b/packages/vite/src/node/optimizer/index.ts +@@ loadCachedDepOptimizationMetadata(environment, force) { + const depsCacheDir = getDepsCacheDir(environment); ++ ignoreInput(depsCacheDir); ++ ignoreOutput(depsCacheDir); +``` + +### 3. Cleaning `outDir` before writing the bundle + +Before writing the bundle, Vite calls [`emptyDir(outDir)`](https://github.com/vitejs/vite/blob/v8.0.8/packages/vite/src/node/plugins/prepareOutDir.ts#L69), which `readdirSync`s the directory to list entries for deletion. Then the bundler writes new files to the same directory. + +**Current State**: Vite Task sees `dist/` as both an input (the cleanup read) and an output (the bundle write). The user has to exclude `dist/**` from inputs while keeping it declared as an output. And [`build.outDir`](https://vite.dev/config/build-options#build-outdir) is user-configurable, so the path in this config has to match whatever the user set. + +**Proposed Changes**: + +Add a call **before `emptyDir`**. If Vite is running outside Vite Task, this call is a no-op. If Vite is running inside Vite Task, it tells Vite Task to **not treat reads in this directory as inputs**. + +```diff +--- a/packages/vite/src/node/plugins/prepareOutDir.ts ++++ b/packages/vite/src/node/plugins/prepareOutDir.ts +@@ prepareOutDir(outDirs, emptyOutDir, environment) { + for (const outDir of outDirs) { ++ ignoreInput(outDir); + if (emptyOutDir !== false && fs.existsSync(outDir)) emptyDir(outDir, ...); +``` + +### 4. Globbing files in `import.meta.glob` + +`import.meta.glob('src/**/*.ts')` lets users import every file matching a pattern. Vite expands the pattern with `tinyglobby`, which reads directory entries under `src/` and filters down to files matching `*.ts`. + +**Current State**: Vite Task sees the directory entry reads but not the filter. For `src/**/*.ts`, it knows that the entries of every directory under `src/` are read, but doesn't know that only `.ts` files matter to Vite. Adding or removing any file under `src/` (say a `.md` file the build wouldn't have picked up) invalidates the cache. + +**Proposed Changes**: + +Add a `glob` function to `@voidzero-dev/vite-task-client`. The first two arguments are the same patterns and options as `tinyglobby`'s `glob`; the third is the original `tinyglobby.glob` itself, used as the fallback when called outside Vite Task. Passing the fallback in keeps `tinyglobby` out of `@voidzero-dev/vite-task-client`'s dependencies. + +When called inside Vite Task, Vite Task does the globbing itself and returns the matching paths; the patterns and the matched paths become the fingerprint input, so only changes that would have affected the match-set invalidate the cache. When called outside Vite Task, it just calls the fallback, so behaviour is unchanged for direct `vite build` invocations. + +```diff +--- a/packages/vite/src/node/plugins/importMetaGlob.ts ++++ b/packages/vite/src/node/plugins/importMetaGlob.ts +-import { glob } from 'tinyglobby' ++import { glob as tinyglobbyGlob } from 'tinyglobby' ++import { glob } from '@voidzero-dev/vite-task-client' + + const files = ( +- await glob(globsResolved, { /* tinyglobby options */ }) ++ await glob(globsResolved, { /* tinyglobby options */ }, tinyglobbyGlob) + ).filter(...).sort() +``` + +## Details of `@voidzero-dev/vite-task-client` package + +`@voidzero-dev/vite-task-client` is a [small ESM package](https://github.com/voidzero-dev/vite-task/blob/runner-aware-tools/packages/vite-task-client/index.js) (~80 lines, no native code, no transitive deps). Its API is five functions: + +```ts +declare module '@voidzero-dev/vite-task-client' { + export function ignoreInput(path: string): void; + export function ignoreOutput(path: string): void; + export function disableCache(): void; + export function getEnv(name: string, opts?: { tracked?: boolean }): void; + export function getEnvs(pattern: string, opts?: { tracked?: boolean }): Record; + // Same patterns and options as `tinyglobby`'s `glob`. The third + // argument is the fallback used when called outside Vite Task; passing + // it in keeps `tinyglobby` out of this package's dependencies. + export function glob( + patterns: string | string[], + options: GlobOptions, + fallback: (patterns: string | string[], options: GlobOptions) => Promise, + ): Promise; +} +``` + +`getEnv` / `getEnvs` populate `process.env` for names Vite Task knows about and that aren't already set, so callers read values back via `process.env[...]` as usual. + +### Runtime mechanics + +The package is a thin wrapper. The wire protocol and IPC transport live in a node module **shipped with Vite Task**. At task start, Vite Task exports that module's path to tasks via an env var; the `@voidzero-dev/vite-task-client` wrapper lazy-requires it on first call. + +```mermaid +sequenceDiagram + participant T as Vite Task + participant V as Vite + participant W as @voidzero-dev/vite-task-client (thin wrapper) + participant N as client implementation bundled in Vite Task + T->>V: spawn with $VP_RUN_NODE_CLIENT_PATH + V->>W: import + W->>N: require($VP_RUN_NODE_CLIENT_PATH) on first call + N->>T: IPC +``` + +The thin-wrapper design has two benefits: + +- **Light logic bundled in Vite.** Only the lazy-load shim ships with Vite. The wire protocol and `node_client` ship with Vite Task. +- **Vite Task can evolve the IPC freely.** The wire protocol and `node_client`'s APIs can change in Vite Task without requiring a new Vite release. Old Vite versions keep working as long as the wrapper's public surface stays stable. + +If the env var is absent (e.g. `vite build` run directly, without Vite Task) or the addon fails to load, every exported function silently no-ops. Vite's behaviour is unchanged outside Vite Task. + +## Future additions + +This proposal covers the cases that demonstrate the high-level approach. The exact shape of the client APIs and any additional call sites can be discussed and added progressively once the high-level approach is agreed on. + +Two examples of what could come next: + +- **Call `disableCache()` in dev.** `vite dev` could opt the task out of caching completely. +- **Plugin-owned cache dirs.** Any plugin with its own read/write cache could declare it with `ignoreInput`/`ignoreOutput` instead of asking every user to declare them in Vite Task config. + +## Alternatives considered + +- **Detect `vite build` and configure its cache in Vite+.** + - `envPrefix`, `cacheDir`, and `outDir` can be dynamically computed in `vite.config.ts`. Vite+ would have to execute the config to know their values. + - Other similar cases may exist or appear in future Vite versions. Vite+ would have to learn each one's semantics and handle them case by case. It'd be easier to maintain them alongside the related code in Vite itself. + +- **Patch the Vite bundled in Vite+.** + - **Logic locality.** Each call belongs next to the Vite code that owns its path; patching from outside puts them somewhere else. + - Users with a self-installed Vite (not the Vite+ bundled one) would get no benefit. + +- **Implement this in a Vite plugin** + - The plugin doesn't have the enough hooks to cover all cases (fetch envs for `envPrefix`). + - Enhancing the plugin API to cover all cases would be a much bigger impact on Vite. diff --git a/docs/runner-task-ipc/vite-rolldown-env-operations.md b/docs/runner-task-ipc/vite-rolldown-env-operations.md new file mode 100644 index 000000000..a16fb928e --- /dev/null +++ b/docs/runner-task-ipc/vite-rolldown-env-operations.md @@ -0,0 +1,83 @@ +# Vite & Rolldown Environment Variable Operations + +## Vite + +### Reads that affect build output (must be tracked for cache correctness) + +| File | Line | Variable | Effect on output | +| -------------------------------------------------------------------------------------------------------------------------------- | ---- | ------------------------------ | ------------------------------------------------- | +| [config.ts](https://github.com/vitejs/vite/blob/v8.0.8/packages/vite/src/node/config.ts#L1383) | 1383 | `NODE_ENV` | Build mode, affects dead-code elimination | +| [config.ts](https://github.com/vitejs/vite/blob/v8.0.8/packages/vite/src/node/config.ts#L1661) | 1661 | `VITE_USER_NODE_ENV` | User-set NODE_ENV from `.env` file | +| [build.ts](https://github.com/vitejs/vite/blob/v8.0.8/packages/vite/src/node/build.ts#L644) | 644 | `NODE_ENV` | Preserved for bundler | +| [plugins/clientInjections.ts](https://github.com/vitejs/vite/blob/v8.0.8/packages/vite/src/node/plugins/clientInjections.ts#L52) | 52 | `NODE_ENV` | Injected into client bundle | +| [plugins/define.ts](https://github.com/vitejs/vite/blob/v8.0.8/packages/vite/src/node/plugins/define.ts#L20) | 20 | `NODE_ENV` | Define replacement in output | +| [optimizer/index.ts](https://github.com/vitejs/vite/blob/v8.0.8/packages/vite/src/node/optimizer/index.ts#L1281) | 1281 | `NODE_ENV` | Dep pre-bundling config | +| [env.ts](https://github.com/vitejs/vite/blob/v8.0.8/packages/vite/src/node/env.ts#L86) | 86 | `VITE_*` (all matching prefix) | Injected into client bundle via `import.meta.env` | + +### Reads that do not affect output (untracked) + +| File | Line | Variable | Effect | +| ---------------------------------------------------------------------------------------------------------------- | ---- | ----------------------- | ------------------------------ | +| [logger.ts](https://github.com/vitejs/vite/blob/v8.0.8/packages/vite/src/node/logger.ts#L84) | 84 | `CI` | Disables color output only | +| [build.ts](https://github.com/vitejs/vite/blob/v8.0.8/packages/vite/src/node/build.ts#L1067) | 1067 | `CI` | Disables TTY progress only | +| [utils.ts](https://github.com/vitejs/vite/blob/v8.0.8/packages/vite/src/node/utils.ts#L176) | 176 | `DEBUG` | Debug logging only | +| [optimizer/index.ts](https://github.com/vitejs/vite/blob/v8.0.8/packages/vite/src/node/optimizer/index.ts#L1269) | 1269 | `npm_config_user_agent` | Package manager detection only | + +### Writes to `process.env` + +| File | Line | Variable | Reason | +| ---------------------------------------------------------------------------------------------- | ---- | -------------------- | ----------------------------------------------------- | +| [config.ts](https://github.com/vitejs/vite/blob/v8.0.8/packages/vite/src/node/config.ts#L1389) | 1389 | `NODE_ENV` | Sets default if unset | +| [config.ts](https://github.com/vitejs/vite/blob/v8.0.8/packages/vite/src/node/config.ts#L1664) | 1664 | `NODE_ENV` | Overrides to `'development'` if user set it in `.env` | +| [env.ts](https://github.com/vitejs/vite/blob/v8.0.8/packages/vite/src/node/env.ts#L62) | 62 | `VITE_USER_NODE_ENV` | Stores NODE_ENV read from `.env` | + +### `.env` file loading + +Handled by `loadEnv()` at [env.ts:27](https://github.com/vitejs/vite/blob/v8.0.8/packages/vite/src/node/env.ts#L27). Reads `.env`, `.env.local`, `.env.{mode}`, `.env.{mode}.local` from `envDir`. All `VITE_*` vars become `import.meta.env.*` in the client bundle. + +This is **file input fingerprinting**, not an env var concern — fspy automatically tracks the `readFileSync` calls on `.env` files as inferred inputs. + +--- + +## Rolldown + +### Rust — reads + +| File | Line | Variable | Effect | +| ---------------------------------------------------------------------------------------------------------------------------- | ---- | ------------------------------- | --------------------------- | +| [rolldown_binding/src/lib.rs](https://github.com/rolldown/rolldown/blob/v1.0.0-rc.15/crates/rolldown_binding/src/lib.rs#L88) | 88 | `ROLLDOWN_MAX_BLOCKING_THREADS` | Tokio blocking thread count | +| [rolldown_binding/src/lib.rs](https://github.com/rolldown/rolldown/blob/v1.0.0-rc.15/crates/rolldown_binding/src/lib.rs#L95) | 95 | `ROLLDOWN_WORKER_THREADS` | Tokio worker thread count | +| [rolldown_tracing/src/lib.rs](https://github.com/rolldown/rolldown/blob/v1.0.0-rc.15/crates/rolldown_tracing/src/lib.rs#L25) | 25 | `RD_LOG` | Tracing log levels | +| [rolldown_tracing/src/lib.rs](https://github.com/rolldown/rolldown/blob/v1.0.0-rc.15/crates/rolldown_tracing/src/lib.rs#L33) | 33 | `RD_LOG_OUTPUT` | Log output mode | + +None of these affect build output. + +### JS (NAPI binding loader) — reads + +| File | Line | Variable | Effect | +| --------------------------------------------------------------------------------------------------------------------------------- | ---- | ------------------------------- | -------------------------------------------------- | +| [packages/rolldown/src/binding.cjs](https://github.com/rolldown/rolldown/blob/v1.0.0-rc.15/packages/rolldown/src/binding.cjs#L64) | 64 | `NAPI_RS_NATIVE_LIBRARY_PATH` | Custom native lib path for loading `.node` binding | +| [packages/rolldown/src/binding.cjs](https://github.com/rolldown/rolldown/blob/v1.0.0-rc.15/packages/rolldown/src/binding.cjs#L80) | 80 | `NAPI_RS_ENFORCE_VERSION_CHECK` | Version mismatch behavior | + +--- + +## Implications for `getEnv` IPC + +Today, env vars read from `process.env` inside the task process are invisible to +the runner — no file read happens, so fspy cannot track them. The runner's current +`env`/`untrackedEnv` config requires the user to declare them manually. + +With `getEnv` IPC, a Vite plugin could request vars at runtime and have them +automatically fingerprinted: + +```ts +buildStart() { + await getEnv('NODE_ENV', { tracked: true }) // affects output — fingerprint it + await getEnv('CI', { tracked: false }) // affects behavior only — pass through +} +``` + +The key vars for Vite cache correctness via `getEnv`: + +- **`NODE_ENV`** — affects dead-code elimination, define replacements, and `import.meta.env.MODE` +- **`VITE_*`** — any matching var is injected into the client bundle; all must be tracked diff --git a/docs/runner-task-ipc/vite-rolldown-fs-operations.md b/docs/runner-task-ipc/vite-rolldown-fs-operations.md new file mode 100644 index 000000000..57cb2bf1d --- /dev/null +++ b/docs/runner-task-ipc/vite-rolldown-fs-operations.md @@ -0,0 +1,109 @@ +# Vite & Rolldown Filesystem Operations + +File reads and writes relevant to output restoration and cache fingerprinting. +All paths are relative to the package root unless noted. + +## Who writes output files + +When Vite uses Rolldown as its bundler, the actual chunk/asset writes happen in +Rolldown's Rust core (`bundle.rs`). Vite calls `bundle.write(output)` on the +`RolldownBuild` object; it does not write chunks itself. Rolldown's TypeScript +`build.ts` is only the standalone public API and is bypassed when called from +Vite. + +Vite owns only the surrounding operations: emptying the output dir and copying +public assets. + +--- + +## Vite — Output Directory (`build.outDir`, default `dist/`) + +| File | Line | Operation | Description | +| ----------------------------------------------------------------------------------------------------------------------- | ---- | -------------------------------- | ------------------------------------------------------------------- | +| [prepareOutDir.ts](https://github.com/vitejs/vite/blob/v8.0.8/packages/vite/src/node/plugins/prepareOutDir.ts#L69) | 69 | read (`readdirSync`) | `emptyDir(outDir)` — lists then deletes all contents before build | +| [utils.ts](https://github.com/vitejs/vite/blob/v8.0.8/packages/vite/src/node/utils.ts#L591) | 591 | read (`readdirSync`, `statSync`) | `emptyDir()` and `copyDir()` implementations | +| [prepareOutDir.ts](https://github.com/vitejs/vite/blob/v8.0.8/packages/vite/src/node/plugins/prepareOutDir.ts#L89) | 89 | write | `copyDir(publicDir, outDir)` — copies public assets into output dir | +| [build.ts](https://github.com/vitejs/vite/blob/v8.0.8/packages/vite/src/node/build.ts#L874) | 874 | write (delegates) | `bundle.write(output)` — hands off to Rolldown Rust core | +| [license.ts](https://github.com/vitejs/vite/blob/v8.0.8/packages/vite/src/node/plugins/license.ts#L97) | 97 | write | emits `dist/.vite/license.json` via `emitFile()` | +| [license.ts](https://github.com/vitejs/vite/blob/v8.0.8/packages/vite/src/node/plugins/license.ts#L107) | 107 | write | emits `dist/.vite/license.md` via `emitFile()` | +| [ssrManifestPlugin.ts](https://github.com/vitejs/vite/blob/v8.0.8/packages/vite/src/node/ssr/ssrManifestPlugin.ts#L106) | 106 | write | emits `dist/.vite/ssr-manifest.json` via `emitFile()` | + +Note: `manifest.json` is emitted by a native Rolldown plugin (`native:manifest`), +not by Vite JS code. + +## Vite — Cache Directory (`cacheDir`, default `node_modules/.vite/`) + +| File | Line | Operation | Description | +| ---------------------------------------------------------------------------------------------------------------- | ---- | ------------------------ | ------------------------------------------------------------------- | +| [optimizer/index.ts](https://github.com/vitejs/vite/blob/v8.0.8/packages/vite/src/node/optimizer/index.ts#L405) | 405 | read | reads `cacheDir/deps/_metadata.json` | +| [optimizer/index.ts](https://github.com/vitejs/vite/blob/v8.0.8/packages/vite/src/node/optimizer/index.ts#L600) | 600 | read | `existsSync(depsCacheDir)` — checks cache presence | +| [optimizer/index.ts](https://github.com/vitejs/vite/blob/v8.0.8/packages/vite/src/node/optimizer/index.ts#L1417) | 1417 | read (`readdir`, `stat`) | scans for stale temp dirs older than 24h | +| [optimizer/index.ts](https://github.com/vitejs/vite/blob/v8.0.8/packages/vite/src/node/optimizer/index.ts#L531) | 531 | write | writes `package.json` (`"type":"module"`) into processing cache dir | +| [optimizer/index.ts](https://github.com/vitejs/vite/blob/v8.0.8/packages/vite/src/node/optimizer/index.ts#L586) | 586 | write | writes `_metadata.json` | +| [optimizer/index.ts](https://github.com/vitejs/vite/blob/v8.0.8/packages/vite/src/node/optimizer/index.ts#L858) | 858 | write | `bundle.write()` — writes pre-bundled deps to `cacheDir/deps/` | +| [config.ts](https://github.com/vitejs/vite/blob/v8.0.8/packages/vite/src/node/config.ts#L2542) | 2542 | write | creates `node_modules/.vite-temp/` for bundled config files | + +--- + +## Rolldown — TypeScript API (`output.dir`, default `dist/`) + +| File | Line | Operation | Description | +| -------------------------------------------------------------------------------------------------------------------------------- | ---- | ----------------- | ---------------------------------------------------------------------- | +| [output-options.ts](https://github.com/rolldown/rolldown/blob/v1.0.0-rc.15/packages/rolldown/src/options/output-options.ts#L702) | 702 | — | `cleanDir?: boolean` option definition | +| [build.ts](https://github.com/rolldown/rolldown/blob/v1.0.0-rc.15/packages/rolldown/src/api/build.ts#L65) | 65 | write (delegates) | `build.write(output)` — standalone entry point, delegates to Rust core | + +## Rolldown — Rust Core + +| File | Line | Operation | Description | +| --------------------------------------------------------------------------------------------------------------------- | ---- | ----------------- | --------------------------------------------------------------------- | +| [bundle/bundle.rs](https://github.com/rolldown/rolldown/blob/v1.0.0-rc.15/crates/rolldown/src/bundle/bundle.rs#L161) | 161 | read + delete | calls `clean_dir(&fs, &dist_dir)` when `clean_dir` option is set | +| [bundle/bundle.rs](https://github.com/rolldown/rolldown/blob/v1.0.0-rc.15/crates/rolldown/src/bundle/bundle.rs#L175) | 175 | write | `fs.create_dir_all(&dist_dir)` — creates output dir | +| [bundle/bundle.rs](https://github.com/rolldown/rolldown/blob/v1.0.0-rc.15/crates/rolldown/src/bundle/bundle.rs#L202) | 202 | write | `fs.create_dir_all(p)` — creates parent dirs for output chunks | +| [bundle/bundle.rs](https://github.com/rolldown/rolldown/blob/v1.0.0-rc.15/crates/rolldown/src/bundle/bundle.rs#L209) | 209 | write | `fs.write(&dest, chunk.content_as_bytes())` — writes each output file | +| [utils/fs_utils.rs](https://github.com/rolldown/rolldown/blob/v1.0.0-rc.15/crates/rolldown/src/utils/fs_utils.rs#L10) | 10 | — | `clean_dir()` function definition | +| [utils/fs_utils.rs](https://github.com/rolldown/rolldown/blob/v1.0.0-rc.15/crates/rolldown/src/utils/fs_utils.rs#L25) | 25 | read (`read_dir`) | lists directory entries to clean | +| [utils/fs_utils.rs](https://github.com/rolldown/rolldown/blob/v1.0.0-rc.15/crates/rolldown/src/utils/fs_utils.rs#L27) | 27 | delete | `remove_dir_all` for subdirectories | +| [utils/fs_utils.rs](https://github.com/rolldown/rolldown/blob/v1.0.0-rc.15/crates/rolldown/src/utils/fs_utils.rs#L29) | 29 | delete | `remove_file` for files | + +Rolldown has no disk cache. + +--- + +## Where to add `ignoreInputs` / `ignoreOutputs` + +A single Vite plugin calling the runner IPC covers all of the above because +Rolldown's Rust code runs as a NAPI addon inside the same Node.js process — +fspy traces syscalls regardless of whether they originate from JS or Rust. + +```ts +// Vite plugin (added once to vite.config.ts, no-op when VP_IPC is absent) +buildStart() { + const ipcPath = process.env.VP_IPC + if (!ipcPath) return + const outDir = this.environment.config.build.outDir // e.g. "dist" + const cacheDir = this.environment.config.cacheDir // e.g. "node_modules/.vite" + ignoreInputs([outDir, cacheDir]) // suppress reads: emptyDir, clean_dir, dep optimizer + ignoreOutputs([cacheDir]) // suppress writes: pre-bundled deps, metadata + // outDir writes are real outputs — do NOT ignore them +} +``` + +`ignoreInputs(["dist"])` covers: + +- Vite `emptyDir` reads (`readdirSync` in `utils.ts:591`) +- Rolldown `clean_dir` reads (`read_dir` in `fs_utils.rs:25`) — same process, same syscalls + +`ignoreInputs(["node_modules/.vite"])` covers: + +- Dep optimizer `readFile`, `existsSync`, `readdir` reads + +`ignoreOutputs(["node_modules/.vite"])` covers: + +- Dep optimizer `bundle.write`, `writeFileSync`, `.vite-temp` writes — not real task outputs + +### Injection without modifying `vite.config.ts` + +Vite has no env-based plugin injection mechanism. Options: + +- **`NODE_OPTIONS=--import`**: monkey-patch Vite's `build`/`createServer` before startup — works but fragile across Vite versions, requires Node 20+ +- **Explicit plugin in config**: stable, recommended — the plugin is a no-op outside of `vp run` diff --git a/playground/README.md b/playground/README.md index 863dce49b..fee21627d 100644 --- a/playground/README.md +++ b/playground/README.md @@ -7,8 +7,8 @@ A workspace for manually testing `cargo run --bin vt run ...`. ``` playground/ ├── packages/ -│ ├── app/ → depends on @playground/lib -│ ├── lib/ → depends on @playground/utils +│ ├── app/ → depends on lib +│ ├── lib/ → depends on utils │ └── utils/ → no dependencies └── vite-task.json → workspace-level task config ``` @@ -19,10 +19,10 @@ Dependency chain: `app → lib → utils` Tasks are defined in each package's `vite-task.json` with caching enabled. `dev` is a package.json script (not cached). -| Name | Type | Packages | Cached | Description | -| ----------- | ------ | --------------- | ------ | ---------------------------------------------- | -| `build` | task | app, lib, utils | yes | Prints a build message | -| `test` | task | app, lib, utils | yes | Prints a test message | -| `lint` | task | app, lib, utils | yes | Prints a lint message | -| `typecheck` | task | app, lib | yes | Prints a typecheck message | -| `dev` | script | app, lib | no | Long-running process (prints every 2s, ctrl-c) | +| Name | Type | Packages | Cached | Description | +| ----------- | ------ | --------------- | ------ | ----------------------------------------------------- | +| `build` | task | app, lib, utils | yes | `vite build` in app; prints a build message elsewhere | +| `test` | task | app, lib, utils | yes | Prints a test message | +| `lint` | task | app, lib, utils | yes | Prints a lint message | +| `typecheck` | task | app, lib | yes | Prints a typecheck message | +| `dev` | script | app, lib | no | Long-running process (prints every 2s, ctrl-c) | diff --git a/playground/packages/app/index.html b/playground/packages/app/index.html new file mode 100644 index 000000000..d4dce07f9 --- /dev/null +++ b/playground/packages/app/index.html @@ -0,0 +1,9 @@ + + + + playground-app + + + + + diff --git a/playground/packages/app/package.json b/playground/packages/app/package.json index 055ace37f..6f1323384 100644 --- a/playground/packages/app/package.json +++ b/playground/packages/app/package.json @@ -1,11 +1,13 @@ { - "name": "@playground/app", + "name": "app", "version": "0.0.0", "private": true, + "type": "module", "scripts": { "dev": "node dev.mjs" }, "dependencies": { - "@playground/lib": "workspace:*" + "lib": "workspace:*", + "vite": "catalog:" } } diff --git a/playground/packages/app/src/index.ts b/playground/packages/app/src/index.ts index 7db55b497..4169f3ebc 100644 --- a/playground/packages/app/src/index.ts +++ b/playground/packages/app/src/index.ts @@ -1,3 +1,13 @@ -import { sum } from '@playground/lib'; +/// -console.log(sum(1, 2, 3)); +import { sum } from 'lib'; + +// `import.meta.env.VITE_MODE` is substituted at build time from the value +// vite's patched `loadEnv` fetched via the runner. Dead-code elimination +// leaves only the branch matching whatever `VITE_MODE=...` was set on the +// run, so the bundle reflects (and the runner tracks) that env. +if (import.meta.env.VITE_MODE === 'production') { + console.log('PROD build:', sum(1, 2, 3)); +} else { + console.log('DEV build:', sum(1, 2, 3)); +} diff --git a/playground/packages/app/vite-task.json b/playground/packages/app/vite-task.json index 58a9b2b48..1ad6fee4b 100644 --- a/playground/packages/app/vite-task.json +++ b/playground/packages/app/vite-task.json @@ -1,7 +1,8 @@ { "tasks": { "build": { - "command": "node build.mjs" + "command": "vite build", + "cache": true }, "test": { "command": "node test.mjs" diff --git a/playground/packages/lib/package.json b/playground/packages/lib/package.json index fddb3aab3..e8b699cbc 100644 --- a/playground/packages/lib/package.json +++ b/playground/packages/lib/package.json @@ -1,11 +1,12 @@ { - "name": "@playground/lib", + "name": "lib", "version": "0.0.0", "private": true, + "main": "./src/index.ts", "scripts": { "dev": "node dev.mjs" }, "dependencies": { - "@playground/utils": "workspace:*" + "utils": "workspace:*" } } diff --git a/playground/packages/lib/src/index.ts b/playground/packages/lib/src/index.ts index f7fa1e131..8e6da590d 100644 --- a/playground/packages/lib/src/index.ts +++ b/playground/packages/lib/src/index.ts @@ -1,4 +1,4 @@ -import { add } from '@playground/utils'; +import { add } from 'utils'; export function sum(...nums: number[]): number { return nums.reduce((acc, n) => add(acc, n), 0); diff --git a/playground/packages/utils/package.json b/playground/packages/utils/package.json index 8036670aa..656f9b115 100644 --- a/playground/packages/utils/package.json +++ b/playground/packages/utils/package.json @@ -1,5 +1,6 @@ { - "name": "@playground/utils", + "name": "utils", "version": "0.0.0", - "private": true + "private": true, + "main": "./src/index.ts" } diff --git a/playground/pnpm-lock.yaml b/playground/pnpm-lock.yaml index ef78a0a05..ad8b7b002 100644 --- a/playground/pnpm-lock.yaml +++ b/playground/pnpm-lock.yaml @@ -4,20 +4,519 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false +catalogs: + default: + vite: + specifier: https://pkg.pr.new/vite@1298951ebc5e5a94164c21f142fe748ca37eea68 + version: 8.0.16 + importers: .: {} packages/app: dependencies: - '@playground/lib': + lib: specifier: workspace:* version: link:../lib + vite: + specifier: 'catalog:' + version: https://pkg.pr.new/vite@1298951ebc5e5a94164c21f142fe748ca37eea68 packages/lib: dependencies: - '@playground/utils': + utils: specifier: workspace:* version: link:../utils packages/utils: {} + +packages: + + '@emnapi/core@1.10.0': + resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} + + '@emnapi/runtime@1.10.0': + resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==} + + '@emnapi/wasi-threads@1.2.1': + resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} + + '@napi-rs/wasm-runtime@1.1.4': + resolution: {integrity: sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==} + peerDependencies: + '@emnapi/core': ^1.7.1 + '@emnapi/runtime': ^1.7.1 + + '@oxc-project/types@0.133.0': + resolution: {integrity: sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==} + + '@rolldown/binding-android-arm64@1.0.3': + resolution: {integrity: sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.0.3': + resolution: {integrity: sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.0.3': + resolution: {integrity: sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.0.3': + resolution: {integrity: sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.0.3': + resolution: {integrity: sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.0.3': + resolution: {integrity: sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-arm64-musl@1.0.3': + resolution: {integrity: sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rolldown/binding-linux-ppc64-gnu@1.0.3': + resolution: {integrity: sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-s390x-gnu@1.0.3': + resolution: {integrity: sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-gnu@1.0.3': + resolution: {integrity: sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-musl@1.0.3': + resolution: {integrity: sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rolldown/binding-openharmony-arm64@1.0.3': + resolution: {integrity: sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-wasm32-wasi@1.0.3': + resolution: {integrity: sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [wasm32] + + '@rolldown/binding-win32-arm64-msvc@1.0.3': + resolution: {integrity: sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.0.3': + resolution: {integrity: sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + + '@tybys/wasm-util@0.10.1': + resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} + + '@voidzero-dev/vite-task-client@0.1.1': + resolution: {integrity: sha512-9zGnSzvzUOKNoMf4zxaDeAyerkvnsXBRWfbTkwhwHsVJFug3j48Et8kr+ulHf3KRdcLr07Np+xDuCvHYVK1k7w==} + engines: {node: '>=18'} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + lightningcss-android-arm64@1.32.0: + resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.32.0: + resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.32.0: + resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.32.0: + resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.32.0: + resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.32.0: + resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-musl@1.32.0: + resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-gnu@1.32.0: + resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-musl@1.32.0: + resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-win32-arm64-msvc@1.32.0: + resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.32.0: + resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.32.0: + resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} + engines: {node: '>= 12.0.0'} + + nanoid@3.3.12: + resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + engines: {node: '>=12'} + + postcss@8.5.15: + resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} + engines: {node: ^10 || ^12 || >=14} + + rolldown@1.0.3: + resolution: {integrity: sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + vite@https://pkg.pr.new/vite@1298951ebc5e5a94164c21f142fe748ca37eea68: + resolution: {tarball: https://pkg.pr.new/vite@1298951ebc5e5a94164c21f142fe748ca37eea68} + version: 8.0.16 + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.1.18 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + +snapshots: + + '@emnapi/core@1.10.0': + dependencies: + '@emnapi/wasi-threads': 1.2.1 + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@1.10.0': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/wasi-threads@1.2.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@napi-rs/wasm-runtime@1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': + dependencies: + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 + '@tybys/wasm-util': 0.10.1 + optional: true + + '@oxc-project/types@0.133.0': {} + + '@rolldown/binding-android-arm64@1.0.3': + optional: true + + '@rolldown/binding-darwin-arm64@1.0.3': + optional: true + + '@rolldown/binding-darwin-x64@1.0.3': + optional: true + + '@rolldown/binding-freebsd-x64@1.0.3': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.0.3': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.0.3': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.0.3': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.0.3': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.0.3': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.0.3': + optional: true + + '@rolldown/binding-linux-x64-musl@1.0.3': + optional: true + + '@rolldown/binding-openharmony-arm64@1.0.3': + optional: true + + '@rolldown/binding-wasm32-wasi@1.0.3': + dependencies: + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 + '@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.0.3': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.0.3': + optional: true + + '@rolldown/pluginutils@1.0.1': {} + + '@tybys/wasm-util@0.10.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@voidzero-dev/vite-task-client@0.1.1': {} + + detect-libc@2.1.2: {} + + fdir@6.5.0(picomatch@4.0.4): + optionalDependencies: + picomatch: 4.0.4 + + fsevents@2.3.3: + optional: true + + lightningcss-android-arm64@1.32.0: + optional: true + + lightningcss-darwin-arm64@1.32.0: + optional: true + + lightningcss-darwin-x64@1.32.0: + optional: true + + lightningcss-freebsd-x64@1.32.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.32.0: + optional: true + + lightningcss-linux-arm64-gnu@1.32.0: + optional: true + + lightningcss-linux-arm64-musl@1.32.0: + optional: true + + lightningcss-linux-x64-gnu@1.32.0: + optional: true + + lightningcss-linux-x64-musl@1.32.0: + optional: true + + lightningcss-win32-arm64-msvc@1.32.0: + optional: true + + lightningcss-win32-x64-msvc@1.32.0: + optional: true + + lightningcss@1.32.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.32.0 + lightningcss-darwin-arm64: 1.32.0 + lightningcss-darwin-x64: 1.32.0 + lightningcss-freebsd-x64: 1.32.0 + lightningcss-linux-arm-gnueabihf: 1.32.0 + lightningcss-linux-arm64-gnu: 1.32.0 + lightningcss-linux-arm64-musl: 1.32.0 + lightningcss-linux-x64-gnu: 1.32.0 + lightningcss-linux-x64-musl: 1.32.0 + lightningcss-win32-arm64-msvc: 1.32.0 + lightningcss-win32-x64-msvc: 1.32.0 + + nanoid@3.3.12: {} + + picocolors@1.1.1: {} + + picomatch@4.0.4: {} + + postcss@8.5.15: + dependencies: + nanoid: 3.3.12 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + rolldown@1.0.3: + dependencies: + '@oxc-project/types': 0.133.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm64': 1.0.3 + '@rolldown/binding-darwin-arm64': 1.0.3 + '@rolldown/binding-darwin-x64': 1.0.3 + '@rolldown/binding-freebsd-x64': 1.0.3 + '@rolldown/binding-linux-arm-gnueabihf': 1.0.3 + '@rolldown/binding-linux-arm64-gnu': 1.0.3 + '@rolldown/binding-linux-arm64-musl': 1.0.3 + '@rolldown/binding-linux-ppc64-gnu': 1.0.3 + '@rolldown/binding-linux-s390x-gnu': 1.0.3 + '@rolldown/binding-linux-x64-gnu': 1.0.3 + '@rolldown/binding-linux-x64-musl': 1.0.3 + '@rolldown/binding-openharmony-arm64': 1.0.3 + '@rolldown/binding-wasm32-wasi': 1.0.3 + '@rolldown/binding-win32-arm64-msvc': 1.0.3 + '@rolldown/binding-win32-x64-msvc': 1.0.3 + + source-map-js@1.2.1: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + + tslib@2.8.1: + optional: true + + vite@https://pkg.pr.new/vite@1298951ebc5e5a94164c21f142fe748ca37eea68: + dependencies: + '@voidzero-dev/vite-task-client': 0.1.1 + lightningcss: 1.32.0 + picomatch: 4.0.4 + postcss: 8.5.15 + rolldown: 1.0.3 + tinyglobby: 0.2.17 + optionalDependencies: + fsevents: 2.3.3 diff --git a/playground/pnpm-workspace.yaml b/playground/pnpm-workspace.yaml index 924b55f42..caf581557 100644 --- a/playground/pnpm-workspace.yaml +++ b/playground/pnpm-workspace.yaml @@ -1,2 +1,8 @@ packages: - packages/* + +catalog: + # Track the vitejs/vite main build (where the vite-task-client integration + # merged in vitejs/vite#22453) until it ships in a tagged vite release. Bump + # the pinned sha to move this forward. + vite: https://pkg.pr.new/vite@1298951ebc5e5a94164c21f142fe748ca37eea68