diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a8b613a8..dc9e10174 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ - **Fixed** Cached tasks can now run scripts whose shebang names an interpreter path longer than 126 bytes. Before, the path was cut short and the script failed to start or ran the wrong program. On macOS, text after a `#` in the shebang line is now ignored, as it is when the script runs on its own ([#788](https://github.com/voidzero-dev/vite-task/pull/788)). - **Fixed** On macOS, tasks with automatic file-access tracking can now run Node.js single executable applications, such as pnpm 11's standalone `pnpm` binary, which previously aborted with `Assertion failed: (magic) == (kMagic)`, and `deno compile` outputs, which previously failed with `Did not find magic bytes` ([#840](https://github.com/voidzero-dev/vite-task/pull/840)). -- **Fixed** Reinstalling dependencies with pnpm, or installing them at another path, no longer causes cache misses, and tasks that run `pnpm run` or `pnpm exec` no longer miss the cache after pnpm rechecks dependencies ([#823](https://github.com/voidzero-dev/vite-task/pull/823), [#824](https://github.com/voidzero-dev/vite-task/pull/824), [#825](https://github.com/voidzero-dev/vite-task/pull/825)). +- **Fixed** Reinstalling dependencies with pnpm, or installing them at another path or with another pnpm version, no longer causes cache misses, and tasks that run `pnpm run` or `pnpm exec` no longer miss the cache after pnpm rechecks dependencies ([#823](https://github.com/voidzero-dev/vite-task/pull/823), [#824](https://github.com/voidzero-dev/vite-task/pull/824), [#825](https://github.com/voidzero-dev/vite-task/pull/825), [#850](https://github.com/voidzero-dev/vite-task/pull/850)). - **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/Cargo.lock b/Cargo.lock index 8f528c0f3..5ef69a282 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4939,7 +4939,6 @@ dependencies = [ "rustc-hash", "serde", "serde_json", - "serde_norway", "supports-color 3.0.2", "tar", "tempfile", diff --git a/crates/vt/Cargo.toml b/crates/vt/Cargo.toml index d11458bb1..1be50ccaf 100644 --- a/crates/vt/Cargo.toml +++ b/crates/vt/Cargo.toml @@ -30,7 +30,6 @@ rusqlite = { workspace = true, features = ["bundled"] } rustc-hash = { workspace = true } serde = { workspace = true, features = ["derive", "rc"] } serde_json = { workspace = true } -serde_norway = { workspace = true } supports-color = { workspace = true } thiserror = { workspace = true } tar = { workspace = true } diff --git a/crates/vt/src/session/execute/hash.rs b/crates/vt/src/session/execute/hash.rs index 738776a09..080963443 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, node_modules_bin, pnpm_modules_manifest}; +use super::redact::{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 { @@ -19,54 +19,16 @@ pub(super) fn hash_content(mut stream: impl io::Read) -> io::Result { Ok(hasher.finish()) } -/// Hash the content of the file at `path`, read from `stream`. Files with -/// noise in their content are hashed without it; see [`super::redact`]. +/// Hash the content of the file at `path`, read from `stream`. Files whose +/// content is noise hash as empty, so only whether they exist is +/// fingerprinted; see [`super::redact`]. pub(super) fn hash_file_content(path: &AbsolutePath, stream: impl io::Read) -> io::Result { - 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); + if pnpm_modules_manifest::matches(path) || node_modules_bin::matches(path) { + return hash_content(io::empty()); } hash_content(stream) } -/// Hash what `redact` writes for the content of `stream`, or the raw content -/// when it is not in the format `redact` expects. -fn hash_redacted( - mut stream: impl io::Read, - redact: impl FnOnce(&[u8], &mut HashWriter) -> Result<(), UnexpectedFormat>, -) -> io::Result { - let mut content = Vec::new(); - stream.read_to_end(&mut content)?; - let mut hasher = HashWriter::default(); - match redact(&content, &mut hasher) { - Ok(()) => Ok(hasher.finish()), - Err(UnexpectedFormat) => hash_content(content.as_slice()), - } -} - -/// Hashes the bytes written to it with `xxHash3_64`. -#[derive(Default)] -struct HashWriter(XxHash3_64); - -impl HashWriter { - fn finish(&self) -> u64 { - self.0.finish() - } -} - -impl io::Write for HashWriter { - fn write(&mut self, buf: &[u8]) -> io::Result { - self.0.write(buf); - Ok(buf.len()) - } - - fn flush(&mut self) -> io::Result<()> { - Ok(()) - } -} - #[cfg(test)] mod tests { use super::*; @@ -74,7 +36,7 @@ mod tests { const MANIFEST: &[u8] = br#"{ "packageManager": "pnpm@11.24.0", "prunedAt": "Thu, 08 Oct 2026 08:18:18 GMT" }"#; const MANIFEST_REINSTALLED: &[u8] = - br#"{ "packageManager": "pnpm@11.24.0", "prunedAt": "Fri, 09 Oct 2026 03:13:58 GMT" }"#; + br#"{ "packageManager": "pnpm@11.25.0", "prunedAt": "Fri, 09 Oct 2026 03:13:58 GMT" }"#; fn hash_at(relative_path: &str, content: &[u8]) -> u64 { let path = vt_path::current_dir().unwrap().join(relative_path); @@ -82,22 +44,13 @@ mod tests { } #[test] - fn redacted_file_hashes_without_noise() { + fn redacted_file_hashes_without_content() { assert_eq!( hash_at("node_modules/.modules.yaml", MANIFEST), hash_at("node_modules/.modules.yaml", MANIFEST_REINSTALLED) ); } - #[test] - fn unparsable_redacted_file_hashes_raw_content() { - let content = b"{ \"prunedAt\": "; - assert_eq!( - hash_at("node_modules/.modules.yaml", content), - hash_content(&content[..]).unwrap() - ); - } - #[test] fn other_files_hash_raw_content() { assert_eq!(hash_at(".modules.yaml", MANIFEST), hash_content(MANIFEST).unwrap()); diff --git a/crates/vt/src/session/execute/redact/mod.rs b/crates/vt/src/session/execute/redact/mod.rs index d5a339b30..6c54686ea 100644 --- a/crates/vt/src/session/execute/redact/mod.rs +++ b/crates/vt/src/session/execute/redact/mod.rs @@ -1,33 +1,9 @@ //! Files whose content carries noise — timestamps, machine-specific paths — //! that would otherwise cause cache misses without any meaningful change. +//! Only whether they exist is fingerprinted. //! -//! To redact another file, add a module here with a `matches` condition and a -//! `redact` action, and call them from -//! [`hash_file_content`](super::hash::hash_file_content). +//! To add another file, add a module here with a `matches` condition, and +//! check it in [`hash_file_content`](super::hash::hash_file_content). pub(super) mod node_modules_bin; pub(super) mod pnpm_modules_manifest; - -use std::io; - -use serde_json::Value; - -/// The content is not in the format a redaction expects, so the raw content is -/// hashed instead. -#[derive(Debug, PartialEq, Eq)] -pub struct UnexpectedFormat; - -/// Writes `value` as JSON with every object's keys sorted, so the output does -/// not depend on the file's key order. -/// -/// Serializing a JSON value fails only when `out` does, which the in-memory -/// writers used here never do. -fn write_canonical_json( - mut value: Value, - out: &mut W, -) -> Result<(), UnexpectedFormat> { - // serde_json's `preserve_order` feature is enabled in this workspace, so - // objects keep the file's key order until sorted. - value.sort_all_objects(); - serde_json::to_writer(out, &value).map_err(|_| UnexpectedFormat) -} diff --git a/crates/vt/src/session/execute/redact/node_modules_bin.rs b/crates/vt/src/session/execute/redact/node_modules_bin.rs index 4595ccfe8..d6c9a9057 100644 --- a/crates/vt/src/session/execute/redact/node_modules_bin.rs +++ b/crates/vt/src/session/execute/redact/node_modules_bin.rs @@ -5,23 +5,13 @@ //! 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::*; diff --git a/crates/vt/src/session/execute/redact/pnpm_modules_manifest.rs b/crates/vt/src/session/execute/redact/pnpm_modules_manifest.rs index 3c96d5081..8affd679e 100644 --- a/crates/vt/src/session/execute/redact/pnpm_modules_manifest.rs +++ b/crates/vt/src/session/execute/redact/pnpm_modules_manifest.rs @@ -1,79 +1,21 @@ //! pnpm's `node_modules/.modules.yaml`, which records how `node_modules` was -//! installed. +//! installed, including timestamps, absolute paths, and pnpm's version. +//! +//! Only whether it exists is fingerprinted. The installation it describes is +//! tracked through the files in `node_modules` the task reads. -use std::io; - -use serde_json::Value; use vt_path::AbsolutePath; -use super::{UnexpectedFormat, write_canonical_json}; - -/// Fields that vary with time or with the machine rather than with what is -/// installed: -/// -/// - `prunedAt`: when pnpm last pruned the virtual store. -/// - `storeDir`: absolute path of pnpm's store. -/// - `virtualStoreDir`: absolute on Windows, and inside pnpm's store with the -/// global virtual store. -/// - `linkedSkills`: absolute for skill entries outside the workspace. -const UNSTABLE_FIELDS: [&str; 4] = ["prunedAt", "storeDir", "virtualStoreDir", "linkedSkills"]; - /// Whether `path` is pnpm's modules manifest. Matches whole path components, /// with either separator on Windows. pub fn matches(path: &AbsolutePath) -> bool { path.ends_with("node_modules/.modules.yaml") } -/// Writes the manifest without [`UNSTABLE_FIELDS`] to `out`. Fails when -/// `content` is neither a JSON nor a YAML object. -pub fn redact(content: &[u8], out: &mut W) -> Result<(), UnexpectedFormat> { - let mut manifest = parse_json_or_yaml(content)?; - let fields = manifest.as_object_mut().ok_or(UnexpectedFormat)?; - for field in UNSTABLE_FIELDS { - fields.remove(field); - } - write_canonical_json(manifest, out) -} - -/// pnpm 10.29 and later write JSON; earlier versions write YAML. JSON is tried -/// first because it parses faster, and because the YAML parser rejects keys -/// longer than 1024 characters, which long dependency paths exceed. -fn parse_json_or_yaml(content: &[u8]) -> Result { - serde_json::from_slice(content) - .or_else(|_| serde_norway::from_slice(content)) - .map_err(|_| UnexpectedFormat) -} - #[cfg(test)] mod tests { - use serde_json::json; - use super::*; - fn manifest() -> Value { - json!({ - "hoistedDependencies": { - "debug@4.4.0": { "debug": "private" }, - "ms@2.1.3": { "ms": "private" }, - }, - "packageManager": "pnpm@11.24.0", - "prunedAt": "Thu, 08 Oct 2026 08:18:18 GMT", - "storeDir": "/Users/alice/Library/pnpm/store/v11", - "virtualStoreDir": ".pnpm", - "linkedSkills": [".claude/skills/a"], - }) - } - - fn redact_manifest(content: &[u8]) -> Result, UnexpectedFormat> { - let mut out = Vec::new(); - redact(content, &mut out)?; - Ok(out) - } - - fn redact_manifest_value(manifest: &Value) -> Vec { - redact_manifest(&serde_json::to_vec_pretty(manifest).unwrap()).unwrap() - } - #[test] fn matches_only_inside_node_modules() { let cwd = vt_path::current_dir().unwrap(); @@ -84,72 +26,4 @@ mod tests { #[cfg(windows)] assert!(matches(&cwd.join(r"packages\a\node_modules\.modules.yaml"))); } - - #[test] - fn unstable_fields_do_not_change_redacted_manifest() { - let mut changed = manifest(); - changed["prunedAt"] = json!("Fri, 09 Oct 2026 03:13:58 GMT"); - changed["storeDir"] = json!("/tmp/store/v11"); - changed["virtualStoreDir"] = json!("../../tmp/store/v11/links"); - changed["linkedSkills"] = json!(["/opt/skills/a"]); - assert_eq!(redact_manifest_value(&manifest()), redact_manifest_value(&changed)); - } - - #[test] - fn other_fields_change_redacted_manifest() { - let mut changed = manifest(); - changed["packageManager"] = json!("pnpm@11.25.0"); - assert_ne!(redact_manifest_value(&manifest()), redact_manifest_value(&changed)); - } - - #[test] - fn key_order_does_not_change_redacted_manifest() { - let reordered = json!({ - "linkedSkills": [".claude/skills/a"], - "virtualStoreDir": ".pnpm", - "storeDir": "/Users/alice/Library/pnpm/store/v11", - "prunedAt": "Thu, 08 Oct 2026 08:18:18 GMT", - "packageManager": "pnpm@11.24.0", - "hoistedDependencies": { - "ms@2.1.3": { "ms": "private" }, - "debug@4.4.0": { "debug": "private" }, - }, - }); - assert_ne!( - serde_json::to_vec(&manifest()).unwrap(), - serde_json::to_vec(&reordered).unwrap() - ); - assert_eq!(redact_manifest_value(&manifest()), redact_manifest_value(&reordered)); - } - - #[test] - fn yaml_manifest_redacts_like_json_manifest() { - let yaml = b"hoistedDependencies: - debug@4.4.0: - debug: private - ms@2.1.3: - ms: private -linkedSkills: - - /opt/skills/a -packageManager: pnpm@11.24.0 -prunedAt: Fri, 09 Oct 2026 03:13:58 GMT -storeDir: /tmp/store/v11 -virtualStoreDir: ../../tmp/store/v11/links -"; - assert_eq!(redact_manifest(yaml).unwrap(), redact_manifest_value(&manifest())); - } - - #[test] - fn json_manifest_with_long_keys_is_redacted() { - let mut manifest = manifest(); - manifest["hoistedDependencies"]["a".repeat(2000)] = json!({ "a": "private" }); - let mut changed = manifest.clone(); - changed["prunedAt"] = json!("Fri, 09 Oct 2026 03:13:58 GMT"); - assert_eq!(redact_manifest_value(&manifest), redact_manifest_value(&changed)); - } - - #[test] - fn unparsable_manifest_is_not_redacted() { - assert_eq!(redact_manifest(b"{ \"prunedAt\": "), Err(UnexpectedFormat)); - } } diff --git a/crates/vt_bin/tests/e2e_snapshots/fixtures/pnpm_modules_manifest/snapshots.toml b/crates/vt_bin/tests/e2e_snapshots/fixtures/pnpm_modules_manifest/snapshots.toml index 0e52316e1..d146134c0 100644 --- a/crates/vt_bin/tests/e2e_snapshots/fixtures/pnpm_modules_manifest/snapshots.toml +++ b/crates/vt_bin/tests/e2e_snapshots/fixtures/pnpm_modules_manifest/snapshots.toml @@ -1,7 +1,7 @@ [[e2e]] -name = "pnpm_modules_manifest_unstable_fields" +name = "pnpm_modules_manifest_content_ignored" comment = """ -Fields of pnpm's `node_modules/.modules.yaml` that vary with time or with the machine are left out of its fingerprint, so changing them keeps the cache hit. Changing any other field is a cache miss. +Only whether pnpm's `node_modules/.modules.yaml` exists is part of the fingerprint, so changing its content keeps the cache hit. Removing it is a cache miss. """ steps = [ [ @@ -32,23 +32,9 @@ steps = [ "vtt", "replace-file-content", "node_modules/.modules.yaml", - "/home/alice/.local/share/pnpm/store/v11", - "/home/bob/.local/share/pnpm/store/v11", - ], comment = "another machine: new storeDir" }, - { argv = [ - "vtt", - "replace-file-content", - "node_modules/.modules.yaml", - "\".pnpm\"", - "\"../../home/bob/.local/share/pnpm/store/v11/links\"", - ], comment = "global virtual store: new virtualStoreDir" }, - { argv = [ - "vtt", - "replace-file-content", - "node_modules/.modules.yaml", - "\".claude/skills/a\"", - "\"/home/bob/skills/a\"", - ], comment = "skill outside the workspace: new linkedSkills" }, + "pnpm@11.24.0", + "pnpm@11.25.0", + ], comment = "install with another pnpm version: new packageManager" }, { argv = [ "vt", "run", @@ -56,14 +42,12 @@ steps = [ ], comment = "cache hit" }, { argv = [ "vtt", - "replace-file-content", + "rm", "node_modules/.modules.yaml", - "pnpm@11.24.0", - "pnpm@11.25.0", - ], comment = "new packageManager" }, + ], comment = "remove the manifest" }, { argv = [ "vt", "run", "read-manifest", - ], comment = "cache miss: 'node_modules/.modules.yaml' modified" }, + ], comment = "cache miss: input removed" }, ] diff --git a/crates/vt_bin/tests/e2e_snapshots/fixtures/pnpm_modules_manifest/snapshots/pnpm_modules_manifest_content_ignored.md b/crates/vt_bin/tests/e2e_snapshots/fixtures/pnpm_modules_manifest/snapshots/pnpm_modules_manifest_content_ignored.md new file mode 100644 index 000000000..028847b42 --- /dev/null +++ b/crates/vt_bin/tests/e2e_snapshots/fixtures/pnpm_modules_manifest/snapshots/pnpm_modules_manifest_content_ignored.md @@ -0,0 +1,76 @@ +# pnpm_modules_manifest_content_ignored + +Only whether pnpm's `node_modules/.modules.yaml` exists is part of the fingerprint, so changing its content keeps the cache hit. Removing it is a cache miss. + +## `vtt mkdir -p node_modules` + +``` +``` + +## `vtt cp modules.json node_modules/.modules.yaml` + +``` +``` + +## `vt run read-manifest` + +cache miss + +``` +$ vtt print-file node_modules/.modules.yaml +{ + "packageManager": "pnpm@11.24.0", + "prunedAt": "Thu, 08 Oct 2026 08:18:18 GMT", + "storeDir": "/home/alice/.local/share/pnpm/store/v11", + "virtualStoreDir": ".pnpm", + "linkedSkills": [".claude/skills/a"] +} +``` + +## `vtt replace-file-content node_modules/.modules.yaml 'Thu, 08 Oct 2026 08:18:18 GMT' 'Fri, 09 Oct 2026 03:13:58 GMT'` + +reinstall: new prunedAt + +``` +``` + +## `vtt replace-file-content node_modules/.modules.yaml pnpm@11.24.0 pnpm@11.25.0` + +install with another pnpm version: new packageManager + +``` +``` + +## `vt run read-manifest` + +cache hit + +``` +$ vtt print-file node_modules/.modules.yaml ◉ cache hit, replaying +{ + "packageManager": "pnpm@11.24.0", + "prunedAt": "Thu, 08 Oct 2026 08:18:18 GMT", + "storeDir": "/home/alice/.local/share/pnpm/store/v11", + "virtualStoreDir": ".pnpm", + "linkedSkills": [".claude/skills/a"] +} + +--- +vt run: cache hit. +``` + +## `vtt rm node_modules/.modules.yaml` + +remove the manifest + +``` +``` + +## `vt run read-manifest` + +cache miss: input removed + +``` +$ vtt print-file node_modules/.modules.yaml ○ cache miss: '.modules.yaml' removed from 'node_modules', executing +node_modules/.modules.yaml: not found +``` diff --git a/crates/vt_bin/tests/e2e_snapshots/fixtures/pnpm_modules_manifest/snapshots/pnpm_modules_manifest_unstable_fields.md b/crates/vt_bin/tests/e2e_snapshots/fixtures/pnpm_modules_manifest/snapshots/pnpm_modules_manifest_unstable_fields.md deleted file mode 100644 index 7613c7021..000000000 --- a/crates/vt_bin/tests/e2e_snapshots/fixtures/pnpm_modules_manifest/snapshots/pnpm_modules_manifest_unstable_fields.md +++ /dev/null @@ -1,96 +0,0 @@ -# pnpm_modules_manifest_unstable_fields - -Fields of pnpm's `node_modules/.modules.yaml` that vary with time or with the machine are left out of its fingerprint, so changing them keeps the cache hit. Changing any other field is a cache miss. - -## `vtt mkdir -p node_modules` - -``` -``` - -## `vtt cp modules.json node_modules/.modules.yaml` - -``` -``` - -## `vt run read-manifest` - -cache miss - -``` -$ vtt print-file node_modules/.modules.yaml -{ - "packageManager": "pnpm@11.24.0", - "prunedAt": "Thu, 08 Oct 2026 08:18:18 GMT", - "storeDir": "/home/alice/.local/share/pnpm/store/v11", - "virtualStoreDir": ".pnpm", - "linkedSkills": [".claude/skills/a"] -} -``` - -## `vtt replace-file-content node_modules/.modules.yaml 'Thu, 08 Oct 2026 08:18:18 GMT' 'Fri, 09 Oct 2026 03:13:58 GMT'` - -reinstall: new prunedAt - -``` -``` - -## `vtt replace-file-content node_modules/.modules.yaml /home/alice/.local/share/pnpm/store/v11 /home/bob/.local/share/pnpm/store/v11` - -another machine: new storeDir - -``` -``` - -## `vtt replace-file-content node_modules/.modules.yaml '".pnpm"' '"../../home/bob/.local/share/pnpm/store/v11/links"'` - -global virtual store: new virtualStoreDir - -``` -``` - -## `vtt replace-file-content node_modules/.modules.yaml '".claude/skills/a"' '"/home/bob/skills/a"'` - -skill outside the workspace: new linkedSkills - -``` -``` - -## `vt run read-manifest` - -cache hit - -``` -$ vtt print-file node_modules/.modules.yaml ◉ cache hit, replaying -{ - "packageManager": "pnpm@11.24.0", - "prunedAt": "Thu, 08 Oct 2026 08:18:18 GMT", - "storeDir": "/home/alice/.local/share/pnpm/store/v11", - "virtualStoreDir": ".pnpm", - "linkedSkills": [".claude/skills/a"] -} - ---- -vt run: cache hit. -``` - -## `vtt replace-file-content node_modules/.modules.yaml pnpm@11.24.0 pnpm@11.25.0` - -new packageManager - -``` -``` - -## `vt run read-manifest` - -cache miss: 'node_modules/.modules.yaml' modified - -``` -$ vtt print-file node_modules/.modules.yaml ○ cache miss: 'node_modules/.modules.yaml' modified, executing -{ - "packageManager": "pnpm@11.25.0", - "prunedAt": "Fri, 09 Oct 2026 03:13:58 GMT", - "storeDir": "/home/bob/.local/share/pnpm/store/v11", - "virtualStoreDir": "../../home/bob/.local/share/pnpm/store/v11/links", - "linkedSkills": ["/home/bob/skills/a"] -} -```