diff --git a/CHANGELOG.md b/CHANGELOG.md index edd3132ec..d0997381b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,6 @@ # Changelog +- **Changed** Explicit `cache.untrackedEnv` patterns now override environment dependencies reported by tools. To keep those variables tracked, declare them in `cache.env` or negate their `untrackedEnv` patterns with `!`. Built-in passthrough defaults do not cancel tool-reported tracking ([#856](https://github.com/voidzero-dev/vite-task/pull/856)). - **Fixed** On Linux, concurrent system calls in tracked programs, such as `pnpm dlx`, no longer cause a stack overflow ([#854](https://github.com/voidzero-dev/vite-task/pull/854)). - **Fixed** Cached tasks can now run scripts whose shebang names an interpreter path longer than 126 bytes. Before, the path was cut short and the script failed to start or ran the wrong program. On macOS, text after a `#` in the shebang line is now ignored, as it is when the script runs on its own ([#788](https://github.com/voidzero-dev/vite-task/pull/788)). - **Fixed** On macOS, tasks with automatic file-access tracking can now run Node.js single executable applications, such as pnpm 11's standalone `pnpm` binary, which previously aborted with `Assertion failed: (magic) == (kMagic)`, and `deno compile` outputs, which previously failed with `Did not find magic bytes` ([#840](https://github.com/voidzero-dev/vite-task/pull/840)). diff --git a/crates/vt/src/session/cache/mod.rs b/crates/vt/src/session/cache/mod.rs index 2caefd817..dc636fca6 100644 --- a/crates/vt/src/session/cache/mod.rs +++ b/crates/vt/src/session/cache/mod.rs @@ -398,6 +398,7 @@ impl ExecutionCache { if let Some(cache_value) = self.get_by_cache_key(cache_key).await? { if let Some(mismatch) = cache_value.validate( &cache_metadata.unfiltered_envs, + &cache_metadata.explicit_untracked_env, globbed_inputs, workspace_root, )? { @@ -445,7 +446,12 @@ impl ExecutionCache { .fetch(remote_config, cache_key, &cache_metadata.execution_cache_key, cancel_token) .await; let validate = |cache_value: &CacheEntryValue| { - cache_value.validate(&cache_metadata.unfiltered_envs, globbed_inputs, workspace_root) + cache_value.validate( + &cache_metadata.unfiltered_envs, + &cache_metadata.explicit_untracked_env, + globbed_inputs, + workspace_root, + ) }; let Restore { value: cache_value, blob_id } = match remote::resolve(fetched, cache_key, validate) { diff --git a/crates/vt/src/session/cache/remote.rs b/crates/vt/src/session/cache/remote.rs index f45f874d5..d49e4f695 100644 --- a/crates/vt/src/session/cache/remote.rs +++ b/crates/vt/src/session/cache/remote.rs @@ -502,7 +502,7 @@ mod tests { ) -> impl FnOnce(&CacheEntryValue) -> anyhow::Result> { move |value| { let workspace_root = vt_path::current_dir().unwrap(); - value.validate(&FxHashMap::default(), &globbed_inputs, &workspace_root) + value.validate(&FxHashMap::default(), &[], &globbed_inputs, &workspace_root) } } diff --git a/crates/vt/src/session/cache/validation.rs b/crates/vt/src/session/cache/validation.rs index 859afec7d..dee486aa7 100644 --- a/crates/vt/src/session/cache/validation.rs +++ b/crates/vt/src/session/cache/validation.rs @@ -20,6 +20,7 @@ impl CacheEntryValue { pub(crate) fn validate( &self, unfiltered_envs: &FxHashMap>, Arc>, + explicit_untracked_env: &[vt_str::Str], globbed_inputs: &BTreeMap, workspace_root: &AbsolutePath, ) -> anyhow::Result> { @@ -28,7 +29,7 @@ impl CacheEntryValue { } self.post_run_fingerprint - .validate(workspace_root, unfiltered_envs) + .validate(workspace_root, unfiltered_envs, explicit_untracked_env) .map(|mismatch| mismatch.map(FingerprintMismatch::from)) } } diff --git a/crates/vt/src/session/execute/cache_update.rs b/crates/vt/src/session/execute/cache_update.rs index 0909839ce..4f59ee17e 100644 --- a/crates/vt/src/session/execute/cache_update.rs +++ b/crates/vt/src/session/execute/cache_update.rs @@ -346,7 +346,8 @@ fn collect_tracked_envs( } /// Select tool-reported bulk env query records to embed in the post-run -/// fingerprint. The full match-set is stored as value hashes. +/// fingerprint. Keep complete observations so removing an `untrackedEnv` +/// override can revalidate previously ignored dependencies. fn collect_tracked_env_queries(reports: &Reports) -> anyhow::Result { let mut tracked_env_queries = BTreeMap::new(); diff --git a/crates/vt/src/session/execute/fingerprint.rs b/crates/vt/src/session/execute/fingerprint.rs index 32f4cb880..decfc9ce2 100644 --- a/crates/vt/src/session/execute/fingerprint.rs +++ b/crates/vt/src/session/execute/fingerprint.rs @@ -15,6 +15,7 @@ use rayon::iter::{IntoParallelRefIterator, ParallelIterator}; use rustc_hash::FxHashMap; use serde::{Deserialize, Serialize}; use vt_casefold::EnvName; +use vt_glob::env::EnvGlobSet; use vt_path::{AbsolutePath, RelativePathBuf}; use vt_plan::cache_metadata::EnvValueHash; use vt_str::Str; @@ -142,6 +143,8 @@ impl PostRunFingerprint { /// unfiltered env context used by runner-aware IPC. `unfiltered_envs` must /// be the same plan env context that served the original `getEnv` request, /// not the filtered env passed to the spawned process. + /// User-declared `untrackedEnv` patterns override both single-name and + /// bulk dependencies. Built-in passthrough defaults do not. /// /// Returns `Some(mismatch)` if anything changed, `None` if all valid. /// Returns an error if a tracked env is currently present but cannot be @@ -152,6 +155,7 @@ impl PostRunFingerprint { &self, base_dir: &AbsolutePath, unfiltered_envs: &FxHashMap>, Arc>, + explicit_untracked_env: &[Str], ) -> anyhow::Result> { let input_mismatch = self.inferred_inputs.par_iter().find_map_any( |(input_relative_path, path_fingerprint)| { @@ -186,6 +190,11 @@ impl PostRunFingerprint { return result.map(Some); } + let has_env_reads = !self.tracked_envs.is_empty() || !self.tracked_env_queries.is_empty(); + let untracked_env = (has_env_reads && !explicit_untracked_env.is_empty()) + .then(|| EnvGlobSet::new(explicit_untracked_env)) + .transpose()?; + for (name, stored_value) in &self.tracked_envs { let current_value = unfiltered_envs .get(EnvName::from_ref(OsStr::new(name.as_str()))) @@ -196,6 +205,9 @@ impl PostRunFingerprint { Ok::<_, anyhow::Error>(EnvValueHash::new(value_str)) }) .transpose()?; + if untracked_env.as_ref().is_some_and(|patterns| patterns.is_match(name.as_str())) { + continue; + } if let Some(mismatch) = EnvMismatch::compare(name, stored_value.as_ref(), current_value.as_ref()) { @@ -213,7 +225,9 @@ impl PostRunFingerprint { })); } }; - if let Some(mismatch) = first_env_glob_mismatch(stored_matches, ¤t_matches) { + if let Some(mismatch) = + first_env_glob_mismatch(stored_matches, ¤t_matches, untracked_env.as_ref()) + { return Ok(Some(PostRunMismatch::TrackedEnvQuery { query: query.clone(), mismatch, @@ -275,9 +289,12 @@ enum EnvQueryValidation { fn first_env_glob_mismatch( stored: &BTreeMap, current: &BTreeMap, + untracked_env: Option<&EnvGlobSet>, ) -> Option { - let mut stored_iter = stored.iter(); - let mut current_iter = current.iter(); + let is_tracked = + |name: &Str| !untracked_env.is_some_and(|patterns| patterns.is_match(name.as_str())); + let mut stored_iter = stored.iter().filter(|(name, _)| is_tracked(name)); + let mut current_iter = current.iter().filter(|(name, _)| is_tracked(name)); let mut s = stored_iter.next(); let mut c = current_iter.next(); @@ -516,6 +533,62 @@ mod tests { OsString::from_wide(&[0xD800]) } + #[test] + fn validate_applies_current_overrides_to_complete_bulk_query_entries() { + for query in [TrackedEnvQuery::Glob(Str::from("*")), TrackedEnvQuery::Prefix(Str::from(""))] + { + let stored_matches = BTreeMap::from([ + (Str::from("VITE_TITLE"), EnvValueHash::new("title")), + (Str::from("ACTIONS_ORCHESTRATION_ID"), EnvValueHash::new("old-run")), + ]); + let fingerprint = PostRunFingerprint { + tracked_env_queries: BTreeMap::from([(query, stored_matches)]), + ..PostRunFingerprint::default() + }; + let unfiltered_envs = FxHashMap::from_iter([ + ( + EnvName::new(Arc::::from(OsStr::new("VITE_TITLE"))), + Arc::::from(OsStr::new("title")), + ), + ( + EnvName::new(Arc::::from(OsStr::new("ACTIONS_ORCHESTRATION_ID"))), + Arc::::from(OsStr::new("new-run")), + ), + ]); + let untracked = [Str::from("ACTIONS_*")]; + let workspace_root = vt_path::current_dir().expect("cwd"); + assert!( + fingerprint + .validate(&workspace_root, &unfiltered_envs, &untracked) + .expect("validation succeeds") + .is_none() + ); + } + } + + #[test] + fn validate_rejects_non_utf8_bulk_values_even_when_untracked() { + let fingerprint = PostRunFingerprint { + tracked_env_queries: BTreeMap::from([( + TrackedEnvQuery::Prefix(Str::from("")), + BTreeMap::new(), + )]), + ..PostRunFingerprint::default() + }; + let unfiltered_envs = FxHashMap::from_iter([( + EnvName::new(Arc::::from(OsStr::new("PROBE_NOISE"))), + Arc::::from(non_utf8_os_string()), + )]); + let untracked = [Str::from("PROBE_*")]; + let workspace_root = vt_path::current_dir().expect("cwd"); + assert!(matches!( + fingerprint + .validate(&workspace_root, &unfiltered_envs, &untracked) + .expect("validation succeeds"), + Some(PostRunMismatch::TrackedEnvQuery { mismatch: EnvMismatch::Changed { .. }, .. }) + )); + } + #[test] fn validate_errors_on_current_non_utf8_tracked_env_value() { let mut tracked_envs = BTreeMap::new(); @@ -530,7 +603,7 @@ mod tests { let workspace_root = vt_path::current_dir().expect("cwd"); let err = fingerprint - .validate(&workspace_root, &unfiltered_envs) + .validate(&workspace_root, &unfiltered_envs, &[]) .expect_err("non-UTF-8 tracked env values must error"); assert!(err.to_string().contains("tracked env value for PROBE_ENV is not valid UTF-8")); @@ -550,8 +623,9 @@ mod tests { ); let workspace_root = vt_path::current_dir().expect("cwd"); - let mismatch = - fingerprint.validate(&workspace_root, &unfiltered_envs).expect("validation succeeds"); + let mismatch = fingerprint + .validate(&workspace_root, &unfiltered_envs, &[]) + .expect("validation succeeds"); match mismatch { Some(PostRunMismatch::TrackedEnvQuery { @@ -585,8 +659,9 @@ mod tests { ); let workspace_root = vt_path::current_dir().expect("cwd"); - let mismatch = - fingerprint.validate(&workspace_root, &unfiltered_envs).expect("validation succeeds"); + let mismatch = fingerprint + .validate(&workspace_root, &unfiltered_envs, &[]) + .expect("validation succeeds"); assert!(mismatch.is_none()); } @@ -605,8 +680,9 @@ mod tests { ); let workspace_root = vt_path::current_dir().expect("cwd"); - let mismatch = - fingerprint.validate(&workspace_root, &unfiltered_envs).expect("validation succeeds"); + let mismatch = fingerprint + .validate(&workspace_root, &unfiltered_envs, &[]) + .expect("validation succeeds"); assert!(mismatch.is_none()); } @@ -624,8 +700,9 @@ mod tests { ); let workspace_root = vt_path::current_dir().expect("cwd"); - let mismatch = - fingerprint.validate(&workspace_root, &unfiltered_envs).expect("validation succeeds"); + let mismatch = fingerprint + .validate(&workspace_root, &unfiltered_envs, &[]) + .expect("validation succeeds"); assert_eq!(mismatch.is_none(), cfg!(windows)); } @@ -646,8 +723,9 @@ mod tests { ); let workspace_root = vt_path::current_dir().expect("cwd"); - let mismatch = - fingerprint.validate(&workspace_root, &unfiltered_envs).expect("validation succeeds"); + let mismatch = fingerprint + .validate(&workspace_root, &unfiltered_envs, &[]) + .expect("validation succeeds"); assert_eq!(mismatch.is_none(), cfg!(windows)); } diff --git a/crates/vt_bin/tests/e2e_snapshots/fixtures/ipc_client_test/scripts/fetch_envs.mjs b/crates/vt_bin/tests/e2e_snapshots/fixtures/ipc_client_test/scripts/fetch_envs.mjs index 66342b899..ef19f878d 100644 --- a/crates/vt_bin/tests/e2e_snapshots/fixtures/ipc_client_test/scripts/fetch_envs.mjs +++ b/crates/vt_bin/tests/e2e_snapshots/fixtures/ipc_client_test/scripts/fetch_envs.mjs @@ -3,9 +3,15 @@ import { getEnvs } from '@voidzero-dev/vite-task-client'; const args = process.argv.slice(2); const tracked = !args.includes('--untracked'); const prefixIndex = args.indexOf('--prefix'); -const query = prefixIndex === -1 ? 'PROBE_*' : { prefix: args[prefixIndex + 1] ?? 'PROBE_' }; +const globIndex = args.indexOf('--glob'); +const glob = globIndex === -1 ? 'PROBE_*' : (args[globIndex + 1] ?? 'PROBE_*'); +const query = prefixIndex === -1 ? glob : { prefix: args[prefixIndex + 1] ?? 'PROBE_' }; const matches = getEnvs(query, { tracked }); -const sorted = Object.entries(matches).sort(([a], [b]) => a.localeCompare(b)); +const printPrefixIndex = args.indexOf('--print-prefix'); +const printPrefix = printPrefixIndex === -1 ? '' : (args[printPrefixIndex + 1] ?? ''); +const sorted = Object.entries(matches) + .filter(([name]) => name.startsWith(printPrefix)) + .sort(([a], [b]) => a.localeCompare(b)); for (const [key, value] of sorted) { console.log(`${key}=${value}`); diff --git a/crates/vt_bin/tests/e2e_snapshots/fixtures/ipc_client_test/snapshots.toml b/crates/vt_bin/tests/e2e_snapshots/fixtures/ipc_client_test/snapshots.toml index 64f809c2f..00632e148 100644 --- a/crates/vt_bin/tests/e2e_snapshots/fixtures/ipc_client_test/snapshots.toml +++ b/crates/vt_bin/tests/e2e_snapshots/fixtures/ipc_client_test/snapshots.toml @@ -670,3 +670,253 @@ steps = [ ], ], comment = "cache hit: changed match set was requested with tracked: false" }, ] + +[[e2e]] +name = "fetch_envs_queries_ignore_untracked_names" +comment = """ +Bulk glob and literal-prefix queries still serve explicitly ignored variables, but changing those values replays the cached result. +""" +ignore = true +steps = [ + { argv = [ + "vt", + "run", + "fetch-envs-untracked-noise", + ], envs = [ + [ + "PROBE_A", + "a", + ], + [ + "PROBE_NOISE_ID", + "run-1", + ], + [ + "ACTIONS_ORCHESTRATION_ID", + "run-1", + ], + ], comment = "populate: the glob serves both tracked values and untracked noise" }, + { argv = [ + "vt", + "run", + "fetch-envs-untracked-noise", + ], envs = [ + [ + "PROBE_A", + "a", + ], + [ + "PROBE_NOISE_ID", + "run-2", + ], + [ + "ACTIONS_ORCHESTRATION_ID", + "run-2", + ], + ], comment = "cache hit: both explicitly ignored values changed" }, + { argv = [ + "vt", + "run", + "fetch-envs-prefix-untracked-noise", + ], envs = [ + [ + "PROBE_A", + "a", + ], + [ + "PROBE_NOISE_ID", + "run-1", + ], + ], comment = "populate: the literal-prefix query serves its untracked match too" }, + { argv = [ + "vt", + "run", + "fetch-envs-prefix-untracked-noise", + ], envs = [ + [ + "PROBE_A", + "a", + ], + [ + "PROBE_NOISE_ID", + "run-2", + ], + ], comment = "cache hit: literal-prefix queries honor the same exclusion" }, +] + +[[e2e]] +name = "fetch_envs_empty_prefix_preserves_negated_dependencies" +comment = """ +An empty prefix queries every ambient variable. Negating VITE_* from untrackedEnv keeps its values, additions and removals as cache dependencies. Only VITE_* results are printed so platform-specific ambient values stay out of the snapshot. +""" +ignore = true +steps = [ + { argv = [ + "vt", + "run", + "fetch-envs-all-untracked-except-vite", + ], envs = [ + [ + "VITE_FEATURE", + "first", + ], + ], comment = "populate: VITE_FEATURE is excluded from the untracked wildcard" }, + { argv = [ + "vt", + "run", + "fetch-envs-all-untracked-except-vite", + ], envs = [ + [ + "VITE_FEATURE", + "second", + ], + ], comment = "cache miss: a negated dependency changed" }, + { argv = [ + "vt", + "run", + "fetch-envs-all-untracked-except-vite", + ], envs = [ + [ + "VITE_FEATURE", + "second", + ], + [ + "VITE_ADDED", + "added", + ], + ], comment = "cache miss: a negated dependency was added" }, + { argv = [ + "vt", + "run", + "fetch-envs-all-untracked-except-vite", + ], envs = [ + [ + "VITE_FEATURE", + "second", + ], + ], comment = "cache miss: a negated dependency was removed" }, +] + +[[e2e]] +name = "fetch_env_reads_honor_explicit_untracked_config" +comment = """ +Explicit untrackedEnv applies equally to getEnv and an exact-name getEnvs query. +""" +ignore = true +steps = [ + { argv = [ + "vt", + "run", + "fetch-env-explicitly-untracked", + ], envs = [ + [ + "PROBE_ENV", + "first", + ], + ], comment = "populate: getEnv serves the explicitly ignored value" }, + { argv = [ + "vt", + "run", + "fetch-env-explicitly-untracked", + ], envs = [ + [ + "PROBE_ENV", + "second", + ], + ], comment = "cache hit: explicit untrackedEnv suppresses the scalar dependency" }, + { argv = [ + "vt", + "run", + "fetch-envs-exact-explicitly-untracked", + ], envs = [ + [ + "PROBE_ENV", + "first", + ], + ], comment = "populate: getEnvs serves the same exact-name query" }, + { argv = [ + "vt", + "run", + "fetch-envs-exact-explicitly-untracked", + ], envs = [ + [ + "PROBE_ENV", + "second", + ], + ], comment = "cache hit: the bulk query honors the same explicit policy" }, +] + +[[e2e]] +name = "fetch_env_declared_input_overrides_untracked_config" +comment = """ +A declared cache.env input still invalidates when the same name is explicitly listed in untrackedEnv. +""" +ignore = true +steps = [ + { argv = [ + "vt", + "run", + "fetch-env-declared-and-untracked", + ], envs = [ + [ + "PROBE_ENV", + "first", + ], + ], comment = "populate: cache.env explicitly fingerprints the value" }, + { argv = [ + "vt", + "run", + "fetch-env-declared-and-untracked", + ], envs = [ + [ + "PROBE_ENV", + "second", + ], + ], comment = "cache miss: declared cache.env takes precedence over untrackedEnv" }, +] + +[[e2e]] +name = "fetch_envs_removed_ignore_uses_original_observation" +comment = """ +Removing an explicit NEXT_* ignore leaves the merged built-in passthrough policy unchanged. The original observed value must survive an ignored cache hit so the next run detects its change. Explicit empty file inputs and outputs isolate the environment policy change. +""" +ignore = true +steps = [ + { argv = [ + "vt", + "run", + "fetch-envs-removed-ignore", + ], envs = [ + [ + "NEXT_PUBLIC_API_URL", + "first", + ], + ], comment = "populate: record the first value even though it is explicitly ignored" }, + { argv = [ + "vt", + "run", + "fetch-envs-removed-ignore", + ], envs = [ + [ + "NEXT_PUBLIC_API_URL", + "second", + ], + ], comment = "cache hit: explicit ignore permits the changed value" }, + { argv = [ + "vtt", + "replace-file-content", + "vite-task.json", + '"untrackedEnv": ["NEXT_*"]', + '"untrackedEnv": []', + ], comment = "remove the explicit ignore without changing the merged passthrough policy" }, + { argv = [ + "vt", + "run", + "fetch-envs-removed-ignore", + ], envs = [ + [ + "NEXT_PUBLIC_API_URL", + "second", + ], + ], comment = "cache miss: the original observation is now a dependency" }, +] diff --git a/crates/vt_bin/tests/e2e_snapshots/fixtures/ipc_client_test/snapshots/fetch_env_declared_input_overrides_untracked_config.md b/crates/vt_bin/tests/e2e_snapshots/fixtures/ipc_client_test/snapshots/fetch_env_declared_input_overrides_untracked_config.md new file mode 100644 index 000000000..35c5fc5d1 --- /dev/null +++ b/crates/vt_bin/tests/e2e_snapshots/fixtures/ipc_client_test/snapshots/fetch_env_declared_input_overrides_untracked_config.md @@ -0,0 +1,23 @@ +# fetch_env_declared_input_overrides_untracked_config + +A declared cache.env input still invalidates when the same name is explicitly listed in untrackedEnv. + +## `PROBE_ENV=first vt run fetch-env-declared-and-untracked` + +populate: cache.env explicitly fingerprints the value + +``` +$ node scripts/fetch_env.mjs PROBE_ENV +served PROBE_ENV=first +process.env PROBE_ENV=first +``` + +## `PROBE_ENV=second vt run fetch-env-declared-and-untracked` + +cache miss: declared cache.env takes precedence over untrackedEnv + +``` +$ node scripts/fetch_env.mjs PROBE_ENV ○ cache miss: env 'PROBE_ENV' changed, executing +served PROBE_ENV=second +process.env PROBE_ENV=second +``` diff --git a/crates/vt_bin/tests/e2e_snapshots/fixtures/ipc_client_test/snapshots/fetch_env_reads_honor_explicit_untracked_config.md b/crates/vt_bin/tests/e2e_snapshots/fixtures/ipc_client_test/snapshots/fetch_env_reads_honor_explicit_untracked_config.md new file mode 100644 index 000000000..4f43f1470 --- /dev/null +++ b/crates/vt_bin/tests/e2e_snapshots/fixtures/ipc_client_test/snapshots/fetch_env_reads_honor_explicit_untracked_config.md @@ -0,0 +1,47 @@ +# fetch_env_reads_honor_explicit_untracked_config + +Explicit untrackedEnv applies equally to getEnv and an exact-name getEnvs query. + +## `PROBE_ENV=first vt run fetch-env-explicitly-untracked` + +populate: getEnv serves the explicitly ignored value + +``` +$ node scripts/fetch_env.mjs PROBE_ENV +served PROBE_ENV=first +process.env PROBE_ENV=first +``` + +## `PROBE_ENV=second vt run fetch-env-explicitly-untracked` + +cache hit: explicit untrackedEnv suppresses the scalar dependency + +``` +$ node scripts/fetch_env.mjs PROBE_ENV ◉ cache hit, replaying +served PROBE_ENV=first +process.env PROBE_ENV=first + +--- +vt run: cache hit. +``` + +## `PROBE_ENV=first vt run fetch-envs-exact-explicitly-untracked` + +populate: getEnvs serves the same exact-name query + +``` +$ node scripts/fetch_envs.mjs --glob PROBE_ENV +PROBE_ENV=first +``` + +## `PROBE_ENV=second vt run fetch-envs-exact-explicitly-untracked` + +cache hit: the bulk query honors the same explicit policy + +``` +$ node scripts/fetch_envs.mjs --glob PROBE_ENV ◉ cache hit, replaying +PROBE_ENV=first + +--- +vt run: cache hit. +``` diff --git a/crates/vt_bin/tests/e2e_snapshots/fixtures/ipc_client_test/snapshots/fetch_envs_empty_prefix_preserves_negated_dependencies.md b/crates/vt_bin/tests/e2e_snapshots/fixtures/ipc_client_test/snapshots/fetch_envs_empty_prefix_preserves_negated_dependencies.md new file mode 100644 index 000000000..21e7daab9 --- /dev/null +++ b/crates/vt_bin/tests/e2e_snapshots/fixtures/ipc_client_test/snapshots/fetch_envs_empty_prefix_preserves_negated_dependencies.md @@ -0,0 +1,40 @@ +# fetch_envs_empty_prefix_preserves_negated_dependencies + +An empty prefix queries every ambient variable. Negating VITE_* from untrackedEnv keeps its values, additions and removals as cache dependencies. Only VITE_* results are printed so platform-specific ambient values stay out of the snapshot. + +## `VITE_FEATURE=first vt run fetch-envs-all-untracked-except-vite` + +populate: VITE_FEATURE is excluded from the untracked wildcard + +``` +$ node scripts/fetch_envs.mjs --prefix "" --print-prefix VITE_ +VITE_FEATURE=first +``` + +## `VITE_FEATURE=second vt run fetch-envs-all-untracked-except-vite` + +cache miss: a negated dependency changed + +``` +$ node scripts/fetch_envs.mjs --prefix "" --print-prefix VITE_ ○ cache miss: env 'VITE_FEATURE' changed, executing +VITE_FEATURE=second +``` + +## `VITE_FEATURE=second VITE_ADDED=added vt run fetch-envs-all-untracked-except-vite` + +cache miss: a negated dependency was added + +``` +$ node scripts/fetch_envs.mjs --prefix "" --print-prefix VITE_ ○ cache miss: env 'VITE_ADDED' added, executing +VITE_ADDED=added +VITE_FEATURE=second +``` + +## `VITE_FEATURE=second vt run fetch-envs-all-untracked-except-vite` + +cache miss: a negated dependency was removed + +``` +$ node scripts/fetch_envs.mjs --prefix "" --print-prefix VITE_ ○ cache miss: env 'VITE_ADDED' removed, executing +VITE_FEATURE=second +``` diff --git a/crates/vt_bin/tests/e2e_snapshots/fixtures/ipc_client_test/snapshots/fetch_envs_queries_ignore_untracked_names.md b/crates/vt_bin/tests/e2e_snapshots/fixtures/ipc_client_test/snapshots/fetch_envs_queries_ignore_untracked_names.md new file mode 100644 index 000000000..1fddf9cb4 --- /dev/null +++ b/crates/vt_bin/tests/e2e_snapshots/fixtures/ipc_client_test/snapshots/fetch_envs_queries_ignore_untracked_names.md @@ -0,0 +1,51 @@ +# fetch_envs_queries_ignore_untracked_names + +Bulk glob and literal-prefix queries still serve explicitly ignored variables, but changing those values replays the cached result. + +## `PROBE_A=a PROBE_NOISE_ID=run-1 ACTIONS_ORCHESTRATION_ID=run-1 vt run fetch-envs-untracked-noise` + +populate: the glob serves both tracked values and untracked noise + +``` +$ node scripts/fetch_envs.mjs --glob "{PROBE_*,ACTIONS_ORCHESTRATION_ID}" +ACTIONS_ORCHESTRATION_ID=run-1 +PROBE_A=a +PROBE_NOISE_ID=run-1 +``` + +## `PROBE_A=a PROBE_NOISE_ID=run-2 ACTIONS_ORCHESTRATION_ID=run-2 vt run fetch-envs-untracked-noise` + +cache hit: both explicitly ignored values changed + +``` +$ node scripts/fetch_envs.mjs --glob "{PROBE_*,ACTIONS_ORCHESTRATION_ID}" ◉ cache hit, replaying +ACTIONS_ORCHESTRATION_ID=run-1 +PROBE_A=a +PROBE_NOISE_ID=run-1 + +--- +vt run: cache hit. +``` + +## `PROBE_A=a PROBE_NOISE_ID=run-1 vt run fetch-envs-prefix-untracked-noise` + +populate: the literal-prefix query serves its untracked match too + +``` +$ node scripts/fetch_envs.mjs --prefix +PROBE_A=a +PROBE_NOISE_ID=run-1 +``` + +## `PROBE_A=a PROBE_NOISE_ID=run-2 vt run fetch-envs-prefix-untracked-noise` + +cache hit: literal-prefix queries honor the same exclusion + +``` +$ node scripts/fetch_envs.mjs --prefix ◉ cache hit, replaying +PROBE_A=a +PROBE_NOISE_ID=run-1 + +--- +vt run: cache hit. +``` diff --git a/crates/vt_bin/tests/e2e_snapshots/fixtures/ipc_client_test/snapshots/fetch_envs_removed_ignore_uses_original_observation.md b/crates/vt_bin/tests/e2e_snapshots/fixtures/ipc_client_test/snapshots/fetch_envs_removed_ignore_uses_original_observation.md new file mode 100644 index 000000000..760a6216e --- /dev/null +++ b/crates/vt_bin/tests/e2e_snapshots/fixtures/ipc_client_test/snapshots/fetch_envs_removed_ignore_uses_original_observation.md @@ -0,0 +1,40 @@ +# fetch_envs_removed_ignore_uses_original_observation + +Removing an explicit NEXT_* ignore leaves the merged built-in passthrough policy unchanged. The original observed value must survive an ignored cache hit so the next run detects its change. Explicit empty file inputs and outputs isolate the environment policy change. + +## `NEXT_PUBLIC_API_URL=first vt run fetch-envs-removed-ignore` + +populate: record the first value even though it is explicitly ignored + +``` +$ node scripts/fetch_envs.mjs --prefix NEXT_PUBLIC_ +NEXT_PUBLIC_API_URL=first +``` + +## `NEXT_PUBLIC_API_URL=second vt run fetch-envs-removed-ignore` + +cache hit: explicit ignore permits the changed value + +``` +$ node scripts/fetch_envs.mjs --prefix NEXT_PUBLIC_ ◉ cache hit, replaying +NEXT_PUBLIC_API_URL=first + +--- +vt run: cache hit. +``` + +## `vtt replace-file-content vite-task.json '"untrackedEnv": ["NEXT_*"]' '"untrackedEnv": []'` + +remove the explicit ignore without changing the merged passthrough policy + +``` +``` + +## `NEXT_PUBLIC_API_URL=second vt run fetch-envs-removed-ignore` + +cache miss: the original observation is now a dependency + +``` +$ node scripts/fetch_envs.mjs --prefix NEXT_PUBLIC_ ○ cache miss: env 'NEXT_PUBLIC_API_URL' changed, executing +NEXT_PUBLIC_API_URL=second +``` diff --git a/crates/vt_bin/tests/e2e_snapshots/fixtures/ipc_client_test/vite-task.json b/crates/vt_bin/tests/e2e_snapshots/fixtures/ipc_client_test/vite-task.json index 1ff9e059e..7bfe51cc3 100644 --- a/crates/vt_bin/tests/e2e_snapshots/fixtures/ipc_client_test/vite-task.json +++ b/crates/vt_bin/tests/e2e_snapshots/fixtures/ipc_client_test/vite-task.json @@ -89,6 +89,51 @@ "command": "node scripts/fetch_envs.mjs --prefix PROBE_*", "cache": true }, + "fetch-envs-untracked-noise": { + "command": "node scripts/fetch_envs.mjs --glob \"{PROBE_*,ACTIONS_ORCHESTRATION_ID}\"", + "cache": { + "untrackedEnv": ["PROBE_NOISE_*", "ACTIONS_ORCHESTRATION_ID"] + } + }, + "fetch-envs-prefix-untracked-noise": { + "command": "node scripts/fetch_envs.mjs --prefix", + "cache": { + "untrackedEnv": ["PROBE_NOISE_*"] + } + }, + "fetch-envs-all-untracked-except-vite": { + "command": "node scripts/fetch_envs.mjs --prefix \"\" --print-prefix VITE_", + "cache": { + "untrackedEnv": ["*", "!VITE_*"] + } + }, + "fetch-env-explicitly-untracked": { + "command": "node scripts/fetch_env.mjs PROBE_ENV", + "cache": { + "untrackedEnv": ["PROBE_ENV"] + } + }, + "fetch-envs-exact-explicitly-untracked": { + "command": "node scripts/fetch_envs.mjs --glob PROBE_ENV", + "cache": { + "untrackedEnv": ["PROBE_ENV"] + } + }, + "fetch-env-declared-and-untracked": { + "command": "node scripts/fetch_env.mjs PROBE_ENV", + "cache": { + "env": ["PROBE_ENV"], + "untrackedEnv": ["PROBE_ENV"] + } + }, + "fetch-envs-removed-ignore": { + "command": "node scripts/fetch_envs.mjs --prefix NEXT_PUBLIC_", + "cache": { + "input": [], + "output": [], + "untrackedEnv": ["NEXT_*"] + } + }, "fetch-env-untracked": { "command": "node scripts/fetch_env.mjs --untracked PROBE_ENV", "cache": true diff --git a/crates/vt_bin/tests/e2e_snapshots/fixtures/vite_build_cache/snapshots.toml b/crates/vt_bin/tests/e2e_snapshots/fixtures/vite_build_cache/snapshots.toml index 4248aa961..085111de2 100644 --- a/crates/vt_bin/tests/e2e_snapshots/fixtures/vite_build_cache/snapshots.toml +++ b/crates/vt_bin/tests/e2e_snapshots/fixtures/vite_build_cache/snapshots.toml @@ -100,6 +100,55 @@ steps = [ ], comment = "cache-bravo label is now in the bundle" }, ] +[[e2e]] +name = "vite_token_env_change_rebuilds_bundle" +comment = """ +Vite's default VITE_ prefix makes VITE_MAPBOX_TOKEN a bundle input even though *_TOKEN is a default untracked environment pattern. Changing only the ambient token must invalidate the cache and replace the old token in the emitted JavaScript without manual cache configuration. +""" +ignore = true +steps = [ + { argv = [ + "vt", + "run", + "--cache", + "build", + ], envs = [ + [ + "VITE_MAPBOX_TOKEN", + "mapbox-token-alpha", + ], + ], comment = "first run: the ambient token is substituted into the bundle" }, + { argv = [ + "vtt", + "grep-file", + "dist/assets/main.js", + "mapbox-token-alpha", + ], comment = "the first bundle contains the first token" }, + { argv = [ + "vt", + "run", + "--cache", + "build", + ], envs = [ + [ + "VITE_MAPBOX_TOKEN", + "mapbox-token-bravo", + ], + ], comment = "cache miss: the token changed while every source file stayed the same" }, + { argv = [ + "vtt", + "grep-file", + "dist/assets/main.js", + "mapbox-token-alpha", + ], comment = "the rebuilt bundle no longer contains the first token" }, + { argv = [ + "vtt", + "grep-file", + "dist/assets/main.js", + "mapbox-token-bravo", + ], comment = "the rebuilt bundle contains the new token" }, +] + [[e2e]] name = "vite_build_cache_is_portable_across_workspace_roots" comment = """ diff --git a/crates/vt_bin/tests/e2e_snapshots/fixtures/vite_build_cache/snapshots/vite_token_env_change_rebuilds_bundle.md b/crates/vt_bin/tests/e2e_snapshots/fixtures/vite_build_cache/snapshots/vite_token_env_change_rebuilds_bundle.md new file mode 100644 index 000000000..f791511ba --- /dev/null +++ b/crates/vt_bin/tests/e2e_snapshots/fixtures/vite_build_cache/snapshots/vite_token_env_change_rebuilds_bundle.md @@ -0,0 +1,43 @@ +# vite_token_env_change_rebuilds_bundle + +Vite's default VITE_ prefix makes VITE_MAPBOX_TOKEN a bundle input even though *_TOKEN is a default untracked environment pattern. Changing only the ambient token must invalidate the cache and replace the old token in the emitted JavaScript without manual cache configuration. + +## `VITE_MAPBOX_TOKEN=mapbox-token-alpha vt run --cache build` + +first run: the ambient token is substituted into the bundle + +``` +$ vite build +``` + +## `vtt grep-file dist/assets/main.js mapbox-token-alpha` + +the first bundle contains the first token + +``` +dist/assets/main.js: found "mapbox-token-alpha" +``` + +## `VITE_MAPBOX_TOKEN=mapbox-token-bravo vt run --cache build` + +cache miss: the token changed while every source file stayed the same + +``` +$ vite build ○ cache miss: env 'VITE_MAPBOX_TOKEN' changed, executing +``` + +## `vtt grep-file dist/assets/main.js mapbox-token-alpha` + +the rebuilt bundle no longer contains the first token + +``` +dist/assets/main.js: missing "mapbox-token-alpha" +``` + +## `vtt grep-file dist/assets/main.js mapbox-token-bravo` + +the rebuilt bundle contains the new token + +``` +dist/assets/main.js: found "mapbox-token-bravo" +``` diff --git a/crates/vt_bin/tests/e2e_snapshots/fixtures/vite_build_cache/src/main.js b/crates/vt_bin/tests/e2e_snapshots/fixtures/vite_build_cache/src/main.js index 918439bfb..f1145b84f 100644 --- a/crates/vt_bin/tests/e2e_snapshots/fixtures/vite_build_cache/src/main.js +++ b/crates/vt_bin/tests/e2e_snapshots/fixtures/vite_build_cache/src/main.js @@ -1,3 +1,4 @@ // Vite replaces this at build time from keys matching `envPrefix` (`VITE_` by // default), so the e2e can verify that glob-tracked env changes rebuild output. console.log(import.meta.env.VITE_CACHE_LABEL); +console.log(import.meta.env.VITE_MAPBOX_TOKEN); diff --git a/crates/vt_graph/run-config.ts b/crates/vt_graph/run-config.ts index b32282aef..dda95012c 100644 --- a/crates/vt_graph/run-config.ts +++ b/crates/vt_graph/run-config.ts @@ -64,6 +64,8 @@ export type TaskCacheConfig = { env?: Array, /** * Environment variable names to be passed to the task without fingerprinting. + * Explicit patterns also override tool-reported env dependencies, unless + * the value is fingerprinted by `env` or a command-prefix assignment. */ untrackedEnv?: Array, /** diff --git a/crates/vt_graph/src/config/mod.rs b/crates/vt_graph/src/config/mod.rs index 967ef82a1..766515a94 100644 --- a/crates/vt_graph/src/config/mod.rs +++ b/crates/vt_graph/src/config/mod.rs @@ -65,8 +65,10 @@ impl ResolvedTaskOptions { let cache_config = match user_options.cache_config.unwrap_or_default().into_enabled() { None => None, Some(enabled_cache_config) => { + let explicit_untracked_env: Arc<[Str]> = + enabled_cache_config.untracked_env.unwrap_or_default().into(); let mut untracked_env: FxHashSet = - enabled_cache_config.untracked_env.unwrap_or_default().into_iter().collect(); + explicit_untracked_env.iter().cloned().collect(); untracked_env.extend(DEFAULT_UNTRACKED_ENV.iter().copied().map(Str::from)); let input_config = ResolvedGlobConfig::from_user_config( @@ -89,6 +91,7 @@ impl ResolvedTaskOptions { .map(|e| e.into_vec().into_iter().collect()) .unwrap_or_default(), untracked_env, + explicit_untracked_env, }, input_config, output_config, @@ -327,6 +330,10 @@ pub struct EnvConfig { pub fingerprinted_envs: FxHashSet, /// environment variable names to be passed to the task without fingerprinting, with defaults populated pub untracked_env: FxHashSet, + /// User-declared overrides for tool-reported dependencies. Built-in + /// passthrough defaults must not suppress values consumed by tools. + #[serde(skip)] + pub explicit_untracked_env: Arc<[Str]>, } #[derive(Debug, thiserror::Error)] diff --git a/crates/vt_graph/src/config/user.rs b/crates/vt_graph/src/config/user.rs index b6fa8c1c1..a7fce51dd 100644 --- a/crates/vt_graph/src/config/user.rs +++ b/crates/vt_graph/src/config/user.rs @@ -206,6 +206,8 @@ pub struct EnabledCacheConfig { pub env: Option>, /// Environment variable names to be passed to the task without fingerprinting. + /// Explicit patterns also override tool-reported env dependencies, unless + /// the value is fingerprinted by `env` or a command-prefix assignment. pub untracked_env: Option>, /// Files to include in the cache fingerprint. diff --git a/crates/vt_plan/src/cache_metadata.rs b/crates/vt_plan/src/cache_metadata.rs index d544e78f1..3fa12dafd 100644 --- a/crates/vt_plan/src/cache_metadata.rs +++ b/crates/vt_plan/src/cache_metadata.rs @@ -68,6 +68,12 @@ pub struct CacheMetadata { /// mirrors the ambient environment and would make snapshots noisy. #[serde(skip)] pub unfiltered_envs: Arc>, Arc>>, + + /// User-declared overrides for tool-reported env dependencies. Kept out + /// of the cache key: complete observations are stored and revalidated + /// under the current policy, including when an override is removed. + #[serde(skip)] + pub explicit_untracked_env: Arc<[Str]>, } /// Fingerprint for spawn execution that affects caching. diff --git a/crates/vt_plan/src/envs.rs b/crates/vt_plan/src/envs.rs index 336540d3a..30dd77500 100644 --- a/crates/vt_plan/src/envs.rs +++ b/crates/vt_plan/src/envs.rs @@ -190,6 +190,7 @@ mod tests { EnvConfig { fingerprinted_envs: fingerprinted.iter().map(|s| Str::from(*s)).collect(), untracked_env: untracked.iter().map(|s| Str::from(*s)).collect(), + explicit_untracked_env: untracked.iter().map(|s| Str::from(*s)).collect(), } } diff --git a/crates/vt_plan/src/plan.rs b/crates/vt_plan/src/plan.rs index c76ba93f8..af3ba76cc 100644 --- a/crates/vt_plan/src/plan.rs +++ b/crates/vt_plan/src/plan.rs @@ -485,7 +485,12 @@ fn resolve_synthetic_cache_config( workspace_path, ) .map_err(Error::ResolveTaskConfig)? - .cache_config) + .cache_config + .map(|mut config| { + // Handler defaults pass envs through without overriding tracked reads. + config.env_config.explicit_untracked_env = Arc::from([]); + config + })) } ParentCacheConfig::Disabled => Ok(Option::None), ParentCacheConfig::Inherited(mut parent_config) => { @@ -697,6 +702,7 @@ fn plan_spawn_execution( None }, unfiltered_envs: Arc::clone(envs), + explicit_untracked_env: Arc::clone(&cache_config.env_config.explicit_untracked_env), }); } } @@ -951,7 +957,7 @@ fn concurrency_limit_from_env( #[cfg(test)] mod tests { - use std::collections::BTreeSet; + use std::{collections::BTreeSet, sync::Arc}; use rustc_hash::FxHashSet; use vt_graph::config::{ @@ -983,6 +989,7 @@ mod tests { env_config: EnvConfig { fingerprinted_envs: FxHashSet::default(), untracked_env: FxHashSet::default(), + explicit_untracked_env: Arc::from([]), }, input_config: ResolvedGlobConfig { includes_auto, @@ -1001,6 +1008,41 @@ mod tests { items.iter().map(|s| Str::from(*s)).collect() } + #[test] + fn synthetic_untracked_env_preserves_user_policy() { + let (pkg, ws) = test_paths(); + let synthetic = || { + UserCacheConfig::with_config(EnabledCacheConfig { + env: None, + untracked_env: Some(vec!["HANDLER_*".into()]), + input: None, + output: None, + remote: None, + }) + }; + let top_level = + resolve_synthetic_cache_config(ParentCacheConfig::None, synthetic(), &pkg, &ws) + .unwrap() + .unwrap(); + assert!(top_level.env_config.untracked_env.contains("HANDLER_*")); + assert!(top_level.env_config.explicit_untracked_env.is_empty()); + + let mut parent = parent_config(true, &[]); + parent.env_config.untracked_env.insert("USER_*".into()); + parent.env_config.explicit_untracked_env = Arc::from([Str::from("USER_*")]); + let inherited = resolve_synthetic_cache_config( + ParentCacheConfig::Inherited(parent), + synthetic(), + &pkg, + &ws, + ) + .unwrap() + .unwrap(); + assert!(inherited.env_config.untracked_env.contains("HANDLER_*")); + assert!(inherited.env_config.untracked_env.contains("USER_*")); + assert_eq!(inherited.env_config.explicit_untracked_env, Arc::from([Str::from("USER_*")])); + } + #[test] fn synthetic_input_none_preserves_parent() { let (pkg, ws) = test_paths(); diff --git a/packages/vite-task-client/src/index.d.ts b/packages/vite-task-client/src/index.d.ts index f61f2882a..1dd43b42c 100644 --- a/packages/vite-task-client/src/index.d.ts +++ b/packages/vite-task-client/src/index.d.ts @@ -30,6 +30,9 @@ export function disableCache(): void; * * With `tracked: true` (the default) the runner records `name` as a * dependency, so a change to its value invalidates this run's cache entry. + * An explicit task `cache.untrackedEnv` pattern overrides this tracking; + * built-in passthrough defaults do not. Values declared in `cache.env` + * remain cache dependencies. * * Has no effect on `process.env`; the caller decides what to do with the * returned value. Returns `undefined` when not running inside a runner. @@ -49,7 +52,11 @@ export function getEnv(name: string, options?: { * * With `tracked: true` (the default) the runner records the pattern as a * dependency, so adding, removing, or changing a matching env invalidates - * this run's cache entry. + * this run's cache entry. Names matching the task's `cache.untrackedEnv` + * patterns explicitly configured by the user are still returned, but their + * addition, removal, or value changes do not invalidate the cache. Built-in + * passthrough defaults do not override tracking. Values declared in + * `cache.env` remain cache dependencies. * * Has no effect on `process.env`; the caller decides what to do with the * returned values. Returns an empty object when not running inside a runner. diff --git a/packages/vite-task-client/src/index.js b/packages/vite-task-client/src/index.js index 7a135f65d..66ec4d3ed 100644 --- a/packages/vite-task-client/src/index.js +++ b/packages/vite-task-client/src/index.js @@ -92,6 +92,9 @@ export function disableCache() { * * With `tracked: true` (the default) the runner records `name` as a * dependency, so a change to its value invalidates this run's cache entry. + * An explicit task `cache.untrackedEnv` pattern overrides this tracking; + * built-in passthrough defaults do not. Values declared in `cache.env` + * remain cache dependencies. * * Has no effect on `process.env`; the caller decides what to do with the * returned value. Returns `undefined` when not running inside a runner. @@ -114,7 +117,11 @@ export function getEnv(name, options) { * * With `tracked: true` (the default) the runner records the pattern as a * dependency, so adding, removing, or changing a matching env invalidates - * this run's cache entry. + * this run's cache entry. Names matching the task's `cache.untrackedEnv` + * patterns explicitly configured by the user are still returned, but their + * addition, removal, or value changes do not invalidate the cache. Built-in + * passthrough defaults do not override tracking. Values declared in + * `cache.env` remain cache dependencies. * * Has no effect on `process.env`; the caller decides what to do with the * returned values. Returns an empty object when not running inside a runner.