From c4a370946a6811694a01dc7aef13bab913922e2f Mon Sep 17 00:00:00 2001 From: branchseer Date: Wed, 14 Jan 2026 04:18:18 +0800 Subject: [PATCH 01/32] feat(cache): add explicit inputs config for cache fingerprinting Add `inputs` field to task configuration supporting: - Explicit glob patterns: `inputs: ["src/**/*.ts"]` - Auto-inference from fspy: `inputs: [{ auto: true }]` - Negative patterns: `inputs: ["src/**", "!**/*.test.ts"]` - Mixed mode: `inputs: ["package.json", { auto: true }, "!dist/**"]` - Empty array to disable file tracking: `inputs: []` Key changes: - Add `ResolvedInputConfig` to parse and normalize user input config - Add `glob_inputs.rs` for walking glob patterns and hashing files - Update `PreRunFingerprint` to include `input_config` and `glob_base` - Bump cache DB version to 6 for new fingerprint structure - Add comprehensive e2e tests for all input combinations Co-Authored-By: Claude Opus 4.5 --- Cargo.lock | 5 +- crates/fspy/src/unix/mod.rs | 1 + crates/fspy/src/windows/mod.rs | 3 +- crates/vite_task/Cargo.toml | 6 +- crates/vite_task/src/session/cache/display.rs | 36 +- crates/vite_task/src/session/cache/mod.rs | 245 ++++++--- .../src/session/execute/fingerprint.rs | 75 ++- .../src/session/execute/glob_inputs.rs | 481 ++++++++++++++++++ crates/vite_task/src/session/execute/mod.rs | 77 ++- crates/vite_task/src/session/execute/spawn.rs | 84 +-- .../vite_task/src/session/reporter/summary.rs | 9 + crates/vite_task_bin/src/lib.rs | 7 +- crates/vite_task_bin/src/main.rs | 1 + .../cache-miss-reasons/snapshots.toml | 8 + .../snapshots/inputs config changed.snap | 12 + .../fixtures/glob-base-test/other/other.ts | 1 + .../fixtures/glob-base-test/package.json | 4 + .../packages/sub-pkg/other/other.ts | 1 + .../packages/sub-pkg/package.json | 4 + .../packages/sub-pkg/src/sub.ts | 1 + .../packages/sub-pkg/vite-task.json | 15 + .../glob-base-test/pnpm-workspace.yaml | 2 + .../fixtures/glob-base-test/snapshots.toml | 87 ++++ .../root glob - matches src files.snap | 12 + ...ckage path unmatched by relative glob.snap | 15 + .../root glob - unmatched directory.snap | 15 + ...wd - glob relative to package not cwd.snap | 12 + ...bpackage glob - matches own src files.snap | 12 + ... root path unmatched by relative glob.snap | 15 + ...b - unmatched directory in subpackage.snap | 15 + ...wd - glob relative to package not cwd.snap | 12 + .../fixtures/glob-base-test/src/root.ts | 1 + .../fixtures/glob-base-test/vite-task.json | 15 + .../fixtures/inputs-cache-test/dist/output.js | 1 + .../fixtures/inputs-cache-test/package.json | 4 + .../fixtures/inputs-cache-test/snapshots.toml | 202 ++++++++ ...nly - hit on non-inferred file change.snap | 15 + ...o only - miss on inferred file change.snap | 12 + ...ative - hit on excluded inferred file.snap | 17 + ... - miss on non-excluded inferred file.snap | 14 + ...pty inputs - hit despite file changes.snap | 15 + ...empty inputs - miss on command change.snap | 12 + ... not set when auto inference disabled.snap | 7 + ...env - set when auto inference enabled.snap | 7 + ... auto negative - hit on excluded file.snap | 15 + ...negative - miss on explicit glob file.snap | 12 + ...auto negative - miss on inferred file.snap | 12 + ...lobs - hit on read but unmatched file.snap | 17 + ... globs only - cache hit on second run.snap | 13 + ...s only - hit on unmatched file change.snap | 15 + ...bs only - miss on matched file change.snap | 12 + ...negative globs - hit on excluded file.snap | 15 + ...ive globs - miss on non-excluded file.snap | 12 + .../inputs-cache-test/src/main.test.ts | 1 + .../fixtures/inputs-cache-test/src/main.ts | 1 + .../fixtures/inputs-cache-test/src/utils.ts | 1 + .../inputs-cache-test/test/main.test.ts | 1 + .../fixtures/inputs-cache-test/vite-task.json | 49 ++ .../package.json | 4 + .../packages/shared/dist/output.js | 1 + .../packages/shared/package.json | 4 + .../packages/shared/src/utils.ts | 1 + .../packages/sub-pkg/dist/output.js | 1 + .../packages/sub-pkg/package.json | 4 + .../packages/sub-pkg/src/main.ts | 1 + .../packages/sub-pkg/vite-task.json | 24 + .../pnpm-workspace.yaml | 2 + .../snapshots.toml | 98 ++++ ...hit on excluded sibling inferred file.snap | 17 + ...on non-excluded sibling inferred file.snap | 14 + ...e glob - hit on unmatched file change.snap | 15 + ...ve glob - miss on sibling file change.snap | 12 + ...gative - hit on excluded sibling file.snap | 17 + ...e - miss on non-excluded sibling file.snap | 14 + ...ative - hit on excluded inferred file.snap | 17 + ... - miss on non-excluded inferred file.snap | 14 + crates/vite_task_graph/Cargo.toml | 1 + crates/vite_task_graph/run-config.ts | 20 + crates/vite_task_graph/src/config/mod.rs | 193 ++++++- crates/vite_task_graph/src/config/user.rs | 125 ++++- crates/vite_task_plan/src/cache_metadata.rs | 30 +- crates/vite_task_plan/src/plan.rs | 14 +- ... env-test synthetic task in user task.snap | 11 +- .../additional-envs/snapshots/task graph.snap | 10 + ...uery - --cache enables script caching.snap | 11 +- ...aching even when cache.tasks is false.snap | 11 +- ...h per-task cache true enables caching.snap | 11 +- .../snapshots/task graph.snap | 20 + ...query - echo and lint with extra args.snap | 11 +- ...query - lint and echo with extra args.snap | 11 +- .../query - normal task with extra args.snap | 11 +- ... synthetic task in user task with cwd.snap | 11 +- .../query - synthetic task in user task.snap | 11 +- ...tic task with extra args in user task.snap | 11 +- .../cache-keys/snapshots/task graph.snap | 20 + .../snapshots/task graph.snap | 10 + .../snapshots/task graph.snap | 10 + .../query - task cached by default.snap | 11 +- ...- task with command cached by default.snap | 11 +- .../snapshots/task graph.snap | 15 + .../cache-sharing/snapshots/task graph.snap | 15 + .../snapshots/task graph.snap | 5 + .../snapshots/task graph.snap | 15 + ... script cached when global cache true.snap | 11 +- ... - task cached when global cache true.snap | 11 +- .../snapshots/task graph.snap | 10 + ...t should put synthetic task under cwd.snap | 11 +- ...n should not affect expanded task cwd.snap | 11 +- .../cd-in-scripts/snapshots/task graph.snap | 15 + .../snapshots/task graph.snap | 115 +++++ .../conflict-test/snapshots/task graph.snap | 15 + .../snapshots/task graph.snap | 10 + .../snapshots/task graph.snap | 10 + .../snapshots/task graph.snap | 10 + .../snapshots/task graph.snap | 40 ++ .../snapshots/task graph.snap | 50 ++ .../snapshots/task graph.snap | 65 +++ .../snapshots/task graph.snap | 5 + ...ed --cache enables inner task caching.snap | 11 +- ...ropagates to nested run without flags.snap | 11 +- ...oes not propagate into nested --cache.snap | 11 +- .../snapshots/task graph.snap | 20 + .../nested-tasks/snapshots/task graph.snap | 10 + .../snapshots/task graph.snap | 5 + .../snapshots/task graph.snap | 40 ++ ...ery - shell fallback for pipe command.snap | 11 +- .../shell-fallback/snapshots/task graph.snap | 5 + ... does not affect expanded query tasks.snap | 11 +- ...s not affect expanded synthetic cache.snap | 11 +- ...assThroughEnvs inherited by synthetic.snap | 11 +- ...th cache true enables synthetic cache.snap | 11 +- .../snapshots/task graph.snap | 25 + .../query - synthetic-in-subpackage.snap | 11 +- .../snapshots/task graph.snap | 10 + .../snapshots/task graph.snap | 15 + .../vpr-shorthand/snapshots/task graph.snap | 10 + .../snapshots/task graph.snap | 10 + .../snapshots/task graph.snap | 20 + .../snapshots/task graph.snap | 10 + .../snapshots/task graph.snap | 20 + .../snapshots/task graph.snap | 10 + .../snapshots/task graph.snap | 15 + docs/inputs.md | 221 ++++++++ packages/tools/src/print-file.ts | 6 +- 144 files changed, 3319 insertions(+), 287 deletions(-) create mode 100644 crates/vite_task/src/session/execute/glob_inputs.rs create mode 100644 crates/vite_task_bin/tests/e2e_snapshots/fixtures/cache-miss-reasons/snapshots/inputs config changed.snap create mode 100644 crates/vite_task_bin/tests/e2e_snapshots/fixtures/glob-base-test/other/other.ts create mode 100644 crates/vite_task_bin/tests/e2e_snapshots/fixtures/glob-base-test/package.json create mode 100644 crates/vite_task_bin/tests/e2e_snapshots/fixtures/glob-base-test/packages/sub-pkg/other/other.ts create mode 100644 crates/vite_task_bin/tests/e2e_snapshots/fixtures/glob-base-test/packages/sub-pkg/package.json create mode 100644 crates/vite_task_bin/tests/e2e_snapshots/fixtures/glob-base-test/packages/sub-pkg/src/sub.ts create mode 100644 crates/vite_task_bin/tests/e2e_snapshots/fixtures/glob-base-test/packages/sub-pkg/vite-task.json create mode 100644 crates/vite_task_bin/tests/e2e_snapshots/fixtures/glob-base-test/pnpm-workspace.yaml create mode 100644 crates/vite_task_bin/tests/e2e_snapshots/fixtures/glob-base-test/snapshots.toml create mode 100644 crates/vite_task_bin/tests/e2e_snapshots/fixtures/glob-base-test/snapshots/root glob - matches src files.snap create mode 100644 crates/vite_task_bin/tests/e2e_snapshots/fixtures/glob-base-test/snapshots/root glob - subpackage path unmatched by relative glob.snap create mode 100644 crates/vite_task_bin/tests/e2e_snapshots/fixtures/glob-base-test/snapshots/root glob - unmatched directory.snap create mode 100644 crates/vite_task_bin/tests/e2e_snapshots/fixtures/glob-base-test/snapshots/root glob with cwd - glob relative to package not cwd.snap create mode 100644 crates/vite_task_bin/tests/e2e_snapshots/fixtures/glob-base-test/snapshots/subpackage glob - matches own src files.snap create mode 100644 crates/vite_task_bin/tests/e2e_snapshots/fixtures/glob-base-test/snapshots/subpackage glob - root path unmatched by relative glob.snap create mode 100644 crates/vite_task_bin/tests/e2e_snapshots/fixtures/glob-base-test/snapshots/subpackage glob - unmatched directory in subpackage.snap create mode 100644 crates/vite_task_bin/tests/e2e_snapshots/fixtures/glob-base-test/snapshots/subpackage glob with cwd - glob relative to package not cwd.snap create mode 100644 crates/vite_task_bin/tests/e2e_snapshots/fixtures/glob-base-test/src/root.ts create mode 100644 crates/vite_task_bin/tests/e2e_snapshots/fixtures/glob-base-test/vite-task.json create mode 100644 crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-cache-test/dist/output.js create mode 100644 crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-cache-test/package.json create mode 100644 crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-cache-test/snapshots.toml create mode 100644 crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-cache-test/snapshots/auto only - hit on non-inferred file change.snap create mode 100644 crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-cache-test/snapshots/auto only - miss on inferred file change.snap create mode 100644 crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-cache-test/snapshots/auto with negative - hit on excluded inferred file.snap create mode 100644 crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-cache-test/snapshots/auto with negative - miss on non-excluded inferred file.snap create mode 100644 crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-cache-test/snapshots/empty inputs - hit despite file changes.snap create mode 100644 crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-cache-test/snapshots/empty inputs - miss on command change.snap create mode 100644 crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-cache-test/snapshots/fspy env - not set when auto inference disabled.snap create mode 100644 crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-cache-test/snapshots/fspy env - set when auto inference enabled.snap create mode 100644 crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-cache-test/snapshots/positive auto negative - hit on excluded file.snap create mode 100644 crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-cache-test/snapshots/positive auto negative - miss on explicit glob file.snap create mode 100644 crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-cache-test/snapshots/positive auto negative - miss on inferred file.snap create mode 100644 crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-cache-test/snapshots/positive globs - hit on read but unmatched file.snap create mode 100644 crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-cache-test/snapshots/positive globs only - cache hit on second run.snap create mode 100644 crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-cache-test/snapshots/positive globs only - hit on unmatched file change.snap create mode 100644 crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-cache-test/snapshots/positive globs only - miss on matched file change.snap create mode 100644 crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-cache-test/snapshots/positive negative globs - hit on excluded file.snap create mode 100644 crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-cache-test/snapshots/positive negative globs - miss on non-excluded file.snap create mode 100644 crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-cache-test/src/main.test.ts create mode 100644 crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-cache-test/src/main.ts create mode 100644 crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-cache-test/src/utils.ts create mode 100644 crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-cache-test/test/main.test.ts create mode 100644 crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-cache-test/vite-task.json create mode 100644 crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-negative-glob-subpackage/package.json create mode 100644 crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-negative-glob-subpackage/packages/shared/dist/output.js create mode 100644 crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-negative-glob-subpackage/packages/shared/package.json create mode 100644 crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-negative-glob-subpackage/packages/shared/src/utils.ts create mode 100644 crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-negative-glob-subpackage/packages/sub-pkg/dist/output.js create mode 100644 crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-negative-glob-subpackage/packages/sub-pkg/package.json create mode 100644 crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-negative-glob-subpackage/packages/sub-pkg/src/main.ts create mode 100644 crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-negative-glob-subpackage/packages/sub-pkg/vite-task.json create mode 100644 crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-negative-glob-subpackage/pnpm-workspace.yaml create mode 100644 crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-negative-glob-subpackage/snapshots.toml create mode 100644 crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-negative-glob-subpackage/snapshots/dotdot auto negative - hit on excluded sibling inferred file.snap create mode 100644 crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-negative-glob-subpackage/snapshots/dotdot auto negative - miss on non-excluded sibling inferred file.snap create mode 100644 crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-negative-glob-subpackage/snapshots/dotdot positive glob - hit on unmatched file change.snap create mode 100644 crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-negative-glob-subpackage/snapshots/dotdot positive glob - miss on sibling file change.snap create mode 100644 crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-negative-glob-subpackage/snapshots/dotdot positive negative - hit on excluded sibling file.snap create mode 100644 crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-negative-glob-subpackage/snapshots/dotdot positive negative - miss on non-excluded sibling file.snap create mode 100644 crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-negative-glob-subpackage/snapshots/subpackage auto with negative - hit on excluded inferred file.snap create mode 100644 crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-negative-glob-subpackage/snapshots/subpackage auto with negative - miss on non-excluded inferred file.snap create mode 100644 docs/inputs.md diff --git a/Cargo.lock b/Cargo.lock index d79cc2f8..835e1228 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3873,23 +3873,25 @@ dependencies = [ "nix 0.30.1", "once_cell", "owo-colors", + "path-clean", "pty_terminal_test_client", "rayon", "rusqlite", "rustc-hash", "serde", "serde_json", + "tempfile", "thiserror 2.0.18", "tokio", "tracing", "twox-hash", - "vite_glob", "vite_path", "vite_select", "vite_str", "vite_task_graph", "vite_task_plan", "vite_workspace", + "wax", ] [[package]] @@ -3926,6 +3928,7 @@ version = "0.1.0" dependencies = [ "anyhow", "async-trait", + "bincode", "monostate", "petgraph", "pretty_assertions", diff --git a/crates/fspy/src/unix/mod.rs b/crates/fspy/src/unix/mod.rs index 72b5dac0..96583f04 100644 --- a/crates/fspy/src/unix/mod.rs +++ b/crates/fspy/src/unix/mod.rs @@ -100,6 +100,7 @@ impl SpyImpl { ) .map_err(|err| SpawnError::Injection(err.into()))?; command.set_exec(exec); + command.env("FSPY", "1"); let mut tokio_command = command.into_tokio_command(); diff --git a/crates/fspy/src/windows/mod.rs b/crates/fspy/src/windows/mod.rs index 03a4c899..b4f1c75e 100644 --- a/crates/fspy/src/windows/mod.rs +++ b/crates/fspy/src/windows/mod.rs @@ -73,8 +73,9 @@ impl SpyImpl { } #[expect(clippy::unused_async, reason = "async signature required by SpyImpl trait")] - pub(crate) async fn spawn(&self, command: Command) -> Result { + pub(crate) async fn spawn(&self, mut command: Command) -> Result { let ansi_dll_path_with_nul = Arc::clone(&self.ansi_dll_path_with_nul); + command.env("FSPY", "1"); let mut command = command.into_tokio_command(); command.creation_flags(CREATE_SUSPENDED); diff --git a/crates/vite_task/Cargo.toml b/crates/vite_task/Cargo.toml index 58f61818..ade7cdb7 100644 --- a/crates/vite_task/Cargo.toml +++ b/crates/vite_task/Cargo.toml @@ -23,6 +23,7 @@ fspy = { workspace = true } futures-util = { workspace = true } once_cell = { workspace = true } owo-colors = { workspace = true } +path-clean = { workspace = true } pty_terminal_test_client = { workspace = true } rayon = { workspace = true } rusqlite = { workspace = true, features = ["bundled"] } @@ -33,13 +34,16 @@ thiserror = { workspace = true } tokio = { workspace = true, features = ["rt-multi-thread", "io-std", "io-util", "macros", "sync"] } tracing = { workspace = true } twox-hash = { workspace = true } -vite_glob = { workspace = true } vite_path = { workspace = true } vite_select = { workspace = true } vite_str = { workspace = true } vite_task_graph = { workspace = true } vite_task_plan = { workspace = true } vite_workspace = { workspace = true } +wax = { workspace = true } + +[dev-dependencies] +tempfile = { workspace = true } [target.'cfg(unix)'.dependencies] nix = { workspace = true } diff --git a/crates/vite_task/src/session/cache/display.rs b/crates/vite_task/src/session/cache/display.rs index 7bf46c96..fb901265 100644 --- a/crates/vite_task/src/session/cache/display.rs +++ b/crates/vite_task/src/session/cache/display.rs @@ -39,12 +39,6 @@ pub enum SpawnFingerprintChange { // Working directory change /// Working directory changed CwdChanged, - - // Fingerprint ignores changes - /// Fingerprint ignore pattern added - FingerprintIgnoreAdded { pattern: Str }, - /// Fingerprint ignore pattern removed - FingerprintIgnoreRemoved { pattern: Str }, } /// Format a single spawn fingerprint change as human-readable text. @@ -70,12 +64,6 @@ pub fn format_spawn_change(change: &SpawnFingerprintChange) -> Str { SpawnFingerprintChange::ProgramChanged => Str::from("program changed"), SpawnFingerprintChange::ArgsChanged => Str::from("args changed"), SpawnFingerprintChange::CwdChanged => Str::from("working directory changed"), - SpawnFingerprintChange::FingerprintIgnoreAdded { pattern } => { - vite_str::format!("fingerprint ignore '{pattern}' added") - } - SpawnFingerprintChange::FingerprintIgnoreRemoved { pattern } => { - vite_str::format!("fingerprint ignore '{pattern}' removed") - } } } @@ -141,20 +129,6 @@ pub fn detect_spawn_fingerprint_changes( changes.push(SpawnFingerprintChange::CwdChanged); } - // Check fingerprint ignores changes - let old_ignores: FxHashSet<_> = - old.fingerprint_ignores().map(|v| v.iter().collect()).unwrap_or_default(); - let new_ignores: FxHashSet<_> = - new.fingerprint_ignores().map(|v| v.iter().collect()).unwrap_or_default(); - for pattern in old_ignores.difference(&new_ignores) { - changes - .push(SpawnFingerprintChange::FingerprintIgnoreRemoved { pattern: (*pattern).clone() }); - } - for pattern in new_ignores.difference(&old_ignores) { - changes - .push(SpawnFingerprintChange::FingerprintIgnoreAdded { pattern: (*pattern).clone() }); - } - changes } @@ -196,13 +170,15 @@ pub fn format_cache_status_inline(cache_status: &CacheStatus) -> Option { Some(SpawnFingerprintChange::ProgramChanged) => "program changed", Some(SpawnFingerprintChange::ArgsChanged) => "args changed", Some(SpawnFingerprintChange::CwdChanged) => "working directory changed", - Some( - SpawnFingerprintChange::FingerprintIgnoreAdded { .. } - | SpawnFingerprintChange::FingerprintIgnoreRemoved { .. }, - ) => "fingerprint ignores changed", None => "configuration changed", } } + FingerprintMismatch::ConfigChanged => "configuration changed", + FingerprintMismatch::GlobbedInputChanged { path } => { + return Some(vite_str::format!( + "✗ cache miss: content of input '{path}' changed, executing" + )); + } FingerprintMismatch::PostRunFingerprintMismatch(diff) => { use crate::session::execute::fingerprint::PostRunFingerprintMismatch; match diff { diff --git a/crates/vite_task/src/session/cache/mod.rs b/crates/vite_task/src/session/cache/mod.rs index 1b686bb5..8f0c612d 100644 --- a/crates/vite_task/src/session/cache/mod.rs +++ b/crates/vite_task/src/session/cache/mod.rs @@ -2,7 +2,7 @@ pub mod display; -use std::{fmt::Display, fs::File, io::Write, sync::Arc, time::Duration}; +use std::{collections::BTreeMap, fmt::Display, fs::File, io::Write, sync::Arc, time::Duration}; use bincode::{Decode, Encode, decode_from_slice, encode_to_vec}; // Re-export display functions for convenience @@ -11,7 +11,8 @@ pub use display::{SpawnFingerprintChange, detect_spawn_fingerprint_changes, form use rusqlite::{Connection, OptionalExtension as _, config::DbConfig}; use serde::{Deserialize, Serialize}; use tokio::sync::Mutex; -use vite_path::AbsolutePath; +use vite_path::{AbsolutePath, RelativePathBuf}; +use vite_task_graph::config::ResolvedInputConfig; use vite_task_plan::cache_metadata::{CacheMetadata, ExecutionCacheKey, SpawnFingerprint}; use super::execute::{ @@ -19,13 +20,64 @@ use super::execute::{ spawn::StdOutput, }; +/// Cache lookup key identifying a task's execution configuration. +/// +/// Contains the spawn fingerprint (command, env, cwd), input configuration, +/// and glob base directory. Explicit input file hashes are stored in +/// [`CacheEntryValue`] so that changes can be detected and reported. +#[derive(Debug, Encode, Decode, Serialize, PartialEq, Eq, Clone)] +pub struct CacheEntryKey { + /// The spawn fingerprint (command, args, cwd, envs) + pub spawn_fingerprint: SpawnFingerprint, + /// Resolved input configuration that affects cache behavior. + pub input_config: ResolvedInputConfig, + /// Base directory for glob patterns, relative to workspace root. + /// This is where the task is defined (package path). + pub glob_base: RelativePathBuf, +} + +impl CacheEntryKey { + #[expect( + clippy::disallowed_macros, + reason = "anyhow::anyhow! internally uses std::format! for error messages" + )] + fn from_metadata( + cache_metadata: &CacheMetadata, + workspace_root: &AbsolutePath, + ) -> anyhow::Result { + // Convert absolute glob_base to relative for cache key + let glob_base = cache_metadata + .glob_base + .strip_prefix(workspace_root) + .map_err(|e| anyhow::anyhow!("failed to strip prefix from glob_base: {e}"))? + .ok_or_else(|| { + anyhow::anyhow!( + "glob_base {:?} is not inside workspace {:?}", + cache_metadata.glob_base, + workspace_root + ) + })?; + + Ok(Self { + spawn_fingerprint: cache_metadata.spawn_fingerprint.clone(), + input_config: cache_metadata.input_config.clone(), + glob_base, + }) + } +} + /// Command cache value, for validating post-run fingerprint after the spawn fingerprint is matched, /// and replaying the std outputs if validated. #[derive(Debug, Encode, Decode, Serialize)] -pub struct CommandCacheValue { +pub struct CacheEntryValue { pub post_run_fingerprint: PostRunFingerprint, pub std_outputs: Arc<[StdOutput]>, pub duration: Duration, + /// Hashes of explicit input files computed from positive globs. + /// Files matching negative globs are already filtered out. + /// Path is relative to workspace root, value is `xxHash3_64` of file content. + /// Stored in the value (not the key) so changes can be detected and reported. + pub globbed_inputs: BTreeMap, } #[derive(Debug)] @@ -46,19 +98,19 @@ pub enum CacheMiss { } #[derive(Debug, Serialize, Deserialize)] -#[expect( - clippy::large_enum_variant, - reason = "SpawnFingerprintMismatch holds two SpawnFingerprints for comparison; boxing would add unnecessary indirection for a short-lived enum" -)] pub enum FingerprintMismatch { - /// Found the cache entry of the same task run, but the spawn fingerprint mismatches - /// this happens when the command itself or an env changes. + /// Found a previous cache entry key for the same task, but the spawn fingerprint differs. + /// This happens when the command itself or an env changes. SpawnFingerprintMismatch { /// The fingerprint from the cached entry old: SpawnFingerprint, /// The fingerprint of the current execution new: SpawnFingerprint, }, + /// Found a previous cache entry key for the same task, but `input_config` or `glob_base` differs. + ConfigChanged, + /// Found the cache entry with the same spawn fingerprint, but an explicit globbed input changed + GlobbedInputChanged { path: RelativePathBuf }, /// Found the cache entry with the same spawn fingerprint, but the post-run fingerprint mismatches PostRunFingerprintMismatch(PostRunFingerprintMismatch), } @@ -69,6 +121,12 @@ impl Display for FingerprintMismatch { Self::SpawnFingerprintMismatch { old, new } => { write!(f, "Spawn fingerprint changed: old={old:?}, new={new:?}") } + Self::ConfigChanged => { + write!(f, "configuration changed") + } + Self::GlobbedInputChanged { path } => { + write!(f, "content of input '{path}' changed") + } Self::PostRunFingerprintMismatch(diff) => Display::fmt(diff, f), } } @@ -98,23 +156,23 @@ impl ExecutionCache { 0 => { // fresh new db conn.execute( - "CREATE TABLE spawn_fingerprint_cache (key BLOB PRIMARY KEY, value BLOB);", + "CREATE TABLE cache_entries (key BLOB PRIMARY KEY, value BLOB);", (), )?; conn.execute( - "CREATE TABLE execution_key_to_fingerprint (key BLOB PRIMARY KEY, value BLOB);", + "CREATE TABLE task_fingerprints (key BLOB PRIMARY KEY, value BLOB);", (), )?; - conn.execute("PRAGMA user_version = 6", ())?; + conn.execute("PRAGMA user_version = 9", ())?; } - 1..=5 => { + 1..=8 => { // old internal db version. reset conn.set_db_config(DbConfig::SQLITE_DBCONFIG_RESET_DATABASE, true)?; conn.execute("VACUUM", ())?; conn.set_db_config(DbConfig::SQLITE_DBCONFIG_RESET_DATABASE, false)?; } - 6 => break, // current version - 7.. => { + 9 => break, // current version + 10.. => { return Err(anyhow::anyhow!("Unrecognized database version: {user_version}")); } } @@ -129,69 +187,131 @@ impl ExecutionCache { Ok(()) } - /// Try to hit cache with spawn fingerprint. + /// Try to hit cache with pre-run fingerprint (spawn + globbed inputs). /// Returns `Ok(Ok(cache_value))` on cache hit, `Ok(Err(cache_miss))` on miss. + /// + /// # Arguments + /// * `cache_metadata` - Cache metadata from plan stage + /// * `globbed_inputs` - Hashes of explicit input files computed from positive globs + /// * `workspace_root` - Workspace root for converting paths and validating fingerprints #[tracing::instrument(level = "debug", skip_all)] pub async fn try_hit( &self, cache_metadata: &CacheMetadata, - base_dir: &AbsolutePath, - ) -> anyhow::Result> { + globbed_inputs: &BTreeMap, + workspace_root: &AbsolutePath, + ) -> anyhow::Result> { let spawn_fingerprint = &cache_metadata.spawn_fingerprint; let execution_cache_key = &cache_metadata.execution_cache_key; + let input_config = &cache_metadata.input_config; + + let cache_key = CacheEntryKey::from_metadata(cache_metadata, workspace_root)?; - // Try to directly find the cache by spawn fingerprint first - if let Some(cache_value) = self.get_by_spawn_fingerprint(spawn_fingerprint).await? { - // Validate post-run fingerprint - if let Some(post_run_fingerprint_mismatch) = - cache_value.post_run_fingerprint.validate(base_dir)? + // Try to directly find the cache by pre-run fingerprint first + if let Some(cache_value) = self.get_by_cache_key(&cache_key).await? { + // Validate explicit globbed inputs against the stored values + if let Some(mismatch) = + detect_globbed_input_change(&cache_value.globbed_inputs, globbed_inputs) + { + return Ok(Err(CacheMiss::FingerprintMismatch(mismatch))); + } + + // Validate post-run fingerprint (inferred inputs) only if auto inference is enabled + if input_config.includes_auto + && let Some(post_run_fingerprint_mismatch) = + cache_value.post_run_fingerprint.validate(workspace_root)? { - // Found the cache with the same spawn fingerprint, but the post-run fingerprint mismatches return Ok(Err(CacheMiss::FingerprintMismatch( FingerprintMismatch::PostRunFingerprintMismatch(post_run_fingerprint_mismatch), ))); } - // Associate the execution key to the spawn fingerprint if not already, - // so that next time we can find it and report spawn fingerprint mismatch - self.upsert_execution_key_to_fingerprint(execution_cache_key, spawn_fingerprint) - .await?; + // Associate the execution key to the cache entry key if not already, + // so that next time we can find it and report what changed + self.upsert_task_fingerprint(execution_cache_key, &cache_key).await?; return Ok(Ok(cache_value)); } - // No cache found with the current spawn fingerprint, - // check if execution key maps to different fingerprint - if let Some(old_spawn_fingerprint) = - self.get_fingerprint_by_execution_key(execution_cache_key).await? + // No cache found with the current cache entry key, + // check if execution key maps to a different cache entry key + if let Some(old_cache_key) = + self.get_cache_key_by_execution_key(execution_cache_key).await? { - // Found a spawn fingerprint associated with the same execution key, - // meaning the command or env has changed since last run - return Ok(Err(CacheMiss::FingerprintMismatch( + // Determine what changed: spawn fingerprint or config (input_config / glob_base) + let mismatch = if old_cache_key.spawn_fingerprint != *spawn_fingerprint { FingerprintMismatch::SpawnFingerprintMismatch { - old: old_spawn_fingerprint, + old: old_cache_key.spawn_fingerprint, new: spawn_fingerprint.clone(), - }, - ))); + } + } else { + // spawn fingerprint is the same but input_config or glob_base changed + FingerprintMismatch::ConfigChanged + }; + return Ok(Err(CacheMiss::FingerprintMismatch(mismatch))); } Ok(Err(CacheMiss::NotFound)) } /// Update cache after successful execution. + /// + /// # Arguments + /// * `cache_metadata` - Cache metadata from plan stage + /// * `globbed_inputs` - Hashes of explicit input files computed from positive globs + /// * `workspace_root` - Workspace root for converting absolute paths to relative + /// * `cache_value` - The cache value to store (outputs and post-run fingerprint) #[tracing::instrument(level = "debug", skip_all)] pub async fn update( &self, cache_metadata: &CacheMetadata, - cache_value: CommandCacheValue, + workspace_root: &AbsolutePath, + cache_value: CacheEntryValue, ) -> anyhow::Result<()> { - let spawn_fingerprint = &cache_metadata.spawn_fingerprint; let execution_cache_key = &cache_metadata.execution_cache_key; - self.upsert_spawn_fingerprint_cache(spawn_fingerprint, &cache_value).await?; - self.upsert_execution_key_to_fingerprint(execution_cache_key, spawn_fingerprint).await?; + let cache_key = CacheEntryKey::from_metadata(cache_metadata, workspace_root)?; + + self.upsert_cache_entry(&cache_key, &cache_value).await?; + self.upsert_task_fingerprint(execution_cache_key, &cache_key).await?; Ok(()) } } +/// Compare stored and current globbed inputs, returning the first changed path. +/// Both maps are `BTreeMap` so we iterate them in sorted lockstep. +fn detect_globbed_input_change( + 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((path, _)), None) | (None, Some((path, _))) => { + return Some(FingerprintMismatch::GlobbedInputChanged { path: path.clone() }); + } + (Some((sp, sh)), Some((cp, ch))) => match sp.cmp(cp) { + std::cmp::Ordering::Equal => { + if sh != ch { + return Some(FingerprintMismatch::GlobbedInputChanged { path: sp.clone() }); + } + s = stored_iter.next(); + c = current_iter.next(); + } + std::cmp::Ordering::Less => { + return Some(FingerprintMismatch::GlobbedInputChanged { path: sp.clone() }); + } + std::cmp::Ordering::Greater => { + return Some(FingerprintMismatch::GlobbedInputChanged { path: cp.clone() }); + } + }, + } + } +} + // Basic database operations impl ExecutionCache { #[expect( @@ -227,18 +347,18 @@ impl ExecutionCache { Ok(Some(value)) } - async fn get_by_spawn_fingerprint( + async fn get_by_cache_key( &self, - spawn_fingerprint: &SpawnFingerprint, - ) -> anyhow::Result> { - self.get_key_by_value("spawn_fingerprint_cache", spawn_fingerprint).await + cache_key: &CacheEntryKey, + ) -> anyhow::Result> { + self.get_key_by_value("cache_entries", cache_key).await } - async fn get_fingerprint_by_execution_key( + async fn get_cache_key_by_execution_key( &self, execution_cache_key: &ExecutionCacheKey, - ) -> anyhow::Result> { - self.get_key_by_value("execution_key_to_fingerprint", execution_cache_key).await + ) -> anyhow::Result> { + self.get_key_by_value("task_fingerprints", execution_cache_key).await } #[expect( @@ -266,20 +386,20 @@ impl ExecutionCache { Ok(()) } - async fn upsert_spawn_fingerprint_cache( + async fn upsert_cache_entry( &self, - spawn_fingerprint: &SpawnFingerprint, - cache_value: &CommandCacheValue, + cache_key: &CacheEntryKey, + cache_value: &CacheEntryValue, ) -> anyhow::Result<()> { - self.upsert("spawn_fingerprint_cache", spawn_fingerprint, cache_value).await + self.upsert("cache_entries", cache_key, cache_value).await } - async fn upsert_execution_key_to_fingerprint( + async fn upsert_task_fingerprint( &self, execution_cache_key: &ExecutionCacheKey, - spawn_fingerprint: &SpawnFingerprint, + cache_entry_key: &CacheEntryKey, ) -> anyhow::Result<()> { - self.upsert("execution_key_to_fingerprint", execution_cache_key, spawn_fingerprint).await + self.upsert("task_fingerprints", execution_cache_key, cache_entry_key).await } #[expect( @@ -314,15 +434,10 @@ impl ExecutionCache { } pub async fn list(&self, mut out: impl Write) -> anyhow::Result<()> { - out.write_all(b"------- execution_key_to_fingerprint -------\n")?; - self.list_table::( - "execution_key_to_fingerprint", - &mut out, - ) - .await?; - out.write_all(b"------- spawn_fingerprint_cache -------\n")?; - self.list_table::("spawn_fingerprint_cache", &mut out) - .await?; + out.write_all(b"------- task_fingerprints -------\n")?; + self.list_table::("task_fingerprints", &mut out).await?; + out.write_all(b"------- cache_entries -------\n")?; + self.list_table::("cache_entries", &mut out).await?; Ok(()) } } diff --git a/crates/vite_task/src/session/execute/fingerprint.rs b/crates/vite_task/src/session/execute/fingerprint.rs index ccf81801..ca1d663d 100644 --- a/crates/vite_task/src/session/execute/fingerprint.rs +++ b/crates/vite_task/src/session/execute/fingerprint.rs @@ -13,19 +13,20 @@ use std::{ use bincode::{Decode, Encode}; use rayon::iter::{IntoParallelRefIterator, ParallelIterator}; use serde::{Deserialize, Serialize}; -use vite_glob::GlobPatternSet; use vite_path::{AbsolutePath, RelativePathBuf}; use vite_str::Str; +use vite_task_graph::config::ResolvedInputConfig; -use super::spawn::PathRead; +use super::{glob_inputs::ResolvedGlob, spawn::PathRead}; use crate::collections::HashMap; /// Post-run fingerprint capturing file state after execution. /// Used to validate whether cached outputs are still valid. #[derive(Encode, Decode, Debug, Serialize)] pub struct PostRunFingerprint { - /// Paths accessed during execution with their content fingerprints - pub inputs: HashMap, + /// Paths inferred from fspy during execution with their content fingerprints. + /// Only populated when `input_config.includes_auto` is true. + pub inferred_inputs: HashMap, } /// Fingerprint for a single path (file or directory) @@ -70,35 +71,58 @@ impl PostRunFingerprint { /// Creates a new fingerprint from path accesses after task execution. /// /// # Arguments - /// * `path_reads` - Map of paths that were read during execution + /// * `path_reads` - Map of paths that were read during execution (from fspy) /// * `base_dir` - Workspace root for resolving relative paths - /// * `fingerprint_ignores` - Optional glob patterns to exclude from fingerprinting + /// * `glob_base` - Package directory where the task is defined (negative globs are relative to this) + /// * `input_config` - Resolved input configuration controlling what to fingerprint #[tracing::instrument(level = "debug", skip_all, name = "create_post_run_fingerprint")] pub fn create( path_reads: &HashMap, base_dir: &AbsolutePath, - fingerprint_ignores: Option<&[Str]>, + glob_base: &AbsolutePath, + input_config: &ResolvedInputConfig, ) -> anyhow::Result { - // Build ignore matcher from patterns if provided - let ignore_matcher = fingerprint_ignores - .filter(|patterns| !patterns.is_empty()) - .map(GlobPatternSet::new) - .transpose()?; + // If inference is disabled, return empty inferred_inputs + if input_config.inference_disabled() { + return Ok(Self { inferred_inputs: HashMap::default() }); + } + + let negatives: Vec = input_config + .negative_globs + .iter() + .map(|p| ResolvedGlob::new(p.as_str(), glob_base)) + .collect::>()?; - let inputs = path_reads + let inferred_inputs = path_reads .par_iter() - .filter(|(path, _)| { - // Apply ignore patterns if present - ignore_matcher.as_ref().is_none_or(|matcher| !matcher.is_match(path.as_str())) - }) - .map(|(relative_path, path_read)| { - let full_path = Arc::::from(base_dir.join(relative_path)); - let fingerprint = fingerprint_path(&full_path, *path_read)?; - Ok((relative_path.clone(), fingerprint)) + .filter_map(|(relative_path, path_read)| { + // Clean the absolute path to normalize `..` from fspy-tracked paths + // (e.g., `packages/sub-pkg/../shared/dist/output.js`). + let cleaned_abs = + path_clean::PathClean::clean(base_dir.join(relative_path).as_path()); + + // Apply negative globs against the cleaned path + if negatives.iter().any(|neg| neg.matches(&cleaned_abs)) { + return None; + } + + // Derive a cleaned workspace-relative key so stored paths are normalized + let clean_key = cleaned_abs + .strip_prefix(base_dir.as_path()) + .ok() + .and_then(|p| RelativePathBuf::new(p).ok()) + .unwrap_or_else(|| relative_path.clone()); + + let full_path = Arc::::from(base_dir.join(&clean_key)); + let fingerprint = match fingerprint_path(&full_path, *path_read) { + Ok(f) => f, + Err(e) => return Some(Err(e)), + }; + Some(Ok((clean_key, fingerprint))) }) .collect::>>()?; - Ok(Self { inputs }) + Ok(Self { inferred_inputs }) } /// Validates the fingerprint against current filesystem state. @@ -108,8 +132,8 @@ impl PostRunFingerprint { &self, base_dir: &AbsolutePath, ) -> anyhow::Result> { - let input_mismatch = - self.inputs.par_iter().find_map_any(|(input_relative_path, path_fingerprint)| { + 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)); let path_read = PathRead { read_dir_entries: matches!(path_fingerprint, PathFingerprint::Folder(Some(_))), @@ -125,7 +149,8 @@ impl PostRunFingerprint { path: input_relative_path.clone(), })) } - }); + }, + ); input_mismatch.transpose() } } diff --git a/crates/vite_task/src/session/execute/glob_inputs.rs b/crates/vite_task/src/session/execute/glob_inputs.rs new file mode 100644 index 00000000..b961072a --- /dev/null +++ b/crates/vite_task/src/session/execute/glob_inputs.rs @@ -0,0 +1,481 @@ +//! Glob-based input file discovery and fingerprinting. +//! +//! This module provides functions to walk glob patterns and compute file hashes +//! for cache invalidation based on explicit input patterns. + +use std::{ + collections::BTreeMap, + fs::File, + hash::Hasher as _, + io::{self, Read}, +}; + +use path_clean::PathClean; +#[cfg(test)] +use vite_path::AbsolutePathBuf; +use vite_path::{AbsolutePath, RelativePathBuf}; +use vite_str::Str; +use wax::{Glob, Program as _}; + +/// A glob pattern resolved to an absolute base directory. +/// +/// Uses [`wax::Glob::partition`] to separate the invariant prefix from the +/// wildcard suffix, then resolves the prefix to an absolute path via +/// [`path_clean`] (normalizing components like `..`). +/// +/// For example, `../shared/src/**` relative to `/ws/packages/app` resolves to: +/// - `resolved_base`: `/ws/packages/shared/src` +/// - `variant`: `Some(Glob("**"))` +#[expect(clippy::disallowed_types, reason = "path_clean returns std::path::PathBuf")] +pub struct ResolvedGlob { + resolved_base: std::path::PathBuf, + variant: Option>, +} + +impl ResolvedGlob { + /// Resolve a glob pattern relative to `base_dir`. + pub fn new(pattern: &str, base_dir: &AbsolutePath) -> anyhow::Result { + let glob = Glob::new(pattern)?.into_owned(); + let (base_pathbuf, variant) = glob.partition(); + let base_str = base_pathbuf.to_str().unwrap_or("."); + let resolved_base = if base_str.is_empty() { + base_dir.as_path().to_path_buf() + } else { + base_dir.join(base_str).as_path().clean() + }; + Ok(Self { resolved_base, variant: variant.map(Glob::into_owned) }) + } + + /// Walk the filesystem and yield matching file paths. + #[expect(clippy::disallowed_types, reason = "yields std::path::PathBuf from wax walker")] + pub fn walk(&self) -> Box + '_> { + match &self.variant { + Some(variant_glob) => Box::new( + variant_glob + .walk(&self.resolved_base) + .filter_map(Result::ok) + .map(wax::walk::Entry::into_path), + ), + None => Box::new(std::iter::once(self.resolved_base.clone())), + } + } + + /// Check if an absolute path matches this resolved glob. + #[expect(clippy::disallowed_types, reason = "matching against std::path::Path")] + pub fn matches(&self, path: &std::path::Path) -> bool { + path.strip_prefix(&self.resolved_base).ok().is_some_and(|remainder| { + self.variant + .as_ref() + .map_or(remainder.as_os_str().is_empty(), |v| v.is_match(remainder)) + }) + } +} + +/// Compute globbed inputs by walking positive glob patterns and filtering with negative patterns. +/// +/// Glob patterns may contain `..` to reference files outside the package directory +/// (e.g., `../shared/src/**` to include a sibling package's source files). +/// +/// # Arguments +/// * `base_dir` - The package directory where the task is defined (globs are relative to this) +/// * `workspace_root` - The workspace root for computing relative paths in the result +/// * `positive_globs` - Glob patterns that should match input files +/// * `negative_globs` - Glob patterns that should exclude files from the result +/// +/// # Returns +/// A sorted map of relative paths (from `workspace_root`) to their content hashes. +/// Only files are included (directories are skipped). +/// +/// # Example +/// ```ignore +/// // For a task defined in `packages/foo/` with inputs: ["src/**/*.ts", "!**/*.test.ts"] +/// let inputs = compute_globbed_inputs( +/// &packages_foo_path, +/// &workspace_root, +/// &["src/**/*.ts".into()].into_iter().collect(), +/// &["**/*.test.ts".into()].into_iter().collect(), +/// )?; +/// // Returns: { "packages/foo/src/index.ts" => 0x1234..., ... } +/// ``` +pub fn compute_globbed_inputs( + base_dir: &AbsolutePath, + workspace_root: &AbsolutePath, + positive_globs: &std::collections::BTreeSet, + negative_globs: &std::collections::BTreeSet, +) -> anyhow::Result> { + // If no positive globs, return empty result + if positive_globs.is_empty() { + return Ok(BTreeMap::new()); + } + + let negatives: Vec = negative_globs + .iter() + .map(|p| ResolvedGlob::new(p.as_str(), base_dir)) + .collect::>()?; + + let mut result = BTreeMap::new(); + + for pattern in positive_globs { + let resolved = ResolvedGlob::new(pattern.as_str(), base_dir)?; + + for absolute_path in resolved.walk() { + // Skip non-files + if !absolute_path.is_file() { + continue; + } + + // Apply negative patterns + if negatives.iter().any(|neg| neg.matches(&absolute_path)) { + continue; + } + + // Compute path relative to workspace_root for the result + let Some(relative_to_workspace) = absolute_path + .strip_prefix(workspace_root.as_path()) + .ok() + .and_then(|p| RelativePathBuf::new(p).ok()) + else { + continue; // Skip if path is outside workspace_root + }; + + // Hash file content + match hash_file_content(&absolute_path) { + Ok(hash) => { + result.insert(relative_to_workspace, hash); + } + Err(err) if err.kind() == io::ErrorKind::NotFound => { + // File was deleted between walk and hash, skip it + } + Err(err) => { + return Err(err.into()); + } + } + } + } + + Ok(result) +} + +/// Hash file content using `xxHash3_64`. +#[expect(clippy::disallowed_types, reason = "receives std::path::Path from wax glob walker")] +fn hash_file_content(path: &std::path::Path) -> io::Result { + let file = File::open(path)?; + let mut reader = io::BufReader::new(file); + let mut hasher = twox_hash::XxHash3_64::default(); + let mut buf = [0u8; 8192]; + loop { + let n = reader.read(&mut buf)?; + if n == 0 { + break; + } + hasher.write(&buf[..n]); + } + Ok(hasher.finish()) +} + +#[cfg(test)] +mod tests { + use std::fs; + + use tempfile::TempDir; + + use super::*; + + fn create_test_workspace() -> (TempDir, AbsolutePathBuf, AbsolutePathBuf) { + let temp_dir = TempDir::new().unwrap(); + let workspace_root = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + + // Create package directory structure + let package_dir = workspace_root.join("packages/my-pkg"); + fs::create_dir_all(&package_dir).unwrap(); + + // Create source files + fs::create_dir_all(package_dir.join("src")).unwrap(); + fs::write(package_dir.join("src/index.ts"), "export const a = 1;").unwrap(); + fs::write(package_dir.join("src/utils.ts"), "export const b = 2;").unwrap(); + fs::write(package_dir.join("src/utils.test.ts"), "test('a', () => {});").unwrap(); + + // Create nested directory + fs::create_dir_all(package_dir.join("src/lib")).unwrap(); + fs::write(package_dir.join("src/lib/helper.ts"), "export const c = 3;").unwrap(); + fs::write(package_dir.join("src/lib/helper.test.ts"), "test('c', () => {});").unwrap(); + + // Create other files + fs::write(package_dir.join("package.json"), "{}").unwrap(); + fs::write(package_dir.join("README.md"), "# Readme").unwrap(); + + let package_abs = AbsolutePathBuf::new(package_dir.into_path_buf()).unwrap(); + (temp_dir, workspace_root, package_abs) + } + + #[test] + fn test_empty_positive_globs_returns_empty() { + let (_temp, workspace, package) = create_test_workspace(); + let positive = std::collections::BTreeSet::new(); + let negative = std::collections::BTreeSet::new(); + + let result = compute_globbed_inputs(&package, &workspace, &positive, &negative).unwrap(); + assert!(result.is_empty()); + } + + #[test] + fn test_single_positive_glob() { + let (_temp, workspace, package) = create_test_workspace(); + let positive: std::collections::BTreeSet = + std::iter::once("src/**/*.ts".into()).collect(); + let negative = std::collections::BTreeSet::new(); + + let result = compute_globbed_inputs(&package, &workspace, &positive, &negative).unwrap(); + + // Should match all .ts files in src/ + assert_eq!(result.len(), 5); + assert!( + result.contains_key(&RelativePathBuf::new("packages/my-pkg/src/index.ts").unwrap()) + ); + assert!( + result.contains_key(&RelativePathBuf::new("packages/my-pkg/src/utils.ts").unwrap()) + ); + assert!( + result + .contains_key(&RelativePathBuf::new("packages/my-pkg/src/utils.test.ts").unwrap()) + ); + assert!( + result + .contains_key(&RelativePathBuf::new("packages/my-pkg/src/lib/helper.ts").unwrap()) + ); + assert!(result.contains_key( + &RelativePathBuf::new("packages/my-pkg/src/lib/helper.test.ts").unwrap() + )); + } + + #[test] + fn test_positive_with_negative_exclusion() { + let (_temp, workspace, package) = create_test_workspace(); + let positive: std::collections::BTreeSet = + std::iter::once("src/**/*.ts".into()).collect(); + let negative: std::collections::BTreeSet = + std::iter::once("**/*.test.ts".into()).collect(); + + let result = compute_globbed_inputs(&package, &workspace, &positive, &negative).unwrap(); + + // Should match only non-test .ts files + assert_eq!(result.len(), 3); + assert!( + result.contains_key(&RelativePathBuf::new("packages/my-pkg/src/index.ts").unwrap()) + ); + assert!( + result.contains_key(&RelativePathBuf::new("packages/my-pkg/src/utils.ts").unwrap()) + ); + assert!( + result + .contains_key(&RelativePathBuf::new("packages/my-pkg/src/lib/helper.ts").unwrap()) + ); + // Test files should be excluded + assert!( + !result + .contains_key(&RelativePathBuf::new("packages/my-pkg/src/utils.test.ts").unwrap()) + ); + assert!(!result.contains_key( + &RelativePathBuf::new("packages/my-pkg/src/lib/helper.test.ts").unwrap() + )); + } + + #[test] + fn test_multiple_positive_globs() { + let (_temp, workspace, package) = create_test_workspace(); + let positive: std::collections::BTreeSet = + ["src/**/*.ts".into(), "package.json".into()].into_iter().collect(); + let negative: std::collections::BTreeSet = + std::iter::once("**/*.test.ts".into()).collect(); + + let result = compute_globbed_inputs(&package, &workspace, &positive, &negative).unwrap(); + + // Should include .ts files (excluding tests) plus package.json + assert_eq!(result.len(), 4); + assert!( + result.contains_key(&RelativePathBuf::new("packages/my-pkg/src/index.ts").unwrap()) + ); + assert!( + result.contains_key(&RelativePathBuf::new("packages/my-pkg/src/utils.ts").unwrap()) + ); + assert!( + result + .contains_key(&RelativePathBuf::new("packages/my-pkg/src/lib/helper.ts").unwrap()) + ); + assert!( + result.contains_key(&RelativePathBuf::new("packages/my-pkg/package.json").unwrap()) + ); + } + + #[test] + fn test_multiple_negative_globs() { + let (_temp, workspace, package) = create_test_workspace(); + let positive: std::collections::BTreeSet = + ["src/**/*.ts".into(), "*.md".into()].into_iter().collect(); + let negative: std::collections::BTreeSet = + ["**/*.test.ts".into(), "**/*.md".into()].into_iter().collect(); + + let result = compute_globbed_inputs(&package, &workspace, &positive, &negative).unwrap(); + + // Should exclude both test files and markdown files + assert_eq!(result.len(), 3); + assert!( + result.contains_key(&RelativePathBuf::new("packages/my-pkg/src/index.ts").unwrap()) + ); + assert!( + result.contains_key(&RelativePathBuf::new("packages/my-pkg/src/utils.ts").unwrap()) + ); + assert!( + result + .contains_key(&RelativePathBuf::new("packages/my-pkg/src/lib/helper.ts").unwrap()) + ); + assert!(!result.contains_key(&RelativePathBuf::new("packages/my-pkg/README.md").unwrap())); + } + + #[test] + fn test_negative_only_returns_empty() { + let (_temp, workspace, package) = create_test_workspace(); + let positive: std::collections::BTreeSet = std::collections::BTreeSet::new(); + let negative: std::collections::BTreeSet = + std::iter::once("**/*.test.ts".into()).collect(); + + let result = compute_globbed_inputs(&package, &workspace, &positive, &negative).unwrap(); + + // No positive globs means empty result (negative globs alone don't select anything) + assert!(result.is_empty()); + } + + #[test] + fn test_file_hashes_are_consistent() { + let (_temp, workspace, package) = create_test_workspace(); + let positive: std::collections::BTreeSet = + std::iter::once("src/index.ts".into()).collect(); + let negative = std::collections::BTreeSet::new(); + + // Run twice and compare hashes + let result1 = compute_globbed_inputs(&package, &workspace, &positive, &negative).unwrap(); + let result2 = compute_globbed_inputs(&package, &workspace, &positive, &negative).unwrap(); + + assert_eq!(result1, result2); + } + + #[test] + fn test_file_hashes_change_with_content() { + let (temp, workspace, package) = create_test_workspace(); + let positive: std::collections::BTreeSet = + std::iter::once("src/index.ts".into()).collect(); + let negative = std::collections::BTreeSet::new(); + + // Get initial hash + let result1 = compute_globbed_inputs(&package, &workspace, &positive, &negative).unwrap(); + let hash1 = + result1.get(&RelativePathBuf::new("packages/my-pkg/src/index.ts").unwrap()).unwrap(); + + // Modify file content + let file_path = temp.path().join("packages/my-pkg/src/index.ts"); + fs::write(&file_path, "export const a = 999;").unwrap(); + + // Get new hash + let result2 = compute_globbed_inputs(&package, &workspace, &positive, &negative).unwrap(); + let hash2 = + result2.get(&RelativePathBuf::new("packages/my-pkg/src/index.ts").unwrap()).unwrap(); + + assert_ne!(hash1, hash2); + } + + #[test] + fn test_skips_directories() { + let (_temp, workspace, package) = create_test_workspace(); + // This glob could match the `src/lib` directory if not filtered + let positive: std::collections::BTreeSet = std::iter::once("src/*".into()).collect(); + let negative = std::collections::BTreeSet::new(); + + let result = compute_globbed_inputs(&package, &workspace, &positive, &negative).unwrap(); + + // Should only have files, not directories + for path in result.keys() { + assert!(!path.as_str().ends_with("/lib")); + assert!(!path.as_str().ends_with("\\lib")); + } + } + + #[test] + fn test_no_matching_files_returns_empty() { + let (_temp, workspace, package) = create_test_workspace(); + let positive: std::collections::BTreeSet = + std::iter::once("nonexistent/**/*.xyz".into()).collect(); + let negative = std::collections::BTreeSet::new(); + + let result = compute_globbed_inputs(&package, &workspace, &positive, &negative).unwrap(); + assert!(result.is_empty()); + } + + /// Creates a workspace with a sibling package for testing `..` globs + fn create_workspace_with_sibling() -> (TempDir, AbsolutePathBuf, AbsolutePathBuf) { + let temp_dir = TempDir::new().unwrap(); + let workspace_root = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + + // Create sub-pkg + let sub_pkg = workspace_root.join("packages/sub-pkg"); + fs::create_dir_all(sub_pkg.join("src")).unwrap(); + fs::write(sub_pkg.join("src/main.ts"), "export const sub = 1;").unwrap(); + + // Create sibling shared package + let shared = workspace_root.join("packages/shared"); + fs::create_dir_all(shared.join("src")).unwrap(); + fs::create_dir_all(shared.join("dist")).unwrap(); + fs::write(shared.join("src/utils.ts"), "export const shared = 1;").unwrap(); + fs::write(shared.join("dist/output.js"), "// output").unwrap(); + + let sub_pkg_abs = AbsolutePathBuf::new(sub_pkg.into_path_buf()).unwrap(); + (temp_dir, workspace_root, sub_pkg_abs) + } + + #[test] + fn test_dotdot_positive_glob_matches_sibling_package() { + let (_temp, workspace, sub_pkg) = create_workspace_with_sibling(); + let positive: std::collections::BTreeSet = + std::iter::once("../shared/src/**".into()).collect(); + let negative = std::collections::BTreeSet::new(); + + let result = compute_globbed_inputs(&sub_pkg, &workspace, &positive, &negative).unwrap(); + assert!( + result.contains_key(&RelativePathBuf::new("packages/shared/src/utils.ts").unwrap()), + "should find sibling package file via ../shared/src/**" + ); + } + + #[test] + fn test_dotdot_negative_glob_excludes_from_sibling() { + let (_temp, workspace, sub_pkg) = create_workspace_with_sibling(); + let positive: std::collections::BTreeSet = + std::iter::once("../shared/**".into()).collect(); + let negative: std::collections::BTreeSet = + std::iter::once("../shared/dist/**".into()).collect(); + + let result = compute_globbed_inputs(&sub_pkg, &workspace, &positive, &negative).unwrap(); + assert!( + result.contains_key(&RelativePathBuf::new("packages/shared/src/utils.ts").unwrap()), + "should include non-excluded sibling file" + ); + assert!( + !result.contains_key(&RelativePathBuf::new("packages/shared/dist/output.js").unwrap()), + "should exclude dist via ../shared/dist/**" + ); + } + + #[test] + fn test_overlapping_positive_globs_deduplicates() { + let (_temp, workspace, package) = create_test_workspace(); + // Both patterns match src/index.ts + let positive: std::collections::BTreeSet = + ["src/**/*.ts".into(), "src/index.ts".into()].into_iter().collect(); + let negative: std::collections::BTreeSet = + std::iter::once("**/*.test.ts".into()).collect(); + + let result = compute_globbed_inputs(&package, &workspace, &positive, &negative).unwrap(); + + // BTreeMap naturally deduplicates by key + assert_eq!(result.len(), 3); + } +} diff --git a/crates/vite_task/src/session/execute/mod.rs b/crates/vite_task/src/session/execute/mod.rs index 10467e22..9b177747 100644 --- a/crates/vite_task/src/session/execute/mod.rs +++ b/crates/vite_task/src/session/execute/mod.rs @@ -1,7 +1,8 @@ pub mod fingerprint; +pub mod glob_inputs; pub mod spawn; -use std::{process::Stdio, sync::Arc}; +use std::{collections::BTreeMap, process::Stdio, sync::Arc}; use futures_util::FutureExt; use tokio::io::AsyncWriteExt as _; @@ -13,10 +14,11 @@ use vite_task_plan::{ use self::{ fingerprint::PostRunFingerprint, - spawn::{SpawnResult, spawn_with_tracking}, + glob_inputs::compute_globbed_inputs, + spawn::{SpawnResult, TrackedPathAccesses, spawn_with_tracking}, }; use super::{ - cache::{CommandCacheValue, ExecutionCache}, + cache::{CacheEntryValue, ExecutionCache}, event::{ CacheDisabledReason, CacheErrorKind, CacheNotUpdatedReason, CacheStatus, CacheUpdateStatus, ExecutionError, @@ -26,7 +28,7 @@ use super::{ StdioSuggestion, }, }; -use crate::{Session, session::execute::spawn::SpawnTrackResult}; +use crate::{Session, collections::HashMap}; /// Outcome of a spawned execution. /// @@ -193,17 +195,40 @@ pub async fn execute_spawn( // 1. Determine cache status FIRST by trying cache hit. // We need to know the status before calling start() so the reporter // can display cache status immediately when execution begins. - let (cache_status, cached_value) = if let Some(cache_metadata) = cache_metadata { - match cache.try_hit(cache_metadata, cache_base_path).await { + let (cache_status, cached_value, globbed_inputs) = if let Some(cache_metadata) = cache_metadata + { + // Compute globbed inputs from positive globs at execution time + let globbed_inputs = match compute_globbed_inputs( + &cache_metadata.glob_base, + cache_base_path, + &cache_metadata.input_config.positive_globs, + &cache_metadata.input_config.negative_globs, + ) { + Ok(inputs) => inputs, + Err(err) => { + leaf_reporter + .finish( + None, + CacheUpdateStatus::NotUpdated(CacheNotUpdatedReason::CacheDisabled), + Some(ExecutionError::Cache { kind: CacheErrorKind::Lookup, source: err }), + ) + .await; + return SpawnOutcome::Failed; + } + }; + + match cache.try_hit(cache_metadata, &globbed_inputs, cache_base_path).await { Ok(Ok(cached)) => ( // Cache hit — we can replay the cached outputs CacheStatus::Hit { replayed_duration: cached.duration }, Some(cached), + globbed_inputs, ), Ok(Err(cache_miss)) => ( // Cache miss — includes detailed reason (NotFound or FingerprintMismatch) CacheStatus::Miss(cache_miss), None, + globbed_inputs, ), Err(err) => { // Cache lookup error — report through finish. @@ -220,7 +245,7 @@ pub async fn execute_spawn( } } else { // No cache metadata provided — caching is disabled for this task - (CacheStatus::Disabled(CacheDisabledReason::NoCacheMetadata), None) + (CacheStatus::Disabled(CacheDisabledReason::NoCacheMetadata), None, BTreeMap::new()) }; // 2. Report execution start with the determined cache status. @@ -283,8 +308,17 @@ pub async fn execute_spawn( } // 5. Piped mode: execute spawn with tracking, streaming output to writers. - let mut track_result_with_cache_metadata = - cache_metadata.map(|cache_metadata| (SpawnTrackResult::default(), cache_metadata)); + // - std_outputs: always captured when caching is enabled (for cache replay) + // - path_accesses: only tracked when includes_auto is true (fspy inference) + let (mut std_outputs, mut path_accesses, cache_metadata_and_inputs) = + cache_metadata.map_or((None, None, None), |cache_metadata| { + let path_accesses = if cache_metadata.input_config.includes_auto { + Some(TrackedPathAccesses::default()) + } else { + None // Skip fspy when inference is disabled + }; + (Some(Vec::new()), path_accesses, Some((cache_metadata, globbed_inputs))) + }); #[expect( clippy::large_futures, @@ -295,7 +329,8 @@ pub async fn execute_spawn( cache_base_path, &mut stdio_config.stdout_writer, &mut stdio_config.stderr_writer, - track_result_with_cache_metadata.as_mut().map(|(track_result, _)| track_result), + std_outputs.as_mut(), + path_accesses.as_mut(), ) .await { @@ -314,25 +349,29 @@ pub async fn execute_spawn( // 6. Update cache if successful and determine cache update status. // Errors during cache update are terminal (reported through finish). - let (cache_update_status, cache_error) = if let Some((track_result, cache_metadata)) = - track_result_with_cache_metadata + let (cache_update_status, cache_error) = if let Some((cache_metadata, globbed_inputs)) = + 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 - let fingerprint_ignores = - cache_metadata.spawn_fingerprint.fingerprint_ignores().map(std::vec::Vec::as_slice); match PostRunFingerprint::create( - &track_result.path_reads, + path_reads, cache_base_path, - fingerprint_ignores, + &cache_metadata.glob_base, + &cache_metadata.input_config, ) { Ok(post_run_fingerprint) => { - let new_cache_value = CommandCacheValue { + let new_cache_value = CacheEntryValue { post_run_fingerprint, - std_outputs: track_result.std_outputs.clone().into(), + std_outputs: std_outputs.unwrap_or_default().into(), duration: result.duration, + globbed_inputs, }; - match cache.update(cache_metadata, new_cache_value).await { + match cache.update(cache_metadata, cache_base_path, new_cache_value).await { Ok(()) => (CacheUpdateStatus::Updated, None), Err(err) => ( CacheUpdateStatus::NotUpdated(CacheNotUpdatedReason::CacheDisabled), diff --git a/crates/vite_task/src/session/execute/spawn.rs b/crates/vite_task/src/session/execute/spawn.rs index 33b8d2c7..4478abbe 100644 --- a/crates/vite_task/src/session/execute/spawn.rs +++ b/crates/vite_task/src/session/execute/spawn.rs @@ -43,12 +43,10 @@ pub struct SpawnResult { pub duration: Duration, } -/// Tracking result from a spawned process for caching +/// Tracked file accesses from fspy. +/// Only populated when fspy tracking is enabled (`includes_auto` is true). #[derive(Default, Debug)] -pub struct SpawnTrackResult { - /// captured stdout/stderr - pub std_outputs: Vec, - +pub struct TrackedPathAccesses { /// Tracked path reads pub path_reads: HashMap, @@ -56,14 +54,14 @@ pub struct SpawnTrackResult { pub path_writes: FxHashSet, } -/// Spawn a command with file system tracking via fspy, using piped stdio. +/// Spawn a command with optional file system tracking via fspy, using piped stdio. /// -/// Returns the execution result including captured outputs, exit status, -/// and tracked file accesses. +/// Returns the execution result including exit status and duration. /// /// - stdin is always `/dev/null` (piped mode is for non-interactive execution). /// - `stdout_writer`/`stderr_writer` receive the child's stdout/stderr output in real-time. -/// - `track_result` if provided, will be populated with captured outputs and path accesses for caching. If `None`, tracking is disabled. +/// - `std_outputs` if provided, will be populated with captured outputs for cache replay. +/// - `path_accesses` if provided, fspy will be used to track file accesses. If `None`, fspy is disabled. #[tracing::instrument(level = "debug", skip_all)] #[expect(clippy::future_not_send, reason = "uses !Send dyn AsyncWrite writers internally")] #[expect( @@ -75,15 +73,17 @@ pub async fn spawn_with_tracking( workspace_root: &AbsolutePath, stdout_writer: &mut (dyn AsyncWrite + Unpin), stderr_writer: &mut (dyn AsyncWrite + Unpin), - track_result: Option<&mut SpawnTrackResult>, + std_outputs: Option<&mut Vec>, + path_accesses: Option<&mut TrackedPathAccesses>, ) -> anyhow::Result { - /// The tracking state of the spawned process - enum TrackingState<'a> { - /// Tacking is enabled, with the tracked child and result reference - Enabled(fspy::TrackedChild, &'a mut SpawnTrackResult), - - /// Tracking is disabled, with the tokio child process - Disabled(tokio::process::Child), + /// The tracking state of the spawned process. + /// Determined by whether `path_accesses` is `Some` (fspy enabled) or `None` (fspy disabled). + enum TrackingState { + /// fspy tracking is enabled + FspyEnabled(fspy::TrackedChild), + + /// fspy tracking is disabled, using plain tokio process + FspyDisabled(tokio::process::Child), } let mut cmd = fspy::Command::new(spawn_command.program_path.as_path()); @@ -92,27 +92,25 @@ pub async fn spawn_with_tracking( cmd.current_dir(&*spawn_command.cwd); cmd.stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::piped()); - let mut tracking_state = if let Some(track_result) = track_result { - // track_result is Some. Spawn with tracking enabled - TrackingState::Enabled(cmd.spawn().await?, track_result) + let mut tracking_state = if path_accesses.is_some() { + // path_accesses is Some, spawn with fspy tracking enabled + TrackingState::FspyEnabled(cmd.spawn().await?) } else { - // Spawn without tracking - TrackingState::Disabled(cmd.into_tokio_command().spawn()?) + // path_accesses is None, spawn without fspy + TrackingState::FspyDisabled(cmd.into_tokio_command().spawn()?) }; let mut child_stdout = match &mut tracking_state { - TrackingState::Enabled(tracked_child, _) => tracked_child.stdout.take().unwrap(), - TrackingState::Disabled(tokio_child) => tokio_child.stdout.take().unwrap(), + TrackingState::FspyEnabled(tracked_child) => tracked_child.stdout.take().unwrap(), + TrackingState::FspyDisabled(tokio_child) => tokio_child.stdout.take().unwrap(), }; let mut child_stderr = match &mut tracking_state { - TrackingState::Enabled(tracked_child, _) => tracked_child.stderr.take().unwrap(), - TrackingState::Disabled(tokio_child) => tokio_child.stderr.take().unwrap(), + TrackingState::FspyEnabled(tracked_child) => tracked_child.stderr.take().unwrap(), + TrackingState::FspyDisabled(tokio_child) => tokio_child.stderr.take().unwrap(), }; - let mut outputs = match &mut tracking_state { - TrackingState::Enabled(_, track_result) => Some(&mut track_result.std_outputs), - TrackingState::Disabled(_) => None, - }; + // Output capturing is independent of fspy tracking + let mut outputs = std_outputs; let mut stdout_buf = [0u8; 8192]; let mut stderr_buf = [0u8; 8192]; let mut stdout_done = false; @@ -169,22 +167,24 @@ pub async fn spawn_with_tracking( } } - let (termination, track_result) = match tracking_state { - TrackingState::Enabled(tracked_child, track_result) => { - (tracked_child.wait_handle.await?, track_result) + // Wait for process termination and process path accesses if fspy was enabled + let (termination, path_accesses) = match tracking_state { + TrackingState::FspyEnabled(tracked_child) => { + let termination = tracked_child.wait_handle.await?; + // path_accesses must be Some when fspy is enabled (they're set together) + let path_accesses = path_accesses.ok_or_else(|| { + anyhow::anyhow!("internal error: fspy enabled but path_accesses is None") + })?; + (termination, path_accesses) } - TrackingState::Disabled(mut tokio_child) => { - return Ok(SpawnResult { - exit_status: tokio_child.wait().await?, - duration: start.elapsed(), - }); + TrackingState::FspyDisabled(mut tokio_child) => { + let exit_status = tokio_child.wait().await?; + return Ok(SpawnResult { exit_status, duration: start.elapsed() }); } }; let duration = start.elapsed(); - - // Process path accesses - let path_reads = &mut track_result.path_reads; - let path_writes = &mut track_result.path_writes; + let path_reads = &mut path_accesses.path_reads; + let path_writes = &mut path_accesses.path_writes; for access in termination.path_accesses.iter() { let relative_path = access.path.strip_path_prefix(workspace_root, |strip_result| { diff --git a/crates/vite_task/src/session/reporter/summary.rs b/crates/vite_task/src/session/reporter/summary.rs index 8178bd2b..d7dd3122 100644 --- a/crates/vite_task/src/session/reporter/summary.rs +++ b/crates/vite_task/src/session/reporter/summary.rs @@ -112,6 +112,8 @@ pub enum SavedCacheMissReason { NotFound, /// Spawn fingerprint changed (command, envs, cwd, etc.). SpawnFingerprintChanged(Vec), + /// Task configuration changed (input_config or glob_base). + ConfigChanged, /// Content of an input file changed. InputContentChanged { path: Str }, } @@ -238,6 +240,10 @@ impl SavedCacheMissReason { FingerprintMismatch::SpawnFingerprintMismatch { old, new } => { Self::SpawnFingerprintChanged(detect_spawn_fingerprint_changes(old, new)) } + FingerprintMismatch::ConfigChanged => Self::ConfigChanged, + FingerprintMismatch::GlobbedInputChanged { path } => { + Self::InputContentChanged { path: Str::from(path.as_str()) } + } FingerprintMismatch::PostRunFingerprintMismatch(diff) => { use crate::session::execute::fingerprint::PostRunFingerprintMismatch; match diff { @@ -425,6 +431,9 @@ impl TaskResult { vite_str::format!("→ Cache miss: {joined}") } } + SavedCacheMissReason::ConfigChanged => { + Str::from("→ Cache miss: configuration changed") + } SavedCacheMissReason::InputContentChanged { path } => { vite_str::format!("→ Cache miss: content of input '{path}' changed") } diff --git a/crates/vite_task_bin/src/lib.rs b/crates/vite_task_bin/src/lib.rs index c2560653..5b6c3adc 100644 --- a/crates/vite_task_bin/src/lib.rs +++ b/crates/vite_task_bin/src/lib.rs @@ -64,6 +64,7 @@ fn synthesize_node_modules_bin_task( cache_config: UserCacheConfig::with_config(EnabledCacheConfig { envs: None, pass_through_envs: None, + inputs: None, }), envs: Arc::clone(envs), }) @@ -125,7 +126,11 @@ impl vite_task::CommandHandler for CommandHandler { program: find_executable(get_path_env(&envs), &command.cwd, "print-env")?, args: [name.clone()].into(), cache_config: UserCacheConfig::with_config({ - EnabledCacheConfig { envs: None, pass_through_envs: Some(vec![name]) } + EnabledCacheConfig { + envs: None, + pass_through_envs: Some(vec![name]), + inputs: None, + } }), envs: Arc::new(envs), })) diff --git a/crates/vite_task_bin/src/main.rs b/crates/vite_task_bin/src/main.rs index b4a1320b..2dfdd252 100644 --- a/crates/vite_task_bin/src/main.rs +++ b/crates/vite_task_bin/src/main.rs @@ -35,6 +35,7 @@ async fn run() -> anyhow::Result { EnabledCacheConfig { envs: Some(Box::from([Str::from("FOO")])), pass_through_envs: None, + inputs: None, } }), envs: Arc::clone(envs), diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/cache-miss-reasons/snapshots.toml b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/cache-miss-reasons/snapshots.toml index 150a6812..ead59c25 100644 --- a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/cache-miss-reasons/snapshots.toml +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/cache-miss-reasons/snapshots.toml @@ -55,3 +55,11 @@ steps = [ "replace-file-content test.txt initial modified # modify input", "vp run test # cache miss: input changed", ] + +[[e2e]] +name = "inputs config changed" +steps = [ + "vp run test # cache miss", + "json-edit vite-task.json \"_.tasks.test.inputs = ['test.txt']\" # change inputs config", + "vp run test # cache miss: configuration changed", +] diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/cache-miss-reasons/snapshots/inputs config changed.snap b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/cache-miss-reasons/snapshots/inputs config changed.snap new file mode 100644 index 00000000..a9ee6259 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/cache-miss-reasons/snapshots/inputs config changed.snap @@ -0,0 +1,12 @@ +--- +source: crates/vite_task_bin/tests/e2e_snapshots/main.rs +expression: e2e_outputs +--- +> vp run test # cache miss +$ print-file test.txt +initial content +> json-edit vite-task.json "_.tasks.test.inputs = ['test.txt']" # change inputs config + +> vp run test # cache miss: configuration changed +$ print-file test.txt ✗ cache miss: configuration changed, executing +initial content diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/glob-base-test/other/other.ts b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/glob-base-test/other/other.ts new file mode 100644 index 00000000..95b7e318 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/glob-base-test/other/other.ts @@ -0,0 +1 @@ +export const rootOther = 'initial'; diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/glob-base-test/package.json b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/glob-base-test/package.json new file mode 100644 index 00000000..62b9c6f6 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/glob-base-test/package.json @@ -0,0 +1,4 @@ +{ + "name": "glob-base-test", + "private": true +} diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/glob-base-test/packages/sub-pkg/other/other.ts b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/glob-base-test/packages/sub-pkg/other/other.ts new file mode 100644 index 00000000..bba04996 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/glob-base-test/packages/sub-pkg/other/other.ts @@ -0,0 +1 @@ +export const other = 'initial'; diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/glob-base-test/packages/sub-pkg/package.json b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/glob-base-test/packages/sub-pkg/package.json new file mode 100644 index 00000000..e0caa109 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/glob-base-test/packages/sub-pkg/package.json @@ -0,0 +1,4 @@ +{ + "name": "sub-pkg", + "private": true +} diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/glob-base-test/packages/sub-pkg/src/sub.ts b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/glob-base-test/packages/sub-pkg/src/sub.ts new file mode 100644 index 00000000..27a97907 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/glob-base-test/packages/sub-pkg/src/sub.ts @@ -0,0 +1 @@ +export const sub = 'initial'; diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/glob-base-test/packages/sub-pkg/vite-task.json b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/glob-base-test/packages/sub-pkg/vite-task.json new file mode 100644 index 00000000..2343b213 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/glob-base-test/packages/sub-pkg/vite-task.json @@ -0,0 +1,15 @@ +{ + "tasks": { + "sub-glob-test": { + "command": "print-file src/sub.ts", + "inputs": ["src/**/*.ts"], + "cache": true + }, + "sub-glob-with-cwd": { + "command": "print-file sub.ts", + "cwd": "src", + "inputs": ["src/**/*.ts"], + "cache": true + } + } +} diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/glob-base-test/pnpm-workspace.yaml b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/glob-base-test/pnpm-workspace.yaml new file mode 100644 index 00000000..18ec407e --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/glob-base-test/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +packages: + - 'packages/*' diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/glob-base-test/snapshots.toml b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/glob-base-test/snapshots.toml new file mode 100644 index 00000000..3cb33454 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/glob-base-test/snapshots.toml @@ -0,0 +1,87 @@ +# Test glob base directory behavior +# Globs are relative to PACKAGE directory, NOT task cwd +# No special cross-package filtering - just normal relative path matching + +# 1. Root package - glob matches files in root's src/ +[[e2e]] +name = "root glob - matches src files" +steps = [ + "vp run root-glob-test", + # Modify matched file + "replace-file-content src/root.ts initial modified", + # Cache miss: file matches glob + "vp run root-glob-test", +] + +[[e2e]] +name = "root glob - unmatched directory" +steps = [ + "vp run root-glob-test", + # Modify file outside glob pattern + "replace-file-content other/other.ts initial modified", + # Cache hit: file doesn't match glob + "vp run root-glob-test", +] + +[[e2e]] +name = "root glob - subpackage path unmatched by relative glob" +steps = [ + "vp run root-glob-test", + # Modify file in subpackage - relative glob src/** doesn't reach packages/sub-pkg/src/ + "replace-file-content packages/sub-pkg/src/sub.ts initial modified", + # Cache hit: path simply doesn't match the relative glob pattern + "vp run root-glob-test", +] + +# 2. Root package with custom cwd - glob still relative to package root +[[e2e]] +name = "root glob with cwd - glob relative to package not cwd" +steps = [ + "vp run root-glob-with-cwd", + # Modify file - glob is src/** relative to package root + "replace-file-content src/root.ts initial modified", + # Cache miss: file matches glob (relative to package, not cwd) + "vp run root-glob-with-cwd", +] + +# 3. Subpackage - glob matches files in subpackage's src/ +[[e2e]] +name = "subpackage glob - matches own src files" +steps = [ + "vp run sub-pkg#sub-glob-test", + # Modify matched file in subpackage + "replace-file-content packages/sub-pkg/src/sub.ts initial modified", + # Cache miss: file matches subpackage's glob + "vp run sub-pkg#sub-glob-test", +] + +[[e2e]] +name = "subpackage glob - unmatched directory in subpackage" +steps = [ + "vp run sub-pkg#sub-glob-test", + # Modify file outside glob pattern + "replace-file-content packages/sub-pkg/other/other.ts initial modified", + # Cache hit: file doesn't match glob + "vp run sub-pkg#sub-glob-test", +] + +[[e2e]] +name = "subpackage glob - root path unmatched by relative glob" +steps = [ + "vp run sub-pkg#sub-glob-test", + # Modify file in root - relative glob src/** is resolved from subpackage dir + "replace-file-content src/root.ts initial modified", + # Cache hit: path simply doesn't match the relative glob pattern + "vp run sub-pkg#sub-glob-test", +] + +# 4. Subpackage with custom cwd - glob still relative to subpackage root +[[e2e]] +name = "subpackage glob with cwd - glob relative to package not cwd" +steps = [ + "vp run sub-pkg#sub-glob-with-cwd", + # Modify file - glob is src/** relative to subpackage root + "replace-file-content packages/sub-pkg/src/sub.ts initial modified", + # Cache miss: file matches glob (relative to subpackage, not cwd) + "vp run sub-pkg#sub-glob-with-cwd", +] diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/glob-base-test/snapshots/root glob - matches src files.snap b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/glob-base-test/snapshots/root glob - matches src files.snap new file mode 100644 index 00000000..e33a40c0 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/glob-base-test/snapshots/root glob - matches src files.snap @@ -0,0 +1,12 @@ +--- +source: crates/vite_task_bin/tests/e2e_snapshots/main.rs +expression: e2e_outputs +--- +> vp run root-glob-test +$ print-file src/root.ts +export const root = 'initial'; +> replace-file-content src/root.ts initial modified + +> vp run root-glob-test +$ print-file src/root.ts ✗ cache miss: content of input 'src/root.ts' changed, executing +export const root = 'modified'; diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/glob-base-test/snapshots/root glob - subpackage path unmatched by relative glob.snap b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/glob-base-test/snapshots/root glob - subpackage path unmatched by relative glob.snap new file mode 100644 index 00000000..39a0e28d --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/glob-base-test/snapshots/root glob - subpackage path unmatched by relative glob.snap @@ -0,0 +1,15 @@ +--- +source: crates/vite_task_bin/tests/e2e_snapshots/main.rs +expression: e2e_outputs +--- +> vp run root-glob-test +$ print-file src/root.ts +export const root = 'initial'; +> replace-file-content packages/sub-pkg/src/sub.ts initial modified + +> vp run root-glob-test +$ print-file src/root.ts ✓ cache hit, replaying +export const root = 'initial'; + +--- +[vp run] cache hit, saved. diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/glob-base-test/snapshots/root glob - unmatched directory.snap b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/glob-base-test/snapshots/root glob - unmatched directory.snap new file mode 100644 index 00000000..e3c88b7f --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/glob-base-test/snapshots/root glob - unmatched directory.snap @@ -0,0 +1,15 @@ +--- +source: crates/vite_task_bin/tests/e2e_snapshots/main.rs +expression: e2e_outputs +--- +> vp run root-glob-test +$ print-file src/root.ts +export const root = 'initial'; +> replace-file-content other/other.ts initial modified + +> vp run root-glob-test +$ print-file src/root.ts ✓ cache hit, replaying +export const root = 'initial'; + +--- +[vp run] cache hit, saved. diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/glob-base-test/snapshots/root glob with cwd - glob relative to package not cwd.snap b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/glob-base-test/snapshots/root glob with cwd - glob relative to package not cwd.snap new file mode 100644 index 00000000..818540ad --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/glob-base-test/snapshots/root glob with cwd - glob relative to package not cwd.snap @@ -0,0 +1,12 @@ +--- +source: crates/vite_task_bin/tests/e2e_snapshots/main.rs +expression: e2e_outputs +--- +> vp run root-glob-with-cwd +~/src$ print-file root.ts +export const root = 'initial'; +> replace-file-content src/root.ts initial modified + +> vp run root-glob-with-cwd +~/src$ print-file root.ts ✗ cache miss: content of input 'src/root.ts' changed, executing +export const root = 'modified'; diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/glob-base-test/snapshots/subpackage glob - matches own src files.snap b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/glob-base-test/snapshots/subpackage glob - matches own src files.snap new file mode 100644 index 00000000..ac5a5a62 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/glob-base-test/snapshots/subpackage glob - matches own src files.snap @@ -0,0 +1,12 @@ +--- +source: crates/vite_task_bin/tests/e2e_snapshots/main.rs +expression: e2e_outputs +--- +> vp run sub-pkg#sub-glob-test +~/packages/sub-pkg$ print-file src/sub.ts +export const sub = 'initial'; +> replace-file-content packages/sub-pkg/src/sub.ts initial modified + +> vp run sub-pkg#sub-glob-test +~/packages/sub-pkg$ print-file src/sub.ts ✗ cache miss: content of input 'packages/sub-pkg/src/sub.ts' changed, executing +export const sub = 'modified'; diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/glob-base-test/snapshots/subpackage glob - root path unmatched by relative glob.snap b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/glob-base-test/snapshots/subpackage glob - root path unmatched by relative glob.snap new file mode 100644 index 00000000..cf4db383 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/glob-base-test/snapshots/subpackage glob - root path unmatched by relative glob.snap @@ -0,0 +1,15 @@ +--- +source: crates/vite_task_bin/tests/e2e_snapshots/main.rs +expression: e2e_outputs +--- +> vp run sub-pkg#sub-glob-test +~/packages/sub-pkg$ print-file src/sub.ts +export const sub = 'initial'; +> replace-file-content src/root.ts initial modified + +> vp run sub-pkg#sub-glob-test +~/packages/sub-pkg$ print-file src/sub.ts ✓ cache hit, replaying +export const sub = 'initial'; + +--- +[vp run] cache hit, saved. diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/glob-base-test/snapshots/subpackage glob - unmatched directory in subpackage.snap b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/glob-base-test/snapshots/subpackage glob - unmatched directory in subpackage.snap new file mode 100644 index 00000000..68793b67 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/glob-base-test/snapshots/subpackage glob - unmatched directory in subpackage.snap @@ -0,0 +1,15 @@ +--- +source: crates/vite_task_bin/tests/e2e_snapshots/main.rs +expression: e2e_outputs +--- +> vp run sub-pkg#sub-glob-test +~/packages/sub-pkg$ print-file src/sub.ts +export const sub = 'initial'; +> replace-file-content packages/sub-pkg/other/other.ts initial modified + +> vp run sub-pkg#sub-glob-test +~/packages/sub-pkg$ print-file src/sub.ts ✓ cache hit, replaying +export const sub = 'initial'; + +--- +[vp run] cache hit, saved. diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/glob-base-test/snapshots/subpackage glob with cwd - glob relative to package not cwd.snap b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/glob-base-test/snapshots/subpackage glob with cwd - glob relative to package not cwd.snap new file mode 100644 index 00000000..30a09767 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/glob-base-test/snapshots/subpackage glob with cwd - glob relative to package not cwd.snap @@ -0,0 +1,12 @@ +--- +source: crates/vite_task_bin/tests/e2e_snapshots/main.rs +expression: e2e_outputs +--- +> vp run sub-pkg#sub-glob-with-cwd +~/packages/sub-pkg/src$ print-file sub.ts +export const sub = 'initial'; +> replace-file-content packages/sub-pkg/src/sub.ts initial modified + +> vp run sub-pkg#sub-glob-with-cwd +~/packages/sub-pkg/src$ print-file sub.ts ✗ cache miss: content of input 'packages/sub-pkg/src/sub.ts' changed, executing +export const sub = 'modified'; diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/glob-base-test/src/root.ts b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/glob-base-test/src/root.ts new file mode 100644 index 00000000..96424f31 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/glob-base-test/src/root.ts @@ -0,0 +1 @@ +export const root = 'initial'; diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/glob-base-test/vite-task.json b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/glob-base-test/vite-task.json new file mode 100644 index 00000000..be9583e2 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/glob-base-test/vite-task.json @@ -0,0 +1,15 @@ +{ + "tasks": { + "root-glob-test": { + "command": "print-file src/root.ts", + "inputs": ["src/**/*.ts"], + "cache": true + }, + "root-glob-with-cwd": { + "command": "print-file root.ts", + "cwd": "src", + "inputs": ["src/**/*.ts"], + "cache": true + } + } +} diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-cache-test/dist/output.js b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-cache-test/dist/output.js new file mode 100644 index 00000000..d8d5d210 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-cache-test/dist/output.js @@ -0,0 +1 @@ +// initial output diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-cache-test/package.json b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-cache-test/package.json new file mode 100644 index 00000000..1d369f44 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-cache-test/package.json @@ -0,0 +1,4 @@ +{ + "name": "inputs-cache-test", + "private": true +} diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-cache-test/snapshots.toml b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-cache-test/snapshots.toml new file mode 100644 index 00000000..bea80054 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-cache-test/snapshots.toml @@ -0,0 +1,202 @@ +# Test all input configuration combinations for cache behavior + +# 1. Positive globs only: inputs: ["src/**/*.ts"] +# - Files matching the glob trigger cache invalidation +# - Files outside the glob do NOT trigger cache invalidation +[[e2e]] +name = "positive globs only - cache hit on second run" +steps = [ + # First run - cache miss + "vp run positive-globs-only", + # Second run - cache hit + "vp run positive-globs-only", +] + +[[e2e]] +name = "positive globs only - miss on matched file change" +steps = [ + # Initial run + "vp run positive-globs-only", + # Modify a file that matches the glob + "replace-file-content src/main.ts initial modified", + # Cache miss: matched file changed + "vp run positive-globs-only", +] + +[[e2e]] +name = "positive globs only - hit on unmatched file change" +steps = [ + # Initial run + "vp run positive-globs-only", + # Modify a file that does NOT match the glob (test/ is outside src/) + "replace-file-content test/main.test.ts outside modified", + # Cache hit: file not in inputs + "vp run positive-globs-only", +] + +# 1b. Positive globs reads unmatched file: inputs: ["src/main.ts"], command reads src/utils.ts too +# - File read by command but NOT matched by glob should NOT be fingerprinted +# - This tests that inference is truly disabled when only explicit globs are used +[[e2e]] +name = "positive globs - hit on read but unmatched file" +steps = [ + # Initial run - reads both src/main.ts and src/utils.ts + "vp run positive-globs-reads-unmatched", + # Modify utils.ts - read by command but NOT in glob + "replace-file-content src/utils.ts initial modified", + # Cache hit: file was read but not matched by glob, inference is off + "vp run positive-globs-reads-unmatched", +] + +# 2. Positive + negative globs: inputs: ["src/**", "!src/**/*.test.ts"] +# - Files matching positive but NOT negative trigger invalidation +# - Files matching negative glob are excluded +[[e2e]] +name = "positive negative globs - miss on non-excluded file" +steps = [ + # Initial run + "vp run positive-negative-globs", + # Modify a file that matches positive but NOT negative + "replace-file-content src/main.ts initial modified", + # Cache miss: file changed + "vp run positive-negative-globs", +] + +[[e2e]] +name = "positive negative globs - hit on excluded file" +steps = [ + # Initial run + "vp run positive-negative-globs", + # Modify a file that matches the negative glob (excluded) + "replace-file-content src/main.test.ts main modified", + # Cache hit: file excluded by negative glob + "vp run positive-negative-globs", +] + +# 3. Auto only: inputs: [{ "auto": true }] +# - Files read by the command trigger invalidation (fspy inference) +# - Files NOT read by the command do NOT trigger invalidation +[[e2e]] +name = "auto only - miss on inferred file change" +steps = [ + # Initial run - reads src/main.ts + "vp run auto-only", + # Modify the file that was read + "replace-file-content src/main.ts initial modified", + # Cache miss: inferred input changed + "vp run auto-only", +] + +[[e2e]] +name = "auto only - hit on non-inferred file change" +steps = [ + # Initial run - reads src/main.ts (NOT utils.ts) + "vp run auto-only", + # Modify a file that was NOT read by the command + "replace-file-content src/utils.ts initial modified", + # Cache hit: file not in inferred inputs + "vp run auto-only", +] + +# 4. Auto + negative: inputs: [{ "auto": true }, "!dist/**"] +# - Inferred files are tracked, but negative globs filter them out +# - Files in excluded directories don't trigger invalidation even if read +[[e2e]] +name = "auto with negative - hit on excluded inferred file" +steps = [ + # Initial run - reads both src/main.ts and dist/output.js + "vp run auto-with-negative", + # Modify file in excluded directory (dist/) + "replace-file-content dist/output.js initial modified", + # Cache hit: dist/ is excluded by negative glob + "vp run auto-with-negative", +] + +[[e2e]] +name = "auto with negative - miss on non-excluded inferred file" +steps = [ + # Initial run + "vp run auto-with-negative", + # Modify file NOT in excluded directory + "replace-file-content src/main.ts initial modified", + # Cache miss: inferred input changed + "vp run auto-with-negative", +] + +# 5. Positive + auto + negative: inputs: ["package.json", { "auto": true }, "!src/**/*.test.ts"] +# - Explicit globs AND inferred files are both tracked +# - Negative globs apply to both explicit and inferred inputs +[[e2e]] +name = "positive auto negative - miss on explicit glob file" +steps = [ + # Initial run + "vp run positive-auto-negative", + # Modify explicit input file + "replace-file-content package.json inputs-cache-test modified-pkg", + # Cache miss: explicit input changed + "vp run positive-auto-negative", +] + +[[e2e]] +name = "positive auto negative - miss on inferred file" +steps = [ + # Initial run + "vp run positive-auto-negative", + # Modify inferred input file (read by command) + "replace-file-content src/main.ts initial modified", + # Cache miss: inferred input changed + "vp run positive-auto-negative", +] + +[[e2e]] +name = "positive auto negative - hit on excluded file" +steps = [ + # Initial run + "vp run positive-auto-negative", + # Modify file excluded by negative glob + "replace-file-content src/main.test.ts main modified", + # Cache hit: file excluded by negative glob + "vp run positive-auto-negative", +] + +# 6. Empty inputs: inputs: [] +# - No file changes affect the cache +# - Only command/env changes trigger cache invalidation +[[e2e]] +name = "empty inputs - hit despite file changes" +steps = [ + # Initial run + "vp run empty-inputs", + # Modify any file - should NOT affect cache + "replace-file-content src/main.ts initial modified", + # Cache hit: no inputs configured + "vp run empty-inputs", +] + +[[e2e]] +name = "empty inputs - miss on command change" +steps = [ + # Initial run + "vp run empty-inputs", + # Change the command + "json-edit vite-task.json \"_.tasks['empty-inputs'].command = 'print-file src/utils.ts'\"", + # Cache miss: command changed + "vp run empty-inputs", +] + +# 7. FSPY environment variable +# - FSPY=1 is set when fspy is enabled (includes_auto is true) +# - FSPY is NOT set when fspy is disabled (explicit globs only) +[[e2e]] +name = "fspy env - set when auto inference enabled" +steps = [ + # Run task with auto inference - should see FSPY=1 + "vp run check-fspy-env-with-auto", +] + +[[e2e]] +name = "fspy env - not set when auto inference disabled" +steps = [ + # Run task without auto inference - should see (undefined) + "vp run check-fspy-env-without-auto", +] diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-cache-test/snapshots/auto only - hit on non-inferred file change.snap b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-cache-test/snapshots/auto only - hit on non-inferred file change.snap new file mode 100644 index 00000000..a524b6b4 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-cache-test/snapshots/auto only - hit on non-inferred file change.snap @@ -0,0 +1,15 @@ +--- +source: crates/vite_task_bin/tests/e2e_snapshots/main.rs +expression: e2e_outputs +--- +> vp run auto-only +$ print-file src/main.ts +export const main = 'initial'; +> replace-file-content src/utils.ts initial modified + +> vp run auto-only +$ print-file src/main.ts ✓ cache hit, replaying +export const main = 'initial'; + +--- +[vp run] cache hit, saved. diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-cache-test/snapshots/auto only - miss on inferred file change.snap b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-cache-test/snapshots/auto only - miss on inferred file change.snap new file mode 100644 index 00000000..6f17df22 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-cache-test/snapshots/auto only - miss on inferred file change.snap @@ -0,0 +1,12 @@ +--- +source: crates/vite_task_bin/tests/e2e_snapshots/main.rs +expression: e2e_outputs +--- +> vp run auto-only +$ print-file src/main.ts +export const main = 'initial'; +> replace-file-content src/main.ts initial modified + +> vp run auto-only +$ print-file src/main.ts ✗ cache miss: content of input 'src/main.ts' changed, executing +export const main = 'modified'; diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-cache-test/snapshots/auto with negative - hit on excluded inferred file.snap b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-cache-test/snapshots/auto with negative - hit on excluded inferred file.snap new file mode 100644 index 00000000..68767f83 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-cache-test/snapshots/auto with negative - hit on excluded inferred file.snap @@ -0,0 +1,17 @@ +--- +source: crates/vite_task_bin/tests/e2e_snapshots/main.rs +expression: e2e_outputs +--- +> vp run auto-with-negative +$ print-file src/main.ts dist/output.js +export const main = 'initial'; +// initial output +> replace-file-content dist/output.js initial modified + +> vp run auto-with-negative +$ print-file src/main.ts dist/output.js ✓ cache hit, replaying +export const main = 'initial'; +// initial output + +--- +[vp run] cache hit, saved. diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-cache-test/snapshots/auto with negative - miss on non-excluded inferred file.snap b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-cache-test/snapshots/auto with negative - miss on non-excluded inferred file.snap new file mode 100644 index 00000000..9a98e9e1 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-cache-test/snapshots/auto with negative - miss on non-excluded inferred file.snap @@ -0,0 +1,14 @@ +--- +source: crates/vite_task_bin/tests/e2e_snapshots/main.rs +expression: e2e_outputs +--- +> vp run auto-with-negative +$ print-file src/main.ts dist/output.js +export const main = 'initial'; +// initial output +> replace-file-content src/main.ts initial modified + +> vp run auto-with-negative +$ print-file src/main.ts dist/output.js ✗ cache miss: content of input 'src/main.ts' changed, executing +export const main = 'modified'; +// initial output diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-cache-test/snapshots/empty inputs - hit despite file changes.snap b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-cache-test/snapshots/empty inputs - hit despite file changes.snap new file mode 100644 index 00000000..4cc5f6f2 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-cache-test/snapshots/empty inputs - hit despite file changes.snap @@ -0,0 +1,15 @@ +--- +source: crates/vite_task_bin/tests/e2e_snapshots/main.rs +expression: e2e_outputs +--- +> vp run empty-inputs +$ print-file src/main.ts +export const main = 'initial'; +> replace-file-content src/main.ts initial modified + +> vp run empty-inputs +$ print-file src/main.ts ✓ cache hit, replaying +export const main = 'initial'; + +--- +[vp run] cache hit, saved. diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-cache-test/snapshots/empty inputs - miss on command change.snap b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-cache-test/snapshots/empty inputs - miss on command change.snap new file mode 100644 index 00000000..6ec2904b --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-cache-test/snapshots/empty inputs - miss on command change.snap @@ -0,0 +1,12 @@ +--- +source: crates/vite_task_bin/tests/e2e_snapshots/main.rs +expression: e2e_outputs +--- +> vp run empty-inputs +$ print-file src/main.ts +export const main = 'initial'; +> json-edit vite-task.json "_.tasks['empty-inputs'].command = 'print-file src/utils.ts'" + +> vp run empty-inputs +$ print-file src/utils.ts ✗ cache miss: args changed, executing +export const utils = 'initial'; diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-cache-test/snapshots/fspy env - not set when auto inference disabled.snap b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-cache-test/snapshots/fspy env - not set when auto inference disabled.snap new file mode 100644 index 00000000..2b726d21 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-cache-test/snapshots/fspy env - not set when auto inference disabled.snap @@ -0,0 +1,7 @@ +--- +source: crates/vite_task_bin/tests/e2e_snapshots/main.rs +expression: e2e_outputs +--- +> vp run check-fspy-env-without-auto +$ print-env FSPY +(undefined) diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-cache-test/snapshots/fspy env - set when auto inference enabled.snap b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-cache-test/snapshots/fspy env - set when auto inference enabled.snap new file mode 100644 index 00000000..5f42dce0 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-cache-test/snapshots/fspy env - set when auto inference enabled.snap @@ -0,0 +1,7 @@ +--- +source: crates/vite_task_bin/tests/e2e_snapshots/main.rs +expression: e2e_outputs +--- +> vp run check-fspy-env-with-auto +$ print-env FSPY +1 diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-cache-test/snapshots/positive auto negative - hit on excluded file.snap b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-cache-test/snapshots/positive auto negative - hit on excluded file.snap new file mode 100644 index 00000000..9e245558 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-cache-test/snapshots/positive auto negative - hit on excluded file.snap @@ -0,0 +1,15 @@ +--- +source: crates/vite_task_bin/tests/e2e_snapshots/main.rs +expression: e2e_outputs +--- +> vp run positive-auto-negative +$ print-file src/main.ts +export const main = 'initial'; +> replace-file-content src/main.test.ts main modified + +> vp run positive-auto-negative +$ print-file src/main.ts ✓ cache hit, replaying +export const main = 'initial'; + +--- +[vp run] cache hit, saved. diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-cache-test/snapshots/positive auto negative - miss on explicit glob file.snap b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-cache-test/snapshots/positive auto negative - miss on explicit glob file.snap new file mode 100644 index 00000000..3dcdee47 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-cache-test/snapshots/positive auto negative - miss on explicit glob file.snap @@ -0,0 +1,12 @@ +--- +source: crates/vite_task_bin/tests/e2e_snapshots/main.rs +expression: e2e_outputs +--- +> vp run positive-auto-negative +$ print-file src/main.ts +export const main = 'initial'; +> replace-file-content package.json inputs-cache-test modified-pkg + +> vp run positive-auto-negative +$ print-file src/main.ts ✗ cache miss: content of input 'package.json' changed, executing +export const main = 'initial'; diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-cache-test/snapshots/positive auto negative - miss on inferred file.snap b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-cache-test/snapshots/positive auto negative - miss on inferred file.snap new file mode 100644 index 00000000..156d17f1 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-cache-test/snapshots/positive auto negative - miss on inferred file.snap @@ -0,0 +1,12 @@ +--- +source: crates/vite_task_bin/tests/e2e_snapshots/main.rs +expression: e2e_outputs +--- +> vp run positive-auto-negative +$ print-file src/main.ts +export const main = 'initial'; +> replace-file-content src/main.ts initial modified + +> vp run positive-auto-negative +$ print-file src/main.ts ✗ cache miss: content of input 'src/main.ts' changed, executing +export const main = 'modified'; diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-cache-test/snapshots/positive globs - hit on read but unmatched file.snap b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-cache-test/snapshots/positive globs - hit on read but unmatched file.snap new file mode 100644 index 00000000..6377b61a --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-cache-test/snapshots/positive globs - hit on read but unmatched file.snap @@ -0,0 +1,17 @@ +--- +source: crates/vite_task_bin/tests/e2e_snapshots/main.rs +expression: e2e_outputs +--- +> vp run positive-globs-reads-unmatched +$ print-file src/main.ts src/utils.ts +export const main = 'initial'; +export const utils = 'initial'; +> replace-file-content src/utils.ts initial modified + +> vp run positive-globs-reads-unmatched +$ print-file src/main.ts src/utils.ts ✓ cache hit, replaying +export const main = 'initial'; +export const utils = 'initial'; + +--- +[vp run] cache hit, saved. diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-cache-test/snapshots/positive globs only - cache hit on second run.snap b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-cache-test/snapshots/positive globs only - cache hit on second run.snap new file mode 100644 index 00000000..b110416a --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-cache-test/snapshots/positive globs only - cache hit on second run.snap @@ -0,0 +1,13 @@ +--- +source: crates/vite_task_bin/tests/e2e_snapshots/main.rs +expression: e2e_outputs +--- +> vp run positive-globs-only +$ print-file src/main.ts +export const main = 'initial'; +> vp run positive-globs-only +$ print-file src/main.ts ✓ cache hit, replaying +export const main = 'initial'; + +--- +[vp run] cache hit, saved. diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-cache-test/snapshots/positive globs only - hit on unmatched file change.snap b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-cache-test/snapshots/positive globs only - hit on unmatched file change.snap new file mode 100644 index 00000000..f257d2f1 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-cache-test/snapshots/positive globs only - hit on unmatched file change.snap @@ -0,0 +1,15 @@ +--- +source: crates/vite_task_bin/tests/e2e_snapshots/main.rs +expression: e2e_outputs +--- +> vp run positive-globs-only +$ print-file src/main.ts +export const main = 'initial'; +> replace-file-content test/main.test.ts outside modified + +> vp run positive-globs-only +$ print-file src/main.ts ✓ cache hit, replaying +export const main = 'initial'; + +--- +[vp run] cache hit, saved. diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-cache-test/snapshots/positive globs only - miss on matched file change.snap b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-cache-test/snapshots/positive globs only - miss on matched file change.snap new file mode 100644 index 00000000..d2ccf7bf --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-cache-test/snapshots/positive globs only - miss on matched file change.snap @@ -0,0 +1,12 @@ +--- +source: crates/vite_task_bin/tests/e2e_snapshots/main.rs +expression: e2e_outputs +--- +> vp run positive-globs-only +$ print-file src/main.ts +export const main = 'initial'; +> replace-file-content src/main.ts initial modified + +> vp run positive-globs-only +$ print-file src/main.ts ✗ cache miss: content of input 'src/main.ts' changed, executing +export const main = 'modified'; diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-cache-test/snapshots/positive negative globs - hit on excluded file.snap b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-cache-test/snapshots/positive negative globs - hit on excluded file.snap new file mode 100644 index 00000000..44f165f7 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-cache-test/snapshots/positive negative globs - hit on excluded file.snap @@ -0,0 +1,15 @@ +--- +source: crates/vite_task_bin/tests/e2e_snapshots/main.rs +expression: e2e_outputs +--- +> vp run positive-negative-globs +$ print-file src/main.ts +export const main = 'initial'; +> replace-file-content src/main.test.ts main modified + +> vp run positive-negative-globs +$ print-file src/main.ts ✓ cache hit, replaying +export const main = 'initial'; + +--- +[vp run] cache hit, saved. diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-cache-test/snapshots/positive negative globs - miss on non-excluded file.snap b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-cache-test/snapshots/positive negative globs - miss on non-excluded file.snap new file mode 100644 index 00000000..b67d6810 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-cache-test/snapshots/positive negative globs - miss on non-excluded file.snap @@ -0,0 +1,12 @@ +--- +source: crates/vite_task_bin/tests/e2e_snapshots/main.rs +expression: e2e_outputs +--- +> vp run positive-negative-globs +$ print-file src/main.ts +export const main = 'initial'; +> replace-file-content src/main.ts initial modified + +> vp run positive-negative-globs +$ print-file src/main.ts ✗ cache miss: content of input 'src/main.ts' changed, executing +export const main = 'modified'; diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-cache-test/src/main.test.ts b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-cache-test/src/main.test.ts new file mode 100644 index 00000000..3a3ca296 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-cache-test/src/main.test.ts @@ -0,0 +1 @@ +test('main', () => {}); diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-cache-test/src/main.ts b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-cache-test/src/main.ts new file mode 100644 index 00000000..38e000a1 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-cache-test/src/main.ts @@ -0,0 +1 @@ +export const main = 'initial'; diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-cache-test/src/utils.ts b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-cache-test/src/utils.ts new file mode 100644 index 00000000..e5b6e206 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-cache-test/src/utils.ts @@ -0,0 +1 @@ +export const utils = 'initial'; diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-cache-test/test/main.test.ts b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-cache-test/test/main.test.ts new file mode 100644 index 00000000..12d25c9c --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-cache-test/test/main.test.ts @@ -0,0 +1 @@ +// test file outside src/ diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-cache-test/vite-task.json b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-cache-test/vite-task.json new file mode 100644 index 00000000..dfbf380b --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-cache-test/vite-task.json @@ -0,0 +1,49 @@ +{ + "tasks": { + "positive-globs-only": { + "command": "print-file src/main.ts", + "inputs": ["src/**/*.ts"], + "cache": true + }, + "positive-globs-reads-unmatched": { + "command": "print-file src/main.ts src/utils.ts", + "inputs": ["src/main.ts"], + "cache": true + }, + "positive-negative-globs": { + "command": "print-file src/main.ts", + "inputs": ["src/**", "!src/**/*.test.ts"], + "cache": true + }, + "auto-only": { + "command": "print-file src/main.ts", + "inputs": [{ "auto": true }], + "cache": true + }, + "auto-with-negative": { + "command": "print-file src/main.ts dist/output.js", + "inputs": [{ "auto": true }, "!dist/**"], + "cache": true + }, + "positive-auto-negative": { + "command": "print-file src/main.ts", + "inputs": ["package.json", { "auto": true }, "!src/**/*.test.ts"], + "cache": true + }, + "empty-inputs": { + "command": "print-file src/main.ts", + "inputs": [], + "cache": true + }, + "check-fspy-env-with-auto": { + "command": "print-env FSPY", + "inputs": [{ "auto": true }], + "cache": true + }, + "check-fspy-env-without-auto": { + "command": "print-env FSPY", + "inputs": ["src/**/*.ts"], + "cache": true + } + } +} diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-negative-glob-subpackage/package.json b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-negative-glob-subpackage/package.json new file mode 100644 index 00000000..84faec77 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-negative-glob-subpackage/package.json @@ -0,0 +1,4 @@ +{ + "name": "inputs-negative-glob-subpackage", + "private": true +} diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-negative-glob-subpackage/packages/shared/dist/output.js b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-negative-glob-subpackage/packages/shared/dist/output.js new file mode 100644 index 00000000..d8d5d210 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-negative-glob-subpackage/packages/shared/dist/output.js @@ -0,0 +1 @@ +// initial output diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-negative-glob-subpackage/packages/shared/package.json b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-negative-glob-subpackage/packages/shared/package.json new file mode 100644 index 00000000..590047bc --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-negative-glob-subpackage/packages/shared/package.json @@ -0,0 +1,4 @@ +{ + "name": "shared", + "private": true +} diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-negative-glob-subpackage/packages/shared/src/utils.ts b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-negative-glob-subpackage/packages/shared/src/utils.ts new file mode 100644 index 00000000..34f12be3 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-negative-glob-subpackage/packages/shared/src/utils.ts @@ -0,0 +1 @@ +export const shared = 'initial'; diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-negative-glob-subpackage/packages/sub-pkg/dist/output.js b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-negative-glob-subpackage/packages/sub-pkg/dist/output.js new file mode 100644 index 00000000..d8d5d210 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-negative-glob-subpackage/packages/sub-pkg/dist/output.js @@ -0,0 +1 @@ +// initial output diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-negative-glob-subpackage/packages/sub-pkg/package.json b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-negative-glob-subpackage/packages/sub-pkg/package.json new file mode 100644 index 00000000..e0caa109 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-negative-glob-subpackage/packages/sub-pkg/package.json @@ -0,0 +1,4 @@ +{ + "name": "sub-pkg", + "private": true +} diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-negative-glob-subpackage/packages/sub-pkg/src/main.ts b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-negative-glob-subpackage/packages/sub-pkg/src/main.ts new file mode 100644 index 00000000..38e000a1 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-negative-glob-subpackage/packages/sub-pkg/src/main.ts @@ -0,0 +1 @@ +export const main = 'initial'; diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-negative-glob-subpackage/packages/sub-pkg/vite-task.json b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-negative-glob-subpackage/packages/sub-pkg/vite-task.json new file mode 100644 index 00000000..91096c33 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-negative-glob-subpackage/packages/sub-pkg/vite-task.json @@ -0,0 +1,24 @@ +{ + "tasks": { + "auto-with-negative": { + "command": "print-file src/main.ts dist/output.js", + "inputs": [{ "auto": true }, "!dist/**"], + "cache": true + }, + "dotdot-positive": { + "command": "print-file ../shared/src/utils.ts", + "inputs": ["../shared/src/**"], + "cache": true + }, + "dotdot-positive-negative": { + "command": "print-file ../shared/src/utils.ts ../shared/dist/output.js", + "inputs": ["../shared/**", "!../shared/dist/**"], + "cache": true + }, + "dotdot-auto-negative": { + "command": "print-file ../shared/src/utils.ts ../shared/dist/output.js", + "inputs": [{ "auto": true }, "!../shared/dist/**"], + "cache": true + } + } +} diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-negative-glob-subpackage/pnpm-workspace.yaml b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-negative-glob-subpackage/pnpm-workspace.yaml new file mode 100644 index 00000000..18ec407e --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-negative-glob-subpackage/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +packages: + - 'packages/*' diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-negative-glob-subpackage/snapshots.toml b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-negative-glob-subpackage/snapshots.toml new file mode 100644 index 00000000..51bac65f --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-negative-glob-subpackage/snapshots.toml @@ -0,0 +1,98 @@ +# Test that negative input globs work correctly for subpackages. +# Bug: negative globs were matched against workspace-relative paths +# instead of package-relative paths, so exclusions like !dist/** +# failed for subpackages. + +# Auto + negative in subpackage: inputs: [{ "auto": true }, "!dist/**"] +# - dist/ should be excluded even though the package is a subpackage +# - Modifying dist/output.js should be a cache hit +[[e2e]] +name = "subpackage auto with negative - hit on excluded inferred file" +steps = [ + # First run - reads both src/main.ts and dist/output.js + "vp run sub-pkg#auto-with-negative", + # Modify file in excluded directory (dist/) + "replace-file-content packages/sub-pkg/dist/output.js initial modified", + # Cache hit: dist/ is excluded by negative glob + "vp run sub-pkg#auto-with-negative", +] + +[[e2e]] +name = "subpackage auto with negative - miss on non-excluded inferred file" +steps = [ + # First run + "vp run sub-pkg#auto-with-negative", + # Modify file NOT in excluded directory + "replace-file-content packages/sub-pkg/src/main.ts initial modified", + # Cache miss: inferred input changed + "vp run sub-pkg#auto-with-negative", +] + +# .. prefix positive globs: inputs: ["../shared/src/**"] +# - Should find files in sibling package via .. +# - Cache miss when sibling file changes, hit when unrelated file changes +[[e2e]] +name = "dotdot positive glob - miss on sibling file change" +steps = [ + "vp run sub-pkg#dotdot-positive", + # Modify a file that matches ../shared/src/** + "replace-file-content packages/shared/src/utils.ts initial modified", + # Cache miss: matched file changed + "vp run sub-pkg#dotdot-positive", +] + +[[e2e]] +name = "dotdot positive glob - hit on unmatched file change" +steps = [ + "vp run sub-pkg#dotdot-positive", + # Modify a file NOT matched by ../shared/src/** + "replace-file-content packages/shared/dist/output.js initial modified", + # Cache hit: file not in inputs + "vp run sub-pkg#dotdot-positive", +] + +# .. prefix positive + negative globs: inputs: ["../shared/**", "!../shared/dist/**"] +# - Positive glob matches sibling package files via .. +# - Negative glob excludes sibling dist/ via .. +[[e2e]] +name = "dotdot positive negative - miss on non-excluded sibling file" +steps = [ + "vp run sub-pkg#dotdot-positive-negative", + # Modify file matching positive but NOT negative + "replace-file-content packages/shared/src/utils.ts initial modified", + # Cache miss: file changed + "vp run sub-pkg#dotdot-positive-negative", +] + +[[e2e]] +name = "dotdot positive negative - hit on excluded sibling file" +steps = [ + "vp run sub-pkg#dotdot-positive-negative", + # Modify file in excluded sibling dist/ + "replace-file-content packages/shared/dist/output.js initial modified", + # Cache hit: excluded by !../shared/dist/** + "vp run sub-pkg#dotdot-positive-negative", +] + +# .. prefix auto + negative: inputs: [{ "auto": true }, "!../shared/dist/**"] +# - Auto-inferred files from sibling package are tracked +# - Negative glob excludes sibling dist/ from inferred inputs +[[e2e]] +name = "dotdot auto negative - hit on excluded sibling inferred file" +steps = [ + "vp run sub-pkg#dotdot-auto-negative", + # Modify file in excluded sibling dist/ + "replace-file-content packages/shared/dist/output.js initial modified", + # Cache hit: excluded by !../shared/dist/** + "vp run sub-pkg#dotdot-auto-negative", +] + +[[e2e]] +name = "dotdot auto negative - miss on non-excluded sibling inferred file" +steps = [ + "vp run sub-pkg#dotdot-auto-negative", + # Modify non-excluded sibling file + "replace-file-content packages/shared/src/utils.ts initial modified", + # Cache miss: inferred input changed + "vp run sub-pkg#dotdot-auto-negative", +] diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-negative-glob-subpackage/snapshots/dotdot auto negative - hit on excluded sibling inferred file.snap b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-negative-glob-subpackage/snapshots/dotdot auto negative - hit on excluded sibling inferred file.snap new file mode 100644 index 00000000..3a127e1f --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-negative-glob-subpackage/snapshots/dotdot auto negative - hit on excluded sibling inferred file.snap @@ -0,0 +1,17 @@ +--- +source: crates/vite_task_bin/tests/e2e_snapshots/main.rs +expression: e2e_outputs +--- +> vp run sub-pkg#dotdot-auto-negative +~/packages/sub-pkg$ print-file ../shared/src/utils.ts ../shared/dist/output.js +export const shared = 'initial'; +// initial output +> replace-file-content packages/shared/dist/output.js initial modified + +> vp run sub-pkg#dotdot-auto-negative +~/packages/sub-pkg$ print-file ../shared/src/utils.ts ../shared/dist/output.js ✓ cache hit, replaying +export const shared = 'initial'; +// initial output + +--- +[vp run] cache hit, saved. diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-negative-glob-subpackage/snapshots/dotdot auto negative - miss on non-excluded sibling inferred file.snap b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-negative-glob-subpackage/snapshots/dotdot auto negative - miss on non-excluded sibling inferred file.snap new file mode 100644 index 00000000..3a6b1ca1 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-negative-glob-subpackage/snapshots/dotdot auto negative - miss on non-excluded sibling inferred file.snap @@ -0,0 +1,14 @@ +--- +source: crates/vite_task_bin/tests/e2e_snapshots/main.rs +expression: e2e_outputs +--- +> vp run sub-pkg#dotdot-auto-negative +~/packages/sub-pkg$ print-file ../shared/src/utils.ts ../shared/dist/output.js +export const shared = 'initial'; +// initial output +> replace-file-content packages/shared/src/utils.ts initial modified + +> vp run sub-pkg#dotdot-auto-negative +~/packages/sub-pkg$ print-file ../shared/src/utils.ts ../shared/dist/output.js ✗ cache miss: content of input 'packages/shared/src/utils.ts' changed, executing +export const shared = 'modified'; +// initial output diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-negative-glob-subpackage/snapshots/dotdot positive glob - hit on unmatched file change.snap b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-negative-glob-subpackage/snapshots/dotdot positive glob - hit on unmatched file change.snap new file mode 100644 index 00000000..4f32efef --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-negative-glob-subpackage/snapshots/dotdot positive glob - hit on unmatched file change.snap @@ -0,0 +1,15 @@ +--- +source: crates/vite_task_bin/tests/e2e_snapshots/main.rs +expression: e2e_outputs +--- +> vp run sub-pkg#dotdot-positive +~/packages/sub-pkg$ print-file ../shared/src/utils.ts +export const shared = 'initial'; +> replace-file-content packages/shared/dist/output.js initial modified + +> vp run sub-pkg#dotdot-positive +~/packages/sub-pkg$ print-file ../shared/src/utils.ts ✓ cache hit, replaying +export const shared = 'initial'; + +--- +[vp run] cache hit, saved. diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-negative-glob-subpackage/snapshots/dotdot positive glob - miss on sibling file change.snap b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-negative-glob-subpackage/snapshots/dotdot positive glob - miss on sibling file change.snap new file mode 100644 index 00000000..385f2fce --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-negative-glob-subpackage/snapshots/dotdot positive glob - miss on sibling file change.snap @@ -0,0 +1,12 @@ +--- +source: crates/vite_task_bin/tests/e2e_snapshots/main.rs +expression: e2e_outputs +--- +> vp run sub-pkg#dotdot-positive +~/packages/sub-pkg$ print-file ../shared/src/utils.ts +export const shared = 'initial'; +> replace-file-content packages/shared/src/utils.ts initial modified + +> vp run sub-pkg#dotdot-positive +~/packages/sub-pkg$ print-file ../shared/src/utils.ts ✗ cache miss: content of input 'packages/shared/src/utils.ts' changed, executing +export const shared = 'modified'; diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-negative-glob-subpackage/snapshots/dotdot positive negative - hit on excluded sibling file.snap b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-negative-glob-subpackage/snapshots/dotdot positive negative - hit on excluded sibling file.snap new file mode 100644 index 00000000..08d6027c --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-negative-glob-subpackage/snapshots/dotdot positive negative - hit on excluded sibling file.snap @@ -0,0 +1,17 @@ +--- +source: crates/vite_task_bin/tests/e2e_snapshots/main.rs +expression: e2e_outputs +--- +> vp run sub-pkg#dotdot-positive-negative +~/packages/sub-pkg$ print-file ../shared/src/utils.ts ../shared/dist/output.js +export const shared = 'initial'; +// initial output +> replace-file-content packages/shared/dist/output.js initial modified + +> vp run sub-pkg#dotdot-positive-negative +~/packages/sub-pkg$ print-file ../shared/src/utils.ts ../shared/dist/output.js ✓ cache hit, replaying +export const shared = 'initial'; +// initial output + +--- +[vp run] cache hit, saved. diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-negative-glob-subpackage/snapshots/dotdot positive negative - miss on non-excluded sibling file.snap b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-negative-glob-subpackage/snapshots/dotdot positive negative - miss on non-excluded sibling file.snap new file mode 100644 index 00000000..735a8bdb --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-negative-glob-subpackage/snapshots/dotdot positive negative - miss on non-excluded sibling file.snap @@ -0,0 +1,14 @@ +--- +source: crates/vite_task_bin/tests/e2e_snapshots/main.rs +expression: e2e_outputs +--- +> vp run sub-pkg#dotdot-positive-negative +~/packages/sub-pkg$ print-file ../shared/src/utils.ts ../shared/dist/output.js +export const shared = 'initial'; +// initial output +> replace-file-content packages/shared/src/utils.ts initial modified + +> vp run sub-pkg#dotdot-positive-negative +~/packages/sub-pkg$ print-file ../shared/src/utils.ts ../shared/dist/output.js ✗ cache miss: content of input 'packages/shared/src/utils.ts' changed, executing +export const shared = 'modified'; +// initial output diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-negative-glob-subpackage/snapshots/subpackage auto with negative - hit on excluded inferred file.snap b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-negative-glob-subpackage/snapshots/subpackage auto with negative - hit on excluded inferred file.snap new file mode 100644 index 00000000..d0dc00e6 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-negative-glob-subpackage/snapshots/subpackage auto with negative - hit on excluded inferred file.snap @@ -0,0 +1,17 @@ +--- +source: crates/vite_task_bin/tests/e2e_snapshots/main.rs +expression: e2e_outputs +--- +> vp run sub-pkg#auto-with-negative +~/packages/sub-pkg$ print-file src/main.ts dist/output.js +export const main = 'initial'; +// initial output +> replace-file-content packages/sub-pkg/dist/output.js initial modified + +> vp run sub-pkg#auto-with-negative +~/packages/sub-pkg$ print-file src/main.ts dist/output.js ✓ cache hit, replaying +export const main = 'initial'; +// initial output + +--- +[vp run] cache hit, saved. diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-negative-glob-subpackage/snapshots/subpackage auto with negative - miss on non-excluded inferred file.snap b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-negative-glob-subpackage/snapshots/subpackage auto with negative - miss on non-excluded inferred file.snap new file mode 100644 index 00000000..8d615517 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-negative-glob-subpackage/snapshots/subpackage auto with negative - miss on non-excluded inferred file.snap @@ -0,0 +1,14 @@ +--- +source: crates/vite_task_bin/tests/e2e_snapshots/main.rs +expression: e2e_outputs +--- +> vp run sub-pkg#auto-with-negative +~/packages/sub-pkg$ print-file src/main.ts dist/output.js +export const main = 'initial'; +// initial output +> replace-file-content packages/sub-pkg/src/main.ts initial modified + +> vp run sub-pkg#auto-with-negative +~/packages/sub-pkg$ print-file src/main.ts dist/output.js ✗ cache miss: content of input 'packages/sub-pkg/src/main.ts' changed, executing +export const main = 'modified'; +// initial output diff --git a/crates/vite_task_graph/Cargo.toml b/crates/vite_task_graph/Cargo.toml index 41a53db6..122bc817 100644 --- a/crates/vite_task_graph/Cargo.toml +++ b/crates/vite_task_graph/Cargo.toml @@ -9,6 +9,7 @@ rust-version.workspace = true [dependencies] anyhow = { workspace = true } async-trait = { workspace = true } +bincode = { workspace = true } monostate = { workspace = true } petgraph = { workspace = true } rustc-hash = { workspace = true } diff --git a/crates/vite_task_graph/run-config.ts b/crates/vite_task_graph/run-config.ts index 0ab58364..9b5b544e 100644 --- a/crates/vite_task_graph/run-config.ts +++ b/crates/vite_task_graph/run-config.ts @@ -29,6 +29,26 @@ export type Task = { * Environment variable names to be passed to the task without fingerprinting. */ passThroughEnvs?: Array; + /** + * Input patterns for cache fingerprinting. + * + * - Omitted: defaults to `[{auto: true}]` - infer from file accesses + * - Empty array: no inputs, inference disabled + * - Glob strings: explicit files to fingerprint + * - `{auto: true}`: enable automatic inference via fspy + * - Negative globs: exclude files (prefix with `!`) + * + * Globs are relative to the package directory where the task is defined. + */ + inputs?: Array< + | string + | { + /** + * Whether automatic file access inference (via fspy) is enabled + */ + auto: boolean; + } + >; } | { /** diff --git a/crates/vite_task_graph/src/config/mod.rs b/crates/vite_task_graph/src/config/mod.rs index 34ba3d8c..1dec96b3 100644 --- a/crates/vite_task_graph/src/config/mod.rs +++ b/crates/vite_task_graph/src/config/mod.rs @@ -1,13 +1,14 @@ pub mod user; -use std::sync::Arc; +use std::{collections::BTreeSet, sync::Arc}; +use bincode::{Decode, Encode}; use monostate::MustBe; use rustc_hash::FxHashSet; use serde::Serialize; pub use user::{ EnabledCacheConfig, ResolvedGlobalCacheConfig, UserCacheConfig, UserGlobalCacheConfig, - UserRunConfig, UserTaskConfig, + UserInputEntry, UserInputsConfig, UserRunConfig, UserTaskConfig, }; use vite_path::AbsolutePath; use vite_str::Str; @@ -60,6 +61,10 @@ impl ResolvedTaskOptions { .into_iter() .collect(); pass_through_envs.extend(DEFAULT_PASSTHROUGH_ENVS.iter().copied().map(Str::from)); + + let input_config = + ResolvedInputConfig::from_user_config(enabled_cache_config.inputs.as_ref()); + Some(CacheConfig { env_config: EnvConfig { fingerprinted_envs: enabled_cache_config @@ -68,6 +73,7 @@ impl ResolvedTaskOptions { .unwrap_or_default(), pass_through_envs, }, + input_config, }) } }; @@ -78,6 +84,81 @@ impl ResolvedTaskOptions { #[derive(Debug, Clone, Serialize)] pub struct CacheConfig { pub env_config: EnvConfig, + pub input_config: ResolvedInputConfig, +} + +/// Resolved input configuration for cache fingerprinting. +/// +/// This is the normalized form after parsing user config. +/// - `includes_auto`: Whether automatic inference from fspy is enabled +/// - `positive_globs`: Glob patterns for files to include (without `!` prefix) +/// - `negative_globs`: Glob patterns for files to exclude (without `!` prefix) +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Encode, Decode)] +pub struct ResolvedInputConfig { + /// Whether automatic file access inference (via fspy) is enabled + pub includes_auto: bool, + + /// Positive glob patterns (files to include). + /// Sorted for deterministic cache keys. + pub positive_globs: BTreeSet, + + /// Negative glob patterns (files to exclude, without the `!` prefix). + /// Sorted for deterministic cache keys. + pub negative_globs: BTreeSet, +} + +impl ResolvedInputConfig { + /// Default configuration: auto-inference enabled, no explicit patterns + #[must_use] + pub const fn default_auto() -> Self { + Self { + includes_auto: true, + positive_globs: BTreeSet::new(), + negative_globs: BTreeSet::new(), + } + } + + /// Resolve from user configuration. + /// + /// - `None`: defaults to auto-inference (`[{auto: true}]`) + /// - `Some([])`: no inputs, inference disabled + /// - `Some([...])`: explicit patterns + #[must_use] + pub fn from_user_config(user_inputs: Option<&UserInputsConfig>) -> Self { + let Some(entries) = user_inputs else { + // None means default to auto-inference + return Self::default_auto(); + }; + + let mut includes_auto = false; + let mut positive_globs = BTreeSet::new(); + let mut negative_globs = BTreeSet::new(); + + for entry in entries { + match entry { + UserInputEntry::Auto { auto: true } => includes_auto = true, + UserInputEntry::Auto { auto: false } => {} // Ignore {auto: false} + UserInputEntry::Glob(pattern) => { + if let Some(negated) = pattern.strip_prefix('!') { + negative_globs.insert(Str::from(negated)); + } else { + positive_globs.insert(pattern.clone()); + } + } + } + } + + Self { includes_auto, positive_globs, negative_globs } + } + + /// Returns true if inference should be disabled. + /// + /// Inference is disabled when `includes_auto` is false. + #[inline] + #[must_use] + pub const fn inference_disabled(&self) -> bool { + !self.includes_auto + } } #[derive(Debug, Clone, Serialize)] @@ -203,3 +284,111 @@ pub const DEFAULT_PASSTHROUGH_ENVS: &[&str] = &[ // Token patterns "*_TOKEN", ]; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_resolved_input_config_default_auto() { + let config = ResolvedInputConfig::default_auto(); + assert!(config.includes_auto); + assert!(config.positive_globs.is_empty()); + assert!(config.negative_globs.is_empty()); + assert!(!config.inference_disabled()); + } + + #[test] + fn test_resolved_input_config_from_none() { + // None means default to auto-inference + let config = ResolvedInputConfig::from_user_config(None); + assert!(config.includes_auto); + assert!(config.positive_globs.is_empty()); + assert!(config.negative_globs.is_empty()); + } + + #[test] + fn test_resolved_input_config_empty_array() { + // Empty array means no inputs, inference disabled + let user_inputs = vec![]; + let config = ResolvedInputConfig::from_user_config(Some(&user_inputs)); + assert!(!config.includes_auto); + assert!(config.positive_globs.is_empty()); + assert!(config.negative_globs.is_empty()); + assert!(config.inference_disabled()); + } + + #[test] + fn test_resolved_input_config_auto_only() { + let user_inputs = vec![UserInputEntry::Auto { auto: true }]; + let config = ResolvedInputConfig::from_user_config(Some(&user_inputs)); + assert!(config.includes_auto); + assert!(config.positive_globs.is_empty()); + assert!(config.negative_globs.is_empty()); + } + + #[test] + fn test_resolved_input_config_auto_false_ignored() { + let user_inputs = vec![UserInputEntry::Auto { auto: false }]; + let config = ResolvedInputConfig::from_user_config(Some(&user_inputs)); + assert!(!config.includes_auto); + assert!(config.positive_globs.is_empty()); + assert!(config.negative_globs.is_empty()); + } + + #[test] + fn test_resolved_input_config_globs_only() { + // Globs without auto means inference disabled + let user_inputs = vec![ + UserInputEntry::Glob("src/**/*.ts".into()), + UserInputEntry::Glob("package.json".into()), + ]; + let config = ResolvedInputConfig::from_user_config(Some(&user_inputs)); + assert!(!config.includes_auto); + assert_eq!(config.positive_globs.len(), 2); + assert!(config.positive_globs.contains("src/**/*.ts")); + assert!(config.positive_globs.contains("package.json")); + assert!(config.negative_globs.is_empty()); + assert!(config.inference_disabled()); + } + + #[test] + fn test_resolved_input_config_negative_globs() { + let user_inputs = vec![ + UserInputEntry::Glob("src/**".into()), + UserInputEntry::Glob("!src/**/*.test.ts".into()), + ]; + let config = ResolvedInputConfig::from_user_config(Some(&user_inputs)); + assert!(!config.includes_auto); + assert_eq!(config.positive_globs.len(), 1); + assert!(config.positive_globs.contains("src/**")); + assert_eq!(config.negative_globs.len(), 1); + assert!(config.negative_globs.contains("src/**/*.test.ts")); // Without ! prefix + } + + #[test] + fn test_resolved_input_config_mixed() { + let user_inputs = vec![ + UserInputEntry::Glob("package.json".into()), + UserInputEntry::Auto { auto: true }, + UserInputEntry::Glob("!node_modules/**".into()), + ]; + let config = ResolvedInputConfig::from_user_config(Some(&user_inputs)); + assert!(config.includes_auto); + assert_eq!(config.positive_globs.len(), 1); + assert!(config.positive_globs.contains("package.json")); + assert_eq!(config.negative_globs.len(), 1); + assert!(config.negative_globs.contains("node_modules/**")); + assert!(!config.inference_disabled()); + } + + #[test] + fn test_resolved_input_config_globs_with_auto() { + // Globs with auto keeps inference enabled + let user_inputs = + vec![UserInputEntry::Glob("src/**/*.ts".into()), UserInputEntry::Auto { auto: true }]; + let config = ResolvedInputConfig::from_user_config(Some(&user_inputs)); + assert!(config.includes_auto); + assert!(!config.inference_disabled()); + } +} diff --git a/crates/vite_task_graph/src/config/user.rs b/crates/vite_task_graph/src/config/user.rs index 1e8c552a..50de89e6 100644 --- a/crates/vite_task_graph/src/config/user.rs +++ b/crates/vite_task_graph/src/config/user.rs @@ -10,6 +10,28 @@ use ts_rs::TS; use vite_path::RelativePathBuf; use vite_str::Str; +/// A single input entry in the `inputs` array. +/// +/// Inputs can be either glob patterns (strings) or auto-inference directives (`{auto: true}`). +#[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 UserInputEntry { + /// Glob pattern (positive or negative starting with `!`) + Glob(Str), + /// Auto-inference directive + Auto { + /// Whether automatic file access inference (via fspy) is enabled + auto: bool, + }, +} + +/// The inputs configuration for cache fingerprinting. +/// +/// Default (when field omitted): `[{auto: true}]` - infer from file accesses. +pub type UserInputsConfig = Vec; + /// 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 @@ -58,6 +80,19 @@ pub struct EnabledCacheConfig { /// Environment variable names to be passed to the task without fingerprinting. pub pass_through_envs: Option>, + + /// Input patterns for cache fingerprinting. + /// + /// - Omitted: defaults to `[{auto: true}]` - infer from file accesses + /// - Empty array: no inputs, inference disabled + /// - Glob strings: explicit files to fingerprint + /// - `{auto: true}`: enable automatic inference via fspy + /// - Negative globs: exclude files (prefix with `!`) + /// + /// Globs are relative to the package directory where the task is defined. + #[serde(default)] + #[cfg_attr(all(test, not(clippy)), ts(inline))] + pub inputs: Option, } /// Options for user-defined tasks in `vite.config.*`, excluding the command. @@ -89,7 +124,11 @@ impl Default for UserTaskOptions { // Caching enabled with no fingerprinted envs cache_config: UserCacheConfig::Enabled { cache: None, - enabled_cache_config: EnabledCacheConfig { envs: None, pass_through_envs: None }, + enabled_cache_config: EnabledCacheConfig { + envs: None, + pass_through_envs: None, + inputs: None, + }, }, } } @@ -374,11 +413,95 @@ mod tests { enabled_cache_config: EnabledCacheConfig { envs: Some(std::iter::once("NODE_ENV".into()).collect()), pass_through_envs: Some(std::iter::once("FOO".into()).collect()), + inputs: None, } }, ); } + #[test] + fn test_inputs_empty_array() { + let user_config_json = json!({ + "inputs": [] + }); + let config: EnabledCacheConfig = serde_json::from_value(user_config_json).unwrap(); + assert_eq!(config.inputs, Some(vec![])); + } + + #[test] + fn test_inputs_auto_true() { + let user_config_json = json!({ + "inputs": [{ "auto": true }] + }); + let config: EnabledCacheConfig = serde_json::from_value(user_config_json).unwrap(); + assert_eq!(config.inputs, Some(vec![UserInputEntry::Auto { auto: true }])); + } + + #[test] + fn test_inputs_auto_false() { + let user_config_json = json!({ + "inputs": [{ "auto": false }] + }); + let config: EnabledCacheConfig = serde_json::from_value(user_config_json).unwrap(); + assert_eq!(config.inputs, Some(vec![UserInputEntry::Auto { auto: false }])); + } + + #[test] + fn test_inputs_globs() { + let user_config_json = json!({ + "inputs": ["src/**/*.ts", "package.json"] + }); + let config: EnabledCacheConfig = serde_json::from_value(user_config_json).unwrap(); + assert_eq!( + config.inputs, + Some(vec![ + UserInputEntry::Glob("src/**/*.ts".into()), + UserInputEntry::Glob("package.json".into()), + ]) + ); + } + + #[test] + fn test_inputs_negative_globs() { + let user_config_json = json!({ + "inputs": ["src/**", "!src/**/*.test.ts"] + }); + let config: EnabledCacheConfig = serde_json::from_value(user_config_json).unwrap(); + assert_eq!( + config.inputs, + Some(vec![ + UserInputEntry::Glob("src/**".into()), + UserInputEntry::Glob("!src/**/*.test.ts".into()), + ]) + ); + } + + #[test] + fn test_inputs_mixed() { + let user_config_json = json!({ + "inputs": ["package.json", { "auto": true }, "!node_modules/**"] + }); + let config: EnabledCacheConfig = serde_json::from_value(user_config_json).unwrap(); + assert_eq!( + config.inputs, + Some(vec![ + UserInputEntry::Glob("package.json".into()), + UserInputEntry::Auto { auto: true }, + UserInputEntry::Glob("!node_modules/**".into()), + ]) + ); + } + + #[test] + fn test_inputs_with_cache_false_error() { + // inputs with cache: false should produce a serde error due to deny_unknown_fields + let user_config_json = json!({ + "cache": false, + "inputs": ["src/**"] + }); + assert!(serde_json::from_value::(user_config_json).is_err()); + } + #[test] fn test_cache_disabled_but_with_fields() { let user_config_json = json!({ diff --git a/crates/vite_task_plan/src/cache_metadata.rs b/crates/vite_task_plan/src/cache_metadata.rs index 9fcb3215..fe73c1fa 100644 --- a/crates/vite_task_plan/src/cache_metadata.rs +++ b/crates/vite_task_plan/src/cache_metadata.rs @@ -2,8 +2,9 @@ use std::sync::Arc; use bincode::{Decode, Encode}; use serde::{Deserialize, Serialize}; -use vite_path::RelativePathBuf; +use vite_path::{AbsolutePath, RelativePathBuf}; use vite_str::{self, Str}; +use vite_task_graph::config::ResolvedInputConfig; use crate::envs::EnvFingerprints; @@ -34,13 +35,21 @@ pub enum ExecutionCacheKey { /// It only contains information needed for hitting existing cache entries pre-execution. /// It doesn't contain any post-execution information like file fingerprints /// (which needs actual execution and is out of scope for planning). -#[derive(Debug, Encode, Decode, Serialize)] +#[derive(Debug, Serialize)] pub struct CacheMetadata { /// Fingerprint for spawn execution that affects caching. pub spawn_fingerprint: SpawnFingerprint, /// Key to identify an execution across sessions. pub execution_cache_key: ExecutionCacheKey, + + /// Resolved input configuration for cache fingerprinting. + /// Used at execution time to determine what files to track. + pub input_config: ResolvedInputConfig, + + /// Absolute base directory for glob patterns. + /// This is the package path where the task is defined. + pub glob_base: Arc, } /// Fingerprint for spawn execution that affects caching. @@ -62,32 +71,15 @@ pub struct CacheMetadata { /// - The resolver provides envs which become part of the fingerprint /// - If resolver provides different envs between runs, cache breaks /// - Each built-in task type must have unique task name to avoid cache collision -/// -/// # Fingerprint Ignores Impact on Cache -/// -/// The `fingerprint_ignores` field controls which files are tracked in `PostRunFingerprint`: -/// - Changes to this config must invalidate the cache -/// - Vec maintains insertion order (pattern order matters for last-match-wins semantics) -/// - Even though ignore patterns only affect `PostRunFingerprint`, the config itself is part of the cache key #[derive(Encode, Decode, Debug, Serialize, Deserialize, PartialEq, Eq, Clone)] pub struct SpawnFingerprint { pub(crate) cwd: RelativePathBuf, pub(crate) program_fingerprint: ProgramFingerprint, pub(crate) args: Arc<[Str]>, pub(crate) env_fingerprints: EnvFingerprints, - - /// Glob patterns for fingerprint filtering. Order matters (last match wins). - /// Changes to this config invalidate the cache to ensure correct fingerprint tracking. - pub(crate) fingerprint_ignores: Option>, } impl SpawnFingerprint { - /// Get the fingerprint ignores patterns. - #[must_use] - pub const fn fingerprint_ignores(&self) -> Option<&Vec> { - self.fingerprint_ignores.as_ref() - } - /// Get the environment fingerprints. #[must_use] pub const fn env_fingerprints(&self) -> &EnvFingerprints { diff --git a/crates/vite_task_plan/src/plan.rs b/crates/vite_task_plan/src/plan.rs index 0311f6e4..c7126162 100644 --- a/crates/vite_task_plan/src/plan.rs +++ b/crates/vite_task_plan/src/plan.rs @@ -285,6 +285,7 @@ async fn plan_task_as_execution_node( &script_command.envs, program_path, script_command.args, + package_path, )?; ExecutionItemKind::Leaf(LeafExecutionKind::Spawn(spawn_execution)) } @@ -350,6 +351,7 @@ async fn plan_task_as_execution_node( context.envs(), Arc::clone(&*SHELL_PROGRAM_PATH), SHELL_ARGS.iter().map(|s| Str::from(*s)).chain(std::iter::once(script)).collect(), + package_path, )?; items.push(ExecutionItem { execution_item_display, @@ -454,6 +456,7 @@ pub fn plan_synthetic_request( &envs, program_path, args, + cwd, // For synthetic requests, the package path is the cwd ) } @@ -479,6 +482,7 @@ fn strip_prefix_for_cache( clippy::needless_pass_by_value, reason = "program_path ownership is needed for Arc construction" )] +#[expect(clippy::too_many_arguments, reason = "internal function with closely-related parameters")] fn plan_spawn_execution( workspace_path: &Arc, execution_cache_key: Option, @@ -487,6 +491,7 @@ fn plan_spawn_execution( envs: &FxHashMap, Arc>, program_path: Arc, args: Arc<[Str]>, + package_path: &Arc, ) -> Result { // all envs available in the current context let mut all_envs = envs.clone(); @@ -544,11 +549,14 @@ fn plan_spawn_execution( program_fingerprint, args: Arc::clone(&args), env_fingerprints, - fingerprint_ignores: None, }; if let Some(execution_cache_key) = execution_cache_key { - resolved_cache_metadata = - Some(CacheMetadata { spawn_fingerprint, execution_cache_key }); + resolved_cache_metadata = Some(CacheMetadata { + spawn_fingerprint, + execution_cache_key, + input_config: cache_config.input_config.clone(), + glob_base: Arc::clone(package_path), + }); } } diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/additional-envs/snapshots/query - env-test synthetic task in user task.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/additional-envs/snapshots/query - env-test synthetic task in user task.snap index 94d675ad..a85032b1 100644 --- a/crates/vite_task_plan/tests/plan_snapshots/fixtures/additional-envs/snapshots/query - env-test synthetic task in user task.snap +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/additional-envs/snapshots/query - env-test synthetic task in user task.snap @@ -51,8 +51,7 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/additional-envs "TEST_VAR", "" ] - }, - "fingerprint_ignores": null + } }, "execution_cache_key": { "UserTask": { @@ -61,7 +60,13 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/additional-envs "extra_args": [], "package_path": "" } - } + }, + "input_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] + }, + "glob_base": "/" }, "spawn_command": { "program_path": "/node_modules/.bin/print-env", diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/additional-envs/snapshots/task graph.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/additional-envs/snapshots/task graph.snap index 19119cb4..d98da2ef 100644 --- a/crates/vite_task_plan/tests/plan_snapshots/fixtures/additional-envs/snapshots/task graph.snap +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/additional-envs/snapshots/task graph.snap @@ -25,6 +25,11 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/additional-envs "pass_through_envs": [ "" ] + }, + "input_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] } } } @@ -54,6 +59,11 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/additional-envs "pass_through_envs": [ "" ] + }, + "input_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] } } } diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/cache-cli-override/snapshots/query - --cache enables script caching.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/cache-cli-override/snapshots/query - --cache enables script caching.snap index 06a1e725..39fc2f69 100644 --- a/crates/vite_task_plan/tests/plan_snapshots/fixtures/cache-cli-override/snapshots/query - --cache enables script caching.snap +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/cache-cli-override/snapshots/query - --cache enables script caching.snap @@ -51,8 +51,7 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/cache-cli-overri "pass_through_env_config": [ "" ] - }, - "fingerprint_ignores": null + } }, "execution_cache_key": { "UserTask": { @@ -61,7 +60,13 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/cache-cli-overri "extra_args": [], "package_path": "" } - } + }, + "input_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] + }, + "glob_base": "/" }, "spawn_command": { "program_path": "/node_modules/.bin/print-file", diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/cache-cli-override/snapshots/query - --cache enables task caching even when cache.tasks is false.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/cache-cli-override/snapshots/query - --cache enables task caching even when cache.tasks is false.snap index 0b09b38f..860ead0f 100644 --- a/crates/vite_task_plan/tests/plan_snapshots/fixtures/cache-cli-override/snapshots/query - --cache enables task caching even when cache.tasks is false.snap +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/cache-cli-override/snapshots/query - --cache enables task caching even when cache.tasks is false.snap @@ -51,8 +51,7 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/cache-cli-overri "pass_through_env_config": [ "" ] - }, - "fingerprint_ignores": null + } }, "execution_cache_key": { "UserTask": { @@ -61,7 +60,13 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/cache-cli-overri "extra_args": [], "package_path": "" } - } + }, + "input_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] + }, + "glob_base": "/" }, "spawn_command": { "program_path": "/node_modules/.bin/print-file", diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/cache-cli-override/snapshots/query - --cache on task with per-task cache true enables caching.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/cache-cli-override/snapshots/query - --cache on task with per-task cache true enables caching.snap index 517c6f69..a54be358 100644 --- a/crates/vite_task_plan/tests/plan_snapshots/fixtures/cache-cli-override/snapshots/query - --cache on task with per-task cache true enables caching.snap +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/cache-cli-override/snapshots/query - --cache on task with per-task cache true enables caching.snap @@ -51,8 +51,7 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/cache-cli-overri "pass_through_env_config": [ "" ] - }, - "fingerprint_ignores": null + } }, "execution_cache_key": { "UserTask": { @@ -61,7 +60,13 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/cache-cli-overri "extra_args": [], "package_path": "" } - } + }, + "input_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] + }, + "glob_base": "/" }, "spawn_command": { "program_path": "/node_modules/.bin/print-file", diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/cache-cli-override/snapshots/task graph.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/cache-cli-override/snapshots/task graph.snap index 59d13b75..afa12573 100644 --- a/crates/vite_task_plan/tests/plan_snapshots/fixtures/cache-cli-override/snapshots/task graph.snap +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/cache-cli-override/snapshots/task graph.snap @@ -25,6 +25,11 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/cache-cli-overri "pass_through_envs": [ "" ] + }, + "input_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] } } } @@ -54,6 +59,11 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/cache-cli-overri "pass_through_envs": [ "" ] + }, + "input_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] } } } @@ -105,6 +115,11 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/cache-cli-overri "pass_through_envs": [ "" ] + }, + "input_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] } } } @@ -134,6 +149,11 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/cache-cli-overri "pass_through_envs": [ "" ] + }, + "input_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] } } } diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/cache-keys/snapshots/query - echo and lint with extra args.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/cache-keys/snapshots/query - echo and lint with extra args.snap index 47472c37..94656ffb 100644 --- a/crates/vite_task_plan/tests/plan_snapshots/fixtures/cache-keys/snapshots/query - echo and lint with extra args.snap +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/cache-keys/snapshots/query - echo and lint with extra args.snap @@ -77,8 +77,7 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/cache-keys "pass_through_env_config": [ "" ] - }, - "fingerprint_ignores": null + } }, "execution_cache_key": { "UserTask": { @@ -89,7 +88,13 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/cache-keys ], "package_path": "" } - } + }, + "input_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] + }, + "glob_base": "/" }, "spawn_command": { "program_path": "/node_modules/.bin/oxlint", diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/cache-keys/snapshots/query - lint and echo with extra args.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/cache-keys/snapshots/query - lint and echo with extra args.snap index 9e967e68..c44a4ccb 100644 --- a/crates/vite_task_plan/tests/plan_snapshots/fixtures/cache-keys/snapshots/query - lint and echo with extra args.snap +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/cache-keys/snapshots/query - lint and echo with extra args.snap @@ -49,8 +49,7 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/cache-keys "pass_through_env_config": [ "" ] - }, - "fingerprint_ignores": null + } }, "execution_cache_key": { "UserTask": { @@ -59,7 +58,13 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/cache-keys "extra_args": [], "package_path": "" } - } + }, + "input_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] + }, + "glob_base": "/" }, "spawn_command": { "program_path": "/node_modules/.bin/oxlint", diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/cache-keys/snapshots/query - normal task with extra args.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/cache-keys/snapshots/query - normal task with extra args.snap index 85be7c84..c5214c24 100644 --- a/crates/vite_task_plan/tests/plan_snapshots/fixtures/cache-keys/snapshots/query - normal task with extra args.snap +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/cache-keys/snapshots/query - normal task with extra args.snap @@ -51,8 +51,7 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/cache-keys "pass_through_env_config": [ "" ] - }, - "fingerprint_ignores": null + } }, "execution_cache_key": { "UserTask": { @@ -63,7 +62,13 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/cache-keys ], "package_path": "" } - } + }, + "input_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] + }, + "glob_base": "/" }, "spawn_command": { "program_path": "/node_modules/.bin/print-file", diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/cache-keys/snapshots/query - synthetic task in user task with cwd.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/cache-keys/snapshots/query - synthetic task in user task with cwd.snap index 2463a68c..b20de8cc 100644 --- a/crates/vite_task_plan/tests/plan_snapshots/fixtures/cache-keys/snapshots/query - synthetic task in user task with cwd.snap +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/cache-keys/snapshots/query - synthetic task in user task with cwd.snap @@ -49,8 +49,7 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/cache-keys "pass_through_env_config": [ "" ] - }, - "fingerprint_ignores": null + } }, "execution_cache_key": { "UserTask": { @@ -59,7 +58,13 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/cache-keys "extra_args": [], "package_path": "" } - } + }, + "input_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] + }, + "glob_base": "/" }, "spawn_command": { "program_path": "/node_modules/.bin/oxlint", diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/cache-keys/snapshots/query - synthetic task in user task.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/cache-keys/snapshots/query - synthetic task in user task.snap index 0ea7bfa0..f2970ea8 100644 --- a/crates/vite_task_plan/tests/plan_snapshots/fixtures/cache-keys/snapshots/query - synthetic task in user task.snap +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/cache-keys/snapshots/query - synthetic task in user task.snap @@ -48,8 +48,7 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/cache-keys "pass_through_env_config": [ "" ] - }, - "fingerprint_ignores": null + } }, "execution_cache_key": { "UserTask": { @@ -58,7 +57,13 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/cache-keys "extra_args": [], "package_path": "" } - } + }, + "input_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] + }, + "glob_base": "/" }, "spawn_command": { "program_path": "/node_modules/.bin/oxlint", diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/cache-keys/snapshots/query - synthetic task with extra args in user task.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/cache-keys/snapshots/query - synthetic task with extra args in user task.snap index b2b5c3db..5452c492 100644 --- a/crates/vite_task_plan/tests/plan_snapshots/fixtures/cache-keys/snapshots/query - synthetic task with extra args in user task.snap +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/cache-keys/snapshots/query - synthetic task with extra args in user task.snap @@ -51,8 +51,7 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/cache-keys "pass_through_env_config": [ "" ] - }, - "fingerprint_ignores": null + } }, "execution_cache_key": { "UserTask": { @@ -63,7 +62,13 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/cache-keys ], "package_path": "" } - } + }, + "input_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] + }, + "glob_base": "/" }, "spawn_command": { "program_path": "/node_modules/.bin/oxlint", diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/cache-keys/snapshots/task graph.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/cache-keys/snapshots/task graph.snap index 1159aa12..71d9d8f8 100644 --- a/crates/vite_task_plan/tests/plan_snapshots/fixtures/cache-keys/snapshots/task graph.snap +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/cache-keys/snapshots/task graph.snap @@ -25,6 +25,11 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/cache-keys "pass_through_envs": [ "" ] + }, + "input_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] } } } @@ -54,6 +59,11 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/cache-keys "pass_through_envs": [ "" ] + }, + "input_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] } } } @@ -83,6 +93,11 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/cache-keys "pass_through_envs": [ "" ] + }, + "input_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] } } } @@ -112,6 +127,11 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/cache-keys "pass_through_envs": [ "" ] + }, + "input_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] } } } diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/cache-scripts-default/snapshots/task graph.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/cache-scripts-default/snapshots/task graph.snap index 852aa4cf..0baec88d 100644 --- a/crates/vite_task_plan/tests/plan_snapshots/fixtures/cache-scripts-default/snapshots/task graph.snap +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/cache-scripts-default/snapshots/task graph.snap @@ -25,6 +25,11 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/cache-scripts-de "pass_through_envs": [ "" ] + }, + "input_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] } } } @@ -54,6 +59,11 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/cache-scripts-de "pass_through_envs": [ "" ] + }, + "input_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] } } } diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/cache-scripts-enabled/snapshots/task graph.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/cache-scripts-enabled/snapshots/task graph.snap index fcaa05d4..5874e5ed 100644 --- a/crates/vite_task_plan/tests/plan_snapshots/fixtures/cache-scripts-enabled/snapshots/task graph.snap +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/cache-scripts-enabled/snapshots/task graph.snap @@ -25,6 +25,11 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/cache-scripts-en "pass_through_envs": [ "" ] + }, + "input_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] } } } @@ -54,6 +59,11 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/cache-scripts-en "pass_through_envs": [ "" ] + }, + "input_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] } } } diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/cache-scripts-task-override/snapshots/query - task cached by default.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/cache-scripts-task-override/snapshots/query - task cached by default.snap index 54a470d9..0f2a5fdd 100644 --- a/crates/vite_task_plan/tests/plan_snapshots/fixtures/cache-scripts-task-override/snapshots/query - task cached by default.snap +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/cache-scripts-task-override/snapshots/query - task cached by default.snap @@ -50,8 +50,7 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/cache-scripts-ta "pass_through_env_config": [ "" ] - }, - "fingerprint_ignores": null + } }, "execution_cache_key": { "UserTask": { @@ -60,7 +59,13 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/cache-scripts-ta "extra_args": [], "package_path": "" } - } + }, + "input_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] + }, + "glob_base": "/" }, "spawn_command": { "program_path": "/node_modules/.bin/print-file", diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/cache-scripts-task-override/snapshots/query - task with command cached by default.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/cache-scripts-task-override/snapshots/query - task with command cached by default.snap index b816a161..7f9d9f19 100644 --- a/crates/vite_task_plan/tests/plan_snapshots/fixtures/cache-scripts-task-override/snapshots/query - task with command cached by default.snap +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/cache-scripts-task-override/snapshots/query - task with command cached by default.snap @@ -50,8 +50,7 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/cache-scripts-ta "pass_through_env_config": [ "" ] - }, - "fingerprint_ignores": null + } }, "execution_cache_key": { "UserTask": { @@ -60,7 +59,13 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/cache-scripts-ta "extra_args": [], "package_path": "" } - } + }, + "input_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] + }, + "glob_base": "/" }, "spawn_command": { "program_path": "/node_modules/.bin/print-file", diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/cache-scripts-task-override/snapshots/task graph.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/cache-scripts-task-override/snapshots/task graph.snap index 4c99d0c5..59a60df8 100644 --- a/crates/vite_task_plan/tests/plan_snapshots/fixtures/cache-scripts-task-override/snapshots/task graph.snap +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/cache-scripts-task-override/snapshots/task graph.snap @@ -25,6 +25,11 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/cache-scripts-ta "pass_through_envs": [ "" ] + }, + "input_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] } } } @@ -54,6 +59,11 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/cache-scripts-ta "pass_through_envs": [ "" ] + }, + "input_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] } } } @@ -83,6 +93,11 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/cache-scripts-ta "pass_through_envs": [ "" ] + }, + "input_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] } } } diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/cache-sharing/snapshots/task graph.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/cache-sharing/snapshots/task graph.snap index 75c12f88..21f30663 100644 --- a/crates/vite_task_plan/tests/plan_snapshots/fixtures/cache-sharing/snapshots/task graph.snap +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/cache-sharing/snapshots/task graph.snap @@ -25,6 +25,11 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/cache-sharing "pass_through_envs": [ "" ] + }, + "input_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] } } } @@ -54,6 +59,11 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/cache-sharing "pass_through_envs": [ "" ] + }, + "input_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] } } } @@ -83,6 +93,11 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/cache-sharing "pass_through_envs": [ "" ] + }, + "input_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] } } } diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/cache-subcommand/snapshots/task graph.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/cache-subcommand/snapshots/task graph.snap index bc3ce966..0551f120 100644 --- a/crates/vite_task_plan/tests/plan_snapshots/fixtures/cache-subcommand/snapshots/task graph.snap +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/cache-subcommand/snapshots/task graph.snap @@ -25,6 +25,11 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/cache-subcommand "pass_through_envs": [ "" ] + }, + "input_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] } } } diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/cache-tasks-disabled/snapshots/task graph.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/cache-tasks-disabled/snapshots/task graph.snap index 370cea7b..6d344ca2 100644 --- a/crates/vite_task_plan/tests/plan_snapshots/fixtures/cache-tasks-disabled/snapshots/task graph.snap +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/cache-tasks-disabled/snapshots/task graph.snap @@ -25,6 +25,11 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/cache-tasks-disa "pass_through_envs": [ "" ] + }, + "input_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] } } } @@ -54,6 +59,11 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/cache-tasks-disa "pass_through_envs": [ "" ] + }, + "input_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] } } } @@ -83,6 +93,11 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/cache-tasks-disa "pass_through_envs": [ "" ] + }, + "input_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] } } } diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/cache-true-no-force-enable/snapshots/query - script cached when global cache true.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/cache-true-no-force-enable/snapshots/query - script cached when global cache true.snap index 2e7fc39b..c38c9a48 100644 --- a/crates/vite_task_plan/tests/plan_snapshots/fixtures/cache-true-no-force-enable/snapshots/query - script cached when global cache true.snap +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/cache-true-no-force-enable/snapshots/query - script cached when global cache true.snap @@ -50,8 +50,7 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/cache-true-no-fo "pass_through_env_config": [ "" ] - }, - "fingerprint_ignores": null + } }, "execution_cache_key": { "UserTask": { @@ -60,7 +59,13 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/cache-true-no-fo "extra_args": [], "package_path": "" } - } + }, + "input_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] + }, + "glob_base": "/" }, "spawn_command": { "program_path": "/node_modules/.bin/print-file", diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/cache-true-no-force-enable/snapshots/query - task cached when global cache true.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/cache-true-no-force-enable/snapshots/query - task cached when global cache true.snap index 50210a10..cff71117 100644 --- a/crates/vite_task_plan/tests/plan_snapshots/fixtures/cache-true-no-force-enable/snapshots/query - task cached when global cache true.snap +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/cache-true-no-force-enable/snapshots/query - task cached when global cache true.snap @@ -50,8 +50,7 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/cache-true-no-fo "pass_through_env_config": [ "" ] - }, - "fingerprint_ignores": null + } }, "execution_cache_key": { "UserTask": { @@ -60,7 +59,13 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/cache-true-no-fo "extra_args": [], "package_path": "" } - } + }, + "input_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] + }, + "glob_base": "/" }, "spawn_command": { "program_path": "/node_modules/.bin/print-file", diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/cache-true-no-force-enable/snapshots/task graph.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/cache-true-no-force-enable/snapshots/task graph.snap index a865f485..b5fe5033 100644 --- a/crates/vite_task_plan/tests/plan_snapshots/fixtures/cache-true-no-force-enable/snapshots/task graph.snap +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/cache-true-no-force-enable/snapshots/task graph.snap @@ -47,6 +47,11 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/cache-true-no-fo "pass_through_envs": [ "" ] + }, + "input_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] } } } @@ -76,6 +81,11 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/cache-true-no-fo "pass_through_envs": [ "" ] + }, + "input_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] } } } diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/cd-in-scripts/snapshots/query - cd before vp lint should put synthetic task under cwd.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/cd-in-scripts/snapshots/query - cd before vp lint should put synthetic task under cwd.snap index 7da67793..6e65544c 100644 --- a/crates/vite_task_plan/tests/plan_snapshots/fixtures/cd-in-scripts/snapshots/query - cd before vp lint should put synthetic task under cwd.snap +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/cd-in-scripts/snapshots/query - cd before vp lint should put synthetic task under cwd.snap @@ -48,8 +48,7 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/cd-in-scripts "pass_through_env_config": [ "" ] - }, - "fingerprint_ignores": null + } }, "execution_cache_key": { "UserTask": { @@ -58,7 +57,13 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/cd-in-scripts "extra_args": [], "package_path": "" } - } + }, + "input_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] + }, + "glob_base": "/src" }, "spawn_command": { "program_path": "/node_modules/.bin/oxlint", diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/cd-in-scripts/snapshots/query - cd before vp run should not affect expanded task cwd.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/cd-in-scripts/snapshots/query - cd before vp run should not affect expanded task cwd.snap index 4c6f8fe4..3abeecca 100644 --- a/crates/vite_task_plan/tests/plan_snapshots/fixtures/cd-in-scripts/snapshots/query - cd before vp run should not affect expanded task cwd.snap +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/cd-in-scripts/snapshots/query - cd before vp run should not affect expanded task cwd.snap @@ -75,8 +75,7 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/cd-in-scripts "pass_through_env_config": [ "" ] - }, - "fingerprint_ignores": null + } }, "execution_cache_key": { "UserTask": { @@ -85,7 +84,13 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/cd-in-scripts "extra_args": [], "package_path": "" } - } + }, + "input_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] + }, + "glob_base": "/" }, "spawn_command": { "program_path": "/node_modules/.bin/print-file", diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/cd-in-scripts/snapshots/task graph.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/cd-in-scripts/snapshots/task graph.snap index 09efb044..df0d8a20 100644 --- a/crates/vite_task_plan/tests/plan_snapshots/fixtures/cd-in-scripts/snapshots/task graph.snap +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/cd-in-scripts/snapshots/task graph.snap @@ -25,6 +25,11 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/cd-in-scripts "pass_through_envs": [ "" ] + }, + "input_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] } } } @@ -54,6 +59,11 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/cd-in-scripts "pass_through_envs": [ "" ] + }, + "input_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] } } } @@ -83,6 +93,11 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/cd-in-scripts "pass_through_envs": [ "" ] + }, + "input_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] } } } diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/comprehensive-task-graph/snapshots/task graph.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/comprehensive-task-graph/snapshots/task graph.snap index a4a0ceda..8c6d3935 100644 --- a/crates/vite_task_plan/tests/plan_snapshots/fixtures/comprehensive-task-graph/snapshots/task graph.snap +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/comprehensive-task-graph/snapshots/task graph.snap @@ -25,6 +25,11 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/comprehensive-ta "pass_through_envs": [ "" ] + }, + "input_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] } } } @@ -54,6 +59,11 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/comprehensive-ta "pass_through_envs": [ "" ] + }, + "input_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] } } } @@ -83,6 +93,11 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/comprehensive-ta "pass_through_envs": [ "" ] + }, + "input_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] } } } @@ -112,6 +127,11 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/comprehensive-ta "pass_through_envs": [ "" ] + }, + "input_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] } } } @@ -141,6 +161,11 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/comprehensive-ta "pass_through_envs": [ "" ] + }, + "input_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] } } } @@ -170,6 +195,11 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/comprehensive-ta "pass_through_envs": [ "" ] + }, + "input_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] } } } @@ -199,6 +229,11 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/comprehensive-ta "pass_through_envs": [ "" ] + }, + "input_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] } } } @@ -228,6 +263,11 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/comprehensive-ta "pass_through_envs": [ "" ] + }, + "input_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] } } } @@ -257,6 +297,11 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/comprehensive-ta "pass_through_envs": [ "" ] + }, + "input_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] } } } @@ -286,6 +331,11 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/comprehensive-ta "pass_through_envs": [ "" ] + }, + "input_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] } } } @@ -315,6 +365,11 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/comprehensive-ta "pass_through_envs": [ "" ] + }, + "input_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] } } } @@ -344,6 +399,11 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/comprehensive-ta "pass_through_envs": [ "" ] + }, + "input_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] } } } @@ -373,6 +433,11 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/comprehensive-ta "pass_through_envs": [ "" ] + }, + "input_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] } } } @@ -402,6 +467,11 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/comprehensive-ta "pass_through_envs": [ "" ] + }, + "input_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] } } } @@ -431,6 +501,11 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/comprehensive-ta "pass_through_envs": [ "" ] + }, + "input_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] } } } @@ -460,6 +535,11 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/comprehensive-ta "pass_through_envs": [ "" ] + }, + "input_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] } } } @@ -489,6 +569,11 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/comprehensive-ta "pass_through_envs": [ "" ] + }, + "input_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] } } } @@ -518,6 +603,11 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/comprehensive-ta "pass_through_envs": [ "" ] + }, + "input_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] } } } @@ -547,6 +637,11 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/comprehensive-ta "pass_through_envs": [ "" ] + }, + "input_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] } } } @@ -576,6 +671,11 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/comprehensive-ta "pass_through_envs": [ "" ] + }, + "input_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] } } } @@ -605,6 +705,11 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/comprehensive-ta "pass_through_envs": [ "" ] + }, + "input_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] } } } @@ -634,6 +739,11 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/comprehensive-ta "pass_through_envs": [ "" ] + }, + "input_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] } } } @@ -663,6 +773,11 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/comprehensive-ta "pass_through_envs": [ "" ] + }, + "input_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] } } } diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/conflict-test/snapshots/task graph.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/conflict-test/snapshots/task graph.snap index 441061e3..8a7028d3 100644 --- a/crates/vite_task_plan/tests/plan_snapshots/fixtures/conflict-test/snapshots/task graph.snap +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/conflict-test/snapshots/task graph.snap @@ -25,6 +25,11 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/conflict-test "pass_through_envs": [ "" ] + }, + "input_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] } } } @@ -54,6 +59,11 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/conflict-test "pass_through_envs": [ "" ] + }, + "input_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] } } } @@ -83,6 +93,11 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/conflict-test "pass_through_envs": [ "" ] + }, + "input_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] } } } diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/cycle-dependency/snapshots/task graph.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/cycle-dependency/snapshots/task graph.snap index 8e578194..defb4cb8 100644 --- a/crates/vite_task_plan/tests/plan_snapshots/fixtures/cycle-dependency/snapshots/task graph.snap +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/cycle-dependency/snapshots/task graph.snap @@ -25,6 +25,11 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/cycle-dependency "pass_through_envs": [ "" ] + }, + "input_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] } } } @@ -59,6 +64,11 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/cycle-dependency "pass_through_envs": [ "" ] + }, + "input_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] } } } diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/dependency-both-topo-and-explicit/snapshots/task graph.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/dependency-both-topo-and-explicit/snapshots/task graph.snap index 659a3440..cb2ab333 100644 --- a/crates/vite_task_plan/tests/plan_snapshots/fixtures/dependency-both-topo-and-explicit/snapshots/task graph.snap +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/dependency-both-topo-and-explicit/snapshots/task graph.snap @@ -25,6 +25,11 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/dependency-both- "pass_through_envs": [ "" ] + }, + "input_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] } } } @@ -59,6 +64,11 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/dependency-both- "pass_through_envs": [ "" ] + }, + "input_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] } } } diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/duplicate-package-names/snapshots/task graph.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/duplicate-package-names/snapshots/task graph.snap index 3df2b750..8e9555c8 100644 --- a/crates/vite_task_plan/tests/plan_snapshots/fixtures/duplicate-package-names/snapshots/task graph.snap +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/duplicate-package-names/snapshots/task graph.snap @@ -25,6 +25,11 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/duplicate-packag "pass_through_envs": [ "" ] + }, + "input_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] } } } @@ -54,6 +59,11 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/duplicate-packag "pass_through_envs": [ "" ] + }, + "input_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] } } } diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/empty-package-test/snapshots/task graph.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/empty-package-test/snapshots/task graph.snap index f33ccfee..680f38d2 100644 --- a/crates/vite_task_plan/tests/plan_snapshots/fixtures/empty-package-test/snapshots/task graph.snap +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/empty-package-test/snapshots/task graph.snap @@ -25,6 +25,11 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/empty-package-te "pass_through_envs": [ "" ] + }, + "input_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] } } } @@ -94,6 +99,11 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/empty-package-te "pass_through_envs": [ "" ] + }, + "input_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] } } } @@ -123,6 +133,11 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/empty-package-te "pass_through_envs": [ "" ] + }, + "input_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] } } } @@ -152,6 +167,11 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/empty-package-te "pass_through_envs": [ "" ] + }, + "input_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] } } } @@ -186,6 +206,11 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/empty-package-te "pass_through_envs": [ "" ] + }, + "input_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] } } } @@ -215,6 +240,11 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/empty-package-te "pass_through_envs": [ "" ] + }, + "input_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] } } } @@ -244,6 +274,11 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/empty-package-te "pass_through_envs": [ "" ] + }, + "input_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] } } } @@ -273,6 +308,11 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/empty-package-te "pass_through_envs": [ "" ] + }, + "input_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] } } } diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/explicit-deps-workspace/snapshots/task graph.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/explicit-deps-workspace/snapshots/task graph.snap index 316a0ad9..caeb9f8a 100644 --- a/crates/vite_task_plan/tests/plan_snapshots/fixtures/explicit-deps-workspace/snapshots/task graph.snap +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/explicit-deps-workspace/snapshots/task graph.snap @@ -25,6 +25,11 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/explicit-deps-wo "pass_through_envs": [ "" ] + }, + "input_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] } } } @@ -89,6 +94,11 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/explicit-deps-wo "pass_through_envs": [ "" ] + }, + "input_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] } } } @@ -118,6 +128,11 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/explicit-deps-wo "pass_through_envs": [ "" ] + }, + "input_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] } } } @@ -147,6 +162,11 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/explicit-deps-wo "pass_through_envs": [ "" ] + }, + "input_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] } } } @@ -176,6 +196,11 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/explicit-deps-wo "pass_through_envs": [ "" ] + }, + "input_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] } } } @@ -205,6 +230,11 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/explicit-deps-wo "pass_through_envs": [ "" ] + }, + "input_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] } } } @@ -239,6 +269,11 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/explicit-deps-wo "pass_through_envs": [ "" ] + }, + "input_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] } } } @@ -268,6 +303,11 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/explicit-deps-wo "pass_through_envs": [ "" ] + }, + "input_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] } } } @@ -297,6 +337,11 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/explicit-deps-wo "pass_through_envs": [ "" ] + }, + "input_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] } } } @@ -335,6 +380,11 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/explicit-deps-wo "pass_through_envs": [ "" ] + }, + "input_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] } } } diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/task graph.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/task graph.snap index 16271d36..c0311c55 100644 --- a/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/task graph.snap +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace/snapshots/task graph.snap @@ -25,6 +25,11 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace "pass_through_envs": [ "" ] + }, + "input_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] } } } @@ -54,6 +59,11 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace "pass_through_envs": [ "" ] + }, + "input_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] } } } @@ -83,6 +93,11 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace "pass_through_envs": [ "" ] + }, + "input_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] } } } @@ -112,6 +127,11 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace "pass_through_envs": [ "" ] + }, + "input_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] } } } @@ -141,6 +161,11 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace "pass_through_envs": [ "" ] + }, + "input_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] } } } @@ -170,6 +195,11 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace "pass_through_envs": [ "" ] + }, + "input_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] } } } @@ -199,6 +229,11 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace "pass_through_envs": [ "" ] + }, + "input_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] } } } @@ -228,6 +263,11 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace "pass_through_envs": [ "" ] + }, + "input_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] } } } @@ -257,6 +297,11 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace "pass_through_envs": [ "" ] + }, + "input_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] } } } @@ -286,6 +331,11 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace "pass_through_envs": [ "" ] + }, + "input_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] } } } @@ -315,6 +365,11 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace "pass_through_envs": [ "" ] + }, + "input_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] } } } @@ -344,6 +399,11 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace "pass_through_envs": [ "" ] + }, + "input_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] } } } @@ -373,6 +433,11 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/filter-workspace "pass_through_envs": [ "" ] + }, + "input_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] } } } diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/fingerprint-ignore-test/snapshots/task graph.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/fingerprint-ignore-test/snapshots/task graph.snap index 0036eca1..9b1922dc 100644 --- a/crates/vite_task_plan/tests/plan_snapshots/fixtures/fingerprint-ignore-test/snapshots/task graph.snap +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/fingerprint-ignore-test/snapshots/task graph.snap @@ -25,6 +25,11 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/fingerprint-igno "pass_through_envs": [ "" ] + }, + "input_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] } } } diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/nested-cache-override/snapshots/query - nested --cache enables inner task caching.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/nested-cache-override/snapshots/query - nested --cache enables inner task caching.snap index f80fb7d7..ef323211 100644 --- a/crates/vite_task_plan/tests/plan_snapshots/fixtures/nested-cache-override/snapshots/query - nested --cache enables inner task caching.snap +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/nested-cache-override/snapshots/query - nested --cache enables inner task caching.snap @@ -75,8 +75,7 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/nested-cache-ove "pass_through_env_config": [ "" ] - }, - "fingerprint_ignores": null + } }, "execution_cache_key": { "UserTask": { @@ -85,7 +84,13 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/nested-cache-ove "extra_args": [], "package_path": "" } - } + }, + "input_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] + }, + "glob_base": "/" }, "spawn_command": { "program_path": "/node_modules/.bin/print-file", diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/nested-cache-override/snapshots/query - outer --cache propagates to nested run without flags.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/nested-cache-override/snapshots/query - outer --cache propagates to nested run without flags.snap index 89d3c5aa..8d4afb06 100644 --- a/crates/vite_task_plan/tests/plan_snapshots/fixtures/nested-cache-override/snapshots/query - outer --cache propagates to nested run without flags.snap +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/nested-cache-override/snapshots/query - outer --cache propagates to nested run without flags.snap @@ -76,8 +76,7 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/nested-cache-ove "pass_through_env_config": [ "" ] - }, - "fingerprint_ignores": null + } }, "execution_cache_key": { "UserTask": { @@ -86,7 +85,13 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/nested-cache-ove "extra_args": [], "package_path": "" } - } + }, + "input_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] + }, + "glob_base": "/" }, "spawn_command": { "program_path": "/node_modules/.bin/print-file", diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/nested-cache-override/snapshots/query - outer --no-cache does not propagate into nested --cache.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/nested-cache-override/snapshots/query - outer --no-cache does not propagate into nested --cache.snap index 51d15e09..4f48b6e6 100644 --- a/crates/vite_task_plan/tests/plan_snapshots/fixtures/nested-cache-override/snapshots/query - outer --no-cache does not propagate into nested --cache.snap +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/nested-cache-override/snapshots/query - outer --no-cache does not propagate into nested --cache.snap @@ -76,8 +76,7 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/nested-cache-ove "pass_through_env_config": [ "" ] - }, - "fingerprint_ignores": null + } }, "execution_cache_key": { "UserTask": { @@ -86,7 +85,13 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/nested-cache-ove "extra_args": [], "package_path": "" } - } + }, + "input_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] + }, + "glob_base": "/" }, "spawn_command": { "program_path": "/node_modules/.bin/print-file", diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/nested-cache-override/snapshots/task graph.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/nested-cache-override/snapshots/task graph.snap index 72e7589a..a414fcad 100644 --- a/crates/vite_task_plan/tests/plan_snapshots/fixtures/nested-cache-override/snapshots/task graph.snap +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/nested-cache-override/snapshots/task graph.snap @@ -25,6 +25,11 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/nested-cache-ove "pass_through_envs": [ "" ] + }, + "input_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] } } } @@ -54,6 +59,11 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/nested-cache-ove "pass_through_envs": [ "" ] + }, + "input_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] } } } @@ -83,6 +93,11 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/nested-cache-ove "pass_through_envs": [ "" ] + }, + "input_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] } } } @@ -112,6 +127,11 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/nested-cache-ove "pass_through_envs": [ "" ] + }, + "input_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] } } } diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/nested-tasks/snapshots/task graph.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/nested-tasks/snapshots/task graph.snap index f70f89a4..cc63812a 100644 --- a/crates/vite_task_plan/tests/plan_snapshots/fixtures/nested-tasks/snapshots/task graph.snap +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/nested-tasks/snapshots/task graph.snap @@ -25,6 +25,11 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/nested-tasks "pass_through_envs": [ "" ] + }, + "input_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] } } } @@ -54,6 +59,11 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/nested-tasks "pass_through_envs": [ "" ] + }, + "input_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] } } } diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/pnpm-workspace-packages-optional/snapshots/task graph.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/pnpm-workspace-packages-optional/snapshots/task graph.snap index 728b66a7..7dcef729 100644 --- a/crates/vite_task_plan/tests/plan_snapshots/fixtures/pnpm-workspace-packages-optional/snapshots/task graph.snap +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/pnpm-workspace-packages-optional/snapshots/task graph.snap @@ -25,6 +25,11 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/pnpm-workspace-p "pass_through_envs": [ "" ] + }, + "input_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] } } } diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/recursive-topological-workspace/snapshots/task graph.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/recursive-topological-workspace/snapshots/task graph.snap index 5a9fd1c4..5f1b9f51 100644 --- a/crates/vite_task_plan/tests/plan_snapshots/fixtures/recursive-topological-workspace/snapshots/task graph.snap +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/recursive-topological-workspace/snapshots/task graph.snap @@ -25,6 +25,11 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/recursive-topolo "pass_through_envs": [ "" ] + }, + "input_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] } } } @@ -54,6 +59,11 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/recursive-topolo "pass_through_envs": [ "" ] + }, + "input_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] } } } @@ -83,6 +93,11 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/recursive-topolo "pass_through_envs": [ "" ] + }, + "input_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] } } } @@ -112,6 +127,11 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/recursive-topolo "pass_through_envs": [ "" ] + }, + "input_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] } } } @@ -141,6 +161,11 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/recursive-topolo "pass_through_envs": [ "" ] + }, + "input_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] } } } @@ -170,6 +195,11 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/recursive-topolo "pass_through_envs": [ "" ] + }, + "input_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] } } } @@ -199,6 +229,11 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/recursive-topolo "pass_through_envs": [ "" ] + }, + "input_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] } } } @@ -228,6 +263,11 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/recursive-topolo "pass_through_envs": [ "" ] + }, + "input_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] } } } diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/shell-fallback/snapshots/query - shell fallback for pipe command.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/shell-fallback/snapshots/query - shell fallback for pipe command.snap index 98ff790d..40f4caac 100644 --- a/crates/vite_task_plan/tests/plan_snapshots/fixtures/shell-fallback/snapshots/query - shell fallback for pipe command.snap +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/shell-fallback/snapshots/query - shell fallback for pipe command.snap @@ -51,8 +51,7 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/shell-fallback "pass_through_env_config": [ "" ] - }, - "fingerprint_ignores": null + } }, "execution_cache_key": { "UserTask": { @@ -61,7 +60,13 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/shell-fallback "extra_args": [], "package_path": "" } - } + }, + "input_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] + }, + "glob_base": "/" }, "spawn_command": { "program_path": "", diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/shell-fallback/snapshots/task graph.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/shell-fallback/snapshots/task graph.snap index 79b113c1..a37823a4 100644 --- a/crates/vite_task_plan/tests/plan_snapshots/fixtures/shell-fallback/snapshots/task graph.snap +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/shell-fallback/snapshots/task graph.snap @@ -25,6 +25,11 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/shell-fallback "pass_through_envs": [ "" ] + }, + "input_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] } } } diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/synthetic-cache-disabled/snapshots/query - parent cache false does not affect expanded query tasks.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/synthetic-cache-disabled/snapshots/query - parent cache false does not affect expanded query tasks.snap index be161143..45bf74bf 100644 --- a/crates/vite_task_plan/tests/plan_snapshots/fixtures/synthetic-cache-disabled/snapshots/query - parent cache false does not affect expanded query tasks.snap +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/synthetic-cache-disabled/snapshots/query - parent cache false does not affect expanded query tasks.snap @@ -73,8 +73,7 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/synthetic-cache- "pass_through_env_config": [ "" ] - }, - "fingerprint_ignores": null + } }, "execution_cache_key": { "UserTask": { @@ -83,7 +82,13 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/synthetic-cache- "extra_args": [], "package_path": "" } - } + }, + "input_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] + }, + "glob_base": "/" }, "spawn_command": { "program_path": "/node_modules/.bin/oxlint", diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/synthetic-cache-disabled/snapshots/query - script cache false does not affect expanded synthetic cache.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/synthetic-cache-disabled/snapshots/query - script cache false does not affect expanded synthetic cache.snap index 403abd2f..73743e40 100644 --- a/crates/vite_task_plan/tests/plan_snapshots/fixtures/synthetic-cache-disabled/snapshots/query - script cache false does not affect expanded synthetic cache.snap +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/synthetic-cache-disabled/snapshots/query - script cache false does not affect expanded synthetic cache.snap @@ -73,8 +73,7 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/synthetic-cache- "pass_through_env_config": [ "" ] - }, - "fingerprint_ignores": null + } }, "execution_cache_key": { "UserTask": { @@ -83,7 +82,13 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/synthetic-cache- "extra_args": [], "package_path": "" } - } + }, + "input_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] + }, + "glob_base": "/" }, "spawn_command": { "program_path": "/node_modules/.bin/oxlint", diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/synthetic-cache-disabled/snapshots/query - task passThroughEnvs inherited by synthetic.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/synthetic-cache-disabled/snapshots/query - task passThroughEnvs inherited by synthetic.snap index a6bdf4a7..4f1b6603 100644 --- a/crates/vite_task_plan/tests/plan_snapshots/fixtures/synthetic-cache-disabled/snapshots/query - task passThroughEnvs inherited by synthetic.snap +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/synthetic-cache-disabled/snapshots/query - task passThroughEnvs inherited by synthetic.snap @@ -49,8 +49,7 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/synthetic-cache- "CUSTOM_VAR", "" ] - }, - "fingerprint_ignores": null + } }, "execution_cache_key": { "UserTask": { @@ -59,7 +58,13 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/synthetic-cache- "extra_args": [], "package_path": "" } - } + }, + "input_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] + }, + "glob_base": "/" }, "spawn_command": { "program_path": "/node_modules/.bin/oxlint", diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/synthetic-cache-disabled/snapshots/query - task with cache true enables synthetic cache.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/synthetic-cache-disabled/snapshots/query - task with cache true enables synthetic cache.snap index 03de80b4..bf28599b 100644 --- a/crates/vite_task_plan/tests/plan_snapshots/fixtures/synthetic-cache-disabled/snapshots/query - task with cache true enables synthetic cache.snap +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/synthetic-cache-disabled/snapshots/query - task with cache true enables synthetic cache.snap @@ -48,8 +48,7 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/synthetic-cache- "pass_through_env_config": [ "" ] - }, - "fingerprint_ignores": null + } }, "execution_cache_key": { "UserTask": { @@ -58,7 +57,13 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/synthetic-cache- "extra_args": [], "package_path": "" } - } + }, + "input_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] + }, + "glob_base": "/" }, "spawn_command": { "program_path": "/node_modules/.bin/oxlint", diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/synthetic-cache-disabled/snapshots/task graph.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/synthetic-cache-disabled/snapshots/task graph.snap index 7be0e02e..dc55b774 100644 --- a/crates/vite_task_plan/tests/plan_snapshots/fixtures/synthetic-cache-disabled/snapshots/task graph.snap +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/synthetic-cache-disabled/snapshots/task graph.snap @@ -25,6 +25,11 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/synthetic-cache- "pass_through_envs": [ "" ] + }, + "input_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] } } } @@ -54,6 +59,11 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/synthetic-cache- "pass_through_envs": [ "" ] + }, + "input_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] } } } @@ -105,6 +115,11 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/synthetic-cache- "pass_through_envs": [ "" ] + }, + "input_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] } } } @@ -135,6 +150,11 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/synthetic-cache- "CUSTOM_VAR", "" ] + }, + "input_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] } } } @@ -164,6 +184,11 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/synthetic-cache- "pass_through_envs": [ "" ] + }, + "input_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] } } } diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/synthetic-in-subpackage/snapshots/query - synthetic-in-subpackage.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/synthetic-in-subpackage/snapshots/query - synthetic-in-subpackage.snap index 411dd5e4..8342c183 100644 --- a/crates/vite_task_plan/tests/plan_snapshots/fixtures/synthetic-in-subpackage/snapshots/query - synthetic-in-subpackage.snap +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/synthetic-in-subpackage/snapshots/query - synthetic-in-subpackage.snap @@ -73,8 +73,7 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/synthetic-in-sub "pass_through_env_config": [ "" ] - }, - "fingerprint_ignores": null + } }, "execution_cache_key": { "UserTask": { @@ -83,7 +82,13 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/synthetic-in-sub "extra_args": [], "package_path": "packages/a" } - } + }, + "input_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] + }, + "glob_base": "/packages/a" }, "spawn_command": { "program_path": "/node_modules/.bin/oxlint", diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/synthetic-in-subpackage/snapshots/task graph.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/synthetic-in-subpackage/snapshots/task graph.snap index 07ed52b1..20a50f36 100644 --- a/crates/vite_task_plan/tests/plan_snapshots/fixtures/synthetic-in-subpackage/snapshots/task graph.snap +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/synthetic-in-subpackage/snapshots/task graph.snap @@ -25,6 +25,11 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/synthetic-in-sub "pass_through_envs": [ "" ] + }, + "input_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] } } } @@ -54,6 +59,11 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/synthetic-in-sub "pass_through_envs": [ "" ] + }, + "input_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] } } } diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/transitive-skip-intermediate/snapshots/task graph.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/transitive-skip-intermediate/snapshots/task graph.snap index 30771c36..949e3f91 100644 --- a/crates/vite_task_plan/tests/plan_snapshots/fixtures/transitive-skip-intermediate/snapshots/task graph.snap +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/transitive-skip-intermediate/snapshots/task graph.snap @@ -25,6 +25,11 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/transitive-skip- "pass_through_envs": [ "" ] + }, + "input_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] } } } @@ -54,6 +59,11 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/transitive-skip- "pass_through_envs": [ "" ] + }, + "input_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] } } } @@ -83,6 +93,11 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/transitive-skip- "pass_through_envs": [ "" ] + }, + "input_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] } } } diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/vpr-shorthand/snapshots/task graph.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/vpr-shorthand/snapshots/task graph.snap index 5aeff92d..50d674f8 100644 --- a/crates/vite_task_plan/tests/plan_snapshots/fixtures/vpr-shorthand/snapshots/task graph.snap +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/vpr-shorthand/snapshots/task graph.snap @@ -25,6 +25,11 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/vpr-shorthand "pass_through_envs": [ "" ] + }, + "input_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] } } } @@ -54,6 +59,11 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/vpr-shorthand "pass_through_envs": [ "" ] + }, + "input_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] } } } diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-cd-no-skip/snapshots/task graph.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-cd-no-skip/snapshots/task graph.snap index 49028ec3..1e34a621 100644 --- a/crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-cd-no-skip/snapshots/task graph.snap +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-cd-no-skip/snapshots/task graph.snap @@ -25,6 +25,11 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-c "pass_through_envs": [ "" ] + }, + "input_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] } } } @@ -54,6 +59,11 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-c "pass_through_envs": [ "" ] + }, + "input_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] } } } diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-depends-on-passthrough/snapshots/task graph.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-depends-on-passthrough/snapshots/task graph.snap index 045c90df..f59e82f9 100644 --- a/crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-depends-on-passthrough/snapshots/task graph.snap +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-depends-on-passthrough/snapshots/task graph.snap @@ -25,6 +25,11 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-d "pass_through_envs": [ "" ] + }, + "input_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] } } } @@ -59,6 +64,11 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-d "pass_through_envs": [ "" ] + }, + "input_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] } } } @@ -88,6 +98,11 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-d "pass_through_envs": [ "" ] + }, + "input_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] } } } @@ -117,6 +132,11 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-d "pass_through_envs": [ "" ] + }, + "input_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] } } } diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-multi-command/snapshots/task graph.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-multi-command/snapshots/task graph.snap index 778e1be9..60af8782 100644 --- a/crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-multi-command/snapshots/task graph.snap +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-multi-command/snapshots/task graph.snap @@ -25,6 +25,11 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-m "pass_through_envs": [ "" ] + }, + "input_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] } } } @@ -54,6 +59,11 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-m "pass_through_envs": [ "" ] + }, + "input_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] } } } diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-mutual-recursion/snapshots/task graph.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-mutual-recursion/snapshots/task graph.snap index 9afec801..deb8564a 100644 --- a/crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-mutual-recursion/snapshots/task graph.snap +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-mutual-recursion/snapshots/task graph.snap @@ -25,6 +25,11 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-m "pass_through_envs": [ "" ] + }, + "input_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] } } } @@ -54,6 +59,11 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-m "pass_through_envs": [ "" ] + }, + "input_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] } } } @@ -83,6 +93,11 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-m "pass_through_envs": [ "" ] + }, + "input_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] } } } @@ -112,6 +127,11 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-m "pass_through_envs": [ "" ] + }, + "input_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] } } } diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-no-package-json/snapshots/task graph.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-no-package-json/snapshots/task graph.snap index 520f4db9..151c7785 100644 --- a/crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-no-package-json/snapshots/task graph.snap +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-no-package-json/snapshots/task graph.snap @@ -25,6 +25,11 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-n "pass_through_envs": [ "" ] + }, + "input_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] } } } @@ -54,6 +59,11 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-n "pass_through_envs": [ "" ] + }, + "input_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] } } } diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-self-reference/snapshots/task graph.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-self-reference/snapshots/task graph.snap index e8b45c72..317b13b3 100644 --- a/crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-self-reference/snapshots/task graph.snap +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-self-reference/snapshots/task graph.snap @@ -25,6 +25,11 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-s "pass_through_envs": [ "" ] + }, + "input_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] } } } @@ -54,6 +59,11 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-s "pass_through_envs": [ "" ] + }, + "input_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] } } } @@ -83,6 +93,11 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/workspace-root-s "pass_through_envs": [ "" ] + }, + "input_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] } } } diff --git a/docs/inputs.md b/docs/inputs.md new file mode 100644 index 00000000..ddd10772 --- /dev/null +++ b/docs/inputs.md @@ -0,0 +1,221 @@ +# Task Inputs Configuration + +The `inputs` field controls which files are tracked for cache invalidation. When any tracked input file changes, the task's cache is invalidated and the task will re-run. + +## Default Behavior + +When `inputs` is omitted, vite-task automatically tracks files that the command reads during execution using fspy (file system spy): + +```json +{ + "tasks": { + "build": { + "command": "tsc" + } + } +} +``` + +Files read by `tsc` are automatically tracked. **In most cases, you don't need to configure `inputs` at all.** + +## Input Types + +### Glob Patterns + +Specify files using glob patterns relative to the package directory: + +```json +{ + "inputs": ["src/**/*.ts", "package.json"] +} +``` + +Supported glob syntax: + +- `*` - matches any characters except `/` +- `**` - matches any characters including `/` +- `?` - matches a single character +- `[abc]` - matches any character in the brackets +- `[!abc]` - matches any character not in the brackets + +### Auto-Inference + +Enable automatic input detection using fspy (file system spy): + +```json +{ + "inputs": [{ "auto": true }] +} +``` + +When enabled, vite-task automatically tracks files that the command actually reads during execution. This is the default behavior when `inputs` is omitted. + +### Negative Patterns + +Exclude files from tracking using `!` prefix: + +```json +{ + "inputs": ["src/**", "!src/**/*.test.ts"] +} +``` + +Negative patterns filter out files that would otherwise be matched by positive patterns or auto-inference. + +## Configuration Examples + +### Explicit Globs Only + +Specify exact files to track, disabling auto-inference: + +```json +{ + "tasks": { + "build": { + "command": "tsc", + "inputs": ["src/**/*.ts", "tsconfig.json"] + } + } +} +``` + +Only files matching the globs are tracked. Files read by the command but not matching the globs are ignored. + +### Auto-Inference with Exclusions + +Track inferred files but exclude certain patterns: + +```json +{ + "tasks": { + "build": { + "command": "tsc", + "inputs": [{ "auto": true }, "!dist/**", "!node_modules/**"] + } + } +} +``` + +Files in `dist/` and `node_modules/` won't trigger cache invalidation even if the command reads them. + +### Mixed Mode + +Combine explicit globs with auto-inference: + +```json +{ + "tasks": { + "build": { + "command": "tsc", + "inputs": ["package.json", { "auto": true }, "!**/*.test.ts"] + } + } +} +``` + +- `package.json` is always tracked (explicit) +- Files read by the command are tracked (auto) +- Test files are excluded from both (negative pattern) + +### No File Inputs + +Disable all file tracking (cache only on command/env changes): + +```json +{ + "tasks": { + "echo": { + "command": "echo hello", + "inputs": [] + } + } +} +``` + +The cache will only invalidate when the command itself or environment variables change. + +## Behavior Summary + +| Configuration | Auto-Inference | File Tracking | +| ---------------------------------------- | -------------- | ----------------------------- | +| `inputs` omitted | Enabled | Inferred files | +| `inputs: [{ "auto": true }]` | Enabled | Inferred files | +| `inputs: ["src/**"]` | Disabled | Matched files only | +| `inputs: [{ "auto": true }, "!dist/**"]` | Enabled | Inferred files except `dist/` | +| `inputs: ["pkg.json", { "auto": true }]` | Enabled | `pkg.json` + inferred files | +| `inputs: []` | Disabled | No files tracked | + +## Important Notes + +### Glob Base Directory + +Glob patterns are resolved relative to the **package directory** (where `package.json` is located), not the task's working directory (`cwd`). + +```json +{ + "tasks": { + "build": { + "command": "tsc", + "cwd": "src", + "inputs": ["src/**/*.ts"] // Still relative to package root + } + } +} +``` + +### Negative Patterns Apply to Both Modes + +When using mixed mode, negative patterns filter both explicit globs AND auto-inferred files: + +```json +{ + "inputs": ["src/**", { "auto": true }, "!**/*.generated.ts"] +} +``` + +Files matching `*.generated.ts` are excluded whether they come from the `src/**` glob or from auto-inference. + +### Auto-Inference Behavior + +The auto-inference (fspy) is intentionally **cautious** - it tracks all files that a command reads, even auxiliary files. This means **negative patterns are expected to be useful** for filtering out files you don't want to trigger cache invalidation. + +Common files you might want to exclude: + +```json +{ + "inputs": [ + { "auto": true }, + "!**/*.tsbuildinfo", // TypeScript incremental build info + "!**/tsconfig.tsbuildinfo", + "!dist/**" // Build outputs that get read during builds + ] +} +``` + +**When to use positive patterns vs negative patterns:** + +- **Negative patterns (expected)**: Use these to exclude files that fspy correctly detected but you don't want tracked (like `.tsbuildinfo`, cache files, build outputs) +- **Positive patterns (usually indicates a bug)**: If you find yourself adding explicit positive patterns because fspy missed files that your command actually reads, this likely indicates a bug in fspy + +If you encounter a case where fspy fails to detect a file read, please [report the issue](https://github.com/voidzero-dev/vite-task/issues) with: + +1. The command being run +2. The file(s) that weren't detected +3. Steps to reproduce + +### Cache Disabled + +The `inputs` field cannot be used with `cache: false`: + +```json +// ERROR: inputs cannot be specified when cache is disabled +{ + "tasks": { + "dev": { + "command": "vite dev", + "cache": false, + "inputs": ["src/**"] // This will cause a parse error + } + } +} +``` diff --git a/packages/tools/src/print-file.ts b/packages/tools/src/print-file.ts index 32cbf849..792f2d67 100755 --- a/packages/tools/src/print-file.ts +++ b/packages/tools/src/print-file.ts @@ -2,5 +2,7 @@ import { readFileSync } from 'node:fs'; -const content = readFileSync(process.argv[2]); -process.stdout.write(content); +for (const file of process.argv.slice(2)) { + const content = readFileSync(file); + process.stdout.write(content); +} From 0ec94fad20ed4090b73fd5a1ccb0e0eb75884b04 Mon Sep 17 00:00:00 2001 From: branchseer Date: Thu, 5 Mar 2026 18:59:55 +0800 Subject: [PATCH 02/32] update message --- crates/vite_task/src/session/cache/display.rs | 2 +- crates/vite_task/src/session/cache/mod.rs | 8 ++++---- crates/vite_task/src/session/reporter/summary.rs | 4 ++-- .../snapshots/inputs config changed.snap | 2 +- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/crates/vite_task/src/session/cache/display.rs b/crates/vite_task/src/session/cache/display.rs index fb901265..aaa18024 100644 --- a/crates/vite_task/src/session/cache/display.rs +++ b/crates/vite_task/src/session/cache/display.rs @@ -173,7 +173,7 @@ pub fn format_cache_status_inline(cache_status: &CacheStatus) -> Option { None => "configuration changed", } } - FingerprintMismatch::ConfigChanged => "configuration changed", + FingerprintMismatch::InputConfigChanged => "inputs configuration changed", FingerprintMismatch::GlobbedInputChanged { path } => { return Some(vite_str::format!( "✗ cache miss: content of input '{path}' changed, executing" diff --git a/crates/vite_task/src/session/cache/mod.rs b/crates/vite_task/src/session/cache/mod.rs index 8f0c612d..4bce7df4 100644 --- a/crates/vite_task/src/session/cache/mod.rs +++ b/crates/vite_task/src/session/cache/mod.rs @@ -108,7 +108,7 @@ pub enum FingerprintMismatch { new: SpawnFingerprint, }, /// Found a previous cache entry key for the same task, but `input_config` or `glob_base` differs. - ConfigChanged, + InputConfigChanged, /// Found the cache entry with the same spawn fingerprint, but an explicit globbed input changed GlobbedInputChanged { path: RelativePathBuf }, /// Found the cache entry with the same spawn fingerprint, but the post-run fingerprint mismatches @@ -121,8 +121,8 @@ impl Display for FingerprintMismatch { Self::SpawnFingerprintMismatch { old, new } => { write!(f, "Spawn fingerprint changed: old={old:?}, new={new:?}") } - Self::ConfigChanged => { - write!(f, "configuration changed") + Self::InputConfigChanged => { + write!(f, "inputs configuration changed") } Self::GlobbedInputChanged { path } => { write!(f, "content of input '{path}' changed") @@ -244,7 +244,7 @@ impl ExecutionCache { } } else { // spawn fingerprint is the same but input_config or glob_base changed - FingerprintMismatch::ConfigChanged + FingerprintMismatch::InputConfigChanged }; return Ok(Err(CacheMiss::FingerprintMismatch(mismatch))); } diff --git a/crates/vite_task/src/session/reporter/summary.rs b/crates/vite_task/src/session/reporter/summary.rs index d7dd3122..dea40031 100644 --- a/crates/vite_task/src/session/reporter/summary.rs +++ b/crates/vite_task/src/session/reporter/summary.rs @@ -240,7 +240,7 @@ impl SavedCacheMissReason { FingerprintMismatch::SpawnFingerprintMismatch { old, new } => { Self::SpawnFingerprintChanged(detect_spawn_fingerprint_changes(old, new)) } - FingerprintMismatch::ConfigChanged => Self::ConfigChanged, + FingerprintMismatch::InputConfigChanged => Self::ConfigChanged, FingerprintMismatch::GlobbedInputChanged { path } => { Self::InputContentChanged { path: Str::from(path.as_str()) } } @@ -432,7 +432,7 @@ impl TaskResult { } } SavedCacheMissReason::ConfigChanged => { - Str::from("→ Cache miss: configuration changed") + Str::from("→ Cache miss: inputs configuration changed") } SavedCacheMissReason::InputContentChanged { path } => { vite_str::format!("→ Cache miss: content of input '{path}' changed") diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/cache-miss-reasons/snapshots/inputs config changed.snap b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/cache-miss-reasons/snapshots/inputs config changed.snap index a9ee6259..fe83d450 100644 --- a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/cache-miss-reasons/snapshots/inputs config changed.snap +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/cache-miss-reasons/snapshots/inputs config changed.snap @@ -8,5 +8,5 @@ initial content > json-edit vite-task.json "_.tasks.test.inputs = ['test.txt']" # change inputs config > vp run test # cache miss: configuration changed -$ print-file test.txt ✗ cache miss: configuration changed, executing +$ print-file test.txt ✗ cache miss: inputs configuration changed, executing initial content From 6daa8fa6ba2cb19d56a749ad3ada91df2fb64d3f Mon Sep 17 00:00:00 2001 From: branchseer Date: Fri, 6 Mar 2026 09:37:21 +0800 Subject: [PATCH 03/32] refactor(cache): rename FingerprintMismatch variants and fix lint warnings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop redundant suffixes from FingerprintMismatch variants (e.g. SpawnFingerprintMismatch → SpawnFingerprint) to fix enum_variant_names lint. Also fix if_not_else and doc_markdown warnings. Co-Authored-By: Claude Opus 4.6 --- crates/vite_task/src/session/cache/display.rs | 8 ++--- crates/vite_task/src/session/cache/mod.rs | 36 +++++++++---------- .../vite_task/src/session/reporter/summary.rs | 10 +++--- 3 files changed, 27 insertions(+), 27 deletions(-) diff --git a/crates/vite_task/src/session/cache/display.rs b/crates/vite_task/src/session/cache/display.rs index aaa18024..9392d41f 100644 --- a/crates/vite_task/src/session/cache/display.rs +++ b/crates/vite_task/src/session/cache/display.rs @@ -155,7 +155,7 @@ pub fn format_cache_status_inline(cache_status: &CacheStatus) -> Option { CacheStatus::Miss(CacheMiss::FingerprintMismatch(mismatch)) => { // Show "cache miss" with reason why cache couldn't be used let reason = match mismatch { - FingerprintMismatch::SpawnFingerprintMismatch { old, new } => { + FingerprintMismatch::SpawnFingerprint { old, new } => { let changes = detect_spawn_fingerprint_changes(old, new); match changes.first() { Some( @@ -173,13 +173,13 @@ pub fn format_cache_status_inline(cache_status: &CacheStatus) -> Option { None => "configuration changed", } } - FingerprintMismatch::InputConfigChanged => "inputs configuration changed", - FingerprintMismatch::GlobbedInputChanged { path } => { + FingerprintMismatch::InputConfig => "inputs configuration changed", + FingerprintMismatch::GlobbedInput { path } => { return Some(vite_str::format!( "✗ cache miss: content of input '{path}' changed, executing" )); } - FingerprintMismatch::PostRunFingerprintMismatch(diff) => { + FingerprintMismatch::PostRunFingerprint(diff) => { use crate::session::execute::fingerprint::PostRunFingerprintMismatch; match diff { PostRunFingerprintMismatch::InputContentChanged { path } => { diff --git a/crates/vite_task/src/session/cache/mod.rs b/crates/vite_task/src/session/cache/mod.rs index 4bce7df4..3a9fb40d 100644 --- a/crates/vite_task/src/session/cache/mod.rs +++ b/crates/vite_task/src/session/cache/mod.rs @@ -101,33 +101,33 @@ pub enum CacheMiss { pub enum FingerprintMismatch { /// Found a previous cache entry key for the same task, but the spawn fingerprint differs. /// This happens when the command itself or an env changes. - SpawnFingerprintMismatch { + SpawnFingerprint { /// The fingerprint from the cached entry old: SpawnFingerprint, /// The fingerprint of the current execution new: SpawnFingerprint, }, /// Found a previous cache entry key for the same task, but `input_config` or `glob_base` differs. - InputConfigChanged, + InputConfig, /// Found the cache entry with the same spawn fingerprint, but an explicit globbed input changed - GlobbedInputChanged { path: RelativePathBuf }, + GlobbedInput { path: RelativePathBuf }, /// Found the cache entry with the same spawn fingerprint, but the post-run fingerprint mismatches - PostRunFingerprintMismatch(PostRunFingerprintMismatch), + PostRunFingerprint(PostRunFingerprintMismatch), } impl Display for FingerprintMismatch { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { - Self::SpawnFingerprintMismatch { old, new } => { + Self::SpawnFingerprint { old, new } => { write!(f, "Spawn fingerprint changed: old={old:?}, new={new:?}") } - Self::InputConfigChanged => { + Self::InputConfig => { write!(f, "inputs configuration changed") } - Self::GlobbedInputChanged { path } => { + Self::GlobbedInput { path } => { write!(f, "content of input '{path}' changed") } - Self::PostRunFingerprintMismatch(diff) => Display::fmt(diff, f), + Self::PostRunFingerprint(diff) => Display::fmt(diff, f), } } } @@ -222,7 +222,7 @@ impl ExecutionCache { cache_value.post_run_fingerprint.validate(workspace_root)? { return Ok(Err(CacheMiss::FingerprintMismatch( - FingerprintMismatch::PostRunFingerprintMismatch(post_run_fingerprint_mismatch), + FingerprintMismatch::PostRunFingerprint(post_run_fingerprint_mismatch), ))); } // Associate the execution key to the cache entry key if not already, @@ -237,14 +237,14 @@ impl ExecutionCache { self.get_cache_key_by_execution_key(execution_cache_key).await? { // Determine what changed: spawn fingerprint or config (input_config / glob_base) - let mismatch = if old_cache_key.spawn_fingerprint != *spawn_fingerprint { - FingerprintMismatch::SpawnFingerprintMismatch { + let mismatch = if old_cache_key.spawn_fingerprint == *spawn_fingerprint { + // spawn fingerprint is the same but input_config or glob_base changed + FingerprintMismatch::InputConfig + } else { + FingerprintMismatch::SpawnFingerprint { old: old_cache_key.spawn_fingerprint, new: spawn_fingerprint.clone(), } - } else { - // spawn fingerprint is the same but input_config or glob_base changed - FingerprintMismatch::InputConfigChanged }; return Ok(Err(CacheMiss::FingerprintMismatch(mismatch))); } @@ -291,21 +291,21 @@ fn detect_globbed_input_change( match (s, c) { (None, None) => return None, (Some((path, _)), None) | (None, Some((path, _))) => { - return Some(FingerprintMismatch::GlobbedInputChanged { path: path.clone() }); + return Some(FingerprintMismatch::GlobbedInput { path: path.clone() }); } (Some((sp, sh)), Some((cp, ch))) => match sp.cmp(cp) { std::cmp::Ordering::Equal => { if sh != ch { - return Some(FingerprintMismatch::GlobbedInputChanged { path: sp.clone() }); + return Some(FingerprintMismatch::GlobbedInput { path: sp.clone() }); } s = stored_iter.next(); c = current_iter.next(); } std::cmp::Ordering::Less => { - return Some(FingerprintMismatch::GlobbedInputChanged { path: sp.clone() }); + return Some(FingerprintMismatch::GlobbedInput { path: sp.clone() }); } std::cmp::Ordering::Greater => { - return Some(FingerprintMismatch::GlobbedInputChanged { path: cp.clone() }); + return Some(FingerprintMismatch::GlobbedInput { path: cp.clone() }); } }, } diff --git a/crates/vite_task/src/session/reporter/summary.rs b/crates/vite_task/src/session/reporter/summary.rs index dea40031..39f5f9e5 100644 --- a/crates/vite_task/src/session/reporter/summary.rs +++ b/crates/vite_task/src/session/reporter/summary.rs @@ -112,7 +112,7 @@ pub enum SavedCacheMissReason { NotFound, /// Spawn fingerprint changed (command, envs, cwd, etc.). SpawnFingerprintChanged(Vec), - /// Task configuration changed (input_config or glob_base). + /// Task configuration changed (`input_config` or `glob_base`). ConfigChanged, /// Content of an input file changed. InputContentChanged { path: Str }, @@ -237,14 +237,14 @@ impl SavedCacheMissReason { match cache_miss { CacheMiss::NotFound => Self::NotFound, CacheMiss::FingerprintMismatch(mismatch) => match mismatch { - FingerprintMismatch::SpawnFingerprintMismatch { old, new } => { + FingerprintMismatch::SpawnFingerprint { old, new } => { Self::SpawnFingerprintChanged(detect_spawn_fingerprint_changes(old, new)) } - FingerprintMismatch::InputConfigChanged => Self::ConfigChanged, - FingerprintMismatch::GlobbedInputChanged { path } => { + FingerprintMismatch::InputConfig => Self::ConfigChanged, + FingerprintMismatch::GlobbedInput { path } => { Self::InputContentChanged { path: Str::from(path.as_str()) } } - FingerprintMismatch::PostRunFingerprintMismatch(diff) => { + FingerprintMismatch::PostRunFingerprint(diff) => { use crate::session::execute::fingerprint::PostRunFingerprintMismatch; match diff { PostRunFingerprintMismatch::InputContentChanged { path } => { From d5ba6e87c613b82e3fd7823cc283d7ea65230187 Mon Sep 17 00:00:00 2001 From: branchseer Date: Fri, 6 Mar 2026 10:55:40 +0800 Subject: [PATCH 04/32] refactor(cache): update comments to remove pre-run/post-run key-value framing The cache key and value are no longer conceptually divided as pre-run vs post-run fingerprints. Update doc comments to describe what each struct contains directly, and remove stale `# Arguments` sections. Co-Authored-By: Claude Opus 4.6 --- crates/vite_task/src/session/cache/mod.rs | 21 ++++++--------------- 1 file changed, 6 insertions(+), 15 deletions(-) diff --git a/crates/vite_task/src/session/cache/mod.rs b/crates/vite_task/src/session/cache/mod.rs index 3a9fb40d..a1681194 100644 --- a/crates/vite_task/src/session/cache/mod.rs +++ b/crates/vite_task/src/session/cache/mod.rs @@ -66,8 +66,10 @@ impl CacheEntryKey { } } -/// Command cache value, for validating post-run fingerprint after the spawn fingerprint is matched, -/// and replaying the std outputs if validated. +/// Cached execution result for a task. +/// +/// Contains the post-run fingerprint (from fspy), captured outputs, +/// execution duration, and explicit input file hashes. #[derive(Debug, Encode, Decode, Serialize)] pub struct CacheEntryValue { pub post_run_fingerprint: PostRunFingerprint, @@ -187,13 +189,8 @@ impl ExecutionCache { Ok(()) } - /// Try to hit cache with pre-run fingerprint (spawn + globbed inputs). + /// 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. - /// - /// # Arguments - /// * `cache_metadata` - Cache metadata from plan stage - /// * `globbed_inputs` - Hashes of explicit input files computed from positive globs - /// * `workspace_root` - Workspace root for converting paths and validating fingerprints #[tracing::instrument(level = "debug", skip_all)] pub async fn try_hit( &self, @@ -207,7 +204,7 @@ impl ExecutionCache { let cache_key = CacheEntryKey::from_metadata(cache_metadata, workspace_root)?; - // Try to directly find the cache by pre-run fingerprint first + // Try to find the cache entry by key (spawn fingerprint + input config + glob base) if let Some(cache_value) = self.get_by_cache_key(&cache_key).await? { // Validate explicit globbed inputs against the stored values if let Some(mismatch) = @@ -253,12 +250,6 @@ impl ExecutionCache { } /// Update cache after successful execution. - /// - /// # Arguments - /// * `cache_metadata` - Cache metadata from plan stage - /// * `globbed_inputs` - Hashes of explicit input files computed from positive globs - /// * `workspace_root` - Workspace root for converting absolute paths to relative - /// * `cache_value` - The cache value to store (outputs and post-run fingerprint) #[tracing::instrument(level = "debug", skip_all)] pub async fn update( &self, From 06775f81aa5a565e0d29a80f28e6a7cb95d01139 Mon Sep 17 00:00:00 2001 From: branchseer Date: Fri, 6 Mar 2026 11:12:45 +0800 Subject: [PATCH 05/32] refactor(cache): remove redundant includes_auto guard on post-run validation The post-run fingerprint is empty when auto inference is disabled, so validate() is a no-op regardless. The input_config is already part of the cache entry key, ensuring consistency. Co-Authored-By: Claude Opus 4.6 --- crates/vite_task/src/session/cache/mod.rs | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/crates/vite_task/src/session/cache/mod.rs b/crates/vite_task/src/session/cache/mod.rs index a1681194..71797222 100644 --- a/crates/vite_task/src/session/cache/mod.rs +++ b/crates/vite_task/src/session/cache/mod.rs @@ -200,7 +200,6 @@ impl ExecutionCache { ) -> anyhow::Result> { let spawn_fingerprint = &cache_metadata.spawn_fingerprint; let execution_cache_key = &cache_metadata.execution_cache_key; - let input_config = &cache_metadata.input_config; let cache_key = CacheEntryKey::from_metadata(cache_metadata, workspace_root)?; @@ -213,10 +212,9 @@ impl ExecutionCache { return Ok(Err(CacheMiss::FingerprintMismatch(mismatch))); } - // Validate post-run fingerprint (inferred inputs) only if auto inference is enabled - if input_config.includes_auto - && let Some(post_run_fingerprint_mismatch) = - cache_value.post_run_fingerprint.validate(workspace_root)? + // Validate post-run fingerprint (inferred inputs from fspy) + if let Some(post_run_fingerprint_mismatch) = + cache_value.post_run_fingerprint.validate(workspace_root)? { return Ok(Err(CacheMiss::FingerprintMismatch( FingerprintMismatch::PostRunFingerprint(post_run_fingerprint_mismatch), From 2c8bd520e98479da32100c1a61a1b250e7c7e5b7 Mon Sep 17 00:00:00 2001 From: branchseer Date: Fri, 6 Mar 2026 11:19:13 +0800 Subject: [PATCH 06/32] docs(cache): document key vs value design principle Key fields create separate entries (reverting a change can hit the old entry). Value fields overwrite the same entry (no need to remember old state). Co-Authored-By: Claude Opus 4.6 --- crates/vite_task/src/session/cache/mod.rs | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/crates/vite_task/src/session/cache/mod.rs b/crates/vite_task/src/session/cache/mod.rs index 71797222..5c9209c8 100644 --- a/crates/vite_task/src/session/cache/mod.rs +++ b/crates/vite_task/src/session/cache/mod.rs @@ -22,9 +22,16 @@ use super::execute::{ /// Cache lookup key identifying a task's execution configuration. /// -/// Contains the spawn fingerprint (command, env, cwd), input configuration, -/// and glob base directory. Explicit input file hashes are stored in -/// [`CacheEntryValue`] so that changes can be detected and reported. +/// # Key vs value design +/// +/// Put a field in the **key** if each distinct value should have its own +/// cache entry (e.g., different env values → different entries, so +/// reverting an env change can still hit the old entry). +/// +/// Put a field in the **value** ([`CacheEntryValue`]) if changes should +/// overwrite the existing entry (e.g., input file hashes — there's no +/// reason to keep the old hash around, and storing them in the value +/// lets us report exactly *which file* changed). #[derive(Debug, Encode, Decode, Serialize, PartialEq, Eq, Clone)] pub struct CacheEntryKey { /// The spawn fingerprint (command, args, cwd, envs) From 8ce4762559d08e9ed4f1f8cf8b523a2be1740b93 Mon Sep 17 00:00:00 2001 From: branchseer Date: Fri, 6 Mar 2026 12:02:47 +0800 Subject: [PATCH 07/32] refactor(cache): destructure CacheEntryKey in mismatch detection Use struct field matching so adding a new field to CacheEntryKey causes a compile error here, forcing the developer to handle it. Co-Authored-By: Claude Opus 4.6 --- crates/vite_task/src/session/cache/mod.rs | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/crates/vite_task/src/session/cache/mod.rs b/crates/vite_task/src/session/cache/mod.rs index 5c9209c8..11de13cb 100644 --- a/crates/vite_task/src/session/cache/mod.rs +++ b/crates/vite_task/src/session/cache/mod.rs @@ -238,13 +238,18 @@ impl ExecutionCache { if let Some(old_cache_key) = self.get_cache_key_by_execution_key(execution_cache_key).await? { - // Determine what changed: spawn fingerprint or config (input_config / glob_base) - let mismatch = if old_cache_key.spawn_fingerprint == *spawn_fingerprint { + // Destructure to ensure we handle all fields when new ones are added + let CacheEntryKey { + spawn_fingerprint: old_spawn_fingerprint, + input_config: _, + glob_base: _, + } = old_cache_key; + let mismatch = if old_spawn_fingerprint == *spawn_fingerprint { // spawn fingerprint is the same but input_config or glob_base changed FingerprintMismatch::InputConfig } else { FingerprintMismatch::SpawnFingerprint { - old: old_cache_key.spawn_fingerprint, + old: old_spawn_fingerprint, new: spawn_fingerprint.clone(), } }; From a6369f513276da1b763e2779783456d0390aeb75 Mon Sep 17 00:00:00 2001 From: branchseer Date: Fri, 6 Mar 2026 12:09:08 +0800 Subject: [PATCH 08/32] refactor: remove ResolvedInputConfig::inference_disabled method The public field `includes_auto` is clear enough on its own; the negated helper added indirection without value. Remove it and use `!includes_auto` directly. Drop redundant test assertions. Co-Authored-By: Claude Opus 4.6 --- .../vite_task/src/session/execute/fingerprint.rs | 6 +++--- crates/vite_task_graph/src/config/mod.rs | 14 -------------- 2 files changed, 3 insertions(+), 17 deletions(-) diff --git a/crates/vite_task/src/session/execute/fingerprint.rs b/crates/vite_task/src/session/execute/fingerprint.rs index ca1d663d..a00e36ed 100644 --- a/crates/vite_task/src/session/execute/fingerprint.rs +++ b/crates/vite_task/src/session/execute/fingerprint.rs @@ -77,13 +77,13 @@ impl PostRunFingerprint { /// * `input_config` - Resolved input configuration controlling what to fingerprint #[tracing::instrument(level = "debug", skip_all, name = "create_post_run_fingerprint")] pub fn create( - path_reads: &HashMap, + inferred_path_reads: &HashMap, base_dir: &AbsolutePath, glob_base: &AbsolutePath, input_config: &ResolvedInputConfig, ) -> anyhow::Result { // If inference is disabled, return empty inferred_inputs - if input_config.inference_disabled() { + if !input_config.includes_auto { return Ok(Self { inferred_inputs: HashMap::default() }); } @@ -93,7 +93,7 @@ impl PostRunFingerprint { .map(|p| ResolvedGlob::new(p.as_str(), glob_base)) .collect::>()?; - let inferred_inputs = path_reads + let inferred_inputs = inferred_path_reads .par_iter() .filter_map(|(relative_path, path_read)| { // Clean the absolute path to normalize `..` from fspy-tracked paths diff --git a/crates/vite_task_graph/src/config/mod.rs b/crates/vite_task_graph/src/config/mod.rs index 1dec96b3..40ffdff0 100644 --- a/crates/vite_task_graph/src/config/mod.rs +++ b/crates/vite_task_graph/src/config/mod.rs @@ -150,15 +150,6 @@ impl ResolvedInputConfig { Self { includes_auto, positive_globs, negative_globs } } - - /// Returns true if inference should be disabled. - /// - /// Inference is disabled when `includes_auto` is false. - #[inline] - #[must_use] - pub const fn inference_disabled(&self) -> bool { - !self.includes_auto - } } #[derive(Debug, Clone, Serialize)] @@ -295,7 +286,6 @@ mod tests { assert!(config.includes_auto); assert!(config.positive_globs.is_empty()); assert!(config.negative_globs.is_empty()); - assert!(!config.inference_disabled()); } #[test] @@ -315,7 +305,6 @@ mod tests { assert!(!config.includes_auto); assert!(config.positive_globs.is_empty()); assert!(config.negative_globs.is_empty()); - assert!(config.inference_disabled()); } #[test] @@ -349,7 +338,6 @@ mod tests { assert!(config.positive_globs.contains("src/**/*.ts")); assert!(config.positive_globs.contains("package.json")); assert!(config.negative_globs.is_empty()); - assert!(config.inference_disabled()); } #[test] @@ -379,7 +367,6 @@ mod tests { assert!(config.positive_globs.contains("package.json")); assert_eq!(config.negative_globs.len(), 1); assert!(config.negative_globs.contains("node_modules/**")); - assert!(!config.inference_disabled()); } #[test] @@ -389,6 +376,5 @@ mod tests { vec![UserInputEntry::Glob("src/**/*.ts".into()), UserInputEntry::Auto { auto: true }]; let config = ResolvedInputConfig::from_user_config(Some(&user_inputs)); assert!(config.includes_auto); - assert!(!config.inference_disabled()); } } From 758c519d06df5356d77c820f87d121f84be39f0d Mon Sep 17 00:00:00 2001 From: branchseer Date: Sat, 7 Mar 2026 20:57:15 +0800 Subject: [PATCH 09/32] feat: add directory pruning for negative glob patterns in input selection Move negative glob filtering from fingerprint.rs to spawn.rs so fspy-tracked paths are filtered at the absolute path stage. Refactor glob_inputs.rs to partition globs and walk from cleaned roots, enabling wax's automatic directory pruning for exhaustive negative patterns like !dist/**. Co-Authored-By: Claude Opus 4.6 --- .../src/session/execute/fingerprint.rs | 38 +--- .../src/session/execute/glob_inputs.rs | 199 +++++++++--------- crates/vite_task/src/session/execute/mod.rs | 54 ++++- crates/vite_task/src/session/execute/spawn.rs | 27 +++ 4 files changed, 187 insertions(+), 131 deletions(-) diff --git a/crates/vite_task/src/session/execute/fingerprint.rs b/crates/vite_task/src/session/execute/fingerprint.rs index a00e36ed..08e0e4f2 100644 --- a/crates/vite_task/src/session/execute/fingerprint.rs +++ b/crates/vite_task/src/session/execute/fingerprint.rs @@ -15,9 +15,8 @@ use rayon::iter::{IntoParallelRefIterator, ParallelIterator}; use serde::{Deserialize, Serialize}; use vite_path::{AbsolutePath, RelativePathBuf}; use vite_str::Str; -use vite_task_graph::config::ResolvedInputConfig; -use super::{glob_inputs::ResolvedGlob, spawn::PathRead}; +use super::spawn::PathRead; use crate::collections::HashMap; /// Post-run fingerprint capturing file state after execution. @@ -70,42 +69,26 @@ impl std::fmt::Display for PostRunFingerprintMismatch { 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. + /// /// # Arguments - /// * `path_reads` - Map of paths that were read during execution (from fspy) + /// * `inferred_path_reads` - Map of paths that were read during execution (from fspy) /// * `base_dir` - Workspace root for resolving relative paths - /// * `glob_base` - Package directory where the task is defined (negative globs are relative to this) - /// * `input_config` - Resolved input configuration controlling what to fingerprint #[tracing::instrument(level = "debug", skip_all, name = "create_post_run_fingerprint")] pub fn create( inferred_path_reads: &HashMap, base_dir: &AbsolutePath, - glob_base: &AbsolutePath, - input_config: &ResolvedInputConfig, ) -> anyhow::Result { - // If inference is disabled, return empty inferred_inputs - if !input_config.includes_auto { - return Ok(Self { inferred_inputs: HashMap::default() }); - } - - let negatives: Vec = input_config - .negative_globs - .iter() - .map(|p| ResolvedGlob::new(p.as_str(), glob_base)) - .collect::>()?; - let inferred_inputs = inferred_path_reads .par_iter() - .filter_map(|(relative_path, path_read)| { + .map(|(relative_path, path_read)| { // Clean the absolute path to normalize `..` from fspy-tracked paths // (e.g., `packages/sub-pkg/../shared/dist/output.js`). let cleaned_abs = path_clean::PathClean::clean(base_dir.join(relative_path).as_path()); - // Apply negative globs against the cleaned path - if negatives.iter().any(|neg| neg.matches(&cleaned_abs)) { - return None; - } - // Derive a cleaned workspace-relative key so stored paths are normalized let clean_key = cleaned_abs .strip_prefix(base_dir.as_path()) @@ -114,11 +97,8 @@ impl PostRunFingerprint { .unwrap_or_else(|| relative_path.clone()); let full_path = Arc::::from(base_dir.join(&clean_key)); - let fingerprint = match fingerprint_path(&full_path, *path_read) { - Ok(f) => f, - Err(e) => return Some(Err(e)), - }; - Some(Ok((clean_key, fingerprint))) + let fingerprint = fingerprint_path(&full_path, *path_read)?; + Ok((clean_key, fingerprint)) }) .collect::>>()?; diff --git a/crates/vite_task/src/session/execute/glob_inputs.rs b/crates/vite_task/src/session/execute/glob_inputs.rs index b961072a..e8d9dbb8 100644 --- a/crates/vite_task/src/session/execute/glob_inputs.rs +++ b/crates/vite_task/src/session/execute/glob_inputs.rs @@ -10,71 +10,90 @@ use std::{ io::{self, Read}, }; -use path_clean::PathClean; #[cfg(test)] use vite_path::AbsolutePathBuf; use vite_path::{AbsolutePath, RelativePathBuf}; use vite_str::Str; -use wax::{Glob, Program as _}; +use wax::{Glob, walk::Entry as _}; -/// A glob pattern resolved to an absolute base directory. +use super::spawn::ResolvedNegativeGlob; + +/// Collect walk entries into the result map, filtering against resolved negatives. /// -/// Uses [`wax::Glob::partition`] to separate the invariant prefix from the -/// wildcard suffix, then resolves the prefix to an absolute path via -/// [`path_clean`] (normalizing components like `..`). +/// Each positive glob is partitioned into an invariant prefix and a variant pattern. +/// The prefix is joined with `base_dir` and cleaned (normalizing `..`) to get the walk root. +/// The variant pattern is then walked from the cleaned root. /// -/// For example, `../shared/src/**` relative to `/ws/packages/app` resolves to: -/// - `resolved_base`: `/ws/packages/shared/src` -/// - `variant`: `Some(Glob("**"))` -#[expect(clippy::disallowed_types, reason = "path_clean returns std::path::PathBuf")] -pub struct ResolvedGlob { - resolved_base: std::path::PathBuf, - variant: Option>, -} - -impl ResolvedGlob { - /// Resolve a glob pattern relative to `base_dir`. - pub fn new(pattern: &str, base_dir: &AbsolutePath) -> anyhow::Result { - let glob = Glob::new(pattern)?.into_owned(); - let (base_pathbuf, variant) = glob.partition(); - let base_str = base_pathbuf.to_str().unwrap_or("."); - let resolved_base = if base_str.is_empty() { - base_dir.as_path().to_path_buf() - } else { - base_dir.join(base_str).as_path().clean() +/// Walk errors for non-existent directories are skipped gracefully. +fn collect_walk_entries( + walk: impl Iterator>, + workspace_root: &AbsolutePath, + resolved_negatives: &[ResolvedNegativeGlob], + result: &mut BTreeMap, +) -> anyhow::Result<()> { + use path_clean::PathClean as _; + use wax::Program as _; + + for entry in walk { + let entry = match entry { + Ok(entry) => entry, + Err(err) => { + // WalkError -> io::Error preserves the error kind + let io_err: io::Error = err.into(); + if io_err.kind() == io::ErrorKind::NotFound { + continue; + } + return Err(io_err.into()); + } }; - Ok(Self { resolved_base, variant: variant.map(Glob::into_owned) }) - } + if !entry.file_type().is_file() { + continue; + } + + // Clean the path to normalize `..` components (from globs like `../shared/src/**`) + let cleaned_path = entry.path().clean(); - /// Walk the filesystem and yield matching file paths. - #[expect(clippy::disallowed_types, reason = "yields std::path::PathBuf from wax walker")] - pub fn walk(&self) -> Box + '_> { - match &self.variant { - Some(variant_glob) => Box::new( - variant_glob - .walk(&self.resolved_base) - .filter_map(Result::ok) - .map(wax::walk::Entry::into_path), - ), - None => Box::new(std::iter::once(self.resolved_base.clone())), + // Filter against resolved negatives + if resolved_negatives.iter().any(|(prefix, variant)| { + let Ok(remainder) = cleaned_path.strip_prefix(prefix) else { + return false; + }; + variant.as_ref().map_or(remainder.as_os_str().is_empty(), |v| v.is_match(remainder)) + }) { + continue; } - } - /// Check if an absolute path matches this resolved glob. - #[expect(clippy::disallowed_types, reason = "matching against std::path::Path")] - pub fn matches(&self, path: &std::path::Path) -> bool { - path.strip_prefix(&self.resolved_base).ok().is_some_and(|remainder| { - self.variant - .as_ref() - .map_or(remainder.as_os_str().is_empty(), |v| v.is_match(remainder)) - }) + // Compute path relative to workspace_root for the result + let Some(relative_to_workspace) = cleaned_path + .strip_prefix(workspace_root.as_path()) + .ok() + .and_then(|p| RelativePathBuf::new(p).ok()) + else { + continue; // Skip if path is outside workspace_root + }; + + // Hash file content + match hash_file_content(&cleaned_path) { + Ok(hash) => { + result.insert(relative_to_workspace, hash); + } + Err(err) if err.kind() == io::ErrorKind::NotFound => { + // File was deleted between walk and hash, skip it + } + Err(err) => { + return Err(err.into()); + } + } } + Ok(()) } /// Compute globbed inputs by walking positive glob patterns and filtering with negative patterns. /// -/// Glob patterns may contain `..` to reference files outside the package directory -/// (e.g., `../shared/src/**` to include a sibling package's source files). +/// Each glob is partitioned into an invariant prefix and a variant pattern. The prefix is +/// joined with `base_dir` and cleaned to normalize `..` components, producing the walk root. +/// The variant pattern walks the cleaned root. Negative patterns are resolved the same way +/// and used to filter walked entries by matching against cleaned absolute paths. /// /// # Arguments /// * `base_dir` - The package directory where the task is defined (globs are relative to this) @@ -85,69 +104,59 @@ impl ResolvedGlob { /// # Returns /// A sorted map of relative paths (from `workspace_root`) to their content hashes. /// Only files are included (directories are skipped). -/// -/// # Example -/// ```ignore -/// // For a task defined in `packages/foo/` with inputs: ["src/**/*.ts", "!**/*.test.ts"] -/// let inputs = compute_globbed_inputs( -/// &packages_foo_path, -/// &workspace_root, -/// &["src/**/*.ts".into()].into_iter().collect(), -/// &["**/*.test.ts".into()].into_iter().collect(), -/// )?; -/// // Returns: { "packages/foo/src/index.ts" => 0x1234..., ... } -/// ``` pub fn compute_globbed_inputs( base_dir: &AbsolutePath, workspace_root: &AbsolutePath, positive_globs: &std::collections::BTreeSet, negative_globs: &std::collections::BTreeSet, ) -> anyhow::Result> { - // If no positive globs, return empty result + use path_clean::PathClean as _; + if positive_globs.is_empty() { return Ok(BTreeMap::new()); } - let negatives: Vec = negative_globs + // Resolve negatives: partition + clean to get (absolute_prefix, variant) + let resolved_negatives: Vec = negative_globs .iter() - .map(|p| ResolvedGlob::new(p.as_str(), base_dir)) + .map(|p| { + let glob = Glob::new(p.as_str())?.into_owned(); + let (prefix, variant) = glob.partition(); + let resolved = base_dir.as_path().join(&prefix).clean(); + Ok((resolved, variant.map(Glob::into_owned))) + }) .collect::>()?; let mut result = BTreeMap::new(); for pattern in positive_globs { - let resolved = ResolvedGlob::new(pattern.as_str(), base_dir)?; - - for absolute_path in resolved.walk() { - // Skip non-files - if !absolute_path.is_file() { - continue; + let pos = Glob::new(pattern.as_str())?.into_owned(); + let (pos_prefix, pos_variant) = pos.partition(); + let walk_root = base_dir.as_path().join(&pos_prefix).clean(); + + if let Some(variant_glob) = pos_variant { + if walk_root.is_dir() { + collect_walk_entries( + variant_glob.into_owned().walk(&walk_root), + workspace_root, + &resolved_negatives, + &mut result, + )?; } - - // Apply negative patterns - if negatives.iter().any(|neg| neg.matches(&absolute_path)) { - continue; - } - - // Compute path relative to workspace_root for the result - let Some(relative_to_workspace) = absolute_path - .strip_prefix(workspace_root.as_path()) - .ok() - .and_then(|p| RelativePathBuf::new(p).ok()) - else { - continue; // Skip if path is outside workspace_root - }; - - // Hash file content - match hash_file_content(&absolute_path) { - Ok(hash) => { - result.insert(relative_to_workspace, hash); - } - Err(err) if err.kind() == io::ErrorKind::NotFound => { - // File was deleted between walk and hash, skip it - } - Err(err) => { - return Err(err.into()); + } else { + // Invariant-only glob (specific file path) — hash directly if it exists + if walk_root.is_file() + && let Some(relative) = walk_root + .strip_prefix(workspace_root.as_path()) + .ok() + .and_then(|p| RelativePathBuf::new(p).ok()) + { + match hash_file_content(&walk_root) { + Ok(hash) => { + result.insert(relative, hash); + } + Err(err) if err.kind() == io::ErrorKind::NotFound => {} + Err(err) => return Err(err.into()), } } } diff --git a/crates/vite_task/src/session/execute/mod.rs b/crates/vite_task/src/session/execute/mod.rs index 9b177747..3e6b368d 100644 --- a/crates/vite_task/src/session/execute/mod.rs +++ b/crates/vite_task/src/session/execute/mod.rs @@ -15,7 +15,7 @@ use vite_task_plan::{ use self::{ fingerprint::PostRunFingerprint, glob_inputs::compute_globbed_inputs, - spawn::{SpawnResult, TrackedPathAccesses, spawn_with_tracking}, + spawn::{ResolvedNegativeGlob, SpawnResult, TrackedPathAccesses, spawn_with_tracking}, }; use super::{ cache::{CacheEntryValue, ExecutionCache}, @@ -320,6 +320,28 @@ pub async fn execute_spawn( (Some(Vec::new()), path_accesses, Some((cache_metadata, globbed_inputs))) }); + // Resolve negative globs for fspy path filtering + let resolved_negatives = if let Some((cache_metadata, _)) = &cache_metadata_and_inputs { + match resolve_negative_globs( + &cache_metadata.glob_base, + &cache_metadata.input_config.negative_globs, + ) { + Ok(negs) => negs, + Err(err) => { + leaf_reporter + .finish( + None, + CacheUpdateStatus::NotUpdated(CacheNotUpdatedReason::CacheDisabled), + Some(ExecutionError::PostRunFingerprint(err)), + ) + .await; + return SpawnOutcome::Failed; + } + } + } else { + Vec::new() + }; + #[expect( clippy::large_futures, reason = "spawn_with_tracking manages process I/O and creates a large future" @@ -331,6 +353,7 @@ pub async fn execute_spawn( &mut stdio_config.stderr_writer, std_outputs.as_mut(), path_accesses.as_mut(), + &resolved_negatives, ) .await { @@ -358,12 +381,7 @@ pub async fn execute_spawn( 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, - &cache_metadata.glob_base, - &cache_metadata.input_config, - ) { + match PostRunFingerprint::create(path_reads, cache_base_path) { Ok(post_run_fingerprint) => { let new_cache_value = CacheEntryValue { post_run_fingerprint, @@ -404,6 +422,28 @@ pub async fn execute_spawn( SpawnOutcome::Spawned(result.exit_status) } +/// Resolve negative glob patterns into absolute prefix + optional variant for fspy path filtering. +/// +/// Each negative glob is partitioned into an invariant prefix and a variant (dynamic) part. +/// The prefix is joined with `glob_base` and cleaned to produce an absolute path for efficient +/// prefix-based filtering in `spawn_with_tracking`. +fn resolve_negative_globs( + glob_base: &AbsolutePath, + negative_globs: &std::collections::BTreeSet, +) -> anyhow::Result> { + use path_clean::PathClean as _; + + negative_globs + .iter() + .map(|p| { + let glob = wax::Glob::new(p.as_str())?.into_owned(); + let (prefix, variant) = glob.partition(); + let resolved = glob_base.as_path().join(&prefix).clean(); + Ok((resolved, variant.map(wax::Glob::into_owned))) + }) + .collect() +} + /// Spawn a command with all three stdio file descriptors inherited from the parent. /// /// Used when the reporter suggests inherited stdio AND caching is disabled. diff --git a/crates/vite_task/src/session/execute/spawn.rs b/crates/vite_task/src/session/execute/spawn.rs index 4478abbe..137f677d 100644 --- a/crates/vite_task/src/session/execute/spawn.rs +++ b/crates/vite_task/src/session/execute/spawn.rs @@ -54,6 +54,12 @@ pub struct TrackedPathAccesses { pub path_writes: FxHashSet, } +/// Resolved negative glob pattern for filtering fspy-tracked paths. +/// The `PathBuf` is the resolved absolute prefix (glob base + invariant prefix, cleaned). +/// The `Option` is the variant (dynamic) part of the pattern, if any. +#[expect(clippy::disallowed_types, reason = "wax partition returns std::path::PathBuf")] +pub type ResolvedNegativeGlob = (std::path::PathBuf, Option>); + /// Spawn a command with optional file system tracking via fspy, using piped stdio. /// /// Returns the execution result including exit status and duration. @@ -62,6 +68,7 @@ pub struct TrackedPathAccesses { /// - `stdout_writer`/`stderr_writer` receive the child's stdout/stderr output in real-time. /// - `std_outputs` if provided, will be populated with captured outputs for cache replay. /// - `path_accesses` if provided, fspy will be used to track file accesses. If `None`, fspy is disabled. +/// - `resolved_negatives` - resolved negative glob patterns for filtering fspy-tracked paths. #[tracing::instrument(level = "debug", skip_all)] #[expect(clippy::future_not_send, reason = "uses !Send dyn AsyncWrite writers internally")] #[expect( @@ -75,6 +82,7 @@ pub async fn spawn_with_tracking( stderr_writer: &mut (dyn AsyncWrite + Unpin), std_outputs: Option<&mut Vec>, path_accesses: Option<&mut TrackedPathAccesses>, + resolved_negatives: &[ResolvedNegativeGlob], ) -> anyhow::Result { /// The tracking state of the spawned process. /// Determined by whether `path_accesses` is `Some` (fspy enabled) or `None` (fspy disabled). @@ -207,6 +215,25 @@ pub async fn spawn_with_tracking( continue; } + // Filter against resolved negative globs. + // Clean the path to normalize `..` only for matching purposes, since + // resolved negatives are cleaned absolute paths. + if !resolved_negatives.is_empty() { + let cleaned_abs = + path_clean::PathClean::clean(workspace_root.join(&relative_path).as_path()); + if resolved_negatives.iter().any(|(resolved_prefix, variant)| { + let Ok(remainder) = cleaned_abs.strip_prefix(resolved_prefix) else { + return false; + }; + variant.as_ref().map_or(remainder.as_os_str().is_empty(), |v| { + use wax::Program as _; + v.is_match(remainder) + }) + }) { + continue; + } + } + if access.mode.contains(AccessMode::READ) { path_reads.entry(relative_path.clone()).or_insert(PathRead { read_dir_entries: false }); } From a285687da240ff1ae63c16caa76e4b887676ad36 Mon Sep 17 00:00:00 2001 From: branchseer Date: Sat, 7 Mar 2026 21:50:31 +0800 Subject: [PATCH 10/32] docs: replace fspy references with user-friendly language in inputs config Co-Authored-By: Claude Opus 4.6 --- crates/vite_task_graph/run-config.ts | 16 ++++++++-------- crates/vite_task_graph/src/config/mod.rs | 4 ++-- crates/vite_task_graph/src/config/user.rs | 16 ++++++++-------- 3 files changed, 18 insertions(+), 18 deletions(-) diff --git a/crates/vite_task_graph/run-config.ts b/crates/vite_task_graph/run-config.ts index 9b5b544e..b79190c9 100644 --- a/crates/vite_task_graph/run-config.ts +++ b/crates/vite_task_graph/run-config.ts @@ -30,21 +30,21 @@ export type Task = { */ passThroughEnvs?: Array; /** - * Input patterns for cache fingerprinting. + * Files to include in the cache fingerprint. * - * - Omitted: defaults to `[{auto: true}]` - infer from file accesses - * - Empty array: no inputs, inference disabled - * - Glob strings: explicit files to fingerprint - * - `{auto: true}`: enable automatic inference via fspy - * - Negative globs: exclude files (prefix with `!`) + * - Omitted: automatically tracks which files the task reads + * - `[]` (empty): disables file tracking entirely + * - Glob patterns (e.g. `"src/**"`) select specific files + * - `{auto: true}` enables automatic file tracking + * - Negative patterns (e.g. `"!dist/**"`) exclude matched files * - * Globs are relative to the package directory where the task is defined. + * Patterns are relative to the package directory. */ inputs?: Array< | string | { /** - * Whether automatic file access inference (via fspy) is enabled + * Automatically track which files the task reads */ auto: boolean; } diff --git a/crates/vite_task_graph/src/config/mod.rs b/crates/vite_task_graph/src/config/mod.rs index 40ffdff0..2ff26587 100644 --- a/crates/vite_task_graph/src/config/mod.rs +++ b/crates/vite_task_graph/src/config/mod.rs @@ -90,12 +90,12 @@ pub struct CacheConfig { /// Resolved input configuration for cache fingerprinting. /// /// This is the normalized form after parsing user config. -/// - `includes_auto`: Whether automatic inference from fspy is enabled +/// - `includes_auto`: Whether automatic file tracking is enabled /// - `positive_globs`: Glob patterns for files to include (without `!` prefix) /// - `negative_globs`: Glob patterns for files to exclude (without `!` prefix) #[derive(Debug, Clone, PartialEq, Eq, Serialize, Encode, Decode)] pub struct ResolvedInputConfig { - /// Whether automatic file access inference (via fspy) is enabled + /// Whether automatic file tracking is enabled pub includes_auto: bool, /// Positive glob patterns (files to include). diff --git a/crates/vite_task_graph/src/config/user.rs b/crates/vite_task_graph/src/config/user.rs index 50de89e6..6210fadf 100644 --- a/crates/vite_task_graph/src/config/user.rs +++ b/crates/vite_task_graph/src/config/user.rs @@ -22,7 +22,7 @@ pub enum UserInputEntry { Glob(Str), /// Auto-inference directive Auto { - /// Whether automatic file access inference (via fspy) is enabled + /// Automatically track which files the task reads auto: bool, }, } @@ -81,15 +81,15 @@ pub struct EnabledCacheConfig { /// Environment variable names to be passed to the task without fingerprinting. pub pass_through_envs: Option>, - /// Input patterns for cache fingerprinting. + /// Files to include in the cache fingerprint. /// - /// - Omitted: defaults to `[{auto: true}]` - infer from file accesses - /// - Empty array: no inputs, inference disabled - /// - Glob strings: explicit files to fingerprint - /// - `{auto: true}`: enable automatic inference via fspy - /// - Negative globs: exclude files (prefix with `!`) + /// - Omitted: automatically tracks which files the task reads + /// - `[]` (empty): disables file tracking entirely + /// - Glob patterns (e.g. `"src/**"`) select specific files + /// - `{auto: true}` enables automatic file tracking + /// - Negative patterns (e.g. `"!dist/**"`) exclude matched files /// - /// Globs are relative to the package directory where the task is defined. + /// Patterns are relative to the package directory. #[serde(default)] #[cfg_attr(all(test, not(clippy)), ts(inline))] pub inputs: Option, From 453e44ea8c47e6d4c7aae66c9395c16e21f45d23 Mon Sep 17 00:00:00 2001 From: branchseer Date: Sat, 7 Mar 2026 22:34:59 +0800 Subject: [PATCH 11/32] refactor: extract AnchoredGlob into vite_glob crate Replace the ResolvedNegativeGlob tuple type alias with a proper AnchoredGlob struct that encapsulates glob partitioning, path cleaning, and prefix-based matching behind a clean API. Co-Authored-By: Claude Opus 4.6 --- Cargo.lock | 3 + crates/vite_glob/Cargo.toml | 2 + crates/vite_glob/src/anchored.rs | 62 +++++++++++++++++++ crates/vite_glob/src/lib.rs | 2 + crates/vite_task/Cargo.toml | 1 + .../src/session/execute/glob_inputs.rs | 33 ++++------ crates/vite_task/src/session/execute/mod.rs | 19 ++---- crates/vite_task/src/session/execute/spawn.rs | 23 ++----- 8 files changed, 91 insertions(+), 54 deletions(-) create mode 100644 crates/vite_glob/src/anchored.rs diff --git a/Cargo.lock b/Cargo.lock index 835e1228..14e5b7ee 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3795,7 +3795,9 @@ checksum = "051eb1abcf10076295e815102942cc58f9d5e3b4560e46e53c21e8ff6f3af7b1" name = "vite_glob" version = "0.0.0" dependencies = [ + "path-clean", "thiserror 2.0.18", + "vite_path", "vite_str", "wax", ] @@ -3885,6 +3887,7 @@ dependencies = [ "tokio", "tracing", "twox-hash", + "vite_glob", "vite_path", "vite_select", "vite_str", diff --git a/crates/vite_glob/Cargo.toml b/crates/vite_glob/Cargo.toml index d749d75f..56c25b8a 100644 --- a/crates/vite_glob/Cargo.toml +++ b/crates/vite_glob/Cargo.toml @@ -8,7 +8,9 @@ publish = false rust-version.workspace = true [dependencies] +path-clean = { workspace = true } thiserror = { workspace = true } +vite_path = { workspace = true } wax = { workspace = true } [dev-dependencies] diff --git a/crates/vite_glob/src/anchored.rs b/crates/vite_glob/src/anchored.rs new file mode 100644 index 00000000..993f76c9 --- /dev/null +++ b/crates/vite_glob/src/anchored.rs @@ -0,0 +1,62 @@ +use std::sync::Arc; + +use vite_path::AbsolutePath; +use wax::Glob; + +use crate::Error; + +/// A glob pattern anchored to an absolute directory. +/// +/// Created by partitioning a glob into an invariant prefix and a variant (dynamic) +/// part, then resolving the prefix against a base directory. The prefix is cleaned +/// to normalize `..` components. +/// +/// For example, `../shared/dist/**` relative to `/ws/packages/app` produces: +/// - `prefix`: `/ws/packages/shared/dist` +/// - `variant`: `Some(Glob("**"))` +#[derive(Debug)] +pub struct AnchoredGlob { + prefix: Arc, + variant: Option>, +} + +impl AnchoredGlob { + /// Create an `AnchoredGlob` by resolving `pattern` relative to `base_dir`. + /// + /// The pattern is partitioned into an invariant prefix and a variant glob. + /// The prefix is joined with `base_dir` and cleaned (normalizing `..`). + /// + /// # Errors + /// + /// Returns an error if the glob pattern is invalid. + /// + /// # Panics + /// + /// Panics if cleaning an absolute path somehow produces a non-absolute path. + pub fn new(pattern: &str, base_dir: &AbsolutePath) -> Result { + use path_clean::PathClean as _; + + let glob = Glob::new(pattern)?; + let (prefix_path, variant) = glob.partition(); + let cleaned = base_dir.as_path().join(&prefix_path).clean(); + // Cleaning an absolute path always produces an absolute path + let prefix = Arc::::from( + vite_path::AbsolutePathBuf::new(cleaned) + .expect("cleaning an absolute path produces an absolute path"), + ); + Ok(Self { prefix, variant: variant.map(Glob::into_owned) }) + } + + /// Check if an absolute path matches this anchored glob. + #[must_use] + pub fn is_match(&self, path: &AbsolutePath) -> bool { + use wax::Program as _; + let Ok(remainder) = path.as_path().strip_prefix(self.prefix.as_path()) else { + return false; + }; + let Some(v) = &self.variant else { + return remainder.as_os_str().is_empty(); + }; + v.is_match(remainder) + } +} diff --git a/crates/vite_glob/src/lib.rs b/crates/vite_glob/src/lib.rs index 46753812..d1680fc4 100644 --- a/crates/vite_glob/src/lib.rs +++ b/crates/vite_glob/src/lib.rs @@ -1,8 +1,10 @@ +mod anchored; mod error; #[expect(clippy::disallowed_types, reason = "wax::Glob::is_match requires std::path::Path")] use std::path::Path; +pub use anchored::AnchoredGlob; pub use error::Error; use wax::{Glob, Program}; diff --git a/crates/vite_task/Cargo.toml b/crates/vite_task/Cargo.toml index ade7cdb7..08088587 100644 --- a/crates/vite_task/Cargo.toml +++ b/crates/vite_task/Cargo.toml @@ -34,6 +34,7 @@ thiserror = { workspace = true } tokio = { workspace = true, features = ["rt-multi-thread", "io-std", "io-util", "macros", "sync"] } tracing = { workspace = true } twox-hash = { workspace = true } +vite_glob = { workspace = true } vite_path = { workspace = true } vite_select = { workspace = true } vite_str = { workspace = true } diff --git a/crates/vite_task/src/session/execute/glob_inputs.rs b/crates/vite_task/src/session/execute/glob_inputs.rs index e8d9dbb8..3e0b691e 100644 --- a/crates/vite_task/src/session/execute/glob_inputs.rs +++ b/crates/vite_task/src/session/execute/glob_inputs.rs @@ -10,14 +10,13 @@ use std::{ io::{self, Read}, }; +use vite_glob::AnchoredGlob; #[cfg(test)] use vite_path::AbsolutePathBuf; use vite_path::{AbsolutePath, RelativePathBuf}; use vite_str::Str; use wax::{Glob, walk::Entry as _}; -use super::spawn::ResolvedNegativeGlob; - /// Collect walk entries into the result map, filtering against resolved negatives. /// /// Each positive glob is partitioned into an invariant prefix and a variant pattern. @@ -28,11 +27,10 @@ use super::spawn::ResolvedNegativeGlob; fn collect_walk_entries( walk: impl Iterator>, workspace_root: &AbsolutePath, - resolved_negatives: &[ResolvedNegativeGlob], + resolved_negatives: &[AnchoredGlob], result: &mut BTreeMap, ) -> anyhow::Result<()> { use path_clean::PathClean as _; - use wax::Program as _; for entry in walk { let entry = match entry { @@ -53,21 +51,18 @@ fn collect_walk_entries( // Clean the path to normalize `..` components (from globs like `../shared/src/**`) let cleaned_path = entry.path().clean(); + // Convert to AbsolutePath for negative matching and workspace-relative stripping + let Some(cleaned_abs) = AbsolutePath::new(&cleaned_path) else { + continue; + }; + // Filter against resolved negatives - if resolved_negatives.iter().any(|(prefix, variant)| { - let Ok(remainder) = cleaned_path.strip_prefix(prefix) else { - return false; - }; - variant.as_ref().map_or(remainder.as_os_str().is_empty(), |v| v.is_match(remainder)) - }) { + if resolved_negatives.iter().any(|neg| neg.is_match(cleaned_abs)) { continue; } // Compute path relative to workspace_root for the result - let Some(relative_to_workspace) = cleaned_path - .strip_prefix(workspace_root.as_path()) - .ok() - .and_then(|p| RelativePathBuf::new(p).ok()) + let Some(relative_to_workspace) = cleaned_abs.strip_prefix(workspace_root).ok().flatten() else { continue; // Skip if path is outside workspace_root }; @@ -116,15 +111,9 @@ pub fn compute_globbed_inputs( return Ok(BTreeMap::new()); } - // Resolve negatives: partition + clean to get (absolute_prefix, variant) - let resolved_negatives: Vec = negative_globs + let resolved_negatives: Vec = negative_globs .iter() - .map(|p| { - let glob = Glob::new(p.as_str())?.into_owned(); - let (prefix, variant) = glob.partition(); - let resolved = base_dir.as_path().join(&prefix).clean(); - Ok((resolved, variant.map(Glob::into_owned))) - }) + .map(|p| Ok(AnchoredGlob::new(p.as_str(), base_dir)?)) .collect::>()?; let mut result = BTreeMap::new(); diff --git a/crates/vite_task/src/session/execute/mod.rs b/crates/vite_task/src/session/execute/mod.rs index 3e6b368d..3619791b 100644 --- a/crates/vite_task/src/session/execute/mod.rs +++ b/crates/vite_task/src/session/execute/mod.rs @@ -15,7 +15,7 @@ use vite_task_plan::{ use self::{ fingerprint::PostRunFingerprint, glob_inputs::compute_globbed_inputs, - spawn::{ResolvedNegativeGlob, SpawnResult, TrackedPathAccesses, spawn_with_tracking}, + spawn::{SpawnResult, TrackedPathAccesses, spawn_with_tracking}, }; use super::{ cache::{CacheEntryValue, ExecutionCache}, @@ -422,25 +422,14 @@ pub async fn execute_spawn( SpawnOutcome::Spawned(result.exit_status) } -/// Resolve negative glob patterns into absolute prefix + optional variant for fspy path filtering. -/// -/// Each negative glob is partitioned into an invariant prefix and a variant (dynamic) part. -/// The prefix is joined with `glob_base` and cleaned to produce an absolute path for efficient -/// prefix-based filtering in `spawn_with_tracking`. +/// Resolve negative glob patterns into [`AnchoredGlob`]s for filtering. fn resolve_negative_globs( glob_base: &AbsolutePath, negative_globs: &std::collections::BTreeSet, -) -> anyhow::Result> { - use path_clean::PathClean as _; - +) -> anyhow::Result> { negative_globs .iter() - .map(|p| { - let glob = wax::Glob::new(p.as_str())?.into_owned(); - let (prefix, variant) = glob.partition(); - let resolved = glob_base.as_path().join(&prefix).clean(); - Ok((resolved, variant.map(wax::Glob::into_owned))) - }) + .map(|p| Ok(vite_glob::AnchoredGlob::new(p.as_str(), glob_base)?)) .collect() } diff --git a/crates/vite_task/src/session/execute/spawn.rs b/crates/vite_task/src/session/execute/spawn.rs index 137f677d..792d7be7 100644 --- a/crates/vite_task/src/session/execute/spawn.rs +++ b/crates/vite_task/src/session/execute/spawn.rs @@ -11,6 +11,7 @@ use fspy::AccessMode; use rustc_hash::FxHashSet; use serde::Serialize; use tokio::io::{AsyncReadExt as _, AsyncWrite, AsyncWriteExt as _}; +use vite_glob::AnchoredGlob; use vite_path::{AbsolutePath, RelativePathBuf}; use vite_task_plan::SpawnCommand; @@ -54,12 +55,6 @@ pub struct TrackedPathAccesses { pub path_writes: FxHashSet, } -/// Resolved negative glob pattern for filtering fspy-tracked paths. -/// The `PathBuf` is the resolved absolute prefix (glob base + invariant prefix, cleaned). -/// The `Option` is the variant (dynamic) part of the pattern, if any. -#[expect(clippy::disallowed_types, reason = "wax partition returns std::path::PathBuf")] -pub type ResolvedNegativeGlob = (std::path::PathBuf, Option>); - /// Spawn a command with optional file system tracking via fspy, using piped stdio. /// /// Returns the execution result including exit status and duration. @@ -82,7 +77,7 @@ pub async fn spawn_with_tracking( stderr_writer: &mut (dyn AsyncWrite + Unpin), std_outputs: Option<&mut Vec>, path_accesses: Option<&mut TrackedPathAccesses>, - resolved_negatives: &[ResolvedNegativeGlob], + resolved_negatives: &[AnchoredGlob], ) -> anyhow::Result { /// The tracking state of the spawned process. /// Determined by whether `path_accesses` is `Some` (fspy enabled) or `None` (fspy disabled). @@ -217,19 +212,13 @@ pub async fn spawn_with_tracking( // Filter against resolved negative globs. // Clean the path to normalize `..` only for matching purposes, since - // resolved negatives are cleaned absolute paths. + // AnchoredGlob prefixes are cleaned absolute paths. if !resolved_negatives.is_empty() { let cleaned_abs = path_clean::PathClean::clean(workspace_root.join(&relative_path).as_path()); - if resolved_negatives.iter().any(|(resolved_prefix, variant)| { - let Ok(remainder) = cleaned_abs.strip_prefix(resolved_prefix) else { - return false; - }; - variant.as_ref().map_or(remainder.as_os_str().is_empty(), |v| { - use wax::Program as _; - v.is_match(remainder) - }) - }) { + if let Some(cleaned) = AbsolutePath::new(&cleaned_abs) + && resolved_negatives.iter().any(|neg| neg.is_match(cleaned)) + { continue; } } From 9263ec2e9d2620e7f2f3eef25726d4ba94bd57e6 Mon Sep 17 00:00:00 2001 From: branchseer Date: Sun, 8 Mar 2026 11:32:49 +0800 Subject: [PATCH 12/32] feat(vite_glob): add walk module with common-ancestor rerooting Walk positive globs using wax, rerooting negative globs onto a common ancestor so wax can prune entire directory subtrees via `.not()`. This handles all prefix relationship cases (equal, ancestor, descendant, unrelated) correctly. Co-Authored-By: Claude Opus 4.6 --- Cargo.lock | 2 + crates/vite_glob/Cargo.toml | 2 + crates/vite_glob/src/anchored.rs | 12 + crates/vite_glob/src/error.rs | 2 + crates/vite_glob/src/lib.rs | 1 + crates/vite_glob/src/walk.rs | 686 +++++++++++++++++++++++++++++++ 6 files changed, 705 insertions(+) create mode 100644 crates/vite_glob/src/walk.rs diff --git a/Cargo.lock b/Cargo.lock index 14e5b7ee..4b602c28 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3796,6 +3796,8 @@ name = "vite_glob" version = "0.0.0" dependencies = [ "path-clean", + "rustc-hash", + "tempfile", "thiserror 2.0.18", "vite_path", "vite_str", diff --git a/crates/vite_glob/Cargo.toml b/crates/vite_glob/Cargo.toml index 56c25b8a..a9965bdf 100644 --- a/crates/vite_glob/Cargo.toml +++ b/crates/vite_glob/Cargo.toml @@ -9,11 +9,13 @@ rust-version.workspace = true [dependencies] path-clean = { workspace = true } +rustc-hash = { workspace = true } thiserror = { workspace = true } vite_path = { workspace = true } wax = { workspace = true } [dev-dependencies] +tempfile = { workspace = true } vite_str = { workspace = true } [lints] diff --git a/crates/vite_glob/src/anchored.rs b/crates/vite_glob/src/anchored.rs index 993f76c9..fc1b4b47 100644 --- a/crates/vite_glob/src/anchored.rs +++ b/crates/vite_glob/src/anchored.rs @@ -47,6 +47,18 @@ impl AnchoredGlob { Ok(Self { prefix, variant: variant.map(Glob::into_owned) }) } + /// The invariant prefix directory of this glob. + #[must_use] + pub(crate) fn prefix(&self) -> &AbsolutePath { + &self.prefix + } + + /// The variant (dynamic) portion of this glob, if any. + #[must_use] + pub(crate) const fn variant(&self) -> Option<&Glob<'static>> { + self.variant.as_ref() + } + /// Check if an absolute path matches this anchored glob. #[must_use] pub fn is_match(&self, path: &AbsolutePath) -> bool { diff --git a/crates/vite_glob/src/error.rs b/crates/vite_glob/src/error.rs index bf399dec..502c2af3 100644 --- a/crates/vite_glob/src/error.rs +++ b/crates/vite_glob/src/error.rs @@ -2,4 +2,6 @@ pub enum Error { #[error(transparent)] WaxBuild(#[from] wax::BuildError), + #[error(transparent)] + Walk(#[from] wax::walk::WalkError), } diff --git a/crates/vite_glob/src/lib.rs b/crates/vite_glob/src/lib.rs index d1680fc4..db722c4f 100644 --- a/crates/vite_glob/src/lib.rs +++ b/crates/vite_glob/src/lib.rs @@ -1,5 +1,6 @@ mod anchored; mod error; +pub mod walk; #[expect(clippy::disallowed_types, reason = "wax::Glob::is_match requires std::path::Path")] use std::path::Path; diff --git a/crates/vite_glob/src/walk.rs b/crates/vite_glob/src/walk.rs new file mode 100644 index 00000000..1e57bfbf --- /dev/null +++ b/crates/vite_glob/src/walk.rs @@ -0,0 +1,686 @@ +// # Walk design: common-ancestor rerooting +// +// Each `AnchoredGlob` has a `prefix` (invariant absolute directory) and an +// optional `variant` (dynamic glob pattern). `Glob::partition()` guarantees +// the prefix is a literal path — all glob metacharacters live in the variant. +// +// To walk a positive glob we call `wax::Glob::walk(root)`. Wax internally +// re-joins the glob's invariant prefix with `root`, descends directly to that +// directory (no extra traversal), and matches entries against the variant. +// +// Negative globs are passed to wax's `.not()`, which filters walk entries by +// matching their path **relative to the original `root`** (not the adjusted +// walk directory). This is the key insight that makes the design work. +// +// ## The rerooting problem +// +// Positive and negative globs can have different prefixes: +// +// positive: prefix=/app/src variant=**/*.rs +// negative: prefix=/app variant=**/test/** +// +// If we walk from `/app/src`, wax produces relative paths like `foo.rs`. +// The negative's `.not()` pattern `**/test/**` would be matched against +// `foo.rs` — but the negative was authored relative to `/app`, where the +// full relative path would be `src/foo.rs`. For patterns starting with +// `**` this happens to work (zero-segment match), but for patterns like +// `*.config.js` it would incorrectly exclude `/app/src/vite.config.js` +// (relative path `vite.config.js` matches `*.config.js`, but the file is +// NOT at the package root where the negative was intended to apply). +// +// ## Solution: walk from the common ancestor +// +// We find the common ancestor of the positive prefix and all related +// negative prefixes, then "reroot" every glob relative to that ancestor: +// +// common ancestor = /app +// positive rerooted: "src/**/*.rs" (bridge "src" + variant "**/*.rs") +// negative rerooted: "**/test/**" (bridge "" + variant "**/test/**") +// +// `Glob::new("src/**/*.rs").walk("/app")` still descends directly to +// `/app/src/` (wax extracts the invariant prefix `src/`), so there is no +// efficiency loss. But `.not()` now sees relative paths like +// `src/foo.rs`, and `*.config.js` correctly fails to match `src/vite.config.js` +// because `*` does not cross path separators. +// +// The bridge is always a literal path (it comes from the difference between +// two invariant prefixes), so escaping its glob metacharacters is sufficient. +// +// ## Relationship cases +// +// Given a positive prefix P and negative prefix N: +// +// P == N → bridge is empty, variant used as-is +// N ancestor P → positive gets a bridge, negative may not +// N descendant P → negative gets a bridge, positive may not +// unrelated → negative cannot affect this walk, skip it + +use std::borrow::Cow; + +use rustc_hash::FxHashSet; +use vite_path::{AbsolutePath, AbsolutePathBuf}; +use wax::{ + Glob, + walk::{Entry as _, FileIterator as _}, +}; + +use crate::{AnchoredGlob, Error}; + +/// Walk the filesystem, returning files matching any of the `positive_globs` +/// while excluding those matching any of the `negative_globs`. +/// +/// For each positive glob, computes a common ancestor with all related negative +/// globs and walks from there. This lets wax's `.not()` see full relative paths +/// for both positive and negative pattern matching, with tree pruning. +/// +/// # Errors +/// +/// Returns an error if a rerooted glob pattern is invalid or if a filesystem +/// walk error occurs. +pub fn walk( + positive_globs: &[AnchoredGlob], + negative_globs: &[AnchoredGlob], +) -> Result, Error> { + let mut results = FxHashSet::default(); + for pos in positive_globs { + walk_positive(pos, negative_globs, &mut results)?; + } + Ok(results) +} + +fn walk_positive( + pos: &AnchoredGlob, + negatives: &[AnchoredGlob], + results: &mut FxHashSet, +) -> Result<(), Error> { + let pos_prefix = pos.prefix(); + + let Some(pos_variant) = pos.variant() else { + // Exact path — include if file exists and no negative matches it + if pos_prefix.as_path().is_file() && !negatives.iter().any(|neg| neg.is_match(pos_prefix)) { + results.insert(pos_prefix.to_absolute_path_buf()); + } + return Ok(()); + }; + + // Only negatives whose prefix is an ancestor or descendant of pos_prefix + // can affect this walk. Unrelated negatives (disjoint subtrees) are skipped. + // + // The walk root is the common ancestor of pos_prefix and every related + // negative prefix. When all negatives share the same prefix as the + // positive (the common case), the walk root stays at pos_prefix — no + // unnecessary traversal. + let walk_root = negatives + .iter() + .filter(|neg| { + pos_prefix.as_path().starts_with(neg.prefix().as_path()) + || neg.prefix().as_path().starts_with(pos_prefix.as_path()) + }) + .fold(pos_prefix.to_absolute_path_buf(), |acc, neg| common_ancestor(&acc, neg.prefix())); + + // Reroot the positive glob: prepend the bridge (walk_root → pos_prefix) + // to the variant so wax walks from walk_root but descends into pos_prefix. + let pos_bridge = + path_bridge(&walk_root, pos_prefix).expect("walk root is an ancestor of pos prefix"); + let pos_pattern = rerooted_pattern(&pos_bridge, pos_variant); + let pos_glob = Glob::new(&pos_pattern)?.into_owned(); + + // Reroot each negative glob the same way: prepend its bridge + // (walk_root → neg_prefix) to the variant. Negatives with unrelated + // prefixes fail path_bridge and are skipped. + let mut neg_globs = Vec::new(); + for neg in negatives { + let Some(bridge) = path_bridge(&walk_root, neg.prefix()) else { + continue; + }; + match neg.variant() { + Some(variant) => { + let pattern = rerooted_pattern(&bridge, variant); + neg_globs.push(Glob::new(&pattern)?.into_owned()); + } + None if !bridge.is_empty() => { + neg_globs.push(Glob::new(&escape_glob(&bridge))?.into_owned()); + } + None => {} // variant-less negative at the walk root itself — cannot exclude files + } + } + + let walk = pos_glob.walk(walk_root.into_path_buf()); + if neg_globs.is_empty() { + collect_entries(walk, results)?; + } else { + collect_entries(walk.not(wax::any(neg_globs)?)?, results)?; + } + + Ok(()) +} + +fn collect_entries( + walk: impl wax::walk::FileIterator, + results: &mut FxHashSet, +) -> Result<(), Error> { + for entry in walk { + let entry = entry?; + if !entry.file_type().is_dir() { + let abs = AbsolutePathBuf::new(entry.into_path()) + .expect("walk entry under absolute root is absolute"); + results.insert(abs); + } + } + Ok(()) +} + +/// Compute the "bridge" — the relative path from `ancestor` to `path` — as a +/// `/`-separated string. Returns `None` if `path` is not under `ancestor` +/// (i.e. the prefixes are unrelated and no rerooting is possible). +#[expect( + clippy::disallowed_types, + clippy::disallowed_methods, + reason = "bridge computation requires std String and str::replace for wax glob patterns" +)] +fn path_bridge(ancestor: &AbsolutePath, path: &AbsolutePath) -> Option { + let remainder = path.as_path().strip_prefix(ancestor.as_path()).ok()?; + Some(remainder.to_string_lossy().replace('\\', "/")) +} + +/// Build a rerooted glob pattern by joining an escaped bridge path with a +/// variant glob. When the bridge is empty (prefix == walk root), the variant +/// is returned unchanged. +#[expect(clippy::disallowed_types, reason = "building glob pattern string for wax requires String")] +fn rerooted_pattern(bridge: &str, variant: &Glob<'_>) -> String { + if bridge.is_empty() { + variant.to_string() + } else { + [&*escape_glob(bridge), "/", &variant.to_string()].concat() + } +} + +/// Compute the longest common ancestor of two absolute paths. +#[expect( + clippy::disallowed_types, + reason = "collecting std::path::Components requires std::path::PathBuf" +)] +fn common_ancestor(a: &AbsolutePath, b: &AbsolutePath) -> AbsolutePathBuf { + let common: std::path::PathBuf = a + .as_path() + .components() + .zip(b.as_path().components()) + .take_while(|(a, b)| a == b) + .map(|(a, _)| a) + .collect(); + AbsolutePathBuf::new(common).expect("common ancestor of absolute paths is absolute") +} + +/// Escape wax glob metacharacters in a literal path string. The bridge is +/// always a literal path (derived from invariant prefixes), but it may +/// contain characters that wax interprets as glob syntax. +fn escape_glob(s: &str) -> Cow<'_, str> { + const GLOB_CHARS: &[char] = &['?', '*', '$', ':', '<', '>', '(', ')', '[', ']', '{', '}', ',']; + if !s.contains(GLOB_CHARS) { + return Cow::Borrowed(s); + } + let mut escaped = s.to_owned(); + escaped.clear(); + escaped.reserve(s.len() + 4); + for c in s.chars() { + if GLOB_CHARS.contains(&c) { + escaped.push('\\'); + } + escaped.push(c); + } + Cow::Owned(escaped) +} + +#[cfg(test)] +mod tests { + use path_clean::PathClean as _; + + use super::*; + + fn setup_files(files: &[&str]) -> tempfile::TempDir { + let tmp = tempfile::TempDir::with_prefix("globtest").unwrap(); + for file in files { + let file = file.trim_start_matches('/'); + let path = tmp.path().join(file); + let parent = path.parent().unwrap(); + std::fs::create_dir_all(parent).unwrap(); + std::fs::File::create(path).unwrap(); + } + tmp + } + + #[expect( + clippy::disallowed_types, + clippy::disallowed_methods, + clippy::disallowed_macros, + reason = "test helper uses std types and format! for path manipulation" + )] + fn run_walk( + tmp: &tempfile::TempDir, + base_path: &str, + include: &[&str], + exclude: &[&str], + ) -> Vec { + let base_path = base_path.trim_start_matches('/'); + let abs_base = + AbsolutePathBuf::new(tmp.path().join(base_path)).expect("tmp path is absolute"); + + let positives: Vec = include + .iter() + .map(|p| AnchoredGlob::new(p, &abs_base)) + .collect::>() + .unwrap(); + let negatives: Vec = exclude + .iter() + .map(|p| AnchoredGlob::new(p, &abs_base)) + .collect::>() + .unwrap(); + + let results = walk(&positives, &negatives).unwrap(); + let clean_root = AbsolutePathBuf::new(tmp.path().clean()).expect("tmp path is absolute"); + + let mut out: Vec = results + .iter() + .filter_map(|p| { + let remainder = p.as_path().strip_prefix(clean_root.as_path()).ok()?; + Some(format!("/{}", remainder.to_string_lossy().replace('\\', "/"))) + }) + .collect(); + out.sort(); + out + } + + #[test] + fn hello_world() { + let files = &["/test.txt"]; + let tmp = setup_files(files); + let result = run_walk(&tmp, "/", &["*.txt"], &[]); + assert_eq!(result, vec!["/test.txt"]); + } + + #[test] + fn bullet_files() { + let files = &["/test.txt", "/subdir/test.txt", "/other/test.txt"]; + let tmp = setup_files(files); + let result = run_walk(&tmp, "/", &["subdir/test.txt", "test.txt"], &[]); + assert_eq!(result, vec!["/subdir/test.txt", "/test.txt"]); + } + + #[test] + fn finding_workspace_package_json() { + let files = &[ + "/external/file.txt", + "/repos/some-app/apps/docs/package.json", + "/repos/some-app/apps/web/package.json", + "/repos/some-app/bower_components/readline/package.json", + "/repos/some-app/examples/package.json", + "/repos/some-app/node_modules/gulp/bower_components/readline/package.json", + "/repos/some-app/node_modules/react/package.json", + "/repos/some-app/package.json", + "/repos/some-app/packages/colors/package.json", + "/repos/some-app/packages/faker/package.json", + "/repos/some-app/packages/left-pad/package.json", + "/repos/some-app/test/mocks/kitchen-sink/package.json", + "/repos/some-app/tests/mocks/kitchen-sink/package.json", + ]; + let tmp = setup_files(files); + let result = run_walk( + &tmp, + "/repos/some-app/", + &["packages/*/package.json", "apps/*/package.json"], + &["**/node_modules/**", "**/bower_components/**", "**/test/**", "**/tests/**"], + ); + assert_eq!( + result, + vec![ + "/repos/some-app/apps/docs/package.json", + "/repos/some-app/apps/web/package.json", + "/repos/some-app/packages/colors/package.json", + "/repos/some-app/packages/faker/package.json", + "/repos/some-app/packages/left-pad/package.json", + ] + ); + } + + #[test] + fn excludes_unexpected_package_json() { + let files = &[ + "/external/file.txt", + "/repos/some-app/apps/docs/package.json", + "/repos/some-app/apps/web/package.json", + "/repos/some-app/bower_components/readline/package.json", + "/repos/some-app/examples/package.json", + "/repos/some-app/node_modules/gulp/bower_components/readline/package.json", + "/repos/some-app/node_modules/react/package.json", + "/repos/some-app/package.json", + "/repos/some-app/packages/colors/package.json", + "/repos/some-app/packages/faker/package.json", + "/repos/some-app/packages/left-pad/package.json", + "/repos/some-app/test/mocks/spanish-inquisition/package.json", + "/repos/some-app/tests/mocks/spanish-inquisition/package.json", + ]; + let tmp = setup_files(files); + let result = run_walk( + &tmp, + "/repos/some-app/", + &["**/package.json"], + &["**/node_modules/**", "**/bower_components/**", "**/test/**", "**/tests/**"], + ); + assert_eq!( + result, + vec![ + "/repos/some-app/apps/docs/package.json", + "/repos/some-app/apps/web/package.json", + "/repos/some-app/examples/package.json", + "/repos/some-app/package.json", + "/repos/some-app/packages/colors/package.json", + "/repos/some-app/packages/faker/package.json", + "/repos/some-app/packages/left-pad/package.json", + ] + ); + } + + #[test] + fn nested_packages() { + let files = &[ + "/repos/some-app/packages/xzibit/package.json", + "/repos/some-app/packages/xzibit/node_modules/street-legal/package.json", + "/repos/some-app/packages/xzibit/node_modules/paint-colors/package.json", + "/repos/some-app/packages/xzibit/packages/yo-dawg/package.json", + "/repos/some-app/packages/xzibit/packages/yo-dawg/node_modules/meme/package.json", + "/repos/some-app/packages/colors/package.json", + "/repos/some-app/packages/faker/package.json", + "/repos/some-app/packages/left-pad/package.json", + ]; + let tmp = setup_files(files); + let result = run_walk( + &tmp, + "/repos/some-app/", + &["packages/**/package.json"], + &["**/node_modules/**", "**/bower_components/**"], + ); + assert_eq!( + result, + vec![ + "/repos/some-app/packages/colors/package.json", + "/repos/some-app/packages/faker/package.json", + "/repos/some-app/packages/left-pad/package.json", + "/repos/some-app/packages/xzibit/package.json", + "/repos/some-app/packages/xzibit/packages/yo-dawg/package.json", + ] + ); + } + + #[test] + fn passing_doublestar_captures_children() { + let files = &[ + "/repos/some-app/dist/index.html", + "/repos/some-app/dist/js/index.js", + "/repos/some-app/dist/js/lib.js", + "/repos/some-app/dist/js/node_modules/browserify.js", + ]; + let tmp = setup_files(files); + let result = run_walk(&tmp, "/repos/some-app/", &["dist/**"], &[]); + assert_eq!( + result, + vec![ + "/repos/some-app/dist/index.html", + "/repos/some-app/dist/js/index.js", + "/repos/some-app/dist/js/lib.js", + "/repos/some-app/dist/js/node_modules/browserify.js", + ] + ); + } + + #[test] + fn exclude_everything_include_everything() { + let files = &["/repos/some-app/dist/index.html", "/repos/some-app/dist/js/index.js"]; + let tmp = setup_files(files); + let result = run_walk(&tmp, "/repos/some-app/", &["**"], &["**"]); + assert_eq!(result, Vec::<&str>::new()); + } + + #[test] + fn exclude_directory_prevents_children() { + let files = &[ + "/repos/some-app/dist/index.html", + "/repos/some-app/dist/js/index.js", + "/repos/some-app/dist/js/lib.js", + "/repos/some-app/dist/js/node_modules/browserify.js", + ]; + let tmp = setup_files(files); + let result = run_walk(&tmp, "/repos/some-app/", &["dist/**"], &["dist/js/**"]); + assert_eq!(result, vec!["/repos/some-app/dist/index.html"]); + } + + #[test] + fn include_with_dotdot_traversal() { + let files = &[ + "/repos/some-app/dist/index.html", + "/repos/some-app/dist/js/index.js", + "/repos/some-app/dist/js/lib.js", + "/repos/some-app/dist/js/node_modules/browserify.js", + ]; + let tmp = setup_files(files); + let result = run_walk(&tmp, "/repos/some-app/", &["dist/js/../**"], &[]); + assert_eq!( + result, + vec![ + "/repos/some-app/dist/index.html", + "/repos/some-app/dist/js/index.js", + "/repos/some-app/dist/js/lib.js", + "/repos/some-app/dist/js/node_modules/browserify.js", + ] + ); + } + + #[test] + fn include_with_dot_self_references() { + let files = &["/repos/some-app/dist/index.html", "/repos/some-app/dist/js/index.js"]; + let tmp = setup_files(files); + let result = run_walk(&tmp, "/repos/some-app/", &["dist/./././**"], &[]); + assert_eq!( + result, + vec!["/repos/some-app/dist/index.html", "/repos/some-app/dist/js/index.js",] + ); + } + + #[test] + fn exclude_single_file() { + let files = &["/repos/some-app/included.txt", "/repos/some-app/excluded.txt"]; + let tmp = setup_files(files); + let result = run_walk(&tmp, "/repos/some-app", &["*.txt"], &["excluded.txt"]); + assert_eq!(result, vec!["/repos/some-app/included.txt"]); + } + + #[test] + fn exclude_nested_single_file() { + let files = &[ + "/repos/some-app/one/included.txt", + "/repos/some-app/one/two/included.txt", + "/repos/some-app/one/two/three/included.txt", + "/repos/some-app/one/excluded.txt", + "/repos/some-app/one/two/excluded.txt", + "/repos/some-app/one/two/three/excluded.txt", + ]; + let tmp = setup_files(files); + let result = run_walk(&tmp, "/repos/some-app", &["**"], &["**/excluded.txt"]); + assert_eq!( + result, + vec![ + "/repos/some-app/one/included.txt", + "/repos/some-app/one/two/included.txt", + "/repos/some-app/one/two/three/included.txt", + ] + ); + } + + #[test] + fn directory_traversal_above_base() { + let files = &["root-file", "child/some-file"]; + let tmp = setup_files(files); + let abs_child = + AbsolutePathBuf::new(tmp.path().join("child")).expect("tmp path is absolute"); + + let positives = vec![AnchoredGlob::new("../*-file", &abs_child).unwrap()]; + let results = walk(&positives, &[]).unwrap(); + + let clean_root = AbsolutePathBuf::new(tmp.path().clean()).expect("tmp path is absolute"); + let names: Vec<_> = results + .iter() + .filter_map(|p| { + let remainder = p.as_path().strip_prefix(clean_root.as_path()).ok()?; + Some(remainder.to_string_lossy().into_owned()) + }) + .collect(); + assert_eq!(names, vec!["root-file"]); + } + + #[test] + fn redundant_includes_do_not_duplicate() { + let files = &[ + "/repos/some-app/dist/index.html", + "/repos/some-app/dist/js/index.js", + "/repos/some-app/dist/js/lib.js", + "/repos/some-app/dist/js/node_modules/browserify.js", + ]; + let tmp = setup_files(files); + let result = run_walk(&tmp, "/repos/some-app/", &["**/*", "dist/**"], &[]); + assert_eq!( + result, + vec![ + "/repos/some-app/dist/index.html", + "/repos/some-app/dist/js/index.js", + "/repos/some-app/dist/js/lib.js", + "/repos/some-app/dist/js/node_modules/browserify.js", + ] + ); + } + + #[test] + fn no_trailing_slash_base_path() { + let files = &["/repos/some-app/dist/index.html", "/repos/some-app/dist/js/index.js"]; + let tmp = setup_files(files); + let result = run_walk(&tmp, "/repos/some-app", &["dist/**"], &[]); + assert_eq!( + result, + vec!["/repos/some-app/dist/index.html", "/repos/some-app/dist/js/index.js",] + ); + } + + #[test] + fn exclude_with_leading_star() { + let files = &[ + "/repos/some-app/foo/bar", + "/repos/some-app/some-foo/bar", + "/repos/some-app/included", + ]; + let tmp = setup_files(files); + let result = run_walk(&tmp, "/repos/some-app", &["**"], &["*foo/**"]); + assert_eq!(result, vec!["/repos/some-app/included"]); + } + + #[test] + fn exclude_with_trailing_star() { + let files = &[ + "/repos/some-app/foo/bar", + "/repos/some-app/foo-file", + "/repos/some-app/foo-dir/bar", + "/repos/some-app/included", + ]; + let tmp = setup_files(files); + // wax's ** matches zero or more components, so foo*/** also matches foo-file + let result = run_walk(&tmp, "/repos/some-app", &["**"], &["foo*/**"]); + assert_eq!(result, vec!["/repos/some-app/included"]); + } + + #[test] + fn output_globbing() { + let files = &[ + "/repos/some-app/src/index.js", + "/repos/some-app/public/src/css/index.css", + "/repos/some-app/.turbo/turbo-build.log", + "/repos/some-app/.turbo/somebody-touched-this-file-into-existence.txt", + "/repos/some-app/.next/log.txt", + "/repos/some-app/.next/cache/db6a76a62043520e7aaadd0bb2104e78.txt", + "/repos/some-app/dist/index.html", + "/repos/some-app/dist/js/index.js", + "/repos/some-app/dist/js/lib.js", + "/repos/some-app/dist/js/node_modules/browserify.js", + "/repos/some-app/public/dist/css/index.css", + "/repos/some-app/public/dist/images/rick_astley.jpg", + ]; + let tmp = setup_files(files); + let result = run_walk( + &tmp, + "/repos/some-app/", + &[".turbo/turbo-build.log", "dist/**", ".next/**", "public/dist/**"], + &[], + ); + assert_eq!( + result, + vec![ + "/repos/some-app/.next/cache/db6a76a62043520e7aaadd0bb2104e78.txt", + "/repos/some-app/.next/log.txt", + "/repos/some-app/.turbo/turbo-build.log", + "/repos/some-app/dist/index.html", + "/repos/some-app/dist/js/index.js", + "/repos/some-app/dist/js/lib.js", + "/repos/some-app/dist/js/node_modules/browserify.js", + "/repos/some-app/public/dist/css/index.css", + "/repos/some-app/public/dist/images/rick_astley.jpg", + ] + ); + } + + #[test] + fn includes_do_not_override_excludes() { + let files = &[ + "/repos/some-app/packages/colors/package.json", + "/repos/some-app/packages/faker/package.json", + "/repos/some-app/packages/left-pad/package.json", + "/repos/some-app/packages/xzibit/package.json", + "/repos/some-app/packages/xzibit/packages/yo-dawg/package.json", + "/repos/some-app/packages/xzibit/node_modules/street-legal/package.json", + "/repos/some-app/tests/mocks/spanish-inquisition/package.json", + ]; + let tmp = setup_files(files); + let result = run_walk( + &tmp, + "/repos/some-app/", + &["packages/**/package.json", "tests/mocks/*/package.json"], + &["**/node_modules/**", "**/bower_components/**", "**/test/**", "**/tests/**"], + ); + assert_eq!( + result, + vec![ + "/repos/some-app/packages/colors/package.json", + "/repos/some-app/packages/faker/package.json", + "/repos/some-app/packages/left-pad/package.json", + "/repos/some-app/packages/xzibit/package.json", + "/repos/some-app/packages/xzibit/packages/yo-dawg/package.json", + ] + ); + } + + #[test] + #[cfg(unix)] + fn base_path_with_symlink_preserves_prefix() { + let files = &["real/file.txt", "real/sub/other.txt"]; + let tmp = setup_files(files); + let link = tmp.path().join("link"); + std::os::unix::fs::symlink(tmp.path().join("real"), &link).unwrap(); + let abs_link = AbsolutePathBuf::new(link).expect("tmp path is absolute"); + + let positives = vec![AnchoredGlob::new("**/*.txt", &abs_link).unwrap()]; + let results = walk(&positives, &[]).unwrap(); + + for path in &results { + assert!( + path.as_path().starts_with(abs_link.as_path()), + "expected path {path:?} to start with {abs_link:?}", + ); + } + assert_eq!(results.len(), 2); + } +} From 3748ecf052c6dc8a891845ecd0076dfef9dbb64c Mon Sep 17 00:00:00 2001 From: branchseer Date: Sun, 8 Mar 2026 17:40:46 +0800 Subject: [PATCH 13/32] refactor(vite_glob): move rerooting logic into AnchoredGlob Move path_bridge, rerooted_pattern, common_ancestor, and escape_glob from walk.rs into anchored.rs, exposed as AnchoredGlob::reroot() and has_related_prefix() methods. This simplifies the walk module by encapsulating glob rerooting within the type that owns the data. Co-Authored-By: Claude Opus 4.6 --- crates/vite_glob/src/anchored.rs | 245 ++++++++++++++++++++++++++++++- crates/vite_glob/src/error.rs | 2 + crates/vite_glob/src/walk.rs | 100 ++----------- 3 files changed, 255 insertions(+), 92 deletions(-) diff --git a/crates/vite_glob/src/anchored.rs b/crates/vite_glob/src/anchored.rs index fc1b4b47..42ef3c2c 100644 --- a/crates/vite_glob/src/anchored.rs +++ b/crates/vite_glob/src/anchored.rs @@ -1,6 +1,6 @@ use std::sync::Arc; -use vite_path::AbsolutePath; +use vite_path::{AbsolutePath, AbsolutePathBuf}; use wax::Glob; use crate::Error; @@ -59,6 +59,38 @@ impl AnchoredGlob { self.variant.as_ref() } + /// Whether this glob's prefix is an ancestor or descendant of `other`, + /// meaning a rerooting between them is possible. + #[must_use] + pub(crate) fn has_related_prefix(&self, other: &AbsolutePath) -> bool { + self.prefix.as_path().starts_with(other.as_path()) + || other.as_path().starts_with(self.prefix.as_path()) + } + + /// Reroot this glob relative to `new_root`, returning a wax `Glob` whose + /// invariant prefix bridges from `new_root` to this glob's prefix. + /// + /// Returns `None` if this glob's prefix is not a descendant of `new_root` + /// (unrelated prefixes), or if the glob is variant-less and sits exactly + /// at `new_root` (cannot exclude/include files from the root itself). + /// + /// # Errors + /// + /// Returns an error if the rerooted glob pattern is invalid. + pub(crate) fn reroot(&self, new_root: &AbsolutePath) -> Result>, Error> { + let Some(bridge) = path_bridge(new_root, &self.prefix) else { + return Ok(None); + }; + match &self.variant { + Some(variant) => { + let pattern = rerooted_pattern(&bridge, variant); + Ok(Some(Glob::new(&pattern)?.into_owned())) + } + None if !bridge.is_empty() => Ok(Some(Glob::new(&wax::escape(&bridge))?.into_owned())), + None => Ok(None), + } + } + /// Check if an absolute path matches this anchored glob. #[must_use] pub fn is_match(&self, path: &AbsolutePath) -> bool { @@ -72,3 +104,214 @@ impl AnchoredGlob { v.is_match(remainder) } } + +/// Compute the longest common ancestor of two absolute paths. +#[expect( + clippy::disallowed_types, + reason = "collecting std::path::Components requires std::path::PathBuf" +)] +pub fn common_ancestor(a: &AbsolutePath, b: &AbsolutePath) -> AbsolutePathBuf { + let common: std::path::PathBuf = a + .as_path() + .components() + .zip(b.as_path().components()) + .take_while(|(a, b)| a == b) + .map(|(a, _)| a) + .collect(); + AbsolutePathBuf::new(common).expect("common ancestor of absolute paths is absolute") +} + +/// Compute the "bridge" — the relative path from `ancestor` to `path` — as a +/// `/`-separated string. Returns `None` if `path` is not under `ancestor` +/// (i.e. the prefixes are unrelated and no rerooting is possible). +#[expect( + clippy::disallowed_types, + clippy::disallowed_methods, + reason = "bridge computation requires std String and str::replace for wax glob patterns" +)] +fn path_bridge(ancestor: &AbsolutePath, path: &AbsolutePath) -> Option { + let remainder = path.as_path().strip_prefix(ancestor.as_path()).ok()?; + Some(remainder.to_string_lossy().replace('\\', "/")) +} + +/// Build a rerooted glob pattern by joining an escaped bridge path with a +/// variant glob. When the bridge is empty (prefix == walk root), the variant +/// is returned unchanged. +#[expect(clippy::disallowed_types, reason = "building glob pattern string for wax requires String")] +fn rerooted_pattern(bridge: &str, variant: &Glob<'_>) -> String { + if bridge.is_empty() { + variant.to_string() + } else { + [&*wax::escape(bridge), "/", &variant.to_string()].concat() + } +} + +/// Escape wax glob metacharacters in a literal path string. The bridge is +/// always a literal path (derived from invariant prefixes), but it may +/// contain characters that wax interprets as glob syntax. +fn escape_glob(s: &str) -> Cow<'_, str> { + const GLOB_CHARS: &[char] = &['?', '*', '$', ':', '<', '>', '(', ')', '[', ']', '{', '}', ',']; + if !s.contains(GLOB_CHARS) { + return Cow::Borrowed(s); + } + let mut escaped = s.to_owned(); + escaped.clear(); + escaped.reserve(s.len() + 4); + for c in s.chars() { + if GLOB_CHARS.contains(&c) { + escaped.push('\\'); + } + escaped.push(c); + } + Cow::Owned(escaped) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn abs(p: &str) -> AbsolutePathBuf { + AbsolutePathBuf::new(std::path::PathBuf::from(p)).expect("test path should be absolute") + } + + #[test] + fn common_ancestor_same_path() { + let a = abs("/app/src"); + let result = common_ancestor(&a, &a); + assert_eq!(result, a); + } + + #[test] + fn common_ancestor_parent_child() { + let parent = abs("/app"); + let child = abs("/app/src/lib"); + assert_eq!(common_ancestor(&parent, &child), parent); + assert_eq!(common_ancestor(&child, &parent), parent); + } + + #[test] + fn common_ancestor_siblings() { + let a = abs("/app/src"); + let b = abs("/app/dist"); + assert_eq!(common_ancestor(&a, &b), abs("/app")); + } + + #[test] + fn common_ancestor_only_root() { + let a = abs("/foo/bar"); + let b = abs("/baz/qux"); + assert_eq!(common_ancestor(&a, &b), abs("/")); + } + + #[test] + fn reroot_same_prefix() { + let root = abs("/app/src"); + let glob = AnchoredGlob::new("**/*.rs", &root).unwrap(); + let rerooted = glob.reroot(&root).unwrap().unwrap(); + assert_eq!(rerooted.to_string(), "**/*.rs"); + } + + #[test] + fn reroot_descendant_prefix() { + let base = abs("/app/src"); + let glob = AnchoredGlob::new("lib/**/*.rs", &base).unwrap(); + // prefix = /app/src/lib, variant = **/*.rs + // reroot to /app → bridge = "src/lib" + let root = abs("/app"); + let rerooted = glob.reroot(&root).unwrap().unwrap(); + assert_eq!(rerooted.to_string(), "src/lib/**/*.rs"); + } + + #[test] + fn reroot_unrelated_returns_none() { + let base = abs("/app/src"); + let glob = AnchoredGlob::new("**/*.rs", &base).unwrap(); + let root = abs("/other"); + assert!(glob.reroot(&root).unwrap().is_none()); + } + + #[test] + fn reroot_variantless_with_bridge() { + let base = abs("/app"); + let glob = AnchoredGlob::new("src/main.rs", &base).unwrap(); + // prefix = /app/src/main.rs, variant = None + let root = abs("/app"); + let rerooted = glob.reroot(&root).unwrap().unwrap(); + assert_eq!(rerooted.to_string(), "src/main.rs"); + } + + #[test] + fn reroot_variantless_at_root_returns_none() { + let base = abs("/app"); + // A pattern that is just the base dir itself (no variant, no bridge) + let glob = AnchoredGlob::new(".", &base).unwrap(); + let result = glob.reroot(&base).unwrap(); + assert!(result.is_none()); + } + + #[test] + fn has_related_prefix_ancestor() { + let glob = AnchoredGlob::new("**", &abs("/app/src")).unwrap(); + assert!(glob.has_related_prefix(&abs("/app"))); + } + + #[test] + fn has_related_prefix_descendant() { + let glob = AnchoredGlob::new("**", &abs("/app")).unwrap(); + assert!(glob.has_related_prefix(&abs("/app/src"))); + } + + #[test] + fn has_related_prefix_same() { + let glob = AnchoredGlob::new("**", &abs("/app")).unwrap(); + assert!(glob.has_related_prefix(&abs("/app"))); + } + + #[test] + fn has_related_prefix_unrelated() { + let glob = AnchoredGlob::new("**", &abs("/app")).unwrap(); + assert!(!glob.has_related_prefix(&abs("/other"))); + } + + #[test] + fn escape_glob_no_metacharacters() { + assert_eq!(escape_glob("src/lib"), "src/lib"); + } + + #[test] + fn escape_glob_with_metacharacters() { + assert_eq!(escape_glob("a[b]*c?d"), "a\\[b\\]\\*c\\?d"); + } + + #[test] + fn path_bridge_direct_child() { + let ancestor = abs("/app"); + let path = abs("/app/src"); + assert_eq!(path_bridge(&ancestor, &path).unwrap(), "src"); + } + + #[test] + fn path_bridge_same_path() { + let path = abs("/app"); + assert_eq!(path_bridge(&path, &path).unwrap(), ""); + } + + #[test] + fn path_bridge_unrelated() { + let a = abs("/app"); + let b = abs("/other"); + assert!(path_bridge(&a, &b).is_none()); + } + + #[test] + fn rerooted_pattern_empty_bridge() { + let glob = Glob::new("**/*.rs").unwrap(); + assert_eq!(rerooted_pattern("", &glob), "**/*.rs"); + } + + #[test] + fn rerooted_pattern_with_bridge() { + let glob = Glob::new("**/*.rs").unwrap(); + assert_eq!(rerooted_pattern("src/lib", &glob), "src/lib/**/*.rs"); + } +} diff --git a/crates/vite_glob/src/error.rs b/crates/vite_glob/src/error.rs index 502c2af3..5d1b75f8 100644 --- a/crates/vite_glob/src/error.rs +++ b/crates/vite_glob/src/error.rs @@ -4,4 +4,6 @@ pub enum Error { WaxBuild(#[from] wax::BuildError), #[error(transparent)] Walk(#[from] wax::walk::WalkError), + #[error(transparent)] + InvalidPathData(#[from] vite_path::relative::InvalidPathDataError), } diff --git a/crates/vite_glob/src/walk.rs b/crates/vite_glob/src/walk.rs index 1e57bfbf..b8b0ca82 100644 --- a/crates/vite_glob/src/walk.rs +++ b/crates/vite_glob/src/walk.rs @@ -55,16 +55,11 @@ // N descendant P → negative gets a bridge, positive may not // unrelated → negative cannot affect this walk, skip it -use std::borrow::Cow; - use rustc_hash::FxHashSet; -use vite_path::{AbsolutePath, AbsolutePathBuf}; -use wax::{ - Glob, - walk::{Entry as _, FileIterator as _}, -}; +use vite_path::AbsolutePathBuf; +use wax::walk::{Entry as _, FileIterator as _}; -use crate::{AnchoredGlob, Error}; +use crate::{AnchoredGlob, Error, anchored::common_ancestor}; /// Walk the filesystem, returning files matching any of the `positive_globs` /// while excluding those matching any of the `negative_globs`. @@ -95,7 +90,7 @@ fn walk_positive( ) -> Result<(), Error> { let pos_prefix = pos.prefix(); - let Some(pos_variant) = pos.variant() else { + let Some(_) = pos.variant() else { // Exact path — include if file exists and no negative matches it if pos_prefix.as_path().is_file() && !negatives.iter().any(|neg| neg.is_match(pos_prefix)) { results.insert(pos_prefix.to_absolute_path_buf()); @@ -112,36 +107,20 @@ fn walk_positive( // unnecessary traversal. let walk_root = negatives .iter() - .filter(|neg| { - pos_prefix.as_path().starts_with(neg.prefix().as_path()) - || neg.prefix().as_path().starts_with(pos_prefix.as_path()) - }) + .filter(|neg| neg.has_related_prefix(pos_prefix)) .fold(pos_prefix.to_absolute_path_buf(), |acc, neg| common_ancestor(&acc, neg.prefix())); // Reroot the positive glob: prepend the bridge (walk_root → pos_prefix) // to the variant so wax walks from walk_root but descends into pos_prefix. - let pos_bridge = - path_bridge(&walk_root, pos_prefix).expect("walk root is an ancestor of pos prefix"); - let pos_pattern = rerooted_pattern(&pos_bridge, pos_variant); - let pos_glob = Glob::new(&pos_pattern)?.into_owned(); + let pos_glob = pos.reroot(&walk_root)?.expect("walk root is an ancestor of pos prefix"); // Reroot each negative glob the same way: prepend its bridge // (walk_root → neg_prefix) to the variant. Negatives with unrelated - // prefixes fail path_bridge and are skipped. + // prefixes return None and are skipped. let mut neg_globs = Vec::new(); for neg in negatives { - let Some(bridge) = path_bridge(&walk_root, neg.prefix()) else { - continue; - }; - match neg.variant() { - Some(variant) => { - let pattern = rerooted_pattern(&bridge, variant); - neg_globs.push(Glob::new(&pattern)?.into_owned()); - } - None if !bridge.is_empty() => { - neg_globs.push(Glob::new(&escape_glob(&bridge))?.into_owned()); - } - None => {} // variant-less negative at the walk root itself — cannot exclude files + if let Some(rerooted) = neg.reroot(&walk_root)? { + neg_globs.push(rerooted); } } @@ -170,67 +149,6 @@ fn collect_entries( Ok(()) } -/// Compute the "bridge" — the relative path from `ancestor` to `path` — as a -/// `/`-separated string. Returns `None` if `path` is not under `ancestor` -/// (i.e. the prefixes are unrelated and no rerooting is possible). -#[expect( - clippy::disallowed_types, - clippy::disallowed_methods, - reason = "bridge computation requires std String and str::replace for wax glob patterns" -)] -fn path_bridge(ancestor: &AbsolutePath, path: &AbsolutePath) -> Option { - let remainder = path.as_path().strip_prefix(ancestor.as_path()).ok()?; - Some(remainder.to_string_lossy().replace('\\', "/")) -} - -/// Build a rerooted glob pattern by joining an escaped bridge path with a -/// variant glob. When the bridge is empty (prefix == walk root), the variant -/// is returned unchanged. -#[expect(clippy::disallowed_types, reason = "building glob pattern string for wax requires String")] -fn rerooted_pattern(bridge: &str, variant: &Glob<'_>) -> String { - if bridge.is_empty() { - variant.to_string() - } else { - [&*escape_glob(bridge), "/", &variant.to_string()].concat() - } -} - -/// Compute the longest common ancestor of two absolute paths. -#[expect( - clippy::disallowed_types, - reason = "collecting std::path::Components requires std::path::PathBuf" -)] -fn common_ancestor(a: &AbsolutePath, b: &AbsolutePath) -> AbsolutePathBuf { - let common: std::path::PathBuf = a - .as_path() - .components() - .zip(b.as_path().components()) - .take_while(|(a, b)| a == b) - .map(|(a, _)| a) - .collect(); - AbsolutePathBuf::new(common).expect("common ancestor of absolute paths is absolute") -} - -/// Escape wax glob metacharacters in a literal path string. The bridge is -/// always a literal path (derived from invariant prefixes), but it may -/// contain characters that wax interprets as glob syntax. -fn escape_glob(s: &str) -> Cow<'_, str> { - const GLOB_CHARS: &[char] = &['?', '*', '$', ':', '<', '>', '(', ')', '[', ']', '{', '}', ',']; - if !s.contains(GLOB_CHARS) { - return Cow::Borrowed(s); - } - let mut escaped = s.to_owned(); - escaped.clear(); - escaped.reserve(s.len() + 4); - for c in s.chars() { - if GLOB_CHARS.contains(&c) { - escaped.push('\\'); - } - escaped.push(c); - } - Cow::Owned(escaped) -} - #[cfg(test)] mod tests { use path_clean::PathClean as _; From f65ef90676ff6fe0a9bd7fa7b52ce163f879fff1 Mon Sep 17 00:00:00 2001 From: branchseer Date: Sun, 8 Mar 2026 17:42:31 +0800 Subject: [PATCH 14/32] refactor(vite_glob): remove AnchoredGlob and walk module Move AnchoredGlob and the filesystem walk logic out of vite_glob, leaving only the core glob matching and error types. Co-Authored-By: Claude Opus 4.6 --- crates/vite_glob/src/anchored.rs | 317 ---------------- crates/vite_glob/src/lib.rs | 3 - crates/vite_glob/src/walk.rs | 604 ------------------------------- 3 files changed, 924 deletions(-) delete mode 100644 crates/vite_glob/src/anchored.rs delete mode 100644 crates/vite_glob/src/walk.rs diff --git a/crates/vite_glob/src/anchored.rs b/crates/vite_glob/src/anchored.rs deleted file mode 100644 index 42ef3c2c..00000000 --- a/crates/vite_glob/src/anchored.rs +++ /dev/null @@ -1,317 +0,0 @@ -use std::sync::Arc; - -use vite_path::{AbsolutePath, AbsolutePathBuf}; -use wax::Glob; - -use crate::Error; - -/// A glob pattern anchored to an absolute directory. -/// -/// Created by partitioning a glob into an invariant prefix and a variant (dynamic) -/// part, then resolving the prefix against a base directory. The prefix is cleaned -/// to normalize `..` components. -/// -/// For example, `../shared/dist/**` relative to `/ws/packages/app` produces: -/// - `prefix`: `/ws/packages/shared/dist` -/// - `variant`: `Some(Glob("**"))` -#[derive(Debug)] -pub struct AnchoredGlob { - prefix: Arc, - variant: Option>, -} - -impl AnchoredGlob { - /// Create an `AnchoredGlob` by resolving `pattern` relative to `base_dir`. - /// - /// The pattern is partitioned into an invariant prefix and a variant glob. - /// The prefix is joined with `base_dir` and cleaned (normalizing `..`). - /// - /// # Errors - /// - /// Returns an error if the glob pattern is invalid. - /// - /// # Panics - /// - /// Panics if cleaning an absolute path somehow produces a non-absolute path. - pub fn new(pattern: &str, base_dir: &AbsolutePath) -> Result { - use path_clean::PathClean as _; - - let glob = Glob::new(pattern)?; - let (prefix_path, variant) = glob.partition(); - let cleaned = base_dir.as_path().join(&prefix_path).clean(); - // Cleaning an absolute path always produces an absolute path - let prefix = Arc::::from( - vite_path::AbsolutePathBuf::new(cleaned) - .expect("cleaning an absolute path produces an absolute path"), - ); - Ok(Self { prefix, variant: variant.map(Glob::into_owned) }) - } - - /// The invariant prefix directory of this glob. - #[must_use] - pub(crate) fn prefix(&self) -> &AbsolutePath { - &self.prefix - } - - /// The variant (dynamic) portion of this glob, if any. - #[must_use] - pub(crate) const fn variant(&self) -> Option<&Glob<'static>> { - self.variant.as_ref() - } - - /// Whether this glob's prefix is an ancestor or descendant of `other`, - /// meaning a rerooting between them is possible. - #[must_use] - pub(crate) fn has_related_prefix(&self, other: &AbsolutePath) -> bool { - self.prefix.as_path().starts_with(other.as_path()) - || other.as_path().starts_with(self.prefix.as_path()) - } - - /// Reroot this glob relative to `new_root`, returning a wax `Glob` whose - /// invariant prefix bridges from `new_root` to this glob's prefix. - /// - /// Returns `None` if this glob's prefix is not a descendant of `new_root` - /// (unrelated prefixes), or if the glob is variant-less and sits exactly - /// at `new_root` (cannot exclude/include files from the root itself). - /// - /// # Errors - /// - /// Returns an error if the rerooted glob pattern is invalid. - pub(crate) fn reroot(&self, new_root: &AbsolutePath) -> Result>, Error> { - let Some(bridge) = path_bridge(new_root, &self.prefix) else { - return Ok(None); - }; - match &self.variant { - Some(variant) => { - let pattern = rerooted_pattern(&bridge, variant); - Ok(Some(Glob::new(&pattern)?.into_owned())) - } - None if !bridge.is_empty() => Ok(Some(Glob::new(&wax::escape(&bridge))?.into_owned())), - None => Ok(None), - } - } - - /// Check if an absolute path matches this anchored glob. - #[must_use] - pub fn is_match(&self, path: &AbsolutePath) -> bool { - use wax::Program as _; - let Ok(remainder) = path.as_path().strip_prefix(self.prefix.as_path()) else { - return false; - }; - let Some(v) = &self.variant else { - return remainder.as_os_str().is_empty(); - }; - v.is_match(remainder) - } -} - -/// Compute the longest common ancestor of two absolute paths. -#[expect( - clippy::disallowed_types, - reason = "collecting std::path::Components requires std::path::PathBuf" -)] -pub fn common_ancestor(a: &AbsolutePath, b: &AbsolutePath) -> AbsolutePathBuf { - let common: std::path::PathBuf = a - .as_path() - .components() - .zip(b.as_path().components()) - .take_while(|(a, b)| a == b) - .map(|(a, _)| a) - .collect(); - AbsolutePathBuf::new(common).expect("common ancestor of absolute paths is absolute") -} - -/// Compute the "bridge" — the relative path from `ancestor` to `path` — as a -/// `/`-separated string. Returns `None` if `path` is not under `ancestor` -/// (i.e. the prefixes are unrelated and no rerooting is possible). -#[expect( - clippy::disallowed_types, - clippy::disallowed_methods, - reason = "bridge computation requires std String and str::replace for wax glob patterns" -)] -fn path_bridge(ancestor: &AbsolutePath, path: &AbsolutePath) -> Option { - let remainder = path.as_path().strip_prefix(ancestor.as_path()).ok()?; - Some(remainder.to_string_lossy().replace('\\', "/")) -} - -/// Build a rerooted glob pattern by joining an escaped bridge path with a -/// variant glob. When the bridge is empty (prefix == walk root), the variant -/// is returned unchanged. -#[expect(clippy::disallowed_types, reason = "building glob pattern string for wax requires String")] -fn rerooted_pattern(bridge: &str, variant: &Glob<'_>) -> String { - if bridge.is_empty() { - variant.to_string() - } else { - [&*wax::escape(bridge), "/", &variant.to_string()].concat() - } -} - -/// Escape wax glob metacharacters in a literal path string. The bridge is -/// always a literal path (derived from invariant prefixes), but it may -/// contain characters that wax interprets as glob syntax. -fn escape_glob(s: &str) -> Cow<'_, str> { - const GLOB_CHARS: &[char] = &['?', '*', '$', ':', '<', '>', '(', ')', '[', ']', '{', '}', ',']; - if !s.contains(GLOB_CHARS) { - return Cow::Borrowed(s); - } - let mut escaped = s.to_owned(); - escaped.clear(); - escaped.reserve(s.len() + 4); - for c in s.chars() { - if GLOB_CHARS.contains(&c) { - escaped.push('\\'); - } - escaped.push(c); - } - Cow::Owned(escaped) -} - -#[cfg(test)] -mod tests { - use super::*; - - fn abs(p: &str) -> AbsolutePathBuf { - AbsolutePathBuf::new(std::path::PathBuf::from(p)).expect("test path should be absolute") - } - - #[test] - fn common_ancestor_same_path() { - let a = abs("/app/src"); - let result = common_ancestor(&a, &a); - assert_eq!(result, a); - } - - #[test] - fn common_ancestor_parent_child() { - let parent = abs("/app"); - let child = abs("/app/src/lib"); - assert_eq!(common_ancestor(&parent, &child), parent); - assert_eq!(common_ancestor(&child, &parent), parent); - } - - #[test] - fn common_ancestor_siblings() { - let a = abs("/app/src"); - let b = abs("/app/dist"); - assert_eq!(common_ancestor(&a, &b), abs("/app")); - } - - #[test] - fn common_ancestor_only_root() { - let a = abs("/foo/bar"); - let b = abs("/baz/qux"); - assert_eq!(common_ancestor(&a, &b), abs("/")); - } - - #[test] - fn reroot_same_prefix() { - let root = abs("/app/src"); - let glob = AnchoredGlob::new("**/*.rs", &root).unwrap(); - let rerooted = glob.reroot(&root).unwrap().unwrap(); - assert_eq!(rerooted.to_string(), "**/*.rs"); - } - - #[test] - fn reroot_descendant_prefix() { - let base = abs("/app/src"); - let glob = AnchoredGlob::new("lib/**/*.rs", &base).unwrap(); - // prefix = /app/src/lib, variant = **/*.rs - // reroot to /app → bridge = "src/lib" - let root = abs("/app"); - let rerooted = glob.reroot(&root).unwrap().unwrap(); - assert_eq!(rerooted.to_string(), "src/lib/**/*.rs"); - } - - #[test] - fn reroot_unrelated_returns_none() { - let base = abs("/app/src"); - let glob = AnchoredGlob::new("**/*.rs", &base).unwrap(); - let root = abs("/other"); - assert!(glob.reroot(&root).unwrap().is_none()); - } - - #[test] - fn reroot_variantless_with_bridge() { - let base = abs("/app"); - let glob = AnchoredGlob::new("src/main.rs", &base).unwrap(); - // prefix = /app/src/main.rs, variant = None - let root = abs("/app"); - let rerooted = glob.reroot(&root).unwrap().unwrap(); - assert_eq!(rerooted.to_string(), "src/main.rs"); - } - - #[test] - fn reroot_variantless_at_root_returns_none() { - let base = abs("/app"); - // A pattern that is just the base dir itself (no variant, no bridge) - let glob = AnchoredGlob::new(".", &base).unwrap(); - let result = glob.reroot(&base).unwrap(); - assert!(result.is_none()); - } - - #[test] - fn has_related_prefix_ancestor() { - let glob = AnchoredGlob::new("**", &abs("/app/src")).unwrap(); - assert!(glob.has_related_prefix(&abs("/app"))); - } - - #[test] - fn has_related_prefix_descendant() { - let glob = AnchoredGlob::new("**", &abs("/app")).unwrap(); - assert!(glob.has_related_prefix(&abs("/app/src"))); - } - - #[test] - fn has_related_prefix_same() { - let glob = AnchoredGlob::new("**", &abs("/app")).unwrap(); - assert!(glob.has_related_prefix(&abs("/app"))); - } - - #[test] - fn has_related_prefix_unrelated() { - let glob = AnchoredGlob::new("**", &abs("/app")).unwrap(); - assert!(!glob.has_related_prefix(&abs("/other"))); - } - - #[test] - fn escape_glob_no_metacharacters() { - assert_eq!(escape_glob("src/lib"), "src/lib"); - } - - #[test] - fn escape_glob_with_metacharacters() { - assert_eq!(escape_glob("a[b]*c?d"), "a\\[b\\]\\*c\\?d"); - } - - #[test] - fn path_bridge_direct_child() { - let ancestor = abs("/app"); - let path = abs("/app/src"); - assert_eq!(path_bridge(&ancestor, &path).unwrap(), "src"); - } - - #[test] - fn path_bridge_same_path() { - let path = abs("/app"); - assert_eq!(path_bridge(&path, &path).unwrap(), ""); - } - - #[test] - fn path_bridge_unrelated() { - let a = abs("/app"); - let b = abs("/other"); - assert!(path_bridge(&a, &b).is_none()); - } - - #[test] - fn rerooted_pattern_empty_bridge() { - let glob = Glob::new("**/*.rs").unwrap(); - assert_eq!(rerooted_pattern("", &glob), "**/*.rs"); - } - - #[test] - fn rerooted_pattern_with_bridge() { - let glob = Glob::new("**/*.rs").unwrap(); - assert_eq!(rerooted_pattern("src/lib", &glob), "src/lib/**/*.rs"); - } -} diff --git a/crates/vite_glob/src/lib.rs b/crates/vite_glob/src/lib.rs index db722c4f..46753812 100644 --- a/crates/vite_glob/src/lib.rs +++ b/crates/vite_glob/src/lib.rs @@ -1,11 +1,8 @@ -mod anchored; mod error; -pub mod walk; #[expect(clippy::disallowed_types, reason = "wax::Glob::is_match requires std::path::Path")] use std::path::Path; -pub use anchored::AnchoredGlob; pub use error::Error; use wax::{Glob, Program}; diff --git a/crates/vite_glob/src/walk.rs b/crates/vite_glob/src/walk.rs deleted file mode 100644 index b8b0ca82..00000000 --- a/crates/vite_glob/src/walk.rs +++ /dev/null @@ -1,604 +0,0 @@ -// # Walk design: common-ancestor rerooting -// -// Each `AnchoredGlob` has a `prefix` (invariant absolute directory) and an -// optional `variant` (dynamic glob pattern). `Glob::partition()` guarantees -// the prefix is a literal path — all glob metacharacters live in the variant. -// -// To walk a positive glob we call `wax::Glob::walk(root)`. Wax internally -// re-joins the glob's invariant prefix with `root`, descends directly to that -// directory (no extra traversal), and matches entries against the variant. -// -// Negative globs are passed to wax's `.not()`, which filters walk entries by -// matching their path **relative to the original `root`** (not the adjusted -// walk directory). This is the key insight that makes the design work. -// -// ## The rerooting problem -// -// Positive and negative globs can have different prefixes: -// -// positive: prefix=/app/src variant=**/*.rs -// negative: prefix=/app variant=**/test/** -// -// If we walk from `/app/src`, wax produces relative paths like `foo.rs`. -// The negative's `.not()` pattern `**/test/**` would be matched against -// `foo.rs` — but the negative was authored relative to `/app`, where the -// full relative path would be `src/foo.rs`. For patterns starting with -// `**` this happens to work (zero-segment match), but for patterns like -// `*.config.js` it would incorrectly exclude `/app/src/vite.config.js` -// (relative path `vite.config.js` matches `*.config.js`, but the file is -// NOT at the package root where the negative was intended to apply). -// -// ## Solution: walk from the common ancestor -// -// We find the common ancestor of the positive prefix and all related -// negative prefixes, then "reroot" every glob relative to that ancestor: -// -// common ancestor = /app -// positive rerooted: "src/**/*.rs" (bridge "src" + variant "**/*.rs") -// negative rerooted: "**/test/**" (bridge "" + variant "**/test/**") -// -// `Glob::new("src/**/*.rs").walk("/app")` still descends directly to -// `/app/src/` (wax extracts the invariant prefix `src/`), so there is no -// efficiency loss. But `.not()` now sees relative paths like -// `src/foo.rs`, and `*.config.js` correctly fails to match `src/vite.config.js` -// because `*` does not cross path separators. -// -// The bridge is always a literal path (it comes from the difference between -// two invariant prefixes), so escaping its glob metacharacters is sufficient. -// -// ## Relationship cases -// -// Given a positive prefix P and negative prefix N: -// -// P == N → bridge is empty, variant used as-is -// N ancestor P → positive gets a bridge, negative may not -// N descendant P → negative gets a bridge, positive may not -// unrelated → negative cannot affect this walk, skip it - -use rustc_hash::FxHashSet; -use vite_path::AbsolutePathBuf; -use wax::walk::{Entry as _, FileIterator as _}; - -use crate::{AnchoredGlob, Error, anchored::common_ancestor}; - -/// Walk the filesystem, returning files matching any of the `positive_globs` -/// while excluding those matching any of the `negative_globs`. -/// -/// For each positive glob, computes a common ancestor with all related negative -/// globs and walks from there. This lets wax's `.not()` see full relative paths -/// for both positive and negative pattern matching, with tree pruning. -/// -/// # Errors -/// -/// Returns an error if a rerooted glob pattern is invalid or if a filesystem -/// walk error occurs. -pub fn walk( - positive_globs: &[AnchoredGlob], - negative_globs: &[AnchoredGlob], -) -> Result, Error> { - let mut results = FxHashSet::default(); - for pos in positive_globs { - walk_positive(pos, negative_globs, &mut results)?; - } - Ok(results) -} - -fn walk_positive( - pos: &AnchoredGlob, - negatives: &[AnchoredGlob], - results: &mut FxHashSet, -) -> Result<(), Error> { - let pos_prefix = pos.prefix(); - - let Some(_) = pos.variant() else { - // Exact path — include if file exists and no negative matches it - if pos_prefix.as_path().is_file() && !negatives.iter().any(|neg| neg.is_match(pos_prefix)) { - results.insert(pos_prefix.to_absolute_path_buf()); - } - return Ok(()); - }; - - // Only negatives whose prefix is an ancestor or descendant of pos_prefix - // can affect this walk. Unrelated negatives (disjoint subtrees) are skipped. - // - // The walk root is the common ancestor of pos_prefix and every related - // negative prefix. When all negatives share the same prefix as the - // positive (the common case), the walk root stays at pos_prefix — no - // unnecessary traversal. - let walk_root = negatives - .iter() - .filter(|neg| neg.has_related_prefix(pos_prefix)) - .fold(pos_prefix.to_absolute_path_buf(), |acc, neg| common_ancestor(&acc, neg.prefix())); - - // Reroot the positive glob: prepend the bridge (walk_root → pos_prefix) - // to the variant so wax walks from walk_root but descends into pos_prefix. - let pos_glob = pos.reroot(&walk_root)?.expect("walk root is an ancestor of pos prefix"); - - // Reroot each negative glob the same way: prepend its bridge - // (walk_root → neg_prefix) to the variant. Negatives with unrelated - // prefixes return None and are skipped. - let mut neg_globs = Vec::new(); - for neg in negatives { - if let Some(rerooted) = neg.reroot(&walk_root)? { - neg_globs.push(rerooted); - } - } - - let walk = pos_glob.walk(walk_root.into_path_buf()); - if neg_globs.is_empty() { - collect_entries(walk, results)?; - } else { - collect_entries(walk.not(wax::any(neg_globs)?)?, results)?; - } - - Ok(()) -} - -fn collect_entries( - walk: impl wax::walk::FileIterator, - results: &mut FxHashSet, -) -> Result<(), Error> { - for entry in walk { - let entry = entry?; - if !entry.file_type().is_dir() { - let abs = AbsolutePathBuf::new(entry.into_path()) - .expect("walk entry under absolute root is absolute"); - results.insert(abs); - } - } - Ok(()) -} - -#[cfg(test)] -mod tests { - use path_clean::PathClean as _; - - use super::*; - - fn setup_files(files: &[&str]) -> tempfile::TempDir { - let tmp = tempfile::TempDir::with_prefix("globtest").unwrap(); - for file in files { - let file = file.trim_start_matches('/'); - let path = tmp.path().join(file); - let parent = path.parent().unwrap(); - std::fs::create_dir_all(parent).unwrap(); - std::fs::File::create(path).unwrap(); - } - tmp - } - - #[expect( - clippy::disallowed_types, - clippy::disallowed_methods, - clippy::disallowed_macros, - reason = "test helper uses std types and format! for path manipulation" - )] - fn run_walk( - tmp: &tempfile::TempDir, - base_path: &str, - include: &[&str], - exclude: &[&str], - ) -> Vec { - let base_path = base_path.trim_start_matches('/'); - let abs_base = - AbsolutePathBuf::new(tmp.path().join(base_path)).expect("tmp path is absolute"); - - let positives: Vec = include - .iter() - .map(|p| AnchoredGlob::new(p, &abs_base)) - .collect::>() - .unwrap(); - let negatives: Vec = exclude - .iter() - .map(|p| AnchoredGlob::new(p, &abs_base)) - .collect::>() - .unwrap(); - - let results = walk(&positives, &negatives).unwrap(); - let clean_root = AbsolutePathBuf::new(tmp.path().clean()).expect("tmp path is absolute"); - - let mut out: Vec = results - .iter() - .filter_map(|p| { - let remainder = p.as_path().strip_prefix(clean_root.as_path()).ok()?; - Some(format!("/{}", remainder.to_string_lossy().replace('\\', "/"))) - }) - .collect(); - out.sort(); - out - } - - #[test] - fn hello_world() { - let files = &["/test.txt"]; - let tmp = setup_files(files); - let result = run_walk(&tmp, "/", &["*.txt"], &[]); - assert_eq!(result, vec!["/test.txt"]); - } - - #[test] - fn bullet_files() { - let files = &["/test.txt", "/subdir/test.txt", "/other/test.txt"]; - let tmp = setup_files(files); - let result = run_walk(&tmp, "/", &["subdir/test.txt", "test.txt"], &[]); - assert_eq!(result, vec!["/subdir/test.txt", "/test.txt"]); - } - - #[test] - fn finding_workspace_package_json() { - let files = &[ - "/external/file.txt", - "/repos/some-app/apps/docs/package.json", - "/repos/some-app/apps/web/package.json", - "/repos/some-app/bower_components/readline/package.json", - "/repos/some-app/examples/package.json", - "/repos/some-app/node_modules/gulp/bower_components/readline/package.json", - "/repos/some-app/node_modules/react/package.json", - "/repos/some-app/package.json", - "/repos/some-app/packages/colors/package.json", - "/repos/some-app/packages/faker/package.json", - "/repos/some-app/packages/left-pad/package.json", - "/repos/some-app/test/mocks/kitchen-sink/package.json", - "/repos/some-app/tests/mocks/kitchen-sink/package.json", - ]; - let tmp = setup_files(files); - let result = run_walk( - &tmp, - "/repos/some-app/", - &["packages/*/package.json", "apps/*/package.json"], - &["**/node_modules/**", "**/bower_components/**", "**/test/**", "**/tests/**"], - ); - assert_eq!( - result, - vec![ - "/repos/some-app/apps/docs/package.json", - "/repos/some-app/apps/web/package.json", - "/repos/some-app/packages/colors/package.json", - "/repos/some-app/packages/faker/package.json", - "/repos/some-app/packages/left-pad/package.json", - ] - ); - } - - #[test] - fn excludes_unexpected_package_json() { - let files = &[ - "/external/file.txt", - "/repos/some-app/apps/docs/package.json", - "/repos/some-app/apps/web/package.json", - "/repos/some-app/bower_components/readline/package.json", - "/repos/some-app/examples/package.json", - "/repos/some-app/node_modules/gulp/bower_components/readline/package.json", - "/repos/some-app/node_modules/react/package.json", - "/repos/some-app/package.json", - "/repos/some-app/packages/colors/package.json", - "/repos/some-app/packages/faker/package.json", - "/repos/some-app/packages/left-pad/package.json", - "/repos/some-app/test/mocks/spanish-inquisition/package.json", - "/repos/some-app/tests/mocks/spanish-inquisition/package.json", - ]; - let tmp = setup_files(files); - let result = run_walk( - &tmp, - "/repos/some-app/", - &["**/package.json"], - &["**/node_modules/**", "**/bower_components/**", "**/test/**", "**/tests/**"], - ); - assert_eq!( - result, - vec![ - "/repos/some-app/apps/docs/package.json", - "/repos/some-app/apps/web/package.json", - "/repos/some-app/examples/package.json", - "/repos/some-app/package.json", - "/repos/some-app/packages/colors/package.json", - "/repos/some-app/packages/faker/package.json", - "/repos/some-app/packages/left-pad/package.json", - ] - ); - } - - #[test] - fn nested_packages() { - let files = &[ - "/repos/some-app/packages/xzibit/package.json", - "/repos/some-app/packages/xzibit/node_modules/street-legal/package.json", - "/repos/some-app/packages/xzibit/node_modules/paint-colors/package.json", - "/repos/some-app/packages/xzibit/packages/yo-dawg/package.json", - "/repos/some-app/packages/xzibit/packages/yo-dawg/node_modules/meme/package.json", - "/repos/some-app/packages/colors/package.json", - "/repos/some-app/packages/faker/package.json", - "/repos/some-app/packages/left-pad/package.json", - ]; - let tmp = setup_files(files); - let result = run_walk( - &tmp, - "/repos/some-app/", - &["packages/**/package.json"], - &["**/node_modules/**", "**/bower_components/**"], - ); - assert_eq!( - result, - vec![ - "/repos/some-app/packages/colors/package.json", - "/repos/some-app/packages/faker/package.json", - "/repos/some-app/packages/left-pad/package.json", - "/repos/some-app/packages/xzibit/package.json", - "/repos/some-app/packages/xzibit/packages/yo-dawg/package.json", - ] - ); - } - - #[test] - fn passing_doublestar_captures_children() { - let files = &[ - "/repos/some-app/dist/index.html", - "/repos/some-app/dist/js/index.js", - "/repos/some-app/dist/js/lib.js", - "/repos/some-app/dist/js/node_modules/browserify.js", - ]; - let tmp = setup_files(files); - let result = run_walk(&tmp, "/repos/some-app/", &["dist/**"], &[]); - assert_eq!( - result, - vec![ - "/repos/some-app/dist/index.html", - "/repos/some-app/dist/js/index.js", - "/repos/some-app/dist/js/lib.js", - "/repos/some-app/dist/js/node_modules/browserify.js", - ] - ); - } - - #[test] - fn exclude_everything_include_everything() { - let files = &["/repos/some-app/dist/index.html", "/repos/some-app/dist/js/index.js"]; - let tmp = setup_files(files); - let result = run_walk(&tmp, "/repos/some-app/", &["**"], &["**"]); - assert_eq!(result, Vec::<&str>::new()); - } - - #[test] - fn exclude_directory_prevents_children() { - let files = &[ - "/repos/some-app/dist/index.html", - "/repos/some-app/dist/js/index.js", - "/repos/some-app/dist/js/lib.js", - "/repos/some-app/dist/js/node_modules/browserify.js", - ]; - let tmp = setup_files(files); - let result = run_walk(&tmp, "/repos/some-app/", &["dist/**"], &["dist/js/**"]); - assert_eq!(result, vec!["/repos/some-app/dist/index.html"]); - } - - #[test] - fn include_with_dotdot_traversal() { - let files = &[ - "/repos/some-app/dist/index.html", - "/repos/some-app/dist/js/index.js", - "/repos/some-app/dist/js/lib.js", - "/repos/some-app/dist/js/node_modules/browserify.js", - ]; - let tmp = setup_files(files); - let result = run_walk(&tmp, "/repos/some-app/", &["dist/js/../**"], &[]); - assert_eq!( - result, - vec![ - "/repos/some-app/dist/index.html", - "/repos/some-app/dist/js/index.js", - "/repos/some-app/dist/js/lib.js", - "/repos/some-app/dist/js/node_modules/browserify.js", - ] - ); - } - - #[test] - fn include_with_dot_self_references() { - let files = &["/repos/some-app/dist/index.html", "/repos/some-app/dist/js/index.js"]; - let tmp = setup_files(files); - let result = run_walk(&tmp, "/repos/some-app/", &["dist/./././**"], &[]); - assert_eq!( - result, - vec!["/repos/some-app/dist/index.html", "/repos/some-app/dist/js/index.js",] - ); - } - - #[test] - fn exclude_single_file() { - let files = &["/repos/some-app/included.txt", "/repos/some-app/excluded.txt"]; - let tmp = setup_files(files); - let result = run_walk(&tmp, "/repos/some-app", &["*.txt"], &["excluded.txt"]); - assert_eq!(result, vec!["/repos/some-app/included.txt"]); - } - - #[test] - fn exclude_nested_single_file() { - let files = &[ - "/repos/some-app/one/included.txt", - "/repos/some-app/one/two/included.txt", - "/repos/some-app/one/two/three/included.txt", - "/repos/some-app/one/excluded.txt", - "/repos/some-app/one/two/excluded.txt", - "/repos/some-app/one/two/three/excluded.txt", - ]; - let tmp = setup_files(files); - let result = run_walk(&tmp, "/repos/some-app", &["**"], &["**/excluded.txt"]); - assert_eq!( - result, - vec![ - "/repos/some-app/one/included.txt", - "/repos/some-app/one/two/included.txt", - "/repos/some-app/one/two/three/included.txt", - ] - ); - } - - #[test] - fn directory_traversal_above_base() { - let files = &["root-file", "child/some-file"]; - let tmp = setup_files(files); - let abs_child = - AbsolutePathBuf::new(tmp.path().join("child")).expect("tmp path is absolute"); - - let positives = vec![AnchoredGlob::new("../*-file", &abs_child).unwrap()]; - let results = walk(&positives, &[]).unwrap(); - - let clean_root = AbsolutePathBuf::new(tmp.path().clean()).expect("tmp path is absolute"); - let names: Vec<_> = results - .iter() - .filter_map(|p| { - let remainder = p.as_path().strip_prefix(clean_root.as_path()).ok()?; - Some(remainder.to_string_lossy().into_owned()) - }) - .collect(); - assert_eq!(names, vec!["root-file"]); - } - - #[test] - fn redundant_includes_do_not_duplicate() { - let files = &[ - "/repos/some-app/dist/index.html", - "/repos/some-app/dist/js/index.js", - "/repos/some-app/dist/js/lib.js", - "/repos/some-app/dist/js/node_modules/browserify.js", - ]; - let tmp = setup_files(files); - let result = run_walk(&tmp, "/repos/some-app/", &["**/*", "dist/**"], &[]); - assert_eq!( - result, - vec![ - "/repos/some-app/dist/index.html", - "/repos/some-app/dist/js/index.js", - "/repos/some-app/dist/js/lib.js", - "/repos/some-app/dist/js/node_modules/browserify.js", - ] - ); - } - - #[test] - fn no_trailing_slash_base_path() { - let files = &["/repos/some-app/dist/index.html", "/repos/some-app/dist/js/index.js"]; - let tmp = setup_files(files); - let result = run_walk(&tmp, "/repos/some-app", &["dist/**"], &[]); - assert_eq!( - result, - vec!["/repos/some-app/dist/index.html", "/repos/some-app/dist/js/index.js",] - ); - } - - #[test] - fn exclude_with_leading_star() { - let files = &[ - "/repos/some-app/foo/bar", - "/repos/some-app/some-foo/bar", - "/repos/some-app/included", - ]; - let tmp = setup_files(files); - let result = run_walk(&tmp, "/repos/some-app", &["**"], &["*foo/**"]); - assert_eq!(result, vec!["/repos/some-app/included"]); - } - - #[test] - fn exclude_with_trailing_star() { - let files = &[ - "/repos/some-app/foo/bar", - "/repos/some-app/foo-file", - "/repos/some-app/foo-dir/bar", - "/repos/some-app/included", - ]; - let tmp = setup_files(files); - // wax's ** matches zero or more components, so foo*/** also matches foo-file - let result = run_walk(&tmp, "/repos/some-app", &["**"], &["foo*/**"]); - assert_eq!(result, vec!["/repos/some-app/included"]); - } - - #[test] - fn output_globbing() { - let files = &[ - "/repos/some-app/src/index.js", - "/repos/some-app/public/src/css/index.css", - "/repos/some-app/.turbo/turbo-build.log", - "/repos/some-app/.turbo/somebody-touched-this-file-into-existence.txt", - "/repos/some-app/.next/log.txt", - "/repos/some-app/.next/cache/db6a76a62043520e7aaadd0bb2104e78.txt", - "/repos/some-app/dist/index.html", - "/repos/some-app/dist/js/index.js", - "/repos/some-app/dist/js/lib.js", - "/repos/some-app/dist/js/node_modules/browserify.js", - "/repos/some-app/public/dist/css/index.css", - "/repos/some-app/public/dist/images/rick_astley.jpg", - ]; - let tmp = setup_files(files); - let result = run_walk( - &tmp, - "/repos/some-app/", - &[".turbo/turbo-build.log", "dist/**", ".next/**", "public/dist/**"], - &[], - ); - assert_eq!( - result, - vec![ - "/repos/some-app/.next/cache/db6a76a62043520e7aaadd0bb2104e78.txt", - "/repos/some-app/.next/log.txt", - "/repos/some-app/.turbo/turbo-build.log", - "/repos/some-app/dist/index.html", - "/repos/some-app/dist/js/index.js", - "/repos/some-app/dist/js/lib.js", - "/repos/some-app/dist/js/node_modules/browserify.js", - "/repos/some-app/public/dist/css/index.css", - "/repos/some-app/public/dist/images/rick_astley.jpg", - ] - ); - } - - #[test] - fn includes_do_not_override_excludes() { - let files = &[ - "/repos/some-app/packages/colors/package.json", - "/repos/some-app/packages/faker/package.json", - "/repos/some-app/packages/left-pad/package.json", - "/repos/some-app/packages/xzibit/package.json", - "/repos/some-app/packages/xzibit/packages/yo-dawg/package.json", - "/repos/some-app/packages/xzibit/node_modules/street-legal/package.json", - "/repos/some-app/tests/mocks/spanish-inquisition/package.json", - ]; - let tmp = setup_files(files); - let result = run_walk( - &tmp, - "/repos/some-app/", - &["packages/**/package.json", "tests/mocks/*/package.json"], - &["**/node_modules/**", "**/bower_components/**", "**/test/**", "**/tests/**"], - ); - assert_eq!( - result, - vec![ - "/repos/some-app/packages/colors/package.json", - "/repos/some-app/packages/faker/package.json", - "/repos/some-app/packages/left-pad/package.json", - "/repos/some-app/packages/xzibit/package.json", - "/repos/some-app/packages/xzibit/packages/yo-dawg/package.json", - ] - ); - } - - #[test] - #[cfg(unix)] - fn base_path_with_symlink_preserves_prefix() { - let files = &["real/file.txt", "real/sub/other.txt"]; - let tmp = setup_files(files); - let link = tmp.path().join("link"); - std::os::unix::fs::symlink(tmp.path().join("real"), &link).unwrap(); - let abs_link = AbsolutePathBuf::new(link).expect("tmp path is absolute"); - - let positives = vec![AnchoredGlob::new("**/*.txt", &abs_link).unwrap()]; - let results = walk(&positives, &[]).unwrap(); - - for path in &results { - assert!( - path.as_path().starts_with(abs_link.as_path()), - "expected path {path:?} to start with {abs_link:?}", - ); - } - assert_eq!(results.len(), 2); - } -} From 9291a76d1a91c93e05d276b6437b0d1d9ea6d57e Mon Sep 17 00:00:00 2001 From: branchseer Date: Sun, 8 Mar 2026 19:04:21 +0800 Subject: [PATCH 15/32] add new globbing plan --- hidden-dazzling-spring.md | 115 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 115 insertions(+) create mode 100644 hidden-dazzling-spring.md diff --git a/hidden-dazzling-spring.md b/hidden-dazzling-spring.md new file mode 100644 index 00000000..b89e78a5 --- /dev/null +++ b/hidden-dazzling-spring.md @@ -0,0 +1,115 @@ +# Plan: Resolve Globs to Workspace-Root-Relative at Task Graph Stage + +## Context + +`ResolvedInputConfig` currently stores raw user-provided glob strings (e.g. `src/**/*.ts`, `../shared/dist/**`) relative to the package directory. These are resolved at execution time using the now-removed `AnchoredGlob` type. The code is broken — `AnchoredGlob` was removed from `vite_glob` (commit f880ca10) but 3 files in `vite_task` still reference it. + +Moving glob resolution to the task_graph stage makes globs workspace-root-relative, eliminating `AnchoredGlob`, `glob_base`, and `base_dir` from the execution pipeline. + +## Algorithm: Resolve a single glob to workspace-root-relative + +``` +partition(glob) → (invariant_prefix, variant) +joined = package_dir.join(invariant_prefix) +cleaned = path_clean::clean(joined) +stripped = cleaned.strip_prefix(workspace_root) // error if fails +result = wax::escape(stripped) + "/" + variant // or just escaped if no variant +``` + +`AbsolutePath::strip_prefix` already normalizes separators, so no special Windows handling needed. + +--- + +## Steps + +### 1. Add deps to `vite_task_graph` + +**File:** `crates/vite_task_graph/Cargo.toml` + +Add `wax` and `path-clean` (already workspace deps). + +### 2. Add glob resolution to `ResolvedInputConfig` + +**File:** `crates/vite_task_graph/src/config/mod.rs` + +- Add error variants to `ResolveTaskConfigError`: + - `GlobOutsideWorkspace { pattern: Str }` — "glob pattern '...' resolves outside the workspace root" + - `InvalidGlob { pattern: Str, source: wax::BuildError }` + +- Add helper `resolve_glob_to_workspace_relative(pattern, package_dir, workspace_root) -> Result` implementing the algorithm above. + +- Change `from_user_config` signature to accept `package_dir` and `workspace_root`, return `Result`. Each raw glob goes through `resolve_glob_to_workspace_relative`. + +- Change `ResolvedTaskOptions::resolve()` to accept `workspace_root`, return `Result`. + +- Change `ResolvedTaskConfig::resolve()` and `resolve_package_json_script()` to accept `workspace_root`, return `Result`. + +### 3. Thread `workspace_root` in `IndexedTaskGraph::load()` + +**File:** `crates/vite_task_graph/src/lib.rs` + +Pass `&workspace_root.path` to `ResolvedTaskConfig::resolve()` (line ~275) and `resolve_package_json_script()` (line ~307). Propagate the new `Result`. + +### 4. Remove `glob_base` from `CacheMetadata` + +**File:** `crates/vite_task_plan/src/cache_metadata.rs` + +Remove `glob_base: Arc` field. + +### 5. Remove `glob_base` from plan construction + +**File:** `crates/vite_task_plan/src/plan.rs` + +Remove `glob_base: Arc::clone(package_path)` at line ~558. + +### 6. Remove `glob_base` from `CacheEntryKey` + bump DB version + +**File:** `crates/vite_task/src/session/cache/mod.rs` + +- Remove `glob_base: RelativePathBuf` from `CacheEntryKey` (line 43). +- Simplify `from_metadata()` — remove glob_base strip_prefix logic (lines 56-66). +- Bump cache version: `1..=8` → `1..=9`, `9 => break` → `10 => break`, new DB `PRAGMA user_version = 10`, unrecognized `10..` → `11..`. + +### 7. Simplify `compute_globbed_inputs()` + +**File:** `crates/vite_task/src/session/execute/glob_inputs.rs` + +- Remove `use vite_glob::AnchoredGlob` import. +- Remove `base_dir` parameter — globs are already workspace-root-relative. +- For each positive glob: `Glob::new(pattern).walk(workspace_root).not(negatives)` — `.not()` supports directory pruning for efficiency. No partition/join/clean. +- Parse all negative globs upfront as `Vec>` and pass to `.not()` for each positive walk. + +### 8. Simplify fspy filtering in `spawn.rs` + +**File:** `crates/vite_task/src/session/execute/spawn.rs` + +- Remove `use vite_glob::AnchoredGlob`. +- Change `resolved_negatives: &[AnchoredGlob]` → `resolved_negatives: &[wax::Glob<'static>]`. +- At lines 216-224: match `relative_path` directly against negative globs (both are workspace-relative). Remove `path_clean`, `workspace_root.join`, `AbsolutePath::new`. + +### 9. Simplify `execute_spawn()` in `mod.rs` + +**File:** `crates/vite_task/src/session/execute/mod.rs` + +- Remove `resolve_negative_globs()` function (lines 425-434). +- Update `compute_globbed_inputs` call: remove `cache_metadata.glob_base`, pass `cache_base_path` as workspace root. +- Build negative globs inline: `negative_globs.iter().map(|p| Glob::new(p).into_owned()).collect()`. + +### 10. Update tests + +- **`config/mod.rs` tests**: Add `package_dir` + `workspace_root` params. Assert workspace-root-relative patterns. Add test for `..` resolution and outside-workspace error. +- **`glob_inputs.rs` tests**: Remove `base_dir` param, pass workspace-root-relative globs. +- **Plan snapshots**: `INSTA_UPDATE=always cargo test -p vite_task_plan --test plan_snapshots` (removes `glob_base` lines). +- **E2E snapshots**: `INSTA_UPDATE=always cargo test -p vite_task_bin --test e2e_snapshots`. + +--- + +## Verification + +```bash +cargo check --all-targets +cargo test +INSTA_UPDATE=always cargo test -p vite_task_plan --test plan_snapshots +INSTA_UPDATE=always cargo test -p vite_task_bin --test e2e_snapshots +just lint +``` From 713bf2813a4372d303da5b2178a63193db01c8fc Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 8 Mar 2026 11:45:01 +0000 Subject: [PATCH 16/32] refactor: resolve glob patterns to workspace-root-relative at task graph stage Move glob pattern resolution from execution time to task graph construction, making all glob patterns workspace-root-relative. This eliminates AnchoredGlob usage, removes glob_base from CacheMetadata/CacheEntryKey, and simplifies the execution pipeline. Key changes: - Add resolve_glob_to_workspace_relative() in vite_task_graph config - Remove glob_base field from CacheMetadata and CacheEntryKey - Update glob_inputs to work with workspace-root-relative patterns - Update spawn.rs negative glob filtering with path cleaning - Bump cache DB version from 9 to 10 - Remove vite_glob dependency from vite_task - Remove plan file https://claude.ai/code/session_01PR9yhnScRoVoHUcviV47u5 --- Cargo.lock | 4 +- crates/vite_task/Cargo.toml | 1 - crates/vite_task/src/session/cache/mod.rs | 55 ++--- .../src/session/execute/glob_inputs.rs | 173 +++++++------- crates/vite_task/src/session/execute/mod.rs | 59 +++-- crates/vite_task/src/session/execute/spawn.rs | 17 +- crates/vite_task_graph/Cargo.toml | 3 + crates/vite_task_graph/src/config/mod.rs | 213 +++++++++++++++--- crates/vite_task_graph/src/lib.rs | 12 +- crates/vite_task_plan/src/cache_metadata.rs | 6 +- crates/vite_task_plan/src/error.rs | 7 + crates/vite_task_plan/src/plan.rs | 23 +- ... env-test synthetic task in user task.snap | 3 +- ...uery - --cache enables script caching.snap | 3 +- ...aching even when cache.tasks is false.snap | 3 +- ...h per-task cache true enables caching.snap | 3 +- ...query - echo and lint with extra args.snap | 3 +- ...query - lint and echo with extra args.snap | 3 +- .../query - normal task with extra args.snap | 3 +- ... synthetic task in user task with cwd.snap | 3 +- .../query - synthetic task in user task.snap | 3 +- ...tic task with extra args in user task.snap | 3 +- .../query - task cached by default.snap | 3 +- ...- task with command cached by default.snap | 3 +- ... script cached when global cache true.snap | 3 +- ... - task cached when global cache true.snap | 3 +- ...t should put synthetic task under cwd.snap | 3 +- ...n should not affect expanded task cwd.snap | 3 +- ...ed --cache enables inner task caching.snap | 3 +- ...ropagates to nested run without flags.snap | 3 +- ...oes not propagate into nested --cache.snap | 3 +- ...ery - shell fallback for pipe command.snap | 3 +- ... does not affect expanded query tasks.snap | 3 +- ...s not affect expanded synthetic cache.snap | 3 +- ...assThroughEnvs inherited by synthetic.snap | 3 +- ...th cache true enables synthetic cache.snap | 3 +- .../query - synthetic-in-subpackage.snap | 3 +- hidden-dazzling-spring.md | 115 ---------- 38 files changed, 374 insertions(+), 389 deletions(-) delete mode 100644 hidden-dazzling-spring.md diff --git a/Cargo.lock b/Cargo.lock index 4b602c28..b435a620 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3889,7 +3889,6 @@ dependencies = [ "tokio", "tracing", "twox-hash", - "vite_glob", "vite_path", "vite_select", "vite_str", @@ -3934,7 +3933,9 @@ dependencies = [ "anyhow", "async-trait", "bincode", + "cow-utils", "monostate", + "path-clean", "petgraph", "pretty_assertions", "rustc-hash", @@ -3947,6 +3948,7 @@ dependencies = [ "vite_path", "vite_str", "vite_workspace", + "wax", "which", ] diff --git a/crates/vite_task/Cargo.toml b/crates/vite_task/Cargo.toml index 08088587..ade7cdb7 100644 --- a/crates/vite_task/Cargo.toml +++ b/crates/vite_task/Cargo.toml @@ -34,7 +34,6 @@ thiserror = { workspace = true } tokio = { workspace = true, features = ["rt-multi-thread", "io-std", "io-util", "macros", "sync"] } tracing = { workspace = true } twox-hash = { workspace = true } -vite_glob = { workspace = true } vite_path = { workspace = true } vite_select = { workspace = true } vite_str = { workspace = true } diff --git a/crates/vite_task/src/session/cache/mod.rs b/crates/vite_task/src/session/cache/mod.rs index 11de13cb..a4b3f588 100644 --- a/crates/vite_task/src/session/cache/mod.rs +++ b/crates/vite_task/src/session/cache/mod.rs @@ -37,39 +37,16 @@ pub struct CacheEntryKey { /// The spawn fingerprint (command, args, cwd, envs) pub spawn_fingerprint: SpawnFingerprint, /// Resolved input configuration that affects cache behavior. + /// Glob patterns are workspace-root-relative. pub input_config: ResolvedInputConfig, - /// Base directory for glob patterns, relative to workspace root. - /// This is where the task is defined (package path). - pub glob_base: RelativePathBuf, } impl CacheEntryKey { - #[expect( - clippy::disallowed_macros, - reason = "anyhow::anyhow! internally uses std::format! for error messages" - )] - fn from_metadata( - cache_metadata: &CacheMetadata, - workspace_root: &AbsolutePath, - ) -> anyhow::Result { - // Convert absolute glob_base to relative for cache key - let glob_base = cache_metadata - .glob_base - .strip_prefix(workspace_root) - .map_err(|e| anyhow::anyhow!("failed to strip prefix from glob_base: {e}"))? - .ok_or_else(|| { - anyhow::anyhow!( - "glob_base {:?} is not inside workspace {:?}", - cache_metadata.glob_base, - workspace_root - ) - })?; - - Ok(Self { + fn from_metadata(cache_metadata: &CacheMetadata) -> Self { + Self { spawn_fingerprint: cache_metadata.spawn_fingerprint.clone(), input_config: cache_metadata.input_config.clone(), - glob_base, - }) + } } } @@ -116,7 +93,7 @@ pub enum FingerprintMismatch { /// The fingerprint of the current execution new: SpawnFingerprint, }, - /// Found a previous cache entry key for the same task, but `input_config` or `glob_base` differs. + /// Found a previous cache entry key for the same task, but `input_config` differs. InputConfig, /// Found the cache entry with the same spawn fingerprint, but an explicit globbed input changed GlobbedInput { path: RelativePathBuf }, @@ -172,16 +149,16 @@ impl ExecutionCache { "CREATE TABLE task_fingerprints (key BLOB PRIMARY KEY, value BLOB);", (), )?; - conn.execute("PRAGMA user_version = 9", ())?; + conn.execute("PRAGMA user_version = 10", ())?; } - 1..=8 => { + 1..=9 => { // old internal db version. reset conn.set_db_config(DbConfig::SQLITE_DBCONFIG_RESET_DATABASE, true)?; conn.execute("VACUUM", ())?; conn.set_db_config(DbConfig::SQLITE_DBCONFIG_RESET_DATABASE, false)?; } - 9 => break, // current version - 10.. => { + 10 => break, // current version + 11.. => { return Err(anyhow::anyhow!("Unrecognized database version: {user_version}")); } } @@ -208,9 +185,9 @@ impl ExecutionCache { let spawn_fingerprint = &cache_metadata.spawn_fingerprint; let execution_cache_key = &cache_metadata.execution_cache_key; - let cache_key = CacheEntryKey::from_metadata(cache_metadata, workspace_root)?; + let cache_key = CacheEntryKey::from_metadata(cache_metadata); - // Try to find the cache entry by key (spawn fingerprint + input config + glob base) + // Try to find the cache entry by key (spawn fingerprint + input config) if let Some(cache_value) = self.get_by_cache_key(&cache_key).await? { // Validate explicit globbed inputs against the stored values if let Some(mismatch) = @@ -239,11 +216,8 @@ impl ExecutionCache { self.get_cache_key_by_execution_key(execution_cache_key).await? { // Destructure to ensure we handle all fields when new ones are added - let CacheEntryKey { - spawn_fingerprint: old_spawn_fingerprint, - input_config: _, - glob_base: _, - } = old_cache_key; + let CacheEntryKey { spawn_fingerprint: old_spawn_fingerprint, input_config: _ } = + old_cache_key; let mismatch = if old_spawn_fingerprint == *spawn_fingerprint { // spawn fingerprint is the same but input_config or glob_base changed FingerprintMismatch::InputConfig @@ -264,12 +238,11 @@ impl ExecutionCache { pub async fn update( &self, cache_metadata: &CacheMetadata, - workspace_root: &AbsolutePath, cache_value: CacheEntryValue, ) -> anyhow::Result<()> { let execution_cache_key = &cache_metadata.execution_cache_key; - let cache_key = CacheEntryKey::from_metadata(cache_metadata, workspace_root)?; + let cache_key = CacheEntryKey::from_metadata(cache_metadata); self.upsert_cache_entry(&cache_key, &cache_value).await?; self.upsert_task_fingerprint(execution_cache_key, &cache_key).await?; diff --git a/crates/vite_task/src/session/execute/glob_inputs.rs b/crates/vite_task/src/session/execute/glob_inputs.rs index 3e0b691e..732a1268 100644 --- a/crates/vite_task/src/session/execute/glob_inputs.rs +++ b/crates/vite_task/src/session/execute/glob_inputs.rs @@ -2,6 +2,8 @@ //! //! This module provides functions to walk glob patterns and compute file hashes //! for cache invalidation based on explicit input patterns. +//! +//! All glob patterns are workspace-root-relative (resolved at task graph stage). use std::{ collections::BTreeMap, @@ -10,28 +12,21 @@ use std::{ io::{self, Read}, }; -use vite_glob::AnchoredGlob; #[cfg(test)] use vite_path::AbsolutePathBuf; use vite_path::{AbsolutePath, RelativePathBuf}; use vite_str::Str; -use wax::{Glob, walk::Entry as _}; +use wax::{Glob, Program as _, walk::Entry as _}; /// Collect walk entries into the result map, filtering against resolved negatives. /// -/// Each positive glob is partitioned into an invariant prefix and a variant pattern. -/// The prefix is joined with `base_dir` and cleaned (normalizing `..`) to get the walk root. -/// The variant pattern is then walked from the cleaned root. -/// /// Walk errors for non-existent directories are skipped gracefully. fn collect_walk_entries( walk: impl Iterator>, workspace_root: &AbsolutePath, - resolved_negatives: &[AnchoredGlob], + resolved_negatives: &[Glob<'static>], result: &mut BTreeMap, ) -> anyhow::Result<()> { - use path_clean::PathClean as _; - for entry in walk { let entry = match entry { Ok(entry) => entry, @@ -48,27 +43,24 @@ fn collect_walk_entries( continue; } - // Clean the path to normalize `..` components (from globs like `../shared/src/**`) - let cleaned_path = entry.path().clean(); - - // Convert to AbsolutePath for negative matching and workspace-relative stripping - let Some(cleaned_abs) = AbsolutePath::new(&cleaned_path) else { - continue; - }; - - // Filter against resolved negatives - if resolved_negatives.iter().any(|neg| neg.is_match(cleaned_abs)) { - continue; - } + let path = entry.path(); // Compute path relative to workspace_root for the result - let Some(relative_to_workspace) = cleaned_abs.strip_prefix(workspace_root).ok().flatten() + let Some(relative_to_workspace) = path + .strip_prefix(workspace_root.as_path()) + .ok() + .and_then(|p| RelativePathBuf::new(p).ok()) else { continue; // Skip if path is outside workspace_root }; + // Filter against resolved negatives (both are workspace-root-relative) + if resolved_negatives.iter().any(|neg| neg.is_match(relative_to_workspace.as_str())) { + continue; + } + // Hash file content - match hash_file_content(&cleaned_path) { + match hash_file_content(path) { Ok(hash) => { result.insert(relative_to_workspace, hash); } @@ -85,35 +77,29 @@ fn collect_walk_entries( /// Compute globbed inputs by walking positive glob patterns and filtering with negative patterns. /// -/// Each glob is partitioned into an invariant prefix and a variant pattern. The prefix is -/// joined with `base_dir` and cleaned to normalize `..` components, producing the walk root. -/// The variant pattern walks the cleaned root. Negative patterns are resolved the same way -/// and used to filter walked entries by matching against cleaned absolute paths. +/// All globs are workspace-root-relative (resolved at task graph stage). +/// Positive globs are walked from `workspace_root`, and negative globs filter the results. /// /// # Arguments -/// * `base_dir` - The package directory where the task is defined (globs are relative to this) -/// * `workspace_root` - The workspace root for computing relative paths in the result -/// * `positive_globs` - Glob patterns that should match input files -/// * `negative_globs` - Glob patterns that should exclude files from the result +/// * `workspace_root` - The workspace root (globs are relative to this) +/// * `positive_globs` - Workspace-root-relative glob patterns for files to include +/// * `negative_globs` - Workspace-root-relative glob patterns for files to exclude /// /// # Returns /// A sorted map of relative paths (from `workspace_root`) to their content hashes. /// Only files are included (directories are skipped). pub fn compute_globbed_inputs( - base_dir: &AbsolutePath, workspace_root: &AbsolutePath, positive_globs: &std::collections::BTreeSet, negative_globs: &std::collections::BTreeSet, ) -> anyhow::Result> { - use path_clean::PathClean as _; - if positive_globs.is_empty() { return Ok(BTreeMap::new()); } - let resolved_negatives: Vec = negative_globs + let resolved_negatives: Vec> = negative_globs .iter() - .map(|p| Ok(AnchoredGlob::new(p.as_str(), base_dir)?)) + .map(|p| Ok(Glob::new(p.as_str())?.into_owned())) .collect::>()?; let mut result = BTreeMap::new(); @@ -121,7 +107,7 @@ pub fn compute_globbed_inputs( for pattern in positive_globs { let pos = Glob::new(pattern.as_str())?.into_owned(); let (pos_prefix, pos_variant) = pos.partition(); - let walk_root = base_dir.as_path().join(&pos_prefix).clean(); + let walk_root = workspace_root.as_path().join(&pos_prefix); if let Some(variant_glob) = pos_variant { if walk_root.is_dir() { @@ -140,6 +126,10 @@ pub fn compute_globbed_inputs( .ok() .and_then(|p| RelativePathBuf::new(p).ok()) { + // Check against negatives + if resolved_negatives.iter().any(|neg| neg.is_match(relative.as_str())) { + continue; + } match hash_file_content(&walk_root) { Ok(hash) => { result.insert(relative, hash); @@ -179,7 +169,7 @@ mod tests { use super::*; - fn create_test_workspace() -> (TempDir, AbsolutePathBuf, AbsolutePathBuf) { + fn create_test_workspace() -> (TempDir, AbsolutePathBuf) { let temp_dir = TempDir::new().unwrap(); let workspace_root = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); @@ -202,28 +192,28 @@ mod tests { fs::write(package_dir.join("package.json"), "{}").unwrap(); fs::write(package_dir.join("README.md"), "# Readme").unwrap(); - let package_abs = AbsolutePathBuf::new(package_dir.into_path_buf()).unwrap(); - (temp_dir, workspace_root, package_abs) + (temp_dir, workspace_root) } #[test] fn test_empty_positive_globs_returns_empty() { - let (_temp, workspace, package) = create_test_workspace(); + let (_temp, workspace) = create_test_workspace(); let positive = std::collections::BTreeSet::new(); let negative = std::collections::BTreeSet::new(); - let result = compute_globbed_inputs(&package, &workspace, &positive, &negative).unwrap(); + let result = compute_globbed_inputs(&workspace, &positive, &negative).unwrap(); assert!(result.is_empty()); } #[test] fn test_single_positive_glob() { - let (_temp, workspace, package) = create_test_workspace(); + let (_temp, workspace) = create_test_workspace(); + // Globs are now workspace-root-relative let positive: std::collections::BTreeSet = - std::iter::once("src/**/*.ts".into()).collect(); + std::iter::once("packages/my-pkg/src/**/*.ts".into()).collect(); let negative = std::collections::BTreeSet::new(); - let result = compute_globbed_inputs(&package, &workspace, &positive, &negative).unwrap(); + let result = compute_globbed_inputs(&workspace, &positive, &negative).unwrap(); // Should match all .ts files in src/ assert_eq!(result.len(), 5); @@ -248,13 +238,13 @@ mod tests { #[test] fn test_positive_with_negative_exclusion() { - let (_temp, workspace, package) = create_test_workspace(); + let (_temp, workspace) = create_test_workspace(); let positive: std::collections::BTreeSet = - std::iter::once("src/**/*.ts".into()).collect(); + std::iter::once("packages/my-pkg/src/**/*.ts".into()).collect(); let negative: std::collections::BTreeSet = std::iter::once("**/*.test.ts".into()).collect(); - let result = compute_globbed_inputs(&package, &workspace, &positive, &negative).unwrap(); + let result = compute_globbed_inputs(&workspace, &positive, &negative).unwrap(); // Should match only non-test .ts files assert_eq!(result.len(), 3); @@ -280,13 +270,15 @@ mod tests { #[test] fn test_multiple_positive_globs() { - let (_temp, workspace, package) = create_test_workspace(); + let (_temp, workspace) = create_test_workspace(); let positive: std::collections::BTreeSet = - ["src/**/*.ts".into(), "package.json".into()].into_iter().collect(); + ["packages/my-pkg/src/**/*.ts".into(), "packages/my-pkg/package.json".into()] + .into_iter() + .collect(); let negative: std::collections::BTreeSet = std::iter::once("**/*.test.ts".into()).collect(); - let result = compute_globbed_inputs(&package, &workspace, &positive, &negative).unwrap(); + let result = compute_globbed_inputs(&workspace, &positive, &negative).unwrap(); // Should include .ts files (excluding tests) plus package.json assert_eq!(result.len(), 4); @@ -307,13 +299,15 @@ mod tests { #[test] fn test_multiple_negative_globs() { - let (_temp, workspace, package) = create_test_workspace(); + let (_temp, workspace) = create_test_workspace(); let positive: std::collections::BTreeSet = - ["src/**/*.ts".into(), "*.md".into()].into_iter().collect(); + ["packages/my-pkg/src/**/*.ts".into(), "packages/my-pkg/*.md".into()] + .into_iter() + .collect(); let negative: std::collections::BTreeSet = ["**/*.test.ts".into(), "**/*.md".into()].into_iter().collect(); - let result = compute_globbed_inputs(&package, &workspace, &positive, &negative).unwrap(); + let result = compute_globbed_inputs(&workspace, &positive, &negative).unwrap(); // Should exclude both test files and markdown files assert_eq!(result.len(), 3); @@ -332,12 +326,12 @@ mod tests { #[test] fn test_negative_only_returns_empty() { - let (_temp, workspace, package) = create_test_workspace(); + let (_temp, workspace) = create_test_workspace(); let positive: std::collections::BTreeSet = std::collections::BTreeSet::new(); let negative: std::collections::BTreeSet = std::iter::once("**/*.test.ts".into()).collect(); - let result = compute_globbed_inputs(&package, &workspace, &positive, &negative).unwrap(); + let result = compute_globbed_inputs(&workspace, &positive, &negative).unwrap(); // No positive globs means empty result (negative globs alone don't select anything) assert!(result.is_empty()); @@ -345,27 +339,27 @@ mod tests { #[test] fn test_file_hashes_are_consistent() { - let (_temp, workspace, package) = create_test_workspace(); + let (_temp, workspace) = create_test_workspace(); let positive: std::collections::BTreeSet = - std::iter::once("src/index.ts".into()).collect(); + std::iter::once("packages/my-pkg/src/index.ts".into()).collect(); let negative = std::collections::BTreeSet::new(); // Run twice and compare hashes - let result1 = compute_globbed_inputs(&package, &workspace, &positive, &negative).unwrap(); - let result2 = compute_globbed_inputs(&package, &workspace, &positive, &negative).unwrap(); + let result1 = compute_globbed_inputs(&workspace, &positive, &negative).unwrap(); + let result2 = compute_globbed_inputs(&workspace, &positive, &negative).unwrap(); assert_eq!(result1, result2); } #[test] fn test_file_hashes_change_with_content() { - let (temp, workspace, package) = create_test_workspace(); + let (temp, workspace) = create_test_workspace(); let positive: std::collections::BTreeSet = - std::iter::once("src/index.ts".into()).collect(); + std::iter::once("packages/my-pkg/src/index.ts".into()).collect(); let negative = std::collections::BTreeSet::new(); // Get initial hash - let result1 = compute_globbed_inputs(&package, &workspace, &positive, &negative).unwrap(); + let result1 = compute_globbed_inputs(&workspace, &positive, &negative).unwrap(); let hash1 = result1.get(&RelativePathBuf::new("packages/my-pkg/src/index.ts").unwrap()).unwrap(); @@ -374,7 +368,7 @@ mod tests { fs::write(&file_path, "export const a = 999;").unwrap(); // Get new hash - let result2 = compute_globbed_inputs(&package, &workspace, &positive, &negative).unwrap(); + let result2 = compute_globbed_inputs(&workspace, &positive, &negative).unwrap(); let hash2 = result2.get(&RelativePathBuf::new("packages/my-pkg/src/index.ts").unwrap()).unwrap(); @@ -383,12 +377,13 @@ mod tests { #[test] fn test_skips_directories() { - let (_temp, workspace, package) = create_test_workspace(); + let (_temp, workspace) = create_test_workspace(); // This glob could match the `src/lib` directory if not filtered - let positive: std::collections::BTreeSet = std::iter::once("src/*".into()).collect(); + let positive: std::collections::BTreeSet = + std::iter::once("packages/my-pkg/src/*".into()).collect(); let negative = std::collections::BTreeSet::new(); - let result = compute_globbed_inputs(&package, &workspace, &positive, &negative).unwrap(); + let result = compute_globbed_inputs(&workspace, &positive, &negative).unwrap(); // Should only have files, not directories for path in result.keys() { @@ -399,17 +394,17 @@ mod tests { #[test] fn test_no_matching_files_returns_empty() { - let (_temp, workspace, package) = create_test_workspace(); + let (_temp, workspace) = create_test_workspace(); let positive: std::collections::BTreeSet = - std::iter::once("nonexistent/**/*.xyz".into()).collect(); + std::iter::once("packages/my-pkg/nonexistent/**/*.xyz".into()).collect(); let negative = std::collections::BTreeSet::new(); - let result = compute_globbed_inputs(&package, &workspace, &positive, &negative).unwrap(); + let result = compute_globbed_inputs(&workspace, &positive, &negative).unwrap(); assert!(result.is_empty()); } - /// Creates a workspace with a sibling package for testing `..` globs - fn create_workspace_with_sibling() -> (TempDir, AbsolutePathBuf, AbsolutePathBuf) { + /// Creates a workspace with sibling packages for testing cross-package globs + fn create_workspace_with_sibling() -> (TempDir, AbsolutePathBuf) { let temp_dir = TempDir::new().unwrap(); let workspace_root = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); @@ -425,53 +420,55 @@ mod tests { fs::write(shared.join("src/utils.ts"), "export const shared = 1;").unwrap(); fs::write(shared.join("dist/output.js"), "// output").unwrap(); - let sub_pkg_abs = AbsolutePathBuf::new(sub_pkg.into_path_buf()).unwrap(); - (temp_dir, workspace_root, sub_pkg_abs) + (temp_dir, workspace_root) } #[test] - fn test_dotdot_positive_glob_matches_sibling_package() { - let (_temp, workspace, sub_pkg) = create_workspace_with_sibling(); + fn test_sibling_positive_glob_matches_sibling_package() { + let (_temp, workspace) = create_workspace_with_sibling(); + // Globs are already workspace-root-relative (resolved at task graph stage) let positive: std::collections::BTreeSet = - std::iter::once("../shared/src/**".into()).collect(); + std::iter::once("packages/shared/src/**".into()).collect(); let negative = std::collections::BTreeSet::new(); - let result = compute_globbed_inputs(&sub_pkg, &workspace, &positive, &negative).unwrap(); + let result = compute_globbed_inputs(&workspace, &positive, &negative).unwrap(); assert!( result.contains_key(&RelativePathBuf::new("packages/shared/src/utils.ts").unwrap()), - "should find sibling package file via ../shared/src/**" + "should find sibling package file via packages/shared/src/**" ); } #[test] - fn test_dotdot_negative_glob_excludes_from_sibling() { - let (_temp, workspace, sub_pkg) = create_workspace_with_sibling(); + fn test_sibling_negative_glob_excludes_from_sibling() { + let (_temp, workspace) = create_workspace_with_sibling(); let positive: std::collections::BTreeSet = - std::iter::once("../shared/**".into()).collect(); + std::iter::once("packages/shared/**".into()).collect(); let negative: std::collections::BTreeSet = - std::iter::once("../shared/dist/**".into()).collect(); + std::iter::once("packages/shared/dist/**".into()).collect(); - let result = compute_globbed_inputs(&sub_pkg, &workspace, &positive, &negative).unwrap(); + let result = compute_globbed_inputs(&workspace, &positive, &negative).unwrap(); assert!( result.contains_key(&RelativePathBuf::new("packages/shared/src/utils.ts").unwrap()), "should include non-excluded sibling file" ); assert!( !result.contains_key(&RelativePathBuf::new("packages/shared/dist/output.js").unwrap()), - "should exclude dist via ../shared/dist/**" + "should exclude dist via packages/shared/dist/**" ); } #[test] fn test_overlapping_positive_globs_deduplicates() { - let (_temp, workspace, package) = create_test_workspace(); + let (_temp, workspace) = create_test_workspace(); // Both patterns match src/index.ts let positive: std::collections::BTreeSet = - ["src/**/*.ts".into(), "src/index.ts".into()].into_iter().collect(); + ["packages/my-pkg/src/**/*.ts".into(), "packages/my-pkg/src/index.ts".into()] + .into_iter() + .collect(); let negative: std::collections::BTreeSet = std::iter::once("**/*.test.ts".into()).collect(); - let result = compute_globbed_inputs(&package, &workspace, &positive, &negative).unwrap(); + let result = compute_globbed_inputs(&workspace, &positive, &negative).unwrap(); // BTreeMap naturally deduplicates by key assert_eq!(result.len(), 3); diff --git a/crates/vite_task/src/session/execute/mod.rs b/crates/vite_task/src/session/execute/mod.rs index 3619791b..9ec9cbe3 100644 --- a/crates/vite_task/src/session/execute/mod.rs +++ b/crates/vite_task/src/session/execute/mod.rs @@ -198,8 +198,8 @@ pub async fn execute_spawn( let (cache_status, cached_value, globbed_inputs) = if let Some(cache_metadata) = cache_metadata { // Compute globbed inputs from positive globs at execution time + // Globs are already workspace-root-relative (resolved at task graph stage) let globbed_inputs = match compute_globbed_inputs( - &cache_metadata.glob_base, cache_base_path, &cache_metadata.input_config.positive_globs, &cache_metadata.input_config.negative_globs, @@ -320,27 +320,31 @@ pub async fn execute_spawn( (Some(Vec::new()), path_accesses, Some((cache_metadata, globbed_inputs))) }); - // Resolve negative globs for fspy path filtering - let resolved_negatives = if let Some((cache_metadata, _)) = &cache_metadata_and_inputs { - match resolve_negative_globs( - &cache_metadata.glob_base, - &cache_metadata.input_config.negative_globs, - ) { - Ok(negs) => negs, - Err(err) => { - leaf_reporter - .finish( - None, - CacheUpdateStatus::NotUpdated(CacheNotUpdatedReason::CacheDisabled), - Some(ExecutionError::PostRunFingerprint(err)), - ) - .await; - return SpawnOutcome::Failed; + // Build negative globs for fspy path filtering (already workspace-root-relative) + let resolved_negatives: Vec> = + if let Some((cache_metadata, _)) = &cache_metadata_and_inputs { + match cache_metadata + .input_config + .negative_globs + .iter() + .map(|p| Ok(wax::Glob::new(p.as_str())?.into_owned())) + .collect::>>() + { + Ok(negs) => negs, + Err(err) => { + leaf_reporter + .finish( + None, + CacheUpdateStatus::NotUpdated(CacheNotUpdatedReason::CacheDisabled), + Some(ExecutionError::PostRunFingerprint(err)), + ) + .await; + return SpawnOutcome::Failed; + } } - } - } else { - Vec::new() - }; + } else { + Vec::new() + }; #[expect( clippy::large_futures, @@ -389,7 +393,7 @@ pub async fn execute_spawn( duration: result.duration, globbed_inputs, }; - match cache.update(cache_metadata, cache_base_path, new_cache_value).await { + match cache.update(cache_metadata, new_cache_value).await { Ok(()) => (CacheUpdateStatus::Updated, None), Err(err) => ( CacheUpdateStatus::NotUpdated(CacheNotUpdatedReason::CacheDisabled), @@ -422,17 +426,6 @@ pub async fn execute_spawn( SpawnOutcome::Spawned(result.exit_status) } -/// Resolve negative glob patterns into [`AnchoredGlob`]s for filtering. -fn resolve_negative_globs( - glob_base: &AbsolutePath, - negative_globs: &std::collections::BTreeSet, -) -> anyhow::Result> { - negative_globs - .iter() - .map(|p| Ok(vite_glob::AnchoredGlob::new(p.as_str(), glob_base)?)) - .collect() -} - /// Spawn a command with all three stdio file descriptors inherited from the parent. /// /// Used when the reporter suggests inherited stdio AND caching is disabled. diff --git a/crates/vite_task/src/session/execute/spawn.rs b/crates/vite_task/src/session/execute/spawn.rs index 792d7be7..ca49e091 100644 --- a/crates/vite_task/src/session/execute/spawn.rs +++ b/crates/vite_task/src/session/execute/spawn.rs @@ -11,9 +11,9 @@ use fspy::AccessMode; use rustc_hash::FxHashSet; use serde::Serialize; use tokio::io::{AsyncReadExt as _, AsyncWrite, AsyncWriteExt as _}; -use vite_glob::AnchoredGlob; use vite_path::{AbsolutePath, RelativePathBuf}; use vite_task_plan::SpawnCommand; +use wax::Program as _; use crate::collections::HashMap; @@ -77,7 +77,7 @@ pub async fn spawn_with_tracking( stderr_writer: &mut (dyn AsyncWrite + Unpin), std_outputs: Option<&mut Vec>, path_accesses: Option<&mut TrackedPathAccesses>, - resolved_negatives: &[AnchoredGlob], + resolved_negatives: &[wax::Glob<'static>], ) -> anyhow::Result { /// The tracking state of the spawned process. /// Determined by whether `path_accesses` is `Some` (fspy enabled) or `None` (fspy disabled). @@ -210,14 +210,13 @@ pub async fn spawn_with_tracking( continue; } - // Filter against resolved negative globs. - // Clean the path to normalize `..` only for matching purposes, since - // AnchoredGlob prefixes are cleaned absolute paths. + // Filter against resolved negative globs (both are workspace-root-relative). + // Clean the relative path to normalize `..` components since fspy may report + // paths like `packages/sub-pkg/../shared/dist/output.js`. if !resolved_negatives.is_empty() { - let cleaned_abs = - path_clean::PathClean::clean(workspace_root.join(&relative_path).as_path()); - if let Some(cleaned) = AbsolutePath::new(&cleaned_abs) - && resolved_negatives.iter().any(|neg| neg.is_match(cleaned)) + let cleaned = path_clean::PathClean::clean(relative_path.as_path()); + if let Some(cleaned_str) = cleaned.to_str() + && resolved_negatives.iter().any(|neg| neg.is_match(cleaned_str)) { continue; } diff --git a/crates/vite_task_graph/Cargo.toml b/crates/vite_task_graph/Cargo.toml index 122bc817..95fdd240 100644 --- a/crates/vite_task_graph/Cargo.toml +++ b/crates/vite_task_graph/Cargo.toml @@ -10,13 +10,16 @@ rust-version.workspace = true anyhow = { workspace = true } async-trait = { workspace = true } bincode = { workspace = true } +cow-utils = { workspace = true } monostate = { workspace = true } petgraph = { workspace = true } rustc-hash = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } +path-clean = { workspace = true } thiserror = { workspace = true } tracing = { workspace = true } +wax = { workspace = true } vite_graph_ser = { workspace = true } vite_path = { workspace = true } vite_str = { workspace = true } diff --git a/crates/vite_task_graph/src/config/mod.rs b/crates/vite_task_graph/src/config/mod.rs index 2ff26587..6453bda1 100644 --- a/crates/vite_task_graph/src/config/mod.rs +++ b/crates/vite_task_graph/src/config/mod.rs @@ -47,7 +47,16 @@ impl ResolvedTaskOptions { /// Resolves from user-defined options and the directory path where the options are defined. /// For user-defined tasks or scripts in package.json, `dir` is the package directory /// For synthetic tasks, `dir` is the cwd of the original command (e.g. the cwd of `vp lint`). - pub fn resolve(user_options: UserTaskOptions, dir: &Arc) -> Self { + /// + /// # Errors + /// + /// Returns [`ResolveTaskConfigError`] if a glob pattern is invalid or resolves + /// outside the workspace root. + pub fn resolve( + user_options: UserTaskOptions, + dir: &Arc, + workspace_root: &AbsolutePath, + ) -> Result { let cwd: Arc = match user_options.cwd_relative_to_package { Some(ref cwd) if !cwd.as_str().is_empty() => dir.join(cwd).into(), _ => Arc::clone(dir), @@ -62,8 +71,11 @@ impl ResolvedTaskOptions { .collect(); pass_through_envs.extend(DEFAULT_PASSTHROUGH_ENVS.iter().copied().map(Str::from)); - let input_config = - ResolvedInputConfig::from_user_config(enabled_cache_config.inputs.as_ref()); + let input_config = ResolvedInputConfig::from_user_config( + enabled_cache_config.inputs.as_ref(), + dir, + workspace_root, + )?; Some(CacheConfig { env_config: EnvConfig { @@ -77,7 +89,7 @@ impl ResolvedTaskOptions { }) } }; - Self { cwd, cache_config } + Ok(Self { cwd, cache_config }) } } @@ -118,16 +130,24 @@ impl ResolvedInputConfig { } } - /// Resolve from user configuration. + /// Resolve from user configuration, making glob patterns workspace-root-relative. /// /// - `None`: defaults to auto-inference (`[{auto: true}]`) /// - `Some([])`: no inputs, inference disabled - /// - `Some([...])`: explicit patterns - #[must_use] - pub fn from_user_config(user_inputs: Option<&UserInputsConfig>) -> Self { + /// - `Some([...])`: explicit patterns resolved to workspace-root-relative + /// + /// # Errors + /// + /// Returns [`ResolveTaskConfigError`] if a glob pattern is invalid or resolves + /// outside the workspace root. + pub fn from_user_config( + user_inputs: Option<&UserInputsConfig>, + package_dir: &AbsolutePath, + workspace_root: &AbsolutePath, + ) -> Result { let Some(entries) = user_inputs else { // None means default to auto-inference - return Self::default_auto(); + return Ok(Self::default_auto()); }; let mut includes_auto = false; @@ -140,18 +160,74 @@ impl ResolvedInputConfig { UserInputEntry::Auto { auto: false } => {} // Ignore {auto: false} UserInputEntry::Glob(pattern) => { if let Some(negated) = pattern.strip_prefix('!') { - negative_globs.insert(Str::from(negated)); + let resolved = resolve_glob_to_workspace_relative( + negated, + package_dir, + workspace_root, + )?; + negative_globs.insert(resolved); } else { - positive_globs.insert(pattern.clone()); + let resolved = resolve_glob_to_workspace_relative( + pattern.as_str(), + package_dir, + workspace_root, + )?; + positive_globs.insert(resolved); } } } } - Self { includes_auto, positive_globs, negative_globs } + Ok(Self { includes_auto, positive_globs, negative_globs }) } } +/// Resolve a single glob pattern to be workspace-root-relative. +/// +/// The algorithm: +/// 1. Partition the glob into an invariant prefix and a variant part +/// 2. Join the invariant prefix with `package_dir` and clean the path +/// 3. Strip the `workspace_root` prefix from the cleaned path +/// 4. Re-escape the stripped prefix and rejoin with the variant +fn resolve_glob_to_workspace_relative( + pattern: &str, + package_dir: &AbsolutePath, + workspace_root: &AbsolutePath, +) -> Result { + use cow_utils::CowUtils as _; + use path_clean::PathClean as _; + + let glob = wax::Glob::new(pattern).map_err(|source| ResolveTaskConfigError::InvalidGlob { + pattern: Str::from(pattern), + source: Box::new(source), + })?; + let (invariant_prefix, variant) = glob.partition(); + + let joined = package_dir.as_path().join(&invariant_prefix).clean(); + let stripped = joined.strip_prefix(workspace_root.as_path()).map_err(|_| { + ResolveTaskConfigError::GlobOutsideWorkspace { pattern: Str::from(pattern) } + })?; + + // Re-escape the prefix path for use in a glob pattern + let stripped_str = stripped.to_str().ok_or_else(|| { + ResolveTaskConfigError::GlobOutsideWorkspace { pattern: Str::from(pattern) } + })?; + // Normalize backslashes to forward slashes for cross-platform compatibility + let escaped = wax::escape(stripped_str); + let escaped_prefix = escaped.cow_replace('\\', "/"); + + let result = match variant { + Some(variant_glob) if escaped_prefix.is_empty() => { + Str::from(variant_glob.to_string().as_str()) + } + Some(variant_glob) => vite_str::format!("{escaped_prefix}/{variant_glob}"), + None if escaped_prefix.is_empty() => Str::from("**"), + None => Str::from(escaped_prefix.as_ref()), + }; + + Ok(result) +} + #[derive(Debug, Clone, Serialize)] pub struct EnvConfig { /// environment variable names to be fingerprinted and passed to the task, with defaults populated @@ -169,6 +245,18 @@ pub enum ResolveTaskConfigError { /// Neither package.json script nor vite.config.* task define a command for the task #[error("Neither package.json script nor vite.config.* task define a command for the task")] NoCommand, + + /// A glob pattern resolves to a path outside the workspace root + #[error("glob pattern '{pattern}' resolves outside the workspace root")] + GlobOutsideWorkspace { pattern: Str }, + + /// A glob pattern is invalid + #[error("invalid glob pattern '{pattern}'")] + InvalidGlob { + pattern: Str, + #[source] + source: Box, + }, } impl ResolvedTaskConfig { @@ -176,15 +264,23 @@ impl ResolvedTaskConfig { /// /// Always resolves with caching enabled (default settings). /// The global cache config is applied at plan time, not here. - #[must_use] + /// + /// # Errors + /// + /// Returns [`ResolveTaskConfigError`] if glob resolution fails. pub fn resolve_package_json_script( package_dir: &Arc, package_json_script: &str, - ) -> Self { - Self { + workspace_root: &AbsolutePath, + ) -> Result { + Ok(Self { command: package_json_script.into(), - resolved_options: ResolvedTaskOptions::resolve(UserTaskOptions::default(), package_dir), - } + resolved_options: ResolvedTaskOptions::resolve( + UserTaskOptions::default(), + package_dir, + workspace_root, + )?, + }) } /// Resolves from user config, package dir, and package.json script (if any). @@ -197,6 +293,7 @@ impl ResolvedTaskConfig { user_config: UserTaskConfig, package_dir: &Arc, package_json_script: Option<&str>, + workspace_root: &AbsolutePath, ) -> Result { let command = match (&user_config.command, package_json_script) { (Some(_), Some(_)) => return Err(ResolveTaskConfigError::CommandConflict), @@ -206,7 +303,11 @@ impl ResolvedTaskConfig { }; Ok(Self { command: command.into(), - resolved_options: ResolvedTaskOptions::resolve(user_config.options, package_dir), + resolved_options: ResolvedTaskOptions::resolve( + user_config.options, + package_dir, + workspace_root, + )?, }) } } @@ -278,8 +379,25 @@ pub const DEFAULT_PASSTHROUGH_ENVS: &[&str] = &[ #[cfg(test)] mod tests { + use vite_path::AbsolutePathBuf; + use super::*; + #[expect(clippy::disallowed_types, reason = "PathBuf needed for AbsolutePathBuf::new in tests")] + fn test_paths() -> (AbsolutePathBuf, AbsolutePathBuf) { + if cfg!(windows) { + ( + AbsolutePathBuf::new("C:\\workspace\\packages\\my-pkg".into()).unwrap(), + AbsolutePathBuf::new("C:\\workspace".into()).unwrap(), + ) + } else { + ( + AbsolutePathBuf::new("/workspace/packages/my-pkg".into()).unwrap(), + AbsolutePathBuf::new("/workspace".into()).unwrap(), + ) + } + } + #[test] fn test_resolved_input_config_default_auto() { let config = ResolvedInputConfig::default_auto(); @@ -290,8 +408,9 @@ mod tests { #[test] fn test_resolved_input_config_from_none() { + let (pkg, ws) = test_paths(); // None means default to auto-inference - let config = ResolvedInputConfig::from_user_config(None); + let config = ResolvedInputConfig::from_user_config(None, &pkg, &ws).unwrap(); assert!(config.includes_auto); assert!(config.positive_globs.is_empty()); assert!(config.negative_globs.is_empty()); @@ -299,9 +418,10 @@ mod tests { #[test] fn test_resolved_input_config_empty_array() { + let (pkg, ws) = test_paths(); // Empty array means no inputs, inference disabled let user_inputs = vec![]; - let config = ResolvedInputConfig::from_user_config(Some(&user_inputs)); + let config = ResolvedInputConfig::from_user_config(Some(&user_inputs), &pkg, &ws).unwrap(); assert!(!config.includes_auto); assert!(config.positive_globs.is_empty()); assert!(config.negative_globs.is_empty()); @@ -309,8 +429,9 @@ mod tests { #[test] fn test_resolved_input_config_auto_only() { + let (pkg, ws) = test_paths(); let user_inputs = vec![UserInputEntry::Auto { auto: true }]; - let config = ResolvedInputConfig::from_user_config(Some(&user_inputs)); + let config = ResolvedInputConfig::from_user_config(Some(&user_inputs), &pkg, &ws).unwrap(); assert!(config.includes_auto); assert!(config.positive_globs.is_empty()); assert!(config.negative_globs.is_empty()); @@ -318,8 +439,9 @@ mod tests { #[test] fn test_resolved_input_config_auto_false_ignored() { + let (pkg, ws) = test_paths(); let user_inputs = vec![UserInputEntry::Auto { auto: false }]; - let config = ResolvedInputConfig::from_user_config(Some(&user_inputs)); + let config = ResolvedInputConfig::from_user_config(Some(&user_inputs), &pkg, &ws).unwrap(); assert!(!config.includes_auto); assert!(config.positive_globs.is_empty()); assert!(config.negative_globs.is_empty()); @@ -327,54 +449,81 @@ mod tests { #[test] fn test_resolved_input_config_globs_only() { + let (pkg, ws) = test_paths(); // Globs without auto means inference disabled let user_inputs = vec![ UserInputEntry::Glob("src/**/*.ts".into()), UserInputEntry::Glob("package.json".into()), ]; - let config = ResolvedInputConfig::from_user_config(Some(&user_inputs)); + let config = ResolvedInputConfig::from_user_config(Some(&user_inputs), &pkg, &ws).unwrap(); assert!(!config.includes_auto); assert_eq!(config.positive_globs.len(), 2); - assert!(config.positive_globs.contains("src/**/*.ts")); - assert!(config.positive_globs.contains("package.json")); + // Globs should now be workspace-root-relative + assert!(config.positive_globs.contains("packages/my-pkg/src/**/*.ts")); + assert!(config.positive_globs.contains("packages/my-pkg/package.json")); assert!(config.negative_globs.is_empty()); } #[test] fn test_resolved_input_config_negative_globs() { + let (pkg, ws) = test_paths(); let user_inputs = vec![ UserInputEntry::Glob("src/**".into()), UserInputEntry::Glob("!src/**/*.test.ts".into()), ]; - let config = ResolvedInputConfig::from_user_config(Some(&user_inputs)); + let config = ResolvedInputConfig::from_user_config(Some(&user_inputs), &pkg, &ws).unwrap(); assert!(!config.includes_auto); assert_eq!(config.positive_globs.len(), 1); - assert!(config.positive_globs.contains("src/**")); + assert!(config.positive_globs.contains("packages/my-pkg/src/**")); assert_eq!(config.negative_globs.len(), 1); - assert!(config.negative_globs.contains("src/**/*.test.ts")); // Without ! prefix + assert!(config.negative_globs.contains("packages/my-pkg/src/**/*.test.ts")); } #[test] fn test_resolved_input_config_mixed() { + let (pkg, ws) = test_paths(); let user_inputs = vec![ UserInputEntry::Glob("package.json".into()), UserInputEntry::Auto { auto: true }, UserInputEntry::Glob("!node_modules/**".into()), ]; - let config = ResolvedInputConfig::from_user_config(Some(&user_inputs)); + let config = ResolvedInputConfig::from_user_config(Some(&user_inputs), &pkg, &ws).unwrap(); assert!(config.includes_auto); assert_eq!(config.positive_globs.len(), 1); - assert!(config.positive_globs.contains("package.json")); + assert!(config.positive_globs.contains("packages/my-pkg/package.json")); assert_eq!(config.negative_globs.len(), 1); - assert!(config.negative_globs.contains("node_modules/**")); + assert!(config.negative_globs.contains("packages/my-pkg/node_modules/**")); } #[test] fn test_resolved_input_config_globs_with_auto() { + let (pkg, ws) = test_paths(); // Globs with auto keeps inference enabled let user_inputs = vec![UserInputEntry::Glob("src/**/*.ts".into()), UserInputEntry::Auto { auto: true }]; - let config = ResolvedInputConfig::from_user_config(Some(&user_inputs)); + let config = ResolvedInputConfig::from_user_config(Some(&user_inputs), &pkg, &ws).unwrap(); assert!(config.includes_auto); } + + #[test] + fn test_resolved_input_config_dotdot_resolution() { + let (pkg, ws) = test_paths(); + let user_inputs = vec![UserInputEntry::Glob("../shared/src/**".into())]; + let config = ResolvedInputConfig::from_user_config(Some(&user_inputs), &pkg, &ws).unwrap(); + assert_eq!(config.positive_globs.len(), 1); + assert!( + config.positive_globs.contains("packages/shared/src/**"), + "expected 'packages/shared/src/**', got {:?}", + config.positive_globs + ); + } + + #[test] + fn test_resolved_input_config_outside_workspace_error() { + let (pkg, ws) = test_paths(); + let user_inputs = vec![UserInputEntry::Glob("../../../outside/**".into())]; + let result = ResolvedInputConfig::from_user_config(Some(&user_inputs), &pkg, &ws); + assert!(result.is_err()); + assert!(matches!(result.unwrap_err(), ResolveTaskConfigError::GlobOutsideWorkspace { .. })); + } } diff --git a/crates/vite_task_graph/src/lib.rs b/crates/vite_task_graph/src/lib.rs index d0f91a81..91016619 100644 --- a/crates/vite_task_graph/src/lib.rs +++ b/crates/vite_task_graph/src/lib.rs @@ -276,6 +276,7 @@ impl IndexedTaskGraph { task_user_config, &package_dir, package_json_script, + &workspace_root.path, ) .map_err(|err| TaskGraphLoadError::ResolveConfigError { error: err, @@ -307,7 +308,16 @@ impl IndexedTaskGraph { let resolved_config = ResolvedTaskConfig::resolve_package_json_script( &package_dir, package_json_script, - ); + &workspace_root.path, + ) + .map_err(|err| TaskGraphLoadError::ResolveConfigError { + error: err, + task_display: TaskDisplay { + package_name: package.package_json.name.clone(), + task_name: script_name.into(), + package_path: Arc::clone(&package_dir), + }, + })?; let node_index = task_graph.add_node(TaskNode { task_display: TaskDisplay { package_name: package.package_json.name.clone(), diff --git a/crates/vite_task_plan/src/cache_metadata.rs b/crates/vite_task_plan/src/cache_metadata.rs index fe73c1fa..15a0b85b 100644 --- a/crates/vite_task_plan/src/cache_metadata.rs +++ b/crates/vite_task_plan/src/cache_metadata.rs @@ -2,7 +2,7 @@ use std::sync::Arc; use bincode::{Decode, Encode}; use serde::{Deserialize, Serialize}; -use vite_path::{AbsolutePath, RelativePathBuf}; +use vite_path::RelativePathBuf; use vite_str::{self, Str}; use vite_task_graph::config::ResolvedInputConfig; @@ -46,10 +46,6 @@ pub struct CacheMetadata { /// Resolved input configuration for cache fingerprinting. /// Used at execution time to determine what files to track. pub input_config: ResolvedInputConfig, - - /// Absolute base directory for glob patterns. - /// This is the package path where the task is defined. - pub glob_base: Arc, } /// Fingerprint for spawn execution that affects caching. diff --git a/crates/vite_task_plan/src/error.rs b/crates/vite_task_plan/src/error.rs index c2dd9090..fea79d03 100644 --- a/crates/vite_task_plan/src/error.rs +++ b/crates/vite_task_plan/src/error.rs @@ -137,6 +137,13 @@ pub enum Error { #[error("Failed to resolve environment variables")] ResolveEnv(#[source] ResolveEnvError), + #[error("Failed to resolve task configuration")] + ResolveTaskConfig( + #[source] + #[from] + vite_task_graph::config::ResolveTaskConfigError, + ), + #[error("No task specifier provided for 'run' command")] MissingTaskSpecifier, diff --git a/crates/vite_task_plan/src/plan.rs b/crates/vite_task_plan/src/plan.rs index c7126162..f045e53e 100644 --- a/crates/vite_task_plan/src/plan.rs +++ b/crates/vite_task_plan/src/plan.rs @@ -285,7 +285,6 @@ async fn plan_task_as_execution_node( &script_command.envs, program_path, script_command.args, - package_path, )?; ExecutionItemKind::Leaf(LeafExecutionKind::Spawn(spawn_execution)) } @@ -351,7 +350,6 @@ async fn plan_task_as_execution_node( context.envs(), Arc::clone(&*SHELL_PROGRAM_PATH), SHELL_ARGS.iter().map(|s| Str::from(*s)).chain(std::iter::once(script)).collect(), - package_path, )?; items.push(ExecutionItem { execution_item_display, @@ -397,25 +395,28 @@ fn resolve_synthetic_cache_config( parent: ParentCacheConfig, synthetic_cache_config: UserCacheConfig, cwd: &Arc, -) -> Option { + workspace_path: &AbsolutePath, +) -> Result, Error> { match parent { ParentCacheConfig::None => { // Top-level: resolve from synthetic's own config - ResolvedTaskOptions::resolve( + Ok(ResolvedTaskOptions::resolve( UserTaskOptions { cache_config: synthetic_cache_config, cwd_relative_to_package: None, depends_on: None, }, cwd, + workspace_path, ) - .cache_config + .map_err(Error::ResolveTaskConfig)? + .cache_config) } - ParentCacheConfig::Disabled => Option::None, + ParentCacheConfig::Disabled => Ok(Option::None), ParentCacheConfig::Inherited(mut parent_config) => { // Cache is enabled only if both parent and synthetic want it. // Merge synthetic's additions into parent's config. - match synthetic_cache_config { + Ok(match synthetic_cache_config { UserCacheConfig::Disabled { .. } => Option::None, UserCacheConfig::Enabled { enabled_cache_config, .. } => { if let Some(extra_envs) = enabled_cache_config.envs { @@ -426,7 +427,7 @@ fn resolve_synthetic_cache_config( } Some(parent_config) } - } + }) } } } @@ -444,7 +445,7 @@ pub fn plan_synthetic_request( let program_path = which(&program, &envs, cwd)?; let resolved_cache_config = - resolve_synthetic_cache_config(parent_cache_config, cache_config, cwd); + resolve_synthetic_cache_config(parent_cache_config, cache_config, cwd, workspace_path)?; let resolved_options = ResolvedTaskOptions { cwd: Arc::clone(cwd), cache_config: resolved_cache_config }; @@ -456,7 +457,6 @@ pub fn plan_synthetic_request( &envs, program_path, args, - cwd, // For synthetic requests, the package path is the cwd ) } @@ -482,7 +482,6 @@ fn strip_prefix_for_cache( clippy::needless_pass_by_value, reason = "program_path ownership is needed for Arc construction" )] -#[expect(clippy::too_many_arguments, reason = "internal function with closely-related parameters")] fn plan_spawn_execution( workspace_path: &Arc, execution_cache_key: Option, @@ -491,7 +490,6 @@ fn plan_spawn_execution( envs: &FxHashMap, Arc>, program_path: Arc, args: Arc<[Str]>, - package_path: &Arc, ) -> Result { // all envs available in the current context let mut all_envs = envs.clone(); @@ -555,7 +553,6 @@ fn plan_spawn_execution( spawn_fingerprint, execution_cache_key, input_config: cache_config.input_config.clone(), - glob_base: Arc::clone(package_path), }); } } diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/additional-envs/snapshots/query - env-test synthetic task in user task.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/additional-envs/snapshots/query - env-test synthetic task in user task.snap index a85032b1..ab8ef29f 100644 --- a/crates/vite_task_plan/tests/plan_snapshots/fixtures/additional-envs/snapshots/query - env-test synthetic task in user task.snap +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/additional-envs/snapshots/query - env-test synthetic task in user task.snap @@ -65,8 +65,7 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/additional-envs "includes_auto": true, "positive_globs": [], "negative_globs": [] - }, - "glob_base": "/" + } }, "spawn_command": { "program_path": "/node_modules/.bin/print-env", diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/cache-cli-override/snapshots/query - --cache enables script caching.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/cache-cli-override/snapshots/query - --cache enables script caching.snap index 39fc2f69..efb6c31e 100644 --- a/crates/vite_task_plan/tests/plan_snapshots/fixtures/cache-cli-override/snapshots/query - --cache enables script caching.snap +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/cache-cli-override/snapshots/query - --cache enables script caching.snap @@ -65,8 +65,7 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/cache-cli-overri "includes_auto": true, "positive_globs": [], "negative_globs": [] - }, - "glob_base": "/" + } }, "spawn_command": { "program_path": "/node_modules/.bin/print-file", diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/cache-cli-override/snapshots/query - --cache enables task caching even when cache.tasks is false.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/cache-cli-override/snapshots/query - --cache enables task caching even when cache.tasks is false.snap index 860ead0f..f555e0db 100644 --- a/crates/vite_task_plan/tests/plan_snapshots/fixtures/cache-cli-override/snapshots/query - --cache enables task caching even when cache.tasks is false.snap +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/cache-cli-override/snapshots/query - --cache enables task caching even when cache.tasks is false.snap @@ -65,8 +65,7 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/cache-cli-overri "includes_auto": true, "positive_globs": [], "negative_globs": [] - }, - "glob_base": "/" + } }, "spawn_command": { "program_path": "/node_modules/.bin/print-file", diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/cache-cli-override/snapshots/query - --cache on task with per-task cache true enables caching.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/cache-cli-override/snapshots/query - --cache on task with per-task cache true enables caching.snap index a54be358..b4d5824e 100644 --- a/crates/vite_task_plan/tests/plan_snapshots/fixtures/cache-cli-override/snapshots/query - --cache on task with per-task cache true enables caching.snap +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/cache-cli-override/snapshots/query - --cache on task with per-task cache true enables caching.snap @@ -65,8 +65,7 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/cache-cli-overri "includes_auto": true, "positive_globs": [], "negative_globs": [] - }, - "glob_base": "/" + } }, "spawn_command": { "program_path": "/node_modules/.bin/print-file", diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/cache-keys/snapshots/query - echo and lint with extra args.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/cache-keys/snapshots/query - echo and lint with extra args.snap index 94656ffb..97c9ecb4 100644 --- a/crates/vite_task_plan/tests/plan_snapshots/fixtures/cache-keys/snapshots/query - echo and lint with extra args.snap +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/cache-keys/snapshots/query - echo and lint with extra args.snap @@ -93,8 +93,7 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/cache-keys "includes_auto": true, "positive_globs": [], "negative_globs": [] - }, - "glob_base": "/" + } }, "spawn_command": { "program_path": "/node_modules/.bin/oxlint", diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/cache-keys/snapshots/query - lint and echo with extra args.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/cache-keys/snapshots/query - lint and echo with extra args.snap index c44a4ccb..59e7604a 100644 --- a/crates/vite_task_plan/tests/plan_snapshots/fixtures/cache-keys/snapshots/query - lint and echo with extra args.snap +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/cache-keys/snapshots/query - lint and echo with extra args.snap @@ -63,8 +63,7 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/cache-keys "includes_auto": true, "positive_globs": [], "negative_globs": [] - }, - "glob_base": "/" + } }, "spawn_command": { "program_path": "/node_modules/.bin/oxlint", diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/cache-keys/snapshots/query - normal task with extra args.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/cache-keys/snapshots/query - normal task with extra args.snap index c5214c24..0120e12d 100644 --- a/crates/vite_task_plan/tests/plan_snapshots/fixtures/cache-keys/snapshots/query - normal task with extra args.snap +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/cache-keys/snapshots/query - normal task with extra args.snap @@ -67,8 +67,7 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/cache-keys "includes_auto": true, "positive_globs": [], "negative_globs": [] - }, - "glob_base": "/" + } }, "spawn_command": { "program_path": "/node_modules/.bin/print-file", diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/cache-keys/snapshots/query - synthetic task in user task with cwd.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/cache-keys/snapshots/query - synthetic task in user task with cwd.snap index b20de8cc..0cd398a9 100644 --- a/crates/vite_task_plan/tests/plan_snapshots/fixtures/cache-keys/snapshots/query - synthetic task in user task with cwd.snap +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/cache-keys/snapshots/query - synthetic task in user task with cwd.snap @@ -63,8 +63,7 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/cache-keys "includes_auto": true, "positive_globs": [], "negative_globs": [] - }, - "glob_base": "/" + } }, "spawn_command": { "program_path": "/node_modules/.bin/oxlint", diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/cache-keys/snapshots/query - synthetic task in user task.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/cache-keys/snapshots/query - synthetic task in user task.snap index f2970ea8..8e413cc9 100644 --- a/crates/vite_task_plan/tests/plan_snapshots/fixtures/cache-keys/snapshots/query - synthetic task in user task.snap +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/cache-keys/snapshots/query - synthetic task in user task.snap @@ -62,8 +62,7 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/cache-keys "includes_auto": true, "positive_globs": [], "negative_globs": [] - }, - "glob_base": "/" + } }, "spawn_command": { "program_path": "/node_modules/.bin/oxlint", diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/cache-keys/snapshots/query - synthetic task with extra args in user task.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/cache-keys/snapshots/query - synthetic task with extra args in user task.snap index 5452c492..2de49ed2 100644 --- a/crates/vite_task_plan/tests/plan_snapshots/fixtures/cache-keys/snapshots/query - synthetic task with extra args in user task.snap +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/cache-keys/snapshots/query - synthetic task with extra args in user task.snap @@ -67,8 +67,7 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/cache-keys "includes_auto": true, "positive_globs": [], "negative_globs": [] - }, - "glob_base": "/" + } }, "spawn_command": { "program_path": "/node_modules/.bin/oxlint", diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/cache-scripts-task-override/snapshots/query - task cached by default.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/cache-scripts-task-override/snapshots/query - task cached by default.snap index 0f2a5fdd..53cb5467 100644 --- a/crates/vite_task_plan/tests/plan_snapshots/fixtures/cache-scripts-task-override/snapshots/query - task cached by default.snap +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/cache-scripts-task-override/snapshots/query - task cached by default.snap @@ -64,8 +64,7 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/cache-scripts-ta "includes_auto": true, "positive_globs": [], "negative_globs": [] - }, - "glob_base": "/" + } }, "spawn_command": { "program_path": "/node_modules/.bin/print-file", diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/cache-scripts-task-override/snapshots/query - task with command cached by default.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/cache-scripts-task-override/snapshots/query - task with command cached by default.snap index 7f9d9f19..56d84f3e 100644 --- a/crates/vite_task_plan/tests/plan_snapshots/fixtures/cache-scripts-task-override/snapshots/query - task with command cached by default.snap +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/cache-scripts-task-override/snapshots/query - task with command cached by default.snap @@ -64,8 +64,7 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/cache-scripts-ta "includes_auto": true, "positive_globs": [], "negative_globs": [] - }, - "glob_base": "/" + } }, "spawn_command": { "program_path": "/node_modules/.bin/print-file", diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/cache-true-no-force-enable/snapshots/query - script cached when global cache true.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/cache-true-no-force-enable/snapshots/query - script cached when global cache true.snap index c38c9a48..a002c6f8 100644 --- a/crates/vite_task_plan/tests/plan_snapshots/fixtures/cache-true-no-force-enable/snapshots/query - script cached when global cache true.snap +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/cache-true-no-force-enable/snapshots/query - script cached when global cache true.snap @@ -64,8 +64,7 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/cache-true-no-fo "includes_auto": true, "positive_globs": [], "negative_globs": [] - }, - "glob_base": "/" + } }, "spawn_command": { "program_path": "/node_modules/.bin/print-file", diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/cache-true-no-force-enable/snapshots/query - task cached when global cache true.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/cache-true-no-force-enable/snapshots/query - task cached when global cache true.snap index cff71117..d3a055e9 100644 --- a/crates/vite_task_plan/tests/plan_snapshots/fixtures/cache-true-no-force-enable/snapshots/query - task cached when global cache true.snap +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/cache-true-no-force-enable/snapshots/query - task cached when global cache true.snap @@ -64,8 +64,7 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/cache-true-no-fo "includes_auto": true, "positive_globs": [], "negative_globs": [] - }, - "glob_base": "/" + } }, "spawn_command": { "program_path": "/node_modules/.bin/print-file", diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/cd-in-scripts/snapshots/query - cd before vp lint should put synthetic task under cwd.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/cd-in-scripts/snapshots/query - cd before vp lint should put synthetic task under cwd.snap index 6e65544c..756a1801 100644 --- a/crates/vite_task_plan/tests/plan_snapshots/fixtures/cd-in-scripts/snapshots/query - cd before vp lint should put synthetic task under cwd.snap +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/cd-in-scripts/snapshots/query - cd before vp lint should put synthetic task under cwd.snap @@ -62,8 +62,7 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/cd-in-scripts "includes_auto": true, "positive_globs": [], "negative_globs": [] - }, - "glob_base": "/src" + } }, "spawn_command": { "program_path": "/node_modules/.bin/oxlint", diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/cd-in-scripts/snapshots/query - cd before vp run should not affect expanded task cwd.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/cd-in-scripts/snapshots/query - cd before vp run should not affect expanded task cwd.snap index 3abeecca..8e8428c2 100644 --- a/crates/vite_task_plan/tests/plan_snapshots/fixtures/cd-in-scripts/snapshots/query - cd before vp run should not affect expanded task cwd.snap +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/cd-in-scripts/snapshots/query - cd before vp run should not affect expanded task cwd.snap @@ -89,8 +89,7 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/cd-in-scripts "includes_auto": true, "positive_globs": [], "negative_globs": [] - }, - "glob_base": "/" + } }, "spawn_command": { "program_path": "/node_modules/.bin/print-file", diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/nested-cache-override/snapshots/query - nested --cache enables inner task caching.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/nested-cache-override/snapshots/query - nested --cache enables inner task caching.snap index ef323211..3af88331 100644 --- a/crates/vite_task_plan/tests/plan_snapshots/fixtures/nested-cache-override/snapshots/query - nested --cache enables inner task caching.snap +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/nested-cache-override/snapshots/query - nested --cache enables inner task caching.snap @@ -89,8 +89,7 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/nested-cache-ove "includes_auto": true, "positive_globs": [], "negative_globs": [] - }, - "glob_base": "/" + } }, "spawn_command": { "program_path": "/node_modules/.bin/print-file", diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/nested-cache-override/snapshots/query - outer --cache propagates to nested run without flags.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/nested-cache-override/snapshots/query - outer --cache propagates to nested run without flags.snap index 8d4afb06..42044a23 100644 --- a/crates/vite_task_plan/tests/plan_snapshots/fixtures/nested-cache-override/snapshots/query - outer --cache propagates to nested run without flags.snap +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/nested-cache-override/snapshots/query - outer --cache propagates to nested run without flags.snap @@ -90,8 +90,7 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/nested-cache-ove "includes_auto": true, "positive_globs": [], "negative_globs": [] - }, - "glob_base": "/" + } }, "spawn_command": { "program_path": "/node_modules/.bin/print-file", diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/nested-cache-override/snapshots/query - outer --no-cache does not propagate into nested --cache.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/nested-cache-override/snapshots/query - outer --no-cache does not propagate into nested --cache.snap index 4f48b6e6..81d55603 100644 --- a/crates/vite_task_plan/tests/plan_snapshots/fixtures/nested-cache-override/snapshots/query - outer --no-cache does not propagate into nested --cache.snap +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/nested-cache-override/snapshots/query - outer --no-cache does not propagate into nested --cache.snap @@ -90,8 +90,7 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/nested-cache-ove "includes_auto": true, "positive_globs": [], "negative_globs": [] - }, - "glob_base": "/" + } }, "spawn_command": { "program_path": "/node_modules/.bin/print-file", diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/shell-fallback/snapshots/query - shell fallback for pipe command.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/shell-fallback/snapshots/query - shell fallback for pipe command.snap index 40f4caac..056a81e0 100644 --- a/crates/vite_task_plan/tests/plan_snapshots/fixtures/shell-fallback/snapshots/query - shell fallback for pipe command.snap +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/shell-fallback/snapshots/query - shell fallback for pipe command.snap @@ -65,8 +65,7 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/shell-fallback "includes_auto": true, "positive_globs": [], "negative_globs": [] - }, - "glob_base": "/" + } }, "spawn_command": { "program_path": "", diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/synthetic-cache-disabled/snapshots/query - parent cache false does not affect expanded query tasks.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/synthetic-cache-disabled/snapshots/query - parent cache false does not affect expanded query tasks.snap index 45bf74bf..c0715ca4 100644 --- a/crates/vite_task_plan/tests/plan_snapshots/fixtures/synthetic-cache-disabled/snapshots/query - parent cache false does not affect expanded query tasks.snap +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/synthetic-cache-disabled/snapshots/query - parent cache false does not affect expanded query tasks.snap @@ -87,8 +87,7 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/synthetic-cache- "includes_auto": true, "positive_globs": [], "negative_globs": [] - }, - "glob_base": "/" + } }, "spawn_command": { "program_path": "/node_modules/.bin/oxlint", diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/synthetic-cache-disabled/snapshots/query - script cache false does not affect expanded synthetic cache.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/synthetic-cache-disabled/snapshots/query - script cache false does not affect expanded synthetic cache.snap index 73743e40..dd59626d 100644 --- a/crates/vite_task_plan/tests/plan_snapshots/fixtures/synthetic-cache-disabled/snapshots/query - script cache false does not affect expanded synthetic cache.snap +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/synthetic-cache-disabled/snapshots/query - script cache false does not affect expanded synthetic cache.snap @@ -87,8 +87,7 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/synthetic-cache- "includes_auto": true, "positive_globs": [], "negative_globs": [] - }, - "glob_base": "/" + } }, "spawn_command": { "program_path": "/node_modules/.bin/oxlint", diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/synthetic-cache-disabled/snapshots/query - task passThroughEnvs inherited by synthetic.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/synthetic-cache-disabled/snapshots/query - task passThroughEnvs inherited by synthetic.snap index 4f1b6603..b4d5d242 100644 --- a/crates/vite_task_plan/tests/plan_snapshots/fixtures/synthetic-cache-disabled/snapshots/query - task passThroughEnvs inherited by synthetic.snap +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/synthetic-cache-disabled/snapshots/query - task passThroughEnvs inherited by synthetic.snap @@ -63,8 +63,7 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/synthetic-cache- "includes_auto": true, "positive_globs": [], "negative_globs": [] - }, - "glob_base": "/" + } }, "spawn_command": { "program_path": "/node_modules/.bin/oxlint", diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/synthetic-cache-disabled/snapshots/query - task with cache true enables synthetic cache.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/synthetic-cache-disabled/snapshots/query - task with cache true enables synthetic cache.snap index bf28599b..782d7ce9 100644 --- a/crates/vite_task_plan/tests/plan_snapshots/fixtures/synthetic-cache-disabled/snapshots/query - task with cache true enables synthetic cache.snap +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/synthetic-cache-disabled/snapshots/query - task with cache true enables synthetic cache.snap @@ -62,8 +62,7 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/synthetic-cache- "includes_auto": true, "positive_globs": [], "negative_globs": [] - }, - "glob_base": "/" + } }, "spawn_command": { "program_path": "/node_modules/.bin/oxlint", diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/synthetic-in-subpackage/snapshots/query - synthetic-in-subpackage.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/synthetic-in-subpackage/snapshots/query - synthetic-in-subpackage.snap index 8342c183..34cf09c1 100644 --- a/crates/vite_task_plan/tests/plan_snapshots/fixtures/synthetic-in-subpackage/snapshots/query - synthetic-in-subpackage.snap +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/synthetic-in-subpackage/snapshots/query - synthetic-in-subpackage.snap @@ -87,8 +87,7 @@ input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/synthetic-in-sub "includes_auto": true, "positive_globs": [], "negative_globs": [] - }, - "glob_base": "/packages/a" + } }, "spawn_command": { "program_path": "/node_modules/.bin/oxlint", diff --git a/hidden-dazzling-spring.md b/hidden-dazzling-spring.md deleted file mode 100644 index b89e78a5..00000000 --- a/hidden-dazzling-spring.md +++ /dev/null @@ -1,115 +0,0 @@ -# Plan: Resolve Globs to Workspace-Root-Relative at Task Graph Stage - -## Context - -`ResolvedInputConfig` currently stores raw user-provided glob strings (e.g. `src/**/*.ts`, `../shared/dist/**`) relative to the package directory. These are resolved at execution time using the now-removed `AnchoredGlob` type. The code is broken — `AnchoredGlob` was removed from `vite_glob` (commit f880ca10) but 3 files in `vite_task` still reference it. - -Moving glob resolution to the task_graph stage makes globs workspace-root-relative, eliminating `AnchoredGlob`, `glob_base`, and `base_dir` from the execution pipeline. - -## Algorithm: Resolve a single glob to workspace-root-relative - -``` -partition(glob) → (invariant_prefix, variant) -joined = package_dir.join(invariant_prefix) -cleaned = path_clean::clean(joined) -stripped = cleaned.strip_prefix(workspace_root) // error if fails -result = wax::escape(stripped) + "/" + variant // or just escaped if no variant -``` - -`AbsolutePath::strip_prefix` already normalizes separators, so no special Windows handling needed. - ---- - -## Steps - -### 1. Add deps to `vite_task_graph` - -**File:** `crates/vite_task_graph/Cargo.toml` - -Add `wax` and `path-clean` (already workspace deps). - -### 2. Add glob resolution to `ResolvedInputConfig` - -**File:** `crates/vite_task_graph/src/config/mod.rs` - -- Add error variants to `ResolveTaskConfigError`: - - `GlobOutsideWorkspace { pattern: Str }` — "glob pattern '...' resolves outside the workspace root" - - `InvalidGlob { pattern: Str, source: wax::BuildError }` - -- Add helper `resolve_glob_to_workspace_relative(pattern, package_dir, workspace_root) -> Result` implementing the algorithm above. - -- Change `from_user_config` signature to accept `package_dir` and `workspace_root`, return `Result`. Each raw glob goes through `resolve_glob_to_workspace_relative`. - -- Change `ResolvedTaskOptions::resolve()` to accept `workspace_root`, return `Result`. - -- Change `ResolvedTaskConfig::resolve()` and `resolve_package_json_script()` to accept `workspace_root`, return `Result`. - -### 3. Thread `workspace_root` in `IndexedTaskGraph::load()` - -**File:** `crates/vite_task_graph/src/lib.rs` - -Pass `&workspace_root.path` to `ResolvedTaskConfig::resolve()` (line ~275) and `resolve_package_json_script()` (line ~307). Propagate the new `Result`. - -### 4. Remove `glob_base` from `CacheMetadata` - -**File:** `crates/vite_task_plan/src/cache_metadata.rs` - -Remove `glob_base: Arc` field. - -### 5. Remove `glob_base` from plan construction - -**File:** `crates/vite_task_plan/src/plan.rs` - -Remove `glob_base: Arc::clone(package_path)` at line ~558. - -### 6. Remove `glob_base` from `CacheEntryKey` + bump DB version - -**File:** `crates/vite_task/src/session/cache/mod.rs` - -- Remove `glob_base: RelativePathBuf` from `CacheEntryKey` (line 43). -- Simplify `from_metadata()` — remove glob_base strip_prefix logic (lines 56-66). -- Bump cache version: `1..=8` → `1..=9`, `9 => break` → `10 => break`, new DB `PRAGMA user_version = 10`, unrecognized `10..` → `11..`. - -### 7. Simplify `compute_globbed_inputs()` - -**File:** `crates/vite_task/src/session/execute/glob_inputs.rs` - -- Remove `use vite_glob::AnchoredGlob` import. -- Remove `base_dir` parameter — globs are already workspace-root-relative. -- For each positive glob: `Glob::new(pattern).walk(workspace_root).not(negatives)` — `.not()` supports directory pruning for efficiency. No partition/join/clean. -- Parse all negative globs upfront as `Vec>` and pass to `.not()` for each positive walk. - -### 8. Simplify fspy filtering in `spawn.rs` - -**File:** `crates/vite_task/src/session/execute/spawn.rs` - -- Remove `use vite_glob::AnchoredGlob`. -- Change `resolved_negatives: &[AnchoredGlob]` → `resolved_negatives: &[wax::Glob<'static>]`. -- At lines 216-224: match `relative_path` directly against negative globs (both are workspace-relative). Remove `path_clean`, `workspace_root.join`, `AbsolutePath::new`. - -### 9. Simplify `execute_spawn()` in `mod.rs` - -**File:** `crates/vite_task/src/session/execute/mod.rs` - -- Remove `resolve_negative_globs()` function (lines 425-434). -- Update `compute_globbed_inputs` call: remove `cache_metadata.glob_base`, pass `cache_base_path` as workspace root. -- Build negative globs inline: `negative_globs.iter().map(|p| Glob::new(p).into_owned()).collect()`. - -### 10. Update tests - -- **`config/mod.rs` tests**: Add `package_dir` + `workspace_root` params. Assert workspace-root-relative patterns. Add test for `..` resolution and outside-workspace error. -- **`glob_inputs.rs` tests**: Remove `base_dir` param, pass workspace-root-relative globs. -- **Plan snapshots**: `INSTA_UPDATE=always cargo test -p vite_task_plan --test plan_snapshots` (removes `glob_base` lines). -- **E2E snapshots**: `INSTA_UPDATE=always cargo test -p vite_task_bin --test e2e_snapshots`. - ---- - -## Verification - -```bash -cargo check --all-targets -cargo test -INSTA_UPDATE=always cargo test -p vite_task_plan --test plan_snapshots -INSTA_UPDATE=always cargo test -p vite_task_bin --test e2e_snapshots -just lint -``` From a7380207ccc6105c55db30168925d6a1fdf6d281 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 8 Mar 2026 11:58:20 +0000 Subject: [PATCH 17/32] Clean fspy paths at strip_path_prefix site, simplify downstream code Move path_clean::PathClean normalization into the strip_path_prefix callback so all fspy-reported paths are clean from the start. This removes the need for separate cleaning in the negative glob filter and in PostRunFingerprint::create. https://claude.ai/code/session_01PR9yhnScRoVoHUcviV47u5 --- .../vite_task/src/session/execute/fingerprint.rs | 16 ++-------------- crates/vite_task/src/session/execute/spawn.rs | 16 ++++++---------- 2 files changed, 8 insertions(+), 24 deletions(-) diff --git a/crates/vite_task/src/session/execute/fingerprint.rs b/crates/vite_task/src/session/execute/fingerprint.rs index 08e0e4f2..bbb28b9e 100644 --- a/crates/vite_task/src/session/execute/fingerprint.rs +++ b/crates/vite_task/src/session/execute/fingerprint.rs @@ -84,21 +84,9 @@ impl PostRunFingerprint { let inferred_inputs = inferred_path_reads .par_iter() .map(|(relative_path, path_read)| { - // Clean the absolute path to normalize `..` from fspy-tracked paths - // (e.g., `packages/sub-pkg/../shared/dist/output.js`). - let cleaned_abs = - path_clean::PathClean::clean(base_dir.join(relative_path).as_path()); - - // Derive a cleaned workspace-relative key so stored paths are normalized - let clean_key = cleaned_abs - .strip_prefix(base_dir.as_path()) - .ok() - .and_then(|p| RelativePathBuf::new(p).ok()) - .unwrap_or_else(|| relative_path.clone()); - - let full_path = Arc::::from(base_dir.join(&clean_key)); + let full_path = Arc::::from(base_dir.join(relative_path)); let fingerprint = fingerprint_path(&full_path, *path_read)?; - Ok((clean_key, fingerprint)) + Ok((relative_path.clone(), fingerprint)) }) .collect::>>()?; diff --git a/crates/vite_task/src/session/execute/spawn.rs b/crates/vite_task/src/session/execute/spawn.rs index ca49e091..e0f9f0ed 100644 --- a/crates/vite_task/src/session/execute/spawn.rs +++ b/crates/vite_task/src/session/execute/spawn.rs @@ -190,6 +190,8 @@ pub async fn spawn_with_tracking( let path_writes = &mut path_accesses.path_writes; for access in termination.path_accesses.iter() { + // Clean the path to normalize `..` components since 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; @@ -197,7 +199,8 @@ pub async fn spawn_with_tracking( // 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. - RelativePathBuf::new(stripped_path).ok() + let cleaned = path_clean::PathClean::clean(stripped_path); + RelativePathBuf::new(cleaned).ok() }); let Some(relative_path) = relative_path else { @@ -211,15 +214,8 @@ pub async fn spawn_with_tracking( } // Filter against resolved negative globs (both are workspace-root-relative). - // Clean the relative path to normalize `..` components since fspy may report - // paths like `packages/sub-pkg/../shared/dist/output.js`. - if !resolved_negatives.is_empty() { - let cleaned = path_clean::PathClean::clean(relative_path.as_path()); - if let Some(cleaned_str) = cleaned.to_str() - && resolved_negatives.iter().any(|neg| neg.is_match(cleaned_str)) - { - continue; - } + if resolved_negatives.iter().any(|neg| neg.is_match(relative_path.as_str())) { + continue; } if access.mode.contains(AccessMode::READ) { From 078f722c4d7664304260db8b6fccec6d96fb81be Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 8 Mar 2026 11:59:38 +0000 Subject: [PATCH 18/32] Fold all fspy path filtering into strip_path_prefix callback Move .git filtering and negative glob filtering into the strip_path_prefix callback alongside path cleaning, so rejected paths return None immediately in one pass. https://claude.ai/code/session_01PR9yhnScRoVoHUcviV47u5 --- crates/vite_task/src/session/execute/spawn.rs | 29 ++++++++++--------- 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/crates/vite_task/src/session/execute/spawn.rs b/crates/vite_task/src/session/execute/spawn.rs index e0f9f0ed..93af2012 100644 --- a/crates/vite_task/src/session/execute/spawn.rs +++ b/crates/vite_task/src/session/execute/spawn.rs @@ -190,8 +190,8 @@ pub async fn spawn_with_tracking( let path_writes = &mut path_accesses.path_writes; for access in termination.path_accesses.iter() { - // Clean the path to normalize `..` components since fspy may report - // paths like `packages/sub-pkg/../shared/dist/output.js`. + // Strip workspace root, clean `..` components, and filter 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; @@ -200,24 +200,25 @@ pub async fn spawn_with_tracking( // For example: c:\workspace\subdir\c:\workspace\subdir // Just ignore those accesses. let cleaned = path_clean::PathClean::clean(stripped_path); - RelativePathBuf::new(cleaned).ok() + let relative = RelativePathBuf::new(cleaned).ok()?; + + // Skip .git directory accesses (workaround for tools like oxlint) + if relative.as_path().strip_prefix(".git").is_ok() { + return None; + } + + // Filter against resolved negative globs (both are workspace-root-relative) + if resolved_negatives.iter().any(|neg| neg.is_match(relative.as_str())) { + return None; + } + + Some(relative) }); let Some(relative_path) = relative_path else { - // Ignore accesses outside the workspace continue; }; - // Skip .git directory accesses (workaround for tools like oxlint) - if relative_path.as_path().strip_prefix(".git").is_ok() { - continue; - } - - // Filter against resolved negative globs (both are workspace-root-relative). - if resolved_negatives.iter().any(|neg| neg.is_match(relative_path.as_str())) { - continue; - } - if access.mode.contains(AccessMode::READ) { path_reads.entry(relative_path.clone()).or_insert(PathRead { read_dir_entries: false }); } From f3440e4188dfc6906469a2e52cf686ca2f8eaeff Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 8 Mar 2026 12:05:38 +0000 Subject: [PATCH 19/32] Keep original fspy path for fingerprinting, only clean for glob matching The cleaned path (with `..` normalized) is used solely for matching against negative globs. The original stripped path is returned and stored in the fingerprint, keeping create/validate consistent. Restore fingerprint.rs to use the path as-is since it already contains the original fspy-reported relative path. https://claude.ai/code/session_01PR9yhnScRoVoHUcviV47u5 --- crates/vite_task/src/session/execute/spawn.rs | 16 +++++++++++----- ...ss on non-excluded sibling inferred file.snap | 2 +- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/crates/vite_task/src/session/execute/spawn.rs b/crates/vite_task/src/session/execute/spawn.rs index 93af2012..c3a5bdcf 100644 --- a/crates/vite_task/src/session/execute/spawn.rs +++ b/crates/vite_task/src/session/execute/spawn.rs @@ -199,17 +199,23 @@ pub async fn spawn_with_tracking( // 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. - let cleaned = path_clean::PathClean::clean(stripped_path); - let relative = RelativePathBuf::new(cleaned).ok()?; + let relative = RelativePathBuf::new(stripped_path).ok()?; // Skip .git directory accesses (workaround for tools like oxlint) if relative.as_path().strip_prefix(".git").is_ok() { return None; } - // Filter against resolved negative globs (both are workspace-root-relative) - if resolved_negatives.iter().any(|neg| neg.is_match(relative.as_str())) { - return None; + // Clean `..` components only for glob matching — fspy may report paths + // like `packages/sub-pkg/../shared/dist/output.js` that won't match + // workspace-root-relative negative globs without normalization. + if !resolved_negatives.is_empty() { + let cleaned = path_clean::PathClean::clean(relative.as_path()); + if let Some(cleaned_str) = cleaned.to_str() + && resolved_negatives.iter().any(|neg| neg.is_match(cleaned_str)) + { + return None; + } } Some(relative) diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-negative-glob-subpackage/snapshots/dotdot auto negative - miss on non-excluded sibling inferred file.snap b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-negative-glob-subpackage/snapshots/dotdot auto negative - miss on non-excluded sibling inferred file.snap index 3a6b1ca1..849cea8e 100644 --- a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-negative-glob-subpackage/snapshots/dotdot auto negative - miss on non-excluded sibling inferred file.snap +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-negative-glob-subpackage/snapshots/dotdot auto negative - miss on non-excluded sibling inferred file.snap @@ -9,6 +9,6 @@ export const shared = 'initial'; > replace-file-content packages/shared/src/utils.ts initial modified > vp run sub-pkg#dotdot-auto-negative -~/packages/sub-pkg$ print-file ../shared/src/utils.ts ../shared/dist/output.js ✗ cache miss: content of input 'packages/shared/src/utils.ts' changed, executing +~/packages/sub-pkg$ print-file ../shared/src/utils.ts ../shared/dist/output.js ✗ cache miss: content of input 'packages/sub-pkg/../shared/src/utils.ts' changed, executing export const shared = 'modified'; // initial output From 1b833d564e3767fecdcb698d483cc7fd855140ef Mon Sep 17 00:00:00 2001 From: branchseer Date: Sun, 8 Mar 2026 21:01:17 +0800 Subject: [PATCH 20/32] refactor: add clean() methods to AbsolutePath/RelativePath, replace direct path_clean usage Move path_clean dependency into vite_path and expose it through typed clean() methods on AbsolutePath and RelativePath, documenting the symlink limitation of purely lexical normalization. Co-Authored-By: Claude Opus 4.6 --- Cargo.lock | 5 +---- crates/vite_glob/Cargo.toml | 1 - crates/vite_path/Cargo.toml | 1 + crates/vite_path/src/absolute/mod.rs | 18 +++++++++++++++ crates/vite_path/src/relative.rs | 22 +++++++++++++++++++ crates/vite_task/Cargo.toml | 1 - crates/vite_task/src/session/execute/spawn.rs | 6 ++--- crates/vite_task_graph/Cargo.toml | 3 +-- crates/vite_task_graph/src/config/mod.rs | 6 ++--- crates/vite_workspace/Cargo.toml | 1 - crates/vite_workspace/src/package_filter.rs | 7 ++---- 11 files changed, 49 insertions(+), 22 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b435a620..f8e85104 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3795,7 +3795,6 @@ checksum = "051eb1abcf10076295e815102942cc58f9d5e3b4560e46e53c21e8ff6f3af7b1" name = "vite_glob" version = "0.0.0" dependencies = [ - "path-clean", "rustc-hash", "tempfile", "thiserror 2.0.18", @@ -3820,6 +3819,7 @@ dependencies = [ "assert2", "bincode", "diff-struct", + "path-clean", "ref-cast", "serde", "thiserror 2.0.18", @@ -3877,7 +3877,6 @@ dependencies = [ "nix 0.30.1", "once_cell", "owo-colors", - "path-clean", "pty_terminal_test_client", "rayon", "rusqlite", @@ -3935,7 +3934,6 @@ dependencies = [ "bincode", "cow-utils", "monostate", - "path-clean", "petgraph", "pretty_assertions", "rustc-hash", @@ -4012,7 +4010,6 @@ name = "vite_workspace" version = "0.0.0" dependencies = [ "clap", - "path-clean", "petgraph", "rustc-hash", "serde", diff --git a/crates/vite_glob/Cargo.toml b/crates/vite_glob/Cargo.toml index a9965bdf..cbe4cbe3 100644 --- a/crates/vite_glob/Cargo.toml +++ b/crates/vite_glob/Cargo.toml @@ -8,7 +8,6 @@ publish = false rust-version.workspace = true [dependencies] -path-clean = { workspace = true } rustc-hash = { workspace = true } thiserror = { workspace = true } vite_path = { workspace = true } diff --git a/crates/vite_path/Cargo.toml b/crates/vite_path/Cargo.toml index 207f05e0..7da9d22e 100644 --- a/crates/vite_path/Cargo.toml +++ b/crates/vite_path/Cargo.toml @@ -9,6 +9,7 @@ rust-version.workspace = true [dependencies] bincode = { workspace = true } diff-struct = { workspace = true } +path-clean = { workspace = true } ref-cast = { workspace = true } serde = { workspace = true, features = ["derive", "rc"] } thiserror = { workspace = true } diff --git a/crates/vite_path/src/absolute/mod.rs b/crates/vite_path/src/absolute/mod.rs index 794eb93b..a7b2476e 100644 --- a/crates/vite_path/src/absolute/mod.rs +++ b/crates/vite_path/src/absolute/mod.rs @@ -200,6 +200,24 @@ impl AbsolutePath { pub fn ends_with>(&self, path: P) -> bool { self.0.ends_with(path.as_ref()) } + + /// Lexically normalizes the path by resolving `.` and `..` components + /// without accessing the filesystem. + /// + /// **Symlink limitation**: Because this is purely lexical, it can produce + /// incorrect results when symlinks are involved. For example, if + /// `/a/link` is a symlink to `/x/y`, then cleaning `/a/link/../c` + /// yields `/a/c` instead of the correct `/x/c`. Use + /// [`std::fs::canonicalize`] when you need symlink-correct resolution. + #[must_use] + pub fn clean(&self) -> AbsolutePathBuf { + use path_clean::PathClean as _; + + let cleaned = self.0.clean(); + // SAFETY: Lexical cleaning of an absolute path preserves absoluteness — + // it only removes `.`/`..` components and redundant separators. + unsafe { AbsolutePathBuf::assume_absolute(cleaned) } + } } /// An Error returned from [`AbsolutePath::strip_prefix`] if the stripped path is not a valid `RelativePath` diff --git a/crates/vite_path/src/relative.rs b/crates/vite_path/src/relative.rs index 548b8017..3d3bd8fe 100644 --- a/crates/vite_path/src/relative.rs +++ b/crates/vite_path/src/relative.rs @@ -62,6 +62,28 @@ impl RelativePath { relative_path_buf } + /// Lexically normalizes the path by resolving `..` components without + /// accessing the filesystem. (`.` components are already stripped by + /// [`RelativePathBuf::new`].) + /// + /// **Symlink limitation**: Because this is purely lexical, it can produce + /// incorrect results when symlinks are involved. For example, if + /// `a/link` is a symlink to `x/y`, then cleaning `a/link/../c` + /// yields `a/c` instead of the correct `x/c`. Use + /// [`std::fs::canonicalize`] when you need symlink-correct resolution. + /// + /// # Panics + /// + /// Panics if the cleaned path is no longer a valid relative path, which + /// should never happen in practice. + #[must_use] + pub fn clean(&self) -> RelativePathBuf { + use path_clean::PathClean as _; + + let cleaned = self.as_path().clean(); + RelativePathBuf::new(cleaned).expect("cleaning a relative path preserves relativity") + } + /// Returns a path that, when joined onto `base`, yields `self`. /// /// If `base` is not a prefix of `self`, returns [`None`]. diff --git a/crates/vite_task/Cargo.toml b/crates/vite_task/Cargo.toml index ade7cdb7..183e32d1 100644 --- a/crates/vite_task/Cargo.toml +++ b/crates/vite_task/Cargo.toml @@ -23,7 +23,6 @@ fspy = { workspace = true } futures-util = { workspace = true } once_cell = { workspace = true } owo-colors = { workspace = true } -path-clean = { workspace = true } pty_terminal_test_client = { workspace = true } rayon = { workspace = true } rusqlite = { workspace = true, features = ["bundled"] } diff --git a/crates/vite_task/src/session/execute/spawn.rs b/crates/vite_task/src/session/execute/spawn.rs index c3a5bdcf..dc0a1ccc 100644 --- a/crates/vite_task/src/session/execute/spawn.rs +++ b/crates/vite_task/src/session/execute/spawn.rs @@ -210,10 +210,8 @@ pub async fn spawn_with_tracking( // like `packages/sub-pkg/../shared/dist/output.js` that won't match // workspace-root-relative negative globs without normalization. if !resolved_negatives.is_empty() { - let cleaned = path_clean::PathClean::clean(relative.as_path()); - if let Some(cleaned_str) = cleaned.to_str() - && resolved_negatives.iter().any(|neg| neg.is_match(cleaned_str)) - { + let cleaned = relative.clean(); + if resolved_negatives.iter().any(|neg| neg.is_match(cleaned.as_str())) { return None; } } diff --git a/crates/vite_task_graph/Cargo.toml b/crates/vite_task_graph/Cargo.toml index 95fdd240..df3579b3 100644 --- a/crates/vite_task_graph/Cargo.toml +++ b/crates/vite_task_graph/Cargo.toml @@ -16,14 +16,13 @@ petgraph = { workspace = true } rustc-hash = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } -path-clean = { workspace = true } thiserror = { workspace = true } tracing = { workspace = true } -wax = { workspace = true } vite_graph_ser = { workspace = true } vite_path = { workspace = true } vite_str = { workspace = true } vite_workspace = { workspace = true } +wax = { workspace = true } [dev-dependencies] pretty_assertions = { workspace = true } diff --git a/crates/vite_task_graph/src/config/mod.rs b/crates/vite_task_graph/src/config/mod.rs index 6453bda1..ffc98d62 100644 --- a/crates/vite_task_graph/src/config/mod.rs +++ b/crates/vite_task_graph/src/config/mod.rs @@ -195,7 +195,6 @@ fn resolve_glob_to_workspace_relative( workspace_root: &AbsolutePath, ) -> Result { use cow_utils::CowUtils as _; - use path_clean::PathClean as _; let glob = wax::Glob::new(pattern).map_err(|source| ResolveTaskConfigError::InvalidGlob { pattern: Str::from(pattern), @@ -203,8 +202,8 @@ fn resolve_glob_to_workspace_relative( })?; let (invariant_prefix, variant) = glob.partition(); - let joined = package_dir.as_path().join(&invariant_prefix).clean(); - let stripped = joined.strip_prefix(workspace_root.as_path()).map_err(|_| { + let joined = package_dir.join(&invariant_prefix).clean(); + let stripped = joined.as_path().strip_prefix(workspace_root.as_path()).map_err(|_| { ResolveTaskConfigError::GlobOutsideWorkspace { pattern: Str::from(pattern) } })?; @@ -383,7 +382,6 @@ mod tests { use super::*; - #[expect(clippy::disallowed_types, reason = "PathBuf needed for AbsolutePathBuf::new in tests")] fn test_paths() -> (AbsolutePathBuf, AbsolutePathBuf) { if cfg!(windows) { ( diff --git a/crates/vite_workspace/Cargo.toml b/crates/vite_workspace/Cargo.toml index da803569..9dd53ad8 100644 --- a/crates/vite_workspace/Cargo.toml +++ b/crates/vite_workspace/Cargo.toml @@ -9,7 +9,6 @@ rust-version.workspace = true [dependencies] clap = { workspace = true, features = ["derive"] } -path-clean = { workspace = true } petgraph = { workspace = true, features = ["serde-1"] } rustc-hash = { workspace = true } serde = { workspace = true, features = ["derive"] } diff --git a/crates/vite_workspace/src/package_filter.rs b/crates/vite_workspace/src/package_filter.rs index e5c3a02e..07fc7fde 100644 --- a/crates/vite_workspace/src/package_filter.rs +++ b/crates/vite_workspace/src/package_filter.rs @@ -32,7 +32,7 @@ use std::{ops::Deref, sync::Arc}; use vec1::Vec1; -use vite_path::{AbsolutePath, AbsolutePathBuf}; +use vite_path::AbsolutePath; use vite_str::Str; use crate::package_graph::PackageQuery; @@ -590,10 +590,7 @@ fn resolve_directory_pattern( /// results when symlinks are involved (e.g. `/a/symlink/../b` → `/a/b`). This /// matches pnpm's behaviour. fn resolve_filter_path(path_str: &str, cwd: &AbsolutePath) -> Arc { - let cleaned = path_clean::clean(cwd.join(path_str).as_path()); - let normalized = AbsolutePathBuf::new(cleaned) - .expect("invariant: cleaning an absolute path preserves absoluteness"); - normalized.into() + cwd.join(path_str).clean().into() } /// Build a [`PackageNamePattern`] from a name or glob string. From eeb389e46b7e1c46d403eb54e5bdedfc0d4f023a Mon Sep 17 00:00:00 2001 From: branchseer Date: Sun, 8 Mar 2026 21:04:32 +0800 Subject: [PATCH 21/32] refactor: use typed AbsolutePath::strip_prefix in resolve_glob_to_workspace_relative RelativePathBuf guarantees valid UTF-8 with forward slashes, so the manual to_str() check and backslash normalization are no longer needed. Co-Authored-By: Claude Opus 4.6 --- crates/vite_task_graph/src/config/mod.rs | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/crates/vite_task_graph/src/config/mod.rs b/crates/vite_task_graph/src/config/mod.rs index ffc98d62..b3cea590 100644 --- a/crates/vite_task_graph/src/config/mod.rs +++ b/crates/vite_task_graph/src/config/mod.rs @@ -194,8 +194,6 @@ fn resolve_glob_to_workspace_relative( package_dir: &AbsolutePath, workspace_root: &AbsolutePath, ) -> Result { - use cow_utils::CowUtils as _; - let glob = wax::Glob::new(pattern).map_err(|source| ResolveTaskConfigError::InvalidGlob { pattern: Str::from(pattern), source: Box::new(source), @@ -203,17 +201,16 @@ fn resolve_glob_to_workspace_relative( let (invariant_prefix, variant) = glob.partition(); let joined = package_dir.join(&invariant_prefix).clean(); - let stripped = joined.as_path().strip_prefix(workspace_root.as_path()).map_err(|_| { + let stripped = joined.strip_prefix(workspace_root).map_err(|_| { ResolveTaskConfigError::GlobOutsideWorkspace { pattern: Str::from(pattern) } })?; // Re-escape the prefix path for use in a glob pattern - let stripped_str = stripped.to_str().ok_or_else(|| { - ResolveTaskConfigError::GlobOutsideWorkspace { pattern: Str::from(pattern) } + let stripped = stripped.ok_or_else(|| ResolveTaskConfigError::GlobOutsideWorkspace { + pattern: Str::from(pattern), })?; - // Normalize backslashes to forward slashes for cross-platform compatibility - let escaped = wax::escape(stripped_str); - let escaped_prefix = escaped.cow_replace('\\', "/"); + + let escaped_prefix = wax::escape(stripped.as_str()); let result = match variant { Some(variant_glob) if escaped_prefix.is_empty() => { From 849336ed422fa910a08d2c16cdd79697b958d5ed Mon Sep 17 00:00:00 2001 From: branchseer Date: Sun, 8 Mar 2026 21:06:23 +0800 Subject: [PATCH 22/32] docs: note that ResolvedInputConfig globs are relative to workspace root Co-Authored-By: Claude Opus 4.6 --- crates/vite_task_graph/src/config/mod.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/vite_task_graph/src/config/mod.rs b/crates/vite_task_graph/src/config/mod.rs index b3cea590..3f3d9306 100644 --- a/crates/vite_task_graph/src/config/mod.rs +++ b/crates/vite_task_graph/src/config/mod.rs @@ -110,11 +110,11 @@ pub struct ResolvedInputConfig { /// Whether automatic file tracking is enabled pub includes_auto: bool, - /// Positive glob patterns (files to include). + /// Positive glob patterns (files to include), relative to the workspace root. /// Sorted for deterministic cache keys. pub positive_globs: BTreeSet, - /// Negative glob patterns (files to exclude, without the `!` prefix). + /// Negative glob patterns (files to exclude, without the `!` prefix), relative to the workspace root. /// Sorted for deterministic cache keys. pub negative_globs: BTreeSet, } From 707d3cd64aee8fa91775bb37a57d046743c5d85f Mon Sep 17 00:00:00 2001 From: branchseer Date: Sun, 8 Mar 2026 22:30:27 +0800 Subject: [PATCH 23/32] add test completion message --- crates/vite_task_bin/tests/e2e_snapshots/main.rs | 1 + crates/vite_task_plan/tests/plan_snapshots/main.rs | 11 +++++------ 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/crates/vite_task_bin/tests/e2e_snapshots/main.rs b/crates/vite_task_bin/tests/e2e_snapshots/main.rs index 47ac04ee..45f32be3 100644 --- a/crates/vite_task_bin/tests/e2e_snapshots/main.rs +++ b/crates/vite_task_bin/tests/e2e_snapshots/main.rs @@ -488,4 +488,5 @@ fn main() { for case_path in &fixture_paths { run_case(&tmp_dir_path, case_path, filter.as_deref()); } + println!("All cases passed."); } diff --git a/crates/vite_task_plan/tests/plan_snapshots/main.rs b/crates/vite_task_plan/tests/plan_snapshots/main.rs index 133ca597..f5d3effe 100644 --- a/crates/vite_task_plan/tests/plan_snapshots/main.rs +++ b/crates/vite_task_plan/tests/plan_snapshots/main.rs @@ -316,10 +316,9 @@ fn main() { let tests_dir = std::env::current_dir().unwrap().join("tests"); - insta::glob!(tests_dir, "plan_snapshots/fixtures/*", |case_path| run_case( - &tokio_runtime, - &tmp_dir_path, - case_path, - filter.as_deref() - )); + insta::glob!(tests_dir, "plan_snapshots/fixtures/*", |case_path| { + run_case(&tokio_runtime, &tmp_dir_path, case_path, filter.as_deref()) + }); + + println!("All cases passed."); } From 3a1f2ae0b43b95a3b85afea90aa59e6ad11ce7ed Mon Sep 17 00:00:00 2001 From: branchseer Date: Sun, 8 Mar 2026 22:52:03 +0800 Subject: [PATCH 24/32] refactor: simplify glob_inputs to walk from workspace root and use wax .not() combinator Replace manual partition()+branch logic with direct glob.walk(workspace_root), and replace manual is_match negative filtering with wax's FileIterator::not(). Co-Authored-By: Claude Opus 4.6 --- .../src/session/execute/glob_inputs.rs | 64 ++++++------------- 1 file changed, 21 insertions(+), 43 deletions(-) diff --git a/crates/vite_task/src/session/execute/glob_inputs.rs b/crates/vite_task/src/session/execute/glob_inputs.rs index 732a1268..fc1f4985 100644 --- a/crates/vite_task/src/session/execute/glob_inputs.rs +++ b/crates/vite_task/src/session/execute/glob_inputs.rs @@ -16,15 +16,17 @@ use std::{ use vite_path::AbsolutePathBuf; use vite_path::{AbsolutePath, RelativePathBuf}; use vite_str::Str; -use wax::{Glob, Program as _, walk::Entry as _}; +use wax::{ + Glob, + walk::{Entry as _, FileIterator as _}, +}; -/// Collect walk entries into the result map, filtering against resolved negatives. +/// Collect walk entries into the result map. /// /// Walk errors for non-existent directories are skipped gracefully. fn collect_walk_entries( walk: impl Iterator>, workspace_root: &AbsolutePath, - resolved_negatives: &[Glob<'static>], result: &mut BTreeMap, ) -> anyhow::Result<()> { for entry in walk { @@ -54,11 +56,6 @@ fn collect_walk_entries( continue; // Skip if path is outside workspace_root }; - // Filter against resolved negatives (both are workspace-root-relative) - if resolved_negatives.iter().any(|neg| neg.is_match(relative_to_workspace.as_str())) { - continue; - } - // Hash file content match hash_file_content(path) { Ok(hash) => { @@ -97,46 +94,27 @@ pub fn compute_globbed_inputs( return Ok(BTreeMap::new()); } - let resolved_negatives: Vec> = negative_globs - .iter() - .map(|p| Ok(Glob::new(p.as_str())?.into_owned())) - .collect::>()?; + let negation = if negative_globs.is_empty() { + None + } else { + let negatives: Vec> = negative_globs + .iter() + .map(|p| Ok(Glob::new(p.as_str())?.into_owned())) + .collect::>()?; + Some(wax::any(negatives)?) + }; let mut result = BTreeMap::new(); for pattern in positive_globs { - let pos = Glob::new(pattern.as_str())?.into_owned(); - let (pos_prefix, pos_variant) = pos.partition(); - let walk_root = workspace_root.as_path().join(&pos_prefix); - - if let Some(variant_glob) = pos_variant { - if walk_root.is_dir() { - collect_walk_entries( - variant_glob.into_owned().walk(&walk_root), - workspace_root, - &resolved_negatives, - &mut result, - )?; + let glob = Glob::new(pattern.as_str())?.into_owned(); + let walk = glob.walk(workspace_root.as_path()); + match &negation { + Some(negation) => { + collect_walk_entries(walk.not(negation.clone())?, workspace_root, &mut result)?; } - } else { - // Invariant-only glob (specific file path) — hash directly if it exists - if walk_root.is_file() - && let Some(relative) = walk_root - .strip_prefix(workspace_root.as_path()) - .ok() - .and_then(|p| RelativePathBuf::new(p).ok()) - { - // Check against negatives - if resolved_negatives.iter().any(|neg| neg.is_match(relative.as_str())) { - continue; - } - match hash_file_content(&walk_root) { - Ok(hash) => { - result.insert(relative, hash); - } - Err(err) if err.kind() == io::ErrorKind::NotFound => {} - Err(err) => return Err(err.into()), - } + None => { + collect_walk_entries(walk, workspace_root, &mut result)?; } } } From 7a277fd90bc419fce099c8e4361ef55ee07ff334 Mon Sep 17 00:00:00 2001 From: branchseer Date: Sun, 8 Mar 2026 23:11:57 +0800 Subject: [PATCH 25/32] fix: remove fstat interception that caused duplicate path tracking fstat(fd) uses F_GETPATH on macOS which returns canonical paths, while open(path) preserves raw relative paths. This caused the same file to be recorded under two different paths, leading to non-deterministic cache miss messages via rayon's find_map_any. Also simplify glob_inputs negation handling (always use .not()) and add e2e test for glob meta characters in package paths (wax::escape). Co-Authored-By: Claude Opus 4.6 --- .../src/interceptions/stat.rs | 15 +----------- .../src/session/execute/glob_inputs.rs | 23 +++++-------------- .../inputs-glob-meta-in-path/package.json | 4 ++++ .../packages/[lib]/package.json | 4 ++++ .../packages/[lib]/src/main.ts | 1 + .../packages/[lib]/vite-task.json | 9 ++++++++ .../pnpm-workspace.yaml | 2 ++ .../inputs-glob-meta-in-path/snapshots.toml | 12 ++++++++++ .../cache hit then miss on file change.snap | 18 +++++++++++++++ 9 files changed, 57 insertions(+), 31 deletions(-) create mode 100644 crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-glob-meta-in-path/package.json create mode 100644 crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-glob-meta-in-path/packages/[lib]/package.json create mode 100644 crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-glob-meta-in-path/packages/[lib]/src/main.ts create mode 100644 crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-glob-meta-in-path/packages/[lib]/vite-task.json create mode 100644 crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-glob-meta-in-path/pnpm-workspace.yaml create mode 100644 crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-glob-meta-in-path/snapshots.toml create mode 100644 crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-glob-meta-in-path/snapshots/cache hit then miss on file change.snap diff --git a/crates/fspy_preload_unix/src/interceptions/stat.rs b/crates/fspy_preload_unix/src/interceptions/stat.rs index c70ff270..fe05c2b0 100644 --- a/crates/fspy_preload_unix/src/interceptions/stat.rs +++ b/crates/fspy_preload_unix/src/interceptions/stat.rs @@ -2,10 +2,7 @@ use fspy_shared::ipc::AccessMode; use libc::{c_char, c_int, stat as stat_struct}; use crate::{ - client::{ - convert::{Fd, PathAt}, - handle_open, - }, + client::{convert::PathAt, handle_open}, macros::intercept, }; @@ -30,16 +27,6 @@ unsafe extern "C" fn lstat(path: *const c_char, buf: *mut stat_struct) -> c_int unsafe { lstat::original()(path, buf) } } -intercept!(fstat(64): unsafe extern "C" fn(fd: c_int, buf: *mut stat_struct) -> c_int); -unsafe extern "C" fn fstat(fd: c_int, buf: *mut stat_struct) -> c_int { - // SAFETY: fd is a valid file descriptor provided by the caller of the interposed function - unsafe { - handle_open(Fd(fd), AccessMode::READ); - } - // SAFETY: calling the original libc fstat() with the same arguments forwarded from the interposed function - unsafe { fstat::original()(fd, buf) } -} - intercept!(fstatat(64): unsafe extern "C" fn(dirfd: c_int, pathname: *const c_char, buf: *mut stat_struct, flags: c_int) -> c_int); unsafe extern "C" fn fstatat( dirfd: c_int, diff --git a/crates/vite_task/src/session/execute/glob_inputs.rs b/crates/vite_task/src/session/execute/glob_inputs.rs index fc1f4985..cb469fa5 100644 --- a/crates/vite_task/src/session/execute/glob_inputs.rs +++ b/crates/vite_task/src/session/execute/glob_inputs.rs @@ -94,29 +94,18 @@ pub fn compute_globbed_inputs( return Ok(BTreeMap::new()); } - let negation = if negative_globs.is_empty() { - None - } else { - let negatives: Vec> = negative_globs - .iter() - .map(|p| Ok(Glob::new(p.as_str())?.into_owned())) - .collect::>()?; - Some(wax::any(negatives)?) - }; + let negatives: Vec> = negative_globs + .iter() + .map(|p| Ok(Glob::new(p.as_str())?.into_owned())) + .collect::>()?; + let negation = wax::any(negatives)?; let mut result = BTreeMap::new(); for pattern in positive_globs { let glob = Glob::new(pattern.as_str())?.into_owned(); let walk = glob.walk(workspace_root.as_path()); - match &negation { - Some(negation) => { - collect_walk_entries(walk.not(negation.clone())?, workspace_root, &mut result)?; - } - None => { - collect_walk_entries(walk, workspace_root, &mut result)?; - } - } + collect_walk_entries(walk.not(negation.clone())?, workspace_root, &mut result)?; } Ok(result) diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-glob-meta-in-path/package.json b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-glob-meta-in-path/package.json new file mode 100644 index 00000000..9e8f0d50 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-glob-meta-in-path/package.json @@ -0,0 +1,4 @@ +{ + "name": "inputs-glob-meta-in-path", + "private": true +} diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-glob-meta-in-path/packages/[lib]/package.json b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-glob-meta-in-path/packages/[lib]/package.json new file mode 100644 index 00000000..9bd69c4c --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-glob-meta-in-path/packages/[lib]/package.json @@ -0,0 +1,4 @@ +{ + "name": "[lib]", + "private": true +} diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-glob-meta-in-path/packages/[lib]/src/main.ts b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-glob-meta-in-path/packages/[lib]/src/main.ts new file mode 100644 index 00000000..834b5a5e --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-glob-meta-in-path/packages/[lib]/src/main.ts @@ -0,0 +1 @@ +export const lib = 'initial'; diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-glob-meta-in-path/packages/[lib]/vite-task.json b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-glob-meta-in-path/packages/[lib]/vite-task.json new file mode 100644 index 00000000..2a31e9ce --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-glob-meta-in-path/packages/[lib]/vite-task.json @@ -0,0 +1,9 @@ +{ + "tasks": { + "build": { + "command": "print-file src/main.ts", + "inputs": ["src/**/*.ts"], + "cache": true + } + } +} diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-glob-meta-in-path/pnpm-workspace.yaml b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-glob-meta-in-path/pnpm-workspace.yaml new file mode 100644 index 00000000..18ec407e --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-glob-meta-in-path/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +packages: + - 'packages/*' diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-glob-meta-in-path/snapshots.toml b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-glob-meta-in-path/snapshots.toml new file mode 100644 index 00000000..c42f9ee8 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-glob-meta-in-path/snapshots.toml @@ -0,0 +1,12 @@ +# Test that glob meta characters in package paths are correctly escaped by wax::escape. +# Without escaping, "packages/[lib]/src/**/*.ts" would interpret [lib] as a character +# class matching 'l', 'i', or 'b' instead of the literal directory name. + +[[e2e]] +name = "cache hit then miss on file change" +steps = [ + "vp run [lib]#build", + "vp run [lib]#build", + "replace-file-content packages/[lib]/src/main.ts initial modified", + "vp run [lib]#build", +] diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-glob-meta-in-path/snapshots/cache hit then miss on file change.snap b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-glob-meta-in-path/snapshots/cache hit then miss on file change.snap new file mode 100644 index 00000000..207f484e --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-glob-meta-in-path/snapshots/cache hit then miss on file change.snap @@ -0,0 +1,18 @@ +--- +source: crates/vite_task_bin/tests/e2e_snapshots/main.rs +expression: e2e_outputs +--- +> vp run [lib]#build +~/packages/[lib]$ print-file src/main.ts +export const lib = 'initial'; +> vp run [lib]#build +~/packages/[lib]$ print-file src/main.ts ✓ cache hit, replaying +export const lib = 'initial'; + +--- +[vp run] cache hit, saved. +> replace-file-content packages/[lib]/src/main.ts initial modified + +> vp run [lib]#build +~/packages/[lib]$ print-file src/main.ts ✗ cache miss: content of input 'packages/[lib]/src/main.ts' changed, executing +export const lib = 'modified'; From 88d719ee45e0797fb5f5d304f4625079b4d93e37 Mon Sep 17 00:00:00 2001 From: branchseer Date: Sun, 8 Mar 2026 23:24:11 +0800 Subject: [PATCH 26/32] test: add e2e test for bare directory name as explicit input MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verifies that inputs: ["src"] (a directory, not a glob) fingerprints nothing — file changes inside and folder deletion are both cache hits. Co-Authored-By: Claude Opus 4.6 --- .../fixtures/inputs-cache-test/snapshots.toml | 17 ++++++++++++++ ...pite file changes and folder deletion.snap | 23 +++++++++++++++++++ .../fixtures/inputs-cache-test/vite-task.json | 5 ++++ 3 files changed, 45 insertions(+) create mode 100644 crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-cache-test/snapshots/folder input - hit despite file changes and folder deletion.snap diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-cache-test/snapshots.toml b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-cache-test/snapshots.toml index bea80054..2387edea 100644 --- a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-cache-test/snapshots.toml +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-cache-test/snapshots.toml @@ -200,3 +200,20 @@ steps = [ # Run task without auto inference - should see (undefined) "vp run check-fspy-env-without-auto", ] + +# 8. Folder path as input: inputs: ["src"] +# - A bare directory name matches nothing (directories are not files) +# - File changes inside the folder should NOT trigger cache invalidation +[[e2e]] +name = "folder input - hit despite file changes and folder deletion" +steps = [ + "vp run folder-input", + # Modify a file inside the folder + "replace-file-content src/main.ts initial modified", + # Cache hit: "src" matches the directory itself, not files inside it + "vp run folder-input", + # Delete the entire folder + "rm -rf src", + # Cache hit: folder removal doesn't affect fingerprint + "vp run folder-input", +] diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-cache-test/snapshots/folder input - hit despite file changes and folder deletion.snap b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-cache-test/snapshots/folder input - hit despite file changes and folder deletion.snap new file mode 100644 index 00000000..b919a7a4 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-cache-test/snapshots/folder input - hit despite file changes and folder deletion.snap @@ -0,0 +1,23 @@ +--- +source: crates/vite_task_bin/tests/e2e_snapshots/main.rs +expression: e2e_outputs +--- +> vp run folder-input +$ print-file src/main.ts +export const main = 'initial'; +> replace-file-content src/main.ts initial modified + +> vp run folder-input +$ print-file src/main.ts ✓ cache hit, replaying +export const main = 'initial'; + +--- +[vp run] cache hit, saved. +> rm -rf src + +> vp run folder-input +$ print-file src/main.ts ✓ cache hit, replaying +export const main = 'initial'; + +--- +[vp run] cache hit, saved. diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-cache-test/vite-task.json b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-cache-test/vite-task.json index dfbf380b..f0a72af6 100644 --- a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-cache-test/vite-task.json +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-cache-test/vite-task.json @@ -44,6 +44,11 @@ "command": "print-env FSPY", "inputs": ["src/**/*.ts"], "cache": true + }, + "folder-input": { + "command": "print-file src/main.ts", + "inputs": ["src"], + "cache": true } } } From 5a4a707b489f5d4ebacd8212fb0de652cf13ceef Mon Sep 17 00:00:00 2001 From: branchseer Date: Sun, 8 Mar 2026 23:33:09 +0800 Subject: [PATCH 27/32] feat: expand trailing `/` in input globs to `/**` Input patterns like `"src/"` are now treated as `"src/**"`, matching all files recursively under that directory. This applies to both positive and negative globs. Co-Authored-By: Claude Opus 4.6 --- .../fixtures/inputs-cache-test/snapshots.toml | 33 +++++++++++++- ...input - hit on file outside directory.snap | 15 +++++++ ...iss on direct and nested file changes.snap | 22 +++++++++ .../inputs-cache-test/src/sub/nested.ts | 1 + .../fixtures/inputs-cache-test/vite-task.json | 5 +++ crates/vite_task_graph/src/config/mod.rs | 9 ++++ .../inputs-trailing-slash/package.json | 3 ++ .../inputs-trailing-slash/snapshots.toml | 1 + .../snapshots/task graph.snap | 45 +++++++++++++++++++ .../inputs-trailing-slash/vite-task.json | 9 ++++ 10 files changed, 142 insertions(+), 1 deletion(-) create mode 100644 crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-cache-test/snapshots/folder slash input - hit on file outside directory.snap create mode 100644 crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-cache-test/snapshots/folder slash input - miss on direct and nested file changes.snap create mode 100644 crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-cache-test/src/sub/nested.ts create mode 100644 crates/vite_task_plan/tests/plan_snapshots/fixtures/inputs-trailing-slash/package.json create mode 100644 crates/vite_task_plan/tests/plan_snapshots/fixtures/inputs-trailing-slash/snapshots.toml create mode 100644 crates/vite_task_plan/tests/plan_snapshots/fixtures/inputs-trailing-slash/snapshots/task graph.snap create mode 100644 crates/vite_task_plan/tests/plan_snapshots/fixtures/inputs-trailing-slash/vite-task.json diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-cache-test/snapshots.toml b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-cache-test/snapshots.toml index 2387edea..36a7ffbd 100644 --- a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-cache-test/snapshots.toml +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-cache-test/snapshots.toml @@ -201,7 +201,38 @@ steps = [ "vp run check-fspy-env-without-auto", ] -# 8. Folder path as input: inputs: ["src"] +# 8. Trailing slash input: inputs: ["src/"] +# - Trailing `/` is expanded to `/**`, matching all files under that directory +# - Direct and nested file changes trigger cache invalidation +# - Files outside the directory do NOT trigger cache invalidation +[[e2e]] +name = "folder slash input - miss on direct and nested file changes" +steps = [ + "vp run folder-slash-input", + # Modify a direct file in src/ + "replace-file-content src/main.ts initial modified", + # Cache miss: direct file changed + "vp run folder-slash-input", + # Reset and run again to re-establish cache + "replace-file-content src/main.ts modified initial", + "vp run folder-slash-input", + # Modify a nested file in src/sub/ + "replace-file-content src/sub/nested.ts initial modified", + # Cache miss: nested file changed + "vp run folder-slash-input", +] + +[[e2e]] +name = "folder slash input - hit on file outside directory" +steps = [ + "vp run folder-slash-input", + # Modify a file outside src/ + "replace-file-content test/main.test.ts outside modified", + # Cache hit: file not under src/ + "vp run folder-slash-input", +] + +# 9. Folder path as input: inputs: ["src"] # - A bare directory name matches nothing (directories are not files) # - File changes inside the folder should NOT trigger cache invalidation [[e2e]] diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-cache-test/snapshots/folder slash input - hit on file outside directory.snap b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-cache-test/snapshots/folder slash input - hit on file outside directory.snap new file mode 100644 index 00000000..899ccfaa --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-cache-test/snapshots/folder slash input - hit on file outside directory.snap @@ -0,0 +1,15 @@ +--- +source: crates/vite_task_bin/tests/e2e_snapshots/main.rs +expression: e2e_outputs +--- +> vp run folder-slash-input +$ print-file src/main.ts +export const main = 'initial'; +> replace-file-content test/main.test.ts outside modified + +> vp run folder-slash-input +$ print-file src/main.ts ✓ cache hit, replaying +export const main = 'initial'; + +--- +[vp run] cache hit, saved. diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-cache-test/snapshots/folder slash input - miss on direct and nested file changes.snap b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-cache-test/snapshots/folder slash input - miss on direct and nested file changes.snap new file mode 100644 index 00000000..82f9346b --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-cache-test/snapshots/folder slash input - miss on direct and nested file changes.snap @@ -0,0 +1,22 @@ +--- +source: crates/vite_task_bin/tests/e2e_snapshots/main.rs +expression: e2e_outputs +--- +> vp run folder-slash-input +$ print-file src/main.ts +export const main = 'initial'; +> replace-file-content src/main.ts initial modified + +> vp run folder-slash-input +$ print-file src/main.ts ✗ cache miss: content of input 'src/main.ts' changed, executing +export const main = 'modified'; +> replace-file-content src/main.ts modified initial + +> vp run folder-slash-input +$ print-file src/main.ts ✗ cache miss: content of input 'src/main.ts' changed, executing +export const main = 'initial'; +> replace-file-content src/sub/nested.ts initial modified + +> vp run folder-slash-input +$ print-file src/main.ts ✗ cache miss: content of input 'src/sub/nested.ts' changed, executing +export const main = 'initial'; diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-cache-test/src/sub/nested.ts b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-cache-test/src/sub/nested.ts new file mode 100644 index 00000000..987cac05 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-cache-test/src/sub/nested.ts @@ -0,0 +1 @@ +export const nested = 'initial'; diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-cache-test/vite-task.json b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-cache-test/vite-task.json index f0a72af6..d608ac41 100644 --- a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-cache-test/vite-task.json +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-cache-test/vite-task.json @@ -49,6 +49,11 @@ "command": "print-file src/main.ts", "inputs": ["src"], "cache": true + }, + "folder-slash-input": { + "command": "print-file src/main.ts", + "inputs": ["src/"], + "cache": true } } } diff --git a/crates/vite_task_graph/src/config/mod.rs b/crates/vite_task_graph/src/config/mod.rs index 3f3d9306..65ff15d6 100644 --- a/crates/vite_task_graph/src/config/mod.rs +++ b/crates/vite_task_graph/src/config/mod.rs @@ -194,6 +194,15 @@ fn resolve_glob_to_workspace_relative( package_dir: &AbsolutePath, workspace_root: &AbsolutePath, ) -> Result { + // A trailing `/` is shorthand for all files under that directory + let expanded; + let pattern = if let Some(prefix) = pattern.strip_suffix('/') { + expanded = vite_str::format!("{prefix}/**"); + expanded.as_str() + } else { + pattern + }; + let glob = wax::Glob::new(pattern).map_err(|source| ResolveTaskConfigError::InvalidGlob { pattern: Str::from(pattern), source: Box::new(source), diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/inputs-trailing-slash/package.json b/crates/vite_task_plan/tests/plan_snapshots/fixtures/inputs-trailing-slash/package.json new file mode 100644 index 00000000..600e5f1a --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/inputs-trailing-slash/package.json @@ -0,0 +1,3 @@ +{ + "name": "test" +} diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/inputs-trailing-slash/snapshots.toml b/crates/vite_task_plan/tests/plan_snapshots/fixtures/inputs-trailing-slash/snapshots.toml new file mode 100644 index 00000000..8370d8ce --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/inputs-trailing-slash/snapshots.toml @@ -0,0 +1 @@ +# Test that trailing `/` in glob patterns is expanded to `/**` diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/inputs-trailing-slash/snapshots/task graph.snap b/crates/vite_task_plan/tests/plan_snapshots/fixtures/inputs-trailing-slash/snapshots/task graph.snap new file mode 100644 index 00000000..071c92e6 --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/inputs-trailing-slash/snapshots/task graph.snap @@ -0,0 +1,45 @@ +--- +source: crates/vite_task_plan/tests/plan_snapshots/main.rs +expression: task_graph_json +input_file: crates/vite_task_plan/tests/plan_snapshots/fixtures/inputs-trailing-slash +--- +[ + { + "key": [ + "/", + "build" + ], + "node": { + "task_display": { + "package_name": "test", + "task_name": "build", + "package_path": "/" + }, + "resolved_config": { + "command": "echo build", + "resolved_options": { + "cwd": "/", + "cache_config": { + "env_config": { + "fingerprinted_envs": [], + "pass_through_envs": [ + "" + ] + }, + "input_config": { + "includes_auto": false, + "positive_globs": [ + "src/**" + ], + "negative_globs": [ + "dist/**" + ] + } + } + } + }, + "source": "TaskConfig" + }, + "neighbors": [] + } +] diff --git a/crates/vite_task_plan/tests/plan_snapshots/fixtures/inputs-trailing-slash/vite-task.json b/crates/vite_task_plan/tests/plan_snapshots/fixtures/inputs-trailing-slash/vite-task.json new file mode 100644 index 00000000..563fc807 --- /dev/null +++ b/crates/vite_task_plan/tests/plan_snapshots/fixtures/inputs-trailing-slash/vite-task.json @@ -0,0 +1,9 @@ +{ + "tasks": { + "build": { + "command": "echo build", + "inputs": ["src/", "!dist/"], + "cache": true + } + } +} From a6d4d1b3a3c8fad55b4368abcb52b9d9f6323522 Mon Sep 17 00:00:00 2001 From: branchseer Date: Sun, 8 Mar 2026 23:39:09 +0800 Subject: [PATCH 28/32] refactor: skip duplicate glob entries via entry API, error on non-UTF-8 paths Co-Authored-By: Claude Opus 4.6 --- .../vite_task/src/session/execute/glob_inputs.rs | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/crates/vite_task/src/session/execute/glob_inputs.rs b/crates/vite_task/src/session/execute/glob_inputs.rs index cb469fa5..c68f3bd0 100644 --- a/crates/vite_task/src/session/execute/glob_inputs.rs +++ b/crates/vite_task/src/session/execute/glob_inputs.rs @@ -48,18 +48,21 @@ fn collect_walk_entries( let path = entry.path(); // Compute path relative to workspace_root for the result - let Some(relative_to_workspace) = path - .strip_prefix(workspace_root.as_path()) - .ok() - .and_then(|p| RelativePathBuf::new(p).ok()) - else { + let Some(stripped) = path.strip_prefix(workspace_root.as_path()).ok() else { continue; // Skip if path is outside workspace_root }; + let relative_to_workspace = RelativePathBuf::new(stripped)?; + + let std::collections::btree_map::Entry::Vacant(vacant) = + result.entry(relative_to_workspace) + else { + continue; // Already hashed by a previous glob pattern + }; // Hash file content match hash_file_content(path) { Ok(hash) => { - result.insert(relative_to_workspace, hash); + vacant.insert(hash); } Err(err) if err.kind() == io::ErrorKind::NotFound => { // File was deleted between walk and hash, skip it From 608e0623f8be4c73843ced56fda8dd6144a668ee Mon Sep 17 00:00:00 2001 From: branchseer Date: Sun, 8 Mar 2026 23:47:00 +0800 Subject: [PATCH 29/32] fix ci issues --- Cargo.lock | 3 --- crates/vite_glob/Cargo.toml | 2 -- crates/vite_task_graph/Cargo.toml | 1 - crates/vite_task_graph/src/config/mod.rs | 6 +++--- crates/vite_task_plan/src/plan.rs | 1 + 5 files changed, 4 insertions(+), 9 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f8e85104..c60596b7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3795,8 +3795,6 @@ checksum = "051eb1abcf10076295e815102942cc58f9d5e3b4560e46e53c21e8ff6f3af7b1" name = "vite_glob" version = "0.0.0" dependencies = [ - "rustc-hash", - "tempfile", "thiserror 2.0.18", "vite_path", "vite_str", @@ -3932,7 +3930,6 @@ dependencies = [ "anyhow", "async-trait", "bincode", - "cow-utils", "monostate", "petgraph", "pretty_assertions", diff --git a/crates/vite_glob/Cargo.toml b/crates/vite_glob/Cargo.toml index cbe4cbe3..90006a26 100644 --- a/crates/vite_glob/Cargo.toml +++ b/crates/vite_glob/Cargo.toml @@ -8,13 +8,11 @@ publish = false rust-version.workspace = true [dependencies] -rustc-hash = { workspace = true } thiserror = { workspace = true } vite_path = { workspace = true } wax = { workspace = true } [dev-dependencies] -tempfile = { workspace = true } vite_str = { workspace = true } [lints] diff --git a/crates/vite_task_graph/Cargo.toml b/crates/vite_task_graph/Cargo.toml index df3579b3..e91a746a 100644 --- a/crates/vite_task_graph/Cargo.toml +++ b/crates/vite_task_graph/Cargo.toml @@ -10,7 +10,6 @@ rust-version.workspace = true anyhow = { workspace = true } async-trait = { workspace = true } bincode = { workspace = true } -cow-utils = { workspace = true } monostate = { workspace = true } petgraph = { workspace = true } rustc-hash = { workspace = true } diff --git a/crates/vite_task_graph/src/config/mod.rs b/crates/vite_task_graph/src/config/mod.rs index 65ff15d6..5a8fc11f 100644 --- a/crates/vite_task_graph/src/config/mod.rs +++ b/crates/vite_task_graph/src/config/mod.rs @@ -195,9 +195,9 @@ fn resolve_glob_to_workspace_relative( workspace_root: &AbsolutePath, ) -> Result { // A trailing `/` is shorthand for all files under that directory - let expanded; - let pattern = if let Some(prefix) = pattern.strip_suffix('/') { - expanded = vite_str::format!("{prefix}/**"); + let expanded: Str; + let pattern = if pattern.ends_with('/') { + expanded = vite_str::format!("{pattern}**"); expanded.as_str() } else { pattern diff --git a/crates/vite_task_plan/src/plan.rs b/crates/vite_task_plan/src/plan.rs index f045e53e..76efc362 100644 --- a/crates/vite_task_plan/src/plan.rs +++ b/crates/vite_task_plan/src/plan.rs @@ -391,6 +391,7 @@ pub enum ParentCacheConfig { /// env config and merges in any additional envs the synthetic command needs. /// - If there is no parent (top-level invocation), the synthetic task's own /// [`UserCacheConfig`] is resolved with defaults. +#[expect(clippy::result_large_err, reason = "Error is large for diagnostics")] fn resolve_synthetic_cache_config( parent: ParentCacheConfig, synthetic_cache_config: UserCacheConfig, From 43111808d42be87190a40029e335d6b3f7783baa Mon Sep 17 00:00:00 2001 From: branchseer Date: Sun, 8 Mar 2026 23:58:21 +0800 Subject: [PATCH 30/32] fix clippy warnings in test harnesses Co-Authored-By: Claude Opus 4.6 --- crates/vite_task_bin/tests/e2e_snapshots/main.rs | 5 ++++- crates/vite_task_plan/tests/plan_snapshots/main.rs | 7 +++++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/crates/vite_task_bin/tests/e2e_snapshots/main.rs b/crates/vite_task_bin/tests/e2e_snapshots/main.rs index 45f32be3..77172b1c 100644 --- a/crates/vite_task_bin/tests/e2e_snapshots/main.rs +++ b/crates/vite_task_bin/tests/e2e_snapshots/main.rs @@ -488,5 +488,8 @@ fn main() { for case_path in &fixture_paths { run_case(&tmp_dir_path, case_path, filter.as_deref()); } - println!("All cases passed."); + #[expect(clippy::print_stdout, reason = "test summary")] + { + println!("All cases passed."); + } } diff --git a/crates/vite_task_plan/tests/plan_snapshots/main.rs b/crates/vite_task_plan/tests/plan_snapshots/main.rs index f5d3effe..1ea6877f 100644 --- a/crates/vite_task_plan/tests/plan_snapshots/main.rs +++ b/crates/vite_task_plan/tests/plan_snapshots/main.rs @@ -317,8 +317,11 @@ fn main() { let tests_dir = std::env::current_dir().unwrap().join("tests"); insta::glob!(tests_dir, "plan_snapshots/fixtures/*", |case_path| { - run_case(&tokio_runtime, &tmp_dir_path, case_path, filter.as_deref()) + run_case(&tokio_runtime, &tmp_dir_path, case_path, filter.as_deref()); }); - println!("All cases passed."); + #[expect(clippy::print_stdout, reason = "test summary")] + { + println!("All cases passed."); + } } From 33e01f8bb48da95684e3e32d6b4e37b9730e8da4 Mon Sep 17 00:00:00 2001 From: branchseer Date: Mon, 9 Mar 2026 00:28:36 +0800 Subject: [PATCH 31/32] fix: normalize `..` in fspy paths for cross-platform consistency fspy reports paths with `..` components (e.g. `packages/sub-pkg/../shared/src/utils.ts`) on macOS but normalized paths on Linux/Windows. Always clean `..` before storing to ensure consistent cache miss messages across platforms. Co-Authored-By: Claude Opus 4.6 --- crates/vite_task/src/session/execute/spawn.rs | 17 +++++++++-------- ...s on non-excluded sibling inferred file.snap | 2 +- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/crates/vite_task/src/session/execute/spawn.rs b/crates/vite_task/src/session/execute/spawn.rs index dc0a1ccc..1798dd18 100644 --- a/crates/vite_task/src/session/execute/spawn.rs +++ b/crates/vite_task/src/session/execute/spawn.rs @@ -206,14 +206,15 @@ pub async fn spawn_with_tracking( return None; } - // Clean `..` components only for glob matching — fspy may report paths - // like `packages/sub-pkg/../shared/dist/output.js` that won't match - // workspace-root-relative negative globs without normalization. - if !resolved_negatives.is_empty() { - let cleaned = relative.clean(); - if resolved_negatives.iter().any(|neg| neg.is_match(cleaned.as_str())) { - return None; - } + // Clean `..` components — fspy may report paths like + // `packages/sub-pkg/../shared/dist/output.js`. Normalize them for + // consistent behavior across platforms and clean user-facing messages. + let relative = relative.clean(); + + if !resolved_negatives.is_empty() + && resolved_negatives.iter().any(|neg| neg.is_match(relative.as_str())) + { + return None; } Some(relative) diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-negative-glob-subpackage/snapshots/dotdot auto negative - miss on non-excluded sibling inferred file.snap b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-negative-glob-subpackage/snapshots/dotdot auto negative - miss on non-excluded sibling inferred file.snap index 849cea8e..3a6b1ca1 100644 --- a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-negative-glob-subpackage/snapshots/dotdot auto negative - miss on non-excluded sibling inferred file.snap +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-negative-glob-subpackage/snapshots/dotdot auto negative - miss on non-excluded sibling inferred file.snap @@ -9,6 +9,6 @@ export const shared = 'initial'; > replace-file-content packages/shared/src/utils.ts initial modified > vp run sub-pkg#dotdot-auto-negative -~/packages/sub-pkg$ print-file ../shared/src/utils.ts ../shared/dist/output.js ✗ cache miss: content of input 'packages/sub-pkg/../shared/src/utils.ts' changed, executing +~/packages/sub-pkg$ print-file ../shared/src/utils.ts ../shared/dist/output.js ✗ cache miss: content of input 'packages/shared/src/utils.ts' changed, executing export const shared = 'modified'; // initial output From 6efbfc2908b20282402f0981989d08913f88d0b6 Mon Sep 17 00:00:00 2001 From: branchseer Date: Mon, 9 Mar 2026 05:45:18 +0800 Subject: [PATCH 32/32] fix: move clean logic --- crates/vite_task/src/session/execute/spawn.rs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/crates/vite_task/src/session/execute/spawn.rs b/crates/vite_task/src/session/execute/spawn.rs index 1798dd18..7c78d621 100644 --- a/crates/vite_task/src/session/execute/spawn.rs +++ b/crates/vite_task/src/session/execute/spawn.rs @@ -201,16 +201,16 @@ pub async fn spawn_with_tracking( // Just ignore those accesses. let relative = RelativePathBuf::new(stripped_path).ok()?; - // Skip .git directory accesses (workaround for tools like oxlint) - if relative.as_path().strip_prefix(".git").is_ok() { - return None; - } - // Clean `..` components — fspy may report paths like // `packages/sub-pkg/../shared/dist/output.js`. Normalize them for // consistent behavior across platforms and clean user-facing messages. let relative = relative.clean(); + // Skip .git directory accesses (workaround for tools like oxlint) + if relative.as_path().strip_prefix(".git").is_ok() { + return None; + } + if !resolved_negatives.is_empty() && resolved_negatives.iter().any(|neg| neg.is_match(relative.as_str())) {