From ec0eba16b5ce9b91d7c3c4bddb72419418398e0e Mon Sep 17 00:00:00 2001 From: wan9chi Date: Fri, 9 Oct 2026 14:04:06 +0800 Subject: [PATCH] fix(cache): fingerprint only whether files in node_modules/.bin exist pnpm's bin shims embed the absolute workspace path in `NODE_PATH` and a `cmd-shim-target` comment, so any task that runs a dependency's executable missed after installing with the workspace at another path. What a shim runs is tracked through the package files the task reads, so the content of files directly inside `node_modules/.bin` is no longer hashed. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 +- crates/vt/src/session/execute/hash.rs | 5 +- crates/vt/src/session/execute/redact/mod.rs | 1 + .../execute/redact/node_modules_bin.rs | 40 ++++++++++++ .../fixtures/node_modules_bin/package.json | 4 ++ .../fixtures/node_modules_bin/shim.sh | 4 ++ .../fixtures/node_modules_bin/snapshots.toml | 46 ++++++++++++++ .../node_modules_bin_content_ignored.md | 63 +++++++++++++++++++ .../fixtures/node_modules_bin/vite-task.json | 8 +++ 9 files changed, 171 insertions(+), 2 deletions(-) create mode 100644 crates/vt/src/session/execute/redact/node_modules_bin.rs create mode 100644 crates/vt_bin/tests/e2e_snapshots/fixtures/node_modules_bin/package.json create mode 100644 crates/vt_bin/tests/e2e_snapshots/fixtures/node_modules_bin/shim.sh create mode 100644 crates/vt_bin/tests/e2e_snapshots/fixtures/node_modules_bin/snapshots.toml create mode 100644 crates/vt_bin/tests/e2e_snapshots/fixtures/node_modules_bin/snapshots/node_modules_bin_content_ignored.md create mode 100644 crates/vt_bin/tests/e2e_snapshots/fixtures/node_modules_bin/vite-task.json diff --git a/CHANGELOG.md b/CHANGELOG.md index c6393b452..d6d8902ae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -- **Fixed** Reinstalling dependencies with pnpm no longer causes cache misses in tasks that run Vite or Vitest ([#823](https://github.com/voidzero-dev/vite-task/pull/823)). +- **Fixed** Reinstalling dependencies with pnpm, or installing them at another path, no longer causes cache misses ([#823](https://github.com/voidzero-dev/vite-task/pull/823), [#824](https://github.com/voidzero-dev/vite-task/pull/824)). - **Added** Vite+ now builds and runs on platforms without file-access tracking support, such as FreeBSD. On those platforms, automatic input and output tracking is unavailable, so tasks that rely on it report that tracking is unsupported and must configure their `input` and `output` manually to be cached ([#829](https://github.com/voidzero-dev/vite-task/pull/829)). - **Changed** The `vp run` summary now reads like `3/4 cache hit (75%), 9/10 successful, 2s saved.` The cache hit count leaves out tasks with caching disabled, and the count of successful tasks out of all tasks replaces the count of failed tasks. When no task has caching enabled, the summary shows only the successful count. In `vp run --verbose` and `vp run --last-details`, the cache hit rate also leaves out tasks with caching disabled, and says `no task has caching enabled` when there are none ([#821](https://github.com/voidzero-dev/vite-task/pull/821)). - **Perf** On Linux, tasks with automatic file-access tracking start faster ([#817](https://github.com/voidzero-dev/vite-task/pull/817)). diff --git a/crates/vt/src/session/execute/hash.rs b/crates/vt/src/session/execute/hash.rs index bfde5c66f..738776a09 100644 --- a/crates/vt/src/session/execute/hash.rs +++ b/crates/vt/src/session/execute/hash.rs @@ -3,7 +3,7 @@ use std::{hash::Hasher as _, io}; use twox_hash::XxHash3_64; use vt_path::AbsolutePath; -use super::redact::{UnexpectedFormat, pnpm_modules_manifest}; +use super::redact::{UnexpectedFormat, node_modules_bin, pnpm_modules_manifest}; /// Hash content using 8 KiB buffered `xxHash3_64`. pub(super) fn hash_content(mut stream: impl io::Read) -> io::Result { @@ -25,6 +25,9 @@ pub(super) fn hash_file_content(path: &AbsolutePath, stream: impl io::Read) -> i if pnpm_modules_manifest::matches(path) { return hash_redacted(stream, pnpm_modules_manifest::redact); } + if node_modules_bin::matches(path) { + return hash_redacted(stream, node_modules_bin::redact); + } hash_content(stream) } diff --git a/crates/vt/src/session/execute/redact/mod.rs b/crates/vt/src/session/execute/redact/mod.rs index 79eb04bcd..d5a339b30 100644 --- a/crates/vt/src/session/execute/redact/mod.rs +++ b/crates/vt/src/session/execute/redact/mod.rs @@ -5,6 +5,7 @@ //! `redact` action, and call them from //! [`hash_file_content`](super::hash::hash_file_content). +pub(super) mod node_modules_bin; pub(super) mod pnpm_modules_manifest; use std::io; diff --git a/crates/vt/src/session/execute/redact/node_modules_bin.rs b/crates/vt/src/session/execute/redact/node_modules_bin.rs new file mode 100644 index 000000000..4595ccfe8 --- /dev/null +++ b/crates/vt/src/session/execute/redact/node_modules_bin.rs @@ -0,0 +1,40 @@ +//! Executables in `node_modules/.bin`. pnpm writes them as shell, `.cmd`, and +//! `.ps1` shims that embed absolute paths of the workspace or pnpm's store; +//! npm and Yarn create symlinks to the packages' bin files instead. +//! +//! Only whether one exists is fingerprinted. What it runs is tracked through +//! the package files the task reads. + +use std::io; + +use vt_path::AbsolutePath; + +use super::UnexpectedFormat; + +/// Whether `path` is directly inside a `node_modules/.bin` directory. +pub fn matches(path: &AbsolutePath) -> bool { + path.parent().is_some_and(|dir| dir.ends_with("node_modules/.bin")) +} + +/// Writes nothing, so every executable in `node_modules/.bin` hashes the same. +#[expect(clippy::unnecessary_wraps, reason = "matches the signature of the other redactions")] +pub const fn redact(_content: &[u8], _out: &mut W) -> Result<(), UnexpectedFormat> { + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn matches_only_directly_inside_node_modules_bin() { + let cwd = vt_path::current_dir().unwrap(); + assert!(matches(&cwd.join("node_modules/.bin/tsc"))); + assert!(matches(&cwd.join("packages/a/node_modules/.bin/tsc.cmd"))); + assert!(!matches(&cwd.join("node_modules/typescript/bin/tsc"))); + assert!(!matches(&cwd.join("node_modules/.bin"))); + assert!(!matches(&cwd.join(".bin/tsc"))); + #[cfg(windows)] + assert!(matches(&cwd.join(r"packages\a\node_modules\.bin\tsc.ps1"))); + } +} diff --git a/crates/vt_bin/tests/e2e_snapshots/fixtures/node_modules_bin/package.json b/crates/vt_bin/tests/e2e_snapshots/fixtures/node_modules_bin/package.json new file mode 100644 index 000000000..75f137f1b --- /dev/null +++ b/crates/vt_bin/tests/e2e_snapshots/fixtures/node_modules_bin/package.json @@ -0,0 +1,4 @@ +{ + "name": "node-modules-bin", + "private": true +} diff --git a/crates/vt_bin/tests/e2e_snapshots/fixtures/node_modules_bin/shim.sh b/crates/vt_bin/tests/e2e_snapshots/fixtures/node_modules_bin/shim.sh new file mode 100644 index 000000000..6007044e3 --- /dev/null +++ b/crates/vt_bin/tests/e2e_snapshots/fixtures/node_modules_bin/shim.sh @@ -0,0 +1,4 @@ +#!/bin/sh +export NODE_PATH="/home/alice/project/node_modules/.pnpm/tool@1.0.0/node_modules" +exec node "$basedir/../tool/bin/tool" "$@" +# cmd-shim-target=/home/alice/project/node_modules/tool/bin/tool diff --git a/crates/vt_bin/tests/e2e_snapshots/fixtures/node_modules_bin/snapshots.toml b/crates/vt_bin/tests/e2e_snapshots/fixtures/node_modules_bin/snapshots.toml new file mode 100644 index 000000000..5fe4d40c9 --- /dev/null +++ b/crates/vt_bin/tests/e2e_snapshots/fixtures/node_modules_bin/snapshots.toml @@ -0,0 +1,46 @@ +[[e2e]] +name = "node_modules_bin_content_ignored" +comment = """ +Only whether a file in `node_modules/.bin` exists is part of the fingerprint, so a shim rewritten with the workspace at another path keeps the cache hit. Removing it is a cache miss. +""" +steps = [ + [ + "vtt", + "mkdir", + "-p", + "node_modules/.bin", + ], + [ + "vtt", + "cp", + "shim.sh", + "node_modules/.bin/tool", + ], + { argv = [ + "vt", + "run", + "read-shim", + ], comment = "cache miss" }, + { argv = [ + "vtt", + "replace-file-content", + "node_modules/.bin/tool", + "/home/alice/project", + "/home/bob/project", + ], comment = "reinstall with the workspace at another path" }, + { argv = [ + "vt", + "run", + "read-shim", + ], comment = "cache hit" }, + { argv = [ + "vtt", + "rm", + "node_modules/.bin/tool", + ], comment = "remove the shim" }, + { argv = [ + "vt", + "run", + "read-shim", + ], comment = "cache miss: input removed" }, +] diff --git a/crates/vt_bin/tests/e2e_snapshots/fixtures/node_modules_bin/snapshots/node_modules_bin_content_ignored.md b/crates/vt_bin/tests/e2e_snapshots/fixtures/node_modules_bin/snapshots/node_modules_bin_content_ignored.md new file mode 100644 index 000000000..4cdea2653 --- /dev/null +++ b/crates/vt_bin/tests/e2e_snapshots/fixtures/node_modules_bin/snapshots/node_modules_bin_content_ignored.md @@ -0,0 +1,63 @@ +# node_modules_bin_content_ignored + +Only whether a file in `node_modules/.bin` exists is part of the fingerprint, so a shim rewritten with the workspace at another path keeps the cache hit. Removing it is a cache miss. + +## `vtt mkdir -p node_modules/.bin` + +``` +``` + +## `vtt cp shim.sh node_modules/.bin/tool` + +``` +``` + +## `vt run read-shim` + +cache miss + +``` +$ vtt print-file node_modules/.bin/tool +#!/bin/sh +export NODE_PATH="/home/alice/project/node_modules/.pnpm/tool@1.0.0/node_modules" +exec node "$basedir/../tool/bin/tool" "$@" +# cmd-shim-target=/home/alice/project/node_modules/tool/bin/tool +``` + +## `vtt replace-file-content node_modules/.bin/tool /home/alice/project /home/bob/project` + +reinstall with the workspace at another path + +``` +``` + +## `vt run read-shim` + +cache hit + +``` +$ vtt print-file node_modules/.bin/tool ◉ cache hit, replaying +#!/bin/sh +export NODE_PATH="/home/alice/project/node_modules/.pnpm/tool@1.0.0/node_modules" +exec node "$basedir/../tool/bin/tool" "$@" +# cmd-shim-target=/home/alice/project/node_modules/tool/bin/tool + +--- +vt run: cache hit. +``` + +## `vtt rm node_modules/.bin/tool` + +remove the shim + +``` +``` + +## `vt run read-shim` + +cache miss: input removed + +``` +$ vtt print-file node_modules/.bin/tool ○ cache miss: 'tool' removed from 'node_modules/.bin', executing +node_modules/.bin/tool: not found +``` diff --git a/crates/vt_bin/tests/e2e_snapshots/fixtures/node_modules_bin/vite-task.json b/crates/vt_bin/tests/e2e_snapshots/fixtures/node_modules_bin/vite-task.json new file mode 100644 index 000000000..3a8b2e625 --- /dev/null +++ b/crates/vt_bin/tests/e2e_snapshots/fixtures/node_modules_bin/vite-task.json @@ -0,0 +1,8 @@ +{ + "tasks": { + "read-shim": { + "command": "vtt print-file node_modules/.bin/tool", + "cache": true + } + } +}