diff --git a/crates/vite_task/src/session/event.rs b/crates/vite_task/src/session/event.rs index 62c77687..48efd793 100644 --- a/crates/vite_task/src/session/event.rs +++ b/crates/vite_task/src/session/event.rs @@ -1,5 +1,7 @@ use std::{process::ExitStatus, time::Duration}; +use vite_path::RelativePathBuf; + use super::cache::CacheMiss; /// The cache operation that failed. @@ -57,6 +59,12 @@ pub enum CacheNotUpdatedReason { CacheDisabled, /// Execution exited with non-zero status NonZeroExitStatus, + /// Task modified files it read during execution (read-write overlap detected by fspy). + /// Caching such tasks is unsound because the prerun input hashes become stale. + InputModified { + /// First path that was both read and written during execution. + path: RelativePathBuf, + }, } #[derive(Debug)] @@ -66,13 +74,7 @@ pub enum CacheUpdateStatus { /// Cache was not updated (with reason). /// The reason is part of the `LeafExecutionReporter` trait contract — reporters /// can use it for detailed logging, even if current implementations don't. - NotUpdated( - #[expect( - dead_code, - reason = "part of LeafExecutionReporter trait contract; reporters may use for detailed logging" - )] - CacheNotUpdatedReason, - ), + NotUpdated(CacheNotUpdatedReason), } #[derive(Debug)] diff --git a/crates/vite_task/src/session/execute/fingerprint.rs b/crates/vite_task/src/session/execute/fingerprint.rs index 672cba59..1c4994a1 100644 --- a/crates/vite_task/src/session/execute/fingerprint.rs +++ b/crates/vite_task/src/session/execute/fingerprint.rs @@ -55,19 +55,24 @@ impl PostRunFingerprint { /// Creates a new fingerprint from path accesses after task execution. /// /// Negative glob filtering is done upstream in `spawn_with_tracking`. - /// Paths may contain `..` components from fspy, so this method cleans them - /// before fingerprinting. + /// Paths already present in `globbed_inputs` are skipped — they are + /// already tracked by the prerun glob fingerprint, and the read-write + /// overlap check in `execute_spawn` guarantees the task did not modify + /// them, so the prerun hash is still correct. /// /// # Arguments /// * `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 #[tracing::instrument(level = "debug", skip_all, name = "create_post_run_fingerprint")] pub fn create( inferred_path_reads: &HashMap, base_dir: &AbsolutePath, + globbed_inputs: &BTreeMap, ) -> anyhow::Result { let inferred_inputs = inferred_path_reads .par_iter() + .filter(|(path, _)| !globbed_inputs.contains_key(*path)) .map(|(relative_path, path_read)| { let full_path = Arc::::from(base_dir.join(relative_path)); let fingerprint = fingerprint_path(&full_path, *path_read)?; diff --git a/crates/vite_task/src/session/execute/mod.rs b/crates/vite_task/src/session/execute/mod.rs index 038043a4..e45dd861 100644 --- a/crates/vite_task/src/session/execute/mod.rs +++ b/crates/vite_task/src/session/execute/mod.rs @@ -397,34 +397,55 @@ pub async fn execute_spawn( cache_metadata_and_inputs { if result.exit_status.success() { - // path_reads is empty when inference is disabled (path_accesses is None) - let empty_path_reads = HashMap::default(); - let path_reads = path_accesses.as_ref().map_or(&empty_path_reads, |pa| &pa.path_reads); - - // Execution succeeded — attempt to create fingerprint and update cache - match PostRunFingerprint::create(path_reads, cache_base_path) { - Ok(post_run_fingerprint) => { - let new_cache_value = CacheEntryValue { - post_run_fingerprint, - std_outputs: std_outputs.unwrap_or_default().into(), - duration: result.duration, - globbed_inputs, - }; - match cache.update(cache_metadata, new_cache_value).await { - Ok(()) => (CacheUpdateStatus::Updated, None), - Err(err) => ( - CacheUpdateStatus::NotUpdated(CacheNotUpdatedReason::CacheDisabled), - Some(ExecutionError::Cache { - kind: CacheErrorKind::Update, - source: err, - }), - ), + // Check for read-write overlap: if the task wrote to any file it also + // read, the inputs were modified during execution — don't cache. + // Note: this only checks fspy-inferred reads, not globbed_inputs keys. + // A task that writes to a glob-matched file without reading it causes + // perpetual cache misses (glob detects the hash change) but not a + // correctness bug, so we don't handle that case here. + if let Some(path) = path_accesses + .as_ref() + .and_then(|pa| pa.path_reads.keys().find(|p| pa.path_writes.contains(*p))) + { + ( + CacheUpdateStatus::NotUpdated(CacheNotUpdatedReason::InputModified { + path: path.clone(), + }), + None, + ) + } else { + // path_reads is empty when inference is disabled (path_accesses is None) + let empty_path_reads = HashMap::default(); + let path_reads = + path_accesses.as_ref().map_or(&empty_path_reads, |pa| &pa.path_reads); + + // Execution succeeded — attempt to create fingerprint and update cache. + // Paths already in globbed_inputs are skipped: Rule 1 (above) guarantees + // no input modification, so the prerun hash is the correct post-exec hash. + match PostRunFingerprint::create(path_reads, cache_base_path, &globbed_inputs) { + Ok(post_run_fingerprint) => { + let new_cache_value = CacheEntryValue { + post_run_fingerprint, + std_outputs: std_outputs.unwrap_or_default().into(), + duration: result.duration, + globbed_inputs, + }; + match cache.update(cache_metadata, new_cache_value).await { + Ok(()) => (CacheUpdateStatus::Updated, None), + Err(err) => ( + CacheUpdateStatus::NotUpdated(CacheNotUpdatedReason::CacheDisabled), + Some(ExecutionError::Cache { + kind: CacheErrorKind::Update, + source: err, + }), + ), + } } + Err(err) => ( + CacheUpdateStatus::NotUpdated(CacheNotUpdatedReason::CacheDisabled), + Some(ExecutionError::PostRunFingerprint(err)), + ), } - Err(err) => ( - CacheUpdateStatus::NotUpdated(CacheNotUpdatedReason::CacheDisabled), - Some(ExecutionError::PostRunFingerprint(err)), - ), } } else { // Execution failed with non-zero exit status — don't update cache diff --git a/crates/vite_task/src/session/reporter/labeled.rs b/crates/vite_task/src/session/reporter/labeled.rs index 15e95b24..d43aed8d 100644 --- a/crates/vite_task/src/session/reporter/labeled.rs +++ b/crates/vite_task/src/session/reporter/labeled.rs @@ -251,7 +251,7 @@ impl LeafExecutionReporter for LabeledLeafReporter { async fn finish( self: Box, status: Option, - _cache_update_status: CacheUpdateStatus, + cache_update_status: CacheUpdateStatus, error: Option, ) { // Convert error before consuming it (need the original for display formatting). @@ -276,7 +276,12 @@ impl LeafExecutionReporter for LabeledLeafReporter { task_name: display.task_display.task_name.clone(), command: display.command.clone(), cwd: cwd_relative, - result: TaskResult::from_execution(&cache_status, status, saved_error.as_ref()), + result: TaskResult::from_execution( + &cache_status, + status, + saved_error.as_ref(), + &cache_update_status, + ), }; shared.borrow_mut().tasks.push(task_summary); diff --git a/crates/vite_task/src/session/reporter/summary.rs b/crates/vite_task/src/session/reporter/summary.rs index 05e82130..81c314b1 100644 --- a/crates/vite_task/src/session/reporter/summary.rs +++ b/crates/vite_task/src/session/reporter/summary.rs @@ -20,7 +20,10 @@ use crate::session::{ CacheMiss, FingerprintMismatch, InputChangeKind, SpawnFingerprintChange, detect_spawn_fingerprint_changes, format_input_change_str, format_spawn_change, }, - event::{CacheDisabledReason, CacheErrorKind, CacheStatus, ExecutionError}, + event::{ + CacheDisabledReason, CacheErrorKind, CacheNotUpdatedReason, CacheStatus, CacheUpdateStatus, + ExecutionError, + }, }; // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ @@ -94,7 +97,12 @@ pub enum SpawnOutcome { /// Process exited successfully (exit code 0). /// May have a post-execution infrastructure error (cache update or fingerprint failed). /// These only run after exit 0, so this field only exists on the success path. - Success { infra_error: Option }, + Success { + infra_error: Option, + /// First path that was both read and written, causing cache to be skipped. + /// Only set when fspy detected a read-write overlap. + input_modified_path: Option, + }, /// Process exited with non-zero status. /// [`NonZeroI32`] enforces that exit code 0 is unrepresentable here. @@ -147,6 +155,8 @@ struct SummaryStats { cache_disabled: usize, failed: usize, total_saved: Duration, + /// Display names of tasks that were not cached due to read-write overlap. + input_modified_task_names: Vec, } impl SummaryStats { @@ -158,6 +168,7 @@ impl SummaryStats { cache_disabled: 0, failed: 0, total_saved: Duration::ZERO, + input_modified_task_names: Vec::new(), }; for task in tasks { @@ -175,10 +186,13 @@ impl SummaryStats { SpawnedCacheStatus::Disabled => stats.cache_disabled += 1, } match outcome { - SpawnOutcome::Success { infra_error: Some(_) } + SpawnOutcome::Success { infra_error: Some(_), .. } | SpawnOutcome::Failed { .. } | SpawnOutcome::SpawnError(_) => stats.failed += 1, - SpawnOutcome::Success { infra_error: None } => {} + SpawnOutcome::Success { input_modified_path: Some(_), .. } => { + stats.input_modified_task_names.push(task.format_task_display()); + } + SpawnOutcome::Success { .. } => {} } } } @@ -255,11 +269,20 @@ impl TaskResult { /// `cache_status`: the cache status determined at `start()` time. /// `exit_status`: the process exit status, or `None` for cache hit / in-process. /// `saved_error`: an optional pre-converted execution error. + /// `cache_update_status`: the post-execution cache update result. pub fn from_execution( cache_status: &CacheStatus, exit_status: Option, saved_error: Option<&SavedExecutionError>, + cache_update_status: &CacheUpdateStatus, ) -> Self { + let input_modified_path = match cache_update_status { + CacheUpdateStatus::NotUpdated(CacheNotUpdatedReason::InputModified { path }) => { + Some(Str::from(path.as_str())) + } + _ => None, + }; + match cache_status { CacheStatus::Hit { replayed_duration } => { Self::CacheHit { saved_duration_ms: duration_to_ms(*replayed_duration) } @@ -267,13 +290,21 @@ impl TaskResult { CacheStatus::Disabled(CacheDisabledReason::InProcessExecution) => Self::InProcess, CacheStatus::Disabled(CacheDisabledReason::NoCacheMetadata) => Self::Spawned { cache_status: SpawnedCacheStatus::Disabled, - outcome: spawn_outcome_from_execution(exit_status, saved_error), + outcome: spawn_outcome_from_execution( + exit_status, + saved_error, + input_modified_path, + ), }, CacheStatus::Miss(cache_miss) => Self::Spawned { cache_status: SpawnedCacheStatus::Miss(SavedCacheMissReason::from_cache_miss( cache_miss, )), - outcome: spawn_outcome_from_execution(exit_status, saved_error), + outcome: spawn_outcome_from_execution( + exit_status, + saved_error, + input_modified_path, + ), }, } } @@ -283,13 +314,14 @@ impl TaskResult { fn spawn_outcome_from_execution( exit_status: Option, saved_error: Option<&SavedExecutionError>, + input_modified_path: Option, ) -> SpawnOutcome { match (exit_status, saved_error) { // Spawn error — process never ran (None, Some(err)) => SpawnOutcome::SpawnError(err.clone()), // Process exited successfully, possible infra error (Some(status), _) if status.success() => { - SpawnOutcome::Success { infra_error: saved_error.cloned() } + SpawnOutcome::Success { infra_error: saved_error.cloned(), input_modified_path } } // Process exited with non-zero code (Some(status), _) => { @@ -304,7 +336,7 @@ fn spawn_outcome_from_execution( // No exit status, no error — this is the cache hit / in-process path, // handled by TaskResult::CacheHit / InProcess before reaching here. // If we somehow get here, treat as success. - (None, None) => SpawnOutcome::Success { infra_error: None }, + (None, None) => SpawnOutcome::Success { infra_error: None, input_modified_path: None }, } } @@ -415,6 +447,15 @@ impl TaskResult { /// - "→ Cache miss: no previous cache entry found" /// - "→ Cache disabled in task configuration" fn format_cache_detail(&self) -> Str { + // Check for input modification first — it overrides the cache miss reason + if let Self::Spawned { + outcome: SpawnOutcome::Success { input_modified_path: Some(path), .. }, + .. + } = self + { + return vite_str::format!("→ Not cached: read and wrote '{path}'"); + } + match self { Self::CacheHit { saved_duration_ms } => { let d = Duration::from_millis(*saved_duration_ms); @@ -467,7 +508,7 @@ impl TaskResult { match self { Self::CacheHit { .. } | Self::InProcess => None, Self::Spawned { outcome, .. } => match outcome { - SpawnOutcome::Success { infra_error } => infra_error.as_ref(), + SpawnOutcome::Success { infra_error, .. } => infra_error.as_ref(), SpawnOutcome::Failed { .. } => None, SpawnOutcome::SpawnError(err) => Some(err), }, @@ -671,8 +712,8 @@ pub fn format_compact_summary(summary: &LastRunSummary, program_name: &str) -> V let is_single_task = summary.tasks.len() == 1; - // Single task + not cache hit → no summary - if is_single_task && stats.cache_hits == 0 { + // Single task + not cache hit + no input modification → no summary + if is_single_task && stats.cache_hits == 0 && stats.input_modified_task_names.is_empty() { return Vec::new(); } @@ -682,16 +723,18 @@ pub fn format_compact_summary(summary: &LastRunSummary, program_name: &str) -> V let _ = writeln!(buf, "{}", "---".style(Style::new().bright_black())); let run_label = vite_str::format!("{program_name} run:"); - if is_single_task { - // Single task cache hit + let mut show_last_details_hint = true; + if is_single_task && stats.cache_hits > 0 { + // Single task cache hit — no need for --last-details hint let formatted_total_saved = format_summary_duration(stats.total_saved); - let _ = writeln!( + let _ = write!( buf, "{} cache hit, {} saved.", run_label.as_str().style(Style::new().blue().bold()), formatted_total_saved.style(Style::new().green().bold()), ); - } else { + show_last_details_hint = false; + } else if !is_single_task { // Multi-task let total = stats.total; let hits = stats.cache_hits; @@ -727,12 +770,42 @@ pub fn format_compact_summary(summary: &LastRunSummary, program_name: &str) -> V let _ = write!(buf, ", {} failed", n.style(Style::new().red())); } + let _ = write!(buf, "."); + } else { + // Single task, no cache hit — only shown when input_modified is non-empty + let _ = write!(buf, "{}", run_label.as_str().style(Style::new().blue().bold())); + } + + // Inline input-modified notice before the --last-details hint + if !stats.input_modified_task_names.is_empty() { + format_input_modified_notice(&mut buf, &stats.input_modified_task_names); + } + + if show_last_details_hint { let last_details_cmd = vite_str::format!("`{program_name} run --last-details`"); - let _ = write!(buf, ". {}", "(Run ".style(Style::new().bright_black())); + let _ = write!(buf, " {}", "(Run ".style(Style::new().bright_black())); let _ = write!(buf, "{}", last_details_cmd.as_str().style(COMMAND_STYLE)); let _ = write!(buf, "{}", " for full details)".style(Style::new().bright_black())); - let _ = writeln!(buf); } + let _ = writeln!(buf); buf } + +/// Write the "not cached because it modified its input" notice inline. +fn format_input_modified_notice(buf: &mut Vec, task_names: &[Str]) { + let _ = write!(buf, " "); + + let first = &task_names[0]; + let _ = write!(buf, "{}", first.as_str().style(Style::new().bold())); + let remaining = task_names.len() - 1; + if remaining > 0 { + let _ = write!(buf, " (and {remaining} more)"); + } + + if task_names.len() == 1 { + let _ = write!(buf, " not cached because it modified its input."); + } else { + let _ = write!(buf, " not cached because they modified their inputs."); + } +} diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/input-read-write-not-cached/package.json b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/input-read-write-not-cached/package.json new file mode 100644 index 00000000..4600be52 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/input-read-write-not-cached/package.json @@ -0,0 +1,4 @@ +{ + "name": "input-read-write-not-cached", + "private": true +} diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/input-read-write-not-cached/packages/normal-pkg/package.json b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/input-read-write-not-cached/packages/normal-pkg/package.json new file mode 100644 index 00000000..36d604f3 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/input-read-write-not-cached/packages/normal-pkg/package.json @@ -0,0 +1,6 @@ +{ + "name": "@test/normal-pkg", + "scripts": { + "task": "print hello" + } +} diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/input-read-write-not-cached/packages/rw-pkg/package.json b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/input-read-write-not-cached/packages/rw-pkg/package.json new file mode 100644 index 00000000..88a7b0e0 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/input-read-write-not-cached/packages/rw-pkg/package.json @@ -0,0 +1,9 @@ +{ + "name": "@test/rw-pkg", + "scripts": { + "task": "replace-file-content src/data.txt original modified" + }, + "dependencies": { + "@test/normal-pkg": "workspace:*" + } +} diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/input-read-write-not-cached/packages/rw-pkg/src/data.txt b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/input-read-write-not-cached/packages/rw-pkg/src/data.txt new file mode 100644 index 00000000..94f3610c --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/input-read-write-not-cached/packages/rw-pkg/src/data.txt @@ -0,0 +1 @@ +original \ No newline at end of file diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/input-read-write-not-cached/packages/touch-pkg/package.json b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/input-read-write-not-cached/packages/touch-pkg/package.json new file mode 100644 index 00000000..a34c6a22 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/input-read-write-not-cached/packages/touch-pkg/package.json @@ -0,0 +1,6 @@ +{ + "name": "@test/touch-pkg", + "scripts": { + "task": "touch-file src/data.txt" + } +} diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/input-read-write-not-cached/packages/touch-pkg/src/data.txt b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/input-read-write-not-cached/packages/touch-pkg/src/data.txt new file mode 100644 index 00000000..94f3610c --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/input-read-write-not-cached/packages/touch-pkg/src/data.txt @@ -0,0 +1 @@ +original \ No newline at end of file diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/input-read-write-not-cached/pnpm-workspace.yaml b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/input-read-write-not-cached/pnpm-workspace.yaml new file mode 100644 index 00000000..924b55f4 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/input-read-write-not-cached/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +packages: + - packages/* diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/input-read-write-not-cached/snapshots.toml b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/input-read-write-not-cached/snapshots.toml new file mode 100644 index 00000000..33301f5d --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/input-read-write-not-cached/snapshots.toml @@ -0,0 +1,25 @@ +# Tests that tasks modifying their own inputs (read-write overlap) are not cached. +# replace-file-content reads then writes the same file — fspy detects both. + +# Single rw-task: compact summary shows "not cached because it modified its input" +[[e2e]] +name = "single read-write task shows not cached message" +cwd = "packages/rw-pkg" +steps = ["vt run task", "vt run task"] + +# Multi-task (recursive): compact summary shows stats + InputModified notice +[[e2e]] +name = "multi task with read-write shows not cached in summary" +steps = ["vt run -r task", "vt run -r task"] + +# Verbose: full summary shows the overlapping path +[[e2e]] +name = "verbose read-write task shows path in full summary" +cwd = "packages/rw-pkg" +steps = ["vt run -v task"] + +# Single O_RDWR open (touch-file) is also detected as read-write overlap +[[e2e]] +name = "single O_RDWR open is not cached" +cwd = "packages/touch-pkg" +steps = ["vt run task", "vt run task"] diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/input-read-write-not-cached/snapshots/multi task with read-write shows not cached in summary.snap b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/input-read-write-not-cached/snapshots/multi task with read-write shows not cached in summary.snap new file mode 100644 index 00000000..caacf8d3 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/input-read-write-not-cached/snapshots/multi task with read-write shows not cached in summary.snap @@ -0,0 +1,24 @@ +--- +source: crates/vite_task_bin/tests/e2e_snapshots/main.rs +expression: e2e_outputs +--- +> vt run -r task +~/packages/normal-pkg$ print hello +hello + +~/packages/touch-pkg$ touch-file src/data.txt + +~/packages/rw-pkg$ replace-file-content src/data.txt original modified + +--- +vt run: 0/3 cache hit (0%). @test/touch-pkg#task (and 1 more) not cached because they modified their inputs. (Run `vt run --last-details` for full details) +> vt run -r task +~/packages/normal-pkg$ print hello ✓ cache hit, replaying +hello + +~/packages/touch-pkg$ touch-file src/data.txt + +~/packages/rw-pkg$ replace-file-content src/data.txt original modified + +--- +vt run: 1/3 cache hit (33%), saved. @test/touch-pkg#task (and 1 more) not cached because they modified their inputs. (Run `vt run --last-details` for full details) diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/input-read-write-not-cached/snapshots/single O_RDWR open is not cached.snap b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/input-read-write-not-cached/snapshots/single O_RDWR open is not cached.snap new file mode 100644 index 00000000..5b506d1f --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/input-read-write-not-cached/snapshots/single O_RDWR open is not cached.snap @@ -0,0 +1,16 @@ +--- +source: crates/vite_task_bin/tests/e2e_snapshots/main.rs +expression: e2e_outputs +info: + cwd: packages/touch-pkg +--- +> vt run task +~/packages/touch-pkg$ touch-file src/data.txt + +--- +vt run: @test/touch-pkg#task not cached because it modified its input. (Run `vt run --last-details` for full details) +> vt run task +~/packages/touch-pkg$ touch-file src/data.txt + +--- +vt run: @test/touch-pkg#task not cached because it modified its input. (Run `vt run --last-details` for full details) diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/input-read-write-not-cached/snapshots/single read-write task shows not cached message.snap b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/input-read-write-not-cached/snapshots/single read-write task shows not cached message.snap new file mode 100644 index 00000000..f4235b0b --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/input-read-write-not-cached/snapshots/single read-write task shows not cached message.snap @@ -0,0 +1,16 @@ +--- +source: crates/vite_task_bin/tests/e2e_snapshots/main.rs +expression: e2e_outputs +info: + cwd: packages/rw-pkg +--- +> vt run task +~/packages/rw-pkg$ replace-file-content src/data.txt original modified + +--- +vt run: @test/rw-pkg#task not cached because it modified its input. (Run `vt run --last-details` for full details) +> vt run task +~/packages/rw-pkg$ replace-file-content src/data.txt original modified + +--- +vt run: @test/rw-pkg#task not cached because it modified its input. (Run `vt run --last-details` for full details) diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/input-read-write-not-cached/snapshots/verbose read-write task shows path in full summary.snap b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/input-read-write-not-cached/snapshots/verbose read-write task shows path in full summary.snap new file mode 100644 index 00000000..f82d5f07 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/input-read-write-not-cached/snapshots/verbose read-write task shows path in full summary.snap @@ -0,0 +1,22 @@ +--- +source: crates/vite_task_bin/tests/e2e_snapshots/main.rs +expression: e2e_outputs +info: + cwd: packages/rw-pkg +--- +> vt run -v task +~/packages/rw-pkg$ replace-file-content src/data.txt original modified + + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + Vite+ Task Runner • Execution Summary +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +Statistics: 1 tasks • 0 cache hits • 1 cache misses +Performance: 0% cache hit rate + +Task Details: +──────────────────────────────────────────────── + [1] @test/rw-pkg#task: ~/packages/rw-pkg$ replace-file-content src/data.txt original modified ✓ + → Not cached: read and wrote 'packages/rw-pkg/src/data.txt' +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/input-read-write-not-cached/vite-task.json b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/input-read-write-not-cached/vite-task.json new file mode 100644 index 00000000..d548edfa --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/input-read-write-not-cached/vite-task.json @@ -0,0 +1,3 @@ +{ + "cache": true +} diff --git a/packages/tools/package.json b/packages/tools/package.json index df227bf1..a65e6745 100644 --- a/packages/tools/package.json +++ b/packages/tools/package.json @@ -8,7 +8,8 @@ "print-env": "./src/print-env.js", "print-file": "./src/print-file.ts", "read-stdin": "./src/read-stdin.js", - "replace-file-content": "./src/replace-file-content.ts" + "replace-file-content": "./src/replace-file-content.ts", + "touch-file": "./src/touch-file.ts" }, "type": "module", "dependencies": { diff --git a/packages/tools/src/touch-file.ts b/packages/tools/src/touch-file.ts new file mode 100755 index 00000000..58e4afbc --- /dev/null +++ b/packages/tools/src/touch-file.ts @@ -0,0 +1,15 @@ +#!/usr/bin/env node + +// Opens a file once in read-write mode (O_RDWR) without modifying content. +// Used to test that fspy detects a single O_RDWR open as both READ and WRITE. + +import { openSync, closeSync, constants } from 'node:fs'; + +const filename = process.argv[2]; +if (!filename) { + console.error('Usage: touch-file '); + process.exit(1); +} + +const fd = openSync(filename, constants.O_RDWR); +closeSync(fd);