diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index 72bffac7a..cd5ad33ac 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -157,7 +157,7 @@ For a **9.0 root lock**, the CLI ensures `pnpm-workspace.yaml` carries `trustLoc * **Hosted and vendored mode (bare `scan` included) — project directories** (`run_project_dirs`). Each PATH is a directory, or a glob (`*?[`) matching directories, relative to `--cwd`; the set is sorted and deduplicated, and each directory is scanned on its own exactly as if it were `--cwd` (its own lockfiles, ledgers and `.socket/`). With more than one directory, each run is headed `== ==` on stdout (unless `--silent`), and the exit code is the worst of the runs. Usage errors (exit 2, stderr only, before any scan): a PATH that is not a directory (`` `X` is not a directory``), a glob matching no directory (`` `X` matches no directory``), an invalid glob, and `--json` with more than one directory (`--json takes one project directory (N given); run one scan per directory`), so stdout stays one document. Likewise `--vex` with more than one directory (`--vex takes one project directory (N given); run one scan per directory`): the one output path would be overwritten by each run. * **Agent mode (and a mode-less `--prune`/`--global` report) — installed-path globs** scoping DISCOVERY at the **purl level**: a package is in scope iff ANY of its crawled installed copies sits under a matching path, and a selected package is then handled with ALL its copies (scoping selects which packages are considered, never which copies). Glob semantics (shared with `rollback`'s path targets, `src/path_scope.rs`): Unix-shell globs with `require_literal_separator` — `*`/`?` never cross a `/`, `**` spans directories; a pattern matching any **ancestor** directory of the copy path also matches, so a bare `scan packages/foo` scopes the whole subtree without `/**`; relative patterns match against the copy path relativized to `--cwd`, absolute patterns against the absolute path (the ONLY way to reach paths outside the project tree, e.g. `--global` stores — a relative pattern never matches outside `--cwd`); leading `./` and trailing `/` are normalized away, matching is purely textual (no filesystem access or symlink resolution), case-sensitive except on Windows (whose filesystems are not); an unparseable or empty pattern is a usage error (exit 2). **The prune universe is never narrowed**: the path filter is applied strictly AFTER the `scanned_purls` capture (and after `--ecosystems`), so `scan PATHS --prune` prunes exactly what an unscoped `scan --prune` would — a scoped scan can never treat an out-of-scope package as uninstalled (the same fail-safe as the `--ecosystems` filter). Lockfile-only and vendor-ledger supplement records have no installed path and are EXCLUDED from a path-scoped scan, surfaced as one run-level `path_scope_excluded_supplements` warning carrying the count. A scope matching nothing is a normal empty scan — exit 0, zero packages, **no GC** (the zero-package early return fires before any GC). `PATHS` combine with `--apply`/`--sync`/`--prune`/`--global`. Every scan JSON shape (success, zero-package, and error alike) carries an always-present `paths` key echoing the patterns verbatim (empty array when unscoped; a hosted/vendored per-directory run is unscoped, so it is `[]`). One-sentence duality rule: **a target that selects nothing is an error on `rollback` (exit 1) and an empty scan on an agent-mode `scan` (exit 0)**. -`scan --vendor` swaps the in-place apply for the vendor pipeline: discover → download the selected patch records **into memory** (no manifest write) → vendor every selected dependency via the same engine as the `vendor` command (under the same lock). Vendored mode is **manifest-free (v5.0)**: `.socket/manifest.json` is never written or read by a vendored run; each ledger entry carries `detached: true` plus an embedded copy of the patch record (`record`) as its verification source, and the run's footprint is `.socket/vendor/**` only. The vendor step's scope is what discovery selected — the former "whole manifest is vendored" re-vendor on an empty discovery is retired (`repair` verifies and redownloads committed vendored state; `scan --prune` reconciles ledger entries whose dependency left the lockfile). A package the ledger holds at an older patch uuid is still **re-vendored automatically** when discovery selects the newer patch (its old uuid dir is removed — `vendor_stale_artifact_removed`); same-uuid re-runs reuse the embedded record, skip the patch-view fetch, and are `already_vendored` skips. **Legacy manifest-mode entries**: when a vendored run vendors a purl that also has a `.socket/manifest.json` record (a project vendored by a pre-5.0 binary, or by standalone `vendor` from an agent-mode manifest), that manifest record is dropped in the same run — the ledger becomes the owner (migration write); an emptied manifest is left as `{"patches": {}}`, never deleted. The migration is reported through the run-level `warnings[]` (stderr in human mode), never as a run error: `vendor_manifest_record_migrated` (`N manifest records moved to the vendor ledger (vendored mode is manifest-free): `) or `vendor_manifest_migration_failed` (the manifest or the ledger could not be read or rewritten; the legacy records were left in place) — so a corrupt `.socket/manifest.json` no longer fails a vendored run (standalone `vendor`, the one manifest-driven writer, still fails closed on it). With `--prune`, GC runs **after** the vendor step (the step never reads the manifest, and running the sweep last lets it reclaim what the run itself orphaned — a migrated legacy record's blobs, a superseded uuid dir). JSON output gains a `download` sub-object — the detached download envelope `{found, downloaded, skipped, failed, detached: true, patches: [{purl, uuid, action: "downloaded" | "skipped" | "failed", …}], warnings?}` (no `applied` field — nothing is applied in place; `detached: true` is pinned and always present; a `downloaded` record whose purl the ledger already holds at another uuid carries the additive `oldUuid` — the re-vendor the vendor step then performs — and its human `[fetch]` line reads ` (replacing )`) — and a `vendor` sub-object (a full vendor Envelope). Patch blobs are held in memory (see "Patch sources stay in memory" under the vendor contract). `--dry-run` previews per-patch `would_vendor` | `would_revendor` (+`oldUuid`) | `already_vendored` — plus, additive, `would_refuse` (+`errorCode`, `error`) for npm purls the wet run's Bun preflight (see the `get --mode vendored` bullet below) would refuse — without network downloads or disk writes; the preview never flips status or exit (the human path — `scan` and `get` alike, through one shared printer — prints `[would-refuse] (): ` lines behind the `--silent` gate). Interactive mode prompts "Download and vendor N patches?" (singular for one). +`scan --vendor` swaps the in-place apply for the vendor pipeline: discover → download the selected patch records **into memory** (no manifest write) → vendor every selected dependency via the same engine as the `vendor` command (under the same lock). Vendored mode is **manifest-free (v5.0)**: `.socket/manifest.json` is never written or read by a vendored run; each ledger entry carries `detached: true` plus an embedded copy of the patch record (`record`) as its verification source, and the run's footprint is `.socket/vendor/**` only. The vendor step's scope is what discovery selected — the former "whole manifest is vendored" re-vendor on an empty discovery is retired (`repair` verifies and redownloads committed vendored state; `scan --prune` reconciles ledger entries whose dependency left the lockfile). The vendor-ledger discovery supplement (the fresh-clone rule: a ledger entry with no installed copy stays discoverable because its committed artifact IS the dependency) holds only while the lockfile still resolves through that artifact: an entry the lockfile in-use probe (the one `--prune` reverts by) proves unwired, because the dependency was upgraded or removed, is NOT discovered and so is never re-vendored. A run without a non-hosted `--prune` reports it through the run-level `vendor_ledger_entry_unwired` warning; a `--prune` run reverts it in its GC and exits 0. That GC runs even when the crawl found no packages, as its vendored half alone (the manifest prune stays skipped there). A package the ledger holds at an older patch uuid is still **re-vendored automatically** when discovery selects the newer patch (its old uuid dir is removed — `vendor_stale_artifact_removed`); same-uuid re-runs reuse the embedded record, skip the patch-view fetch, and are `already_vendored` skips. **Legacy manifest-mode entries**: when a vendored run vendors a purl that also has a `.socket/manifest.json` record (a project vendored by a pre-5.0 binary, or by standalone `vendor` from an agent-mode manifest), that manifest record is dropped in the same run — the ledger becomes the owner (migration write); an emptied manifest is left as `{"patches": {}}`, never deleted. The migration is reported through the run-level `warnings[]` (stderr in human mode), never as a run error: `vendor_manifest_record_migrated` (`N manifest records moved to the vendor ledger (vendored mode is manifest-free): `) or `vendor_manifest_migration_failed` (the manifest or the ledger could not be read or rewritten; the legacy records were left in place) — so a corrupt `.socket/manifest.json` no longer fails a vendored run (standalone `vendor`, the one manifest-driven writer, still fails closed on it). With `--prune`, GC runs **after** the vendor step (the step never reads the manifest, and running the sweep last lets it reclaim what the run itself orphaned — a migrated legacy record's blobs, a superseded uuid dir). JSON output gains a `download` sub-object — the detached download envelope `{found, downloaded, skipped, failed, detached: true, patches: [{purl, uuid, action: "downloaded" | "skipped" | "failed", …}], warnings?}` (no `applied` field — nothing is applied in place; `detached: true` is pinned and always present; a `downloaded` record whose purl the ledger already holds at another uuid carries the additive `oldUuid` — the re-vendor the vendor step then performs — and its human `[fetch]` line reads ` (replacing )`) — and a `vendor` sub-object (a full vendor Envelope). Patch blobs are held in memory (see "Patch sources stay in memory" under the vendor contract). `--dry-run` previews per-patch `would_vendor` | `would_revendor` (+`oldUuid`) | `already_vendored` — plus, additive, `would_refuse` (+`errorCode`, `error`) for npm purls the wet run's Bun preflight (see the `get --mode vendored` bullet below) would refuse — without network downloads or disk writes; the preview never flips status or exit (the human path — `scan` and `get` alike, through one shared printer — prints `[would-refuse] (): ` lines behind the `--silent` gate). Interactive mode prompts "Download and vendor N patches?" (singular for one). **Vendored entries and the rest of the CLI.** Because nothing is in the manifest, vendored patches are invisible to `apply` (nothing to apply in place) but fully visible to `list` (listed from the ledger, labeled `Mode: vendored (recorded in .socket/vendor/state.json)` in human mode, exit 0 on a vendored-only project), `vex` (attested from the embedded records while a lockfile still wires the artifact — see "Manifest-less VEX"), `repair` (health-checked and rebuilt from the ledger), and `scan --prune` (lockfile-driven reconcile). They are exempt from standalone `vendor`'s manifest reconcile (`reconcile_dropped` never touches `detached` entries) and exit via `remove ` (which reverts them), `vendor --revert`, or `rollback`, whose vendored leg reverts every in-scope ledger entry (unscoped and identifier-scoped runs; path-scoped runs reach them only when an installed copy matches). @@ -1149,6 +1149,7 @@ Every `--json` invocation emits a single JSON object that follows the **unified | `reinstall_required` | rollback `warnings[]` | rollback (v5.0): vendored/hosted wiring was unwound, but installed trees keep their patched bytes until the next package-manager install — the stale-install advisory. | | `hosted_state_not_preservable` | rollback `warnings[]` | rollback `--preserve-state` (v5.0): hosted pins were restored to upstream anyway — the lockfile pins are hosted mode's only record, so there is no local state to preserve; re-run `scan --mode hosted` to re-wire. (`remove --preserve-state` prints the same note on stderr.) | | `out_of_scope_copies_restored` | rollback `warnings[]` | path-scoped rollback (v5.0): a selected patch had installed copies outside the given patterns; ALL copies were restored (patches are per-package). Informational — never flips the exit. | +| `vendor_ledger_entry_unwired` | scan `warnings[]` | a vendored entry's dependency left the lockfile (upgraded or removed), so the ledger supplement skipped it; the detail names the purls and points at `scan --prune`, which reverts them (no warning on a pruning non-hosted run). An entry that prune drift-keeps (its lock entries were re-resolved since vendoring, e.g. an npm uninstall re-locked it away) is reported on the prune's `GC: kept` line and keeps being warned about. | | `path_scope_excluded_supplements` | scan `warnings[]` | path-scoped scan (v5.0): lockfile-only / vendor-ledger supplement packages have no installed path and were excluded from the scoped scan; the detail carries the count. | | `vendor_commit_failed` | top-level error (`vendor`, and the nested vendor envelope of `scan` / `get --mode vendored`) | v5.0 group commit: the run's lockfile / manifest / ledger edits could not be written (the detail names the I/O error). Exit 1; the project's lockfiles and `.socket/vendor/state.json` are left as they were before the run (a partially-applied commit is put back), and the per-package events describe the uncommitted outcome. When putting a partially-applied commit back fails too, the journal is kept instead and the detail says the next socket-patch command in the project finishes the commit. | | `vendor_state_unreadable` | rollback `warnings[]`; remove top-level error | corrupt-ledger containment (v5.0). Rollback: an unreadable vendor ledger skips the vendored leg + manifest cleanup + GC and drives `partial_failure` exit 1 while the agent and hosted legs still run. Remove: a hard top-level error before any mutation. Also the Bun vendored preflight's refusal code: `get` / `scan --mode vendored`, `vendor`'s pre-takeover check and the `--dry-run` `would_refuse` preview report an unreadable `.socket/vendor/state.json` as itself (`errorCode` in `patches[]` / `download.patches[]`, or `get `'s top-level `error.code`), fail-closed — nothing is exempt — instead of a Bun lock code. (v4's `redirect_state_unreadable` is no longer emitted: v5 never reads the redirect ledger on these paths.) | diff --git a/crates/socket-patch-cli/src/commands/get.rs b/crates/socket-patch-cli/src/commands/get.rs index 1e5392389..7c4708208 100644 --- a/crates/socket-patch-cli/src/commands/get.rs +++ b/crates/socket-patch-cli/src/commands/get.rs @@ -1492,7 +1492,7 @@ async fn filter_to_installed_purls( let vendored = super::scan::project_vendored_supplement(common, &[], &ctx.loaded().await.vendor) .await; - present.extend(vendored.iter().map(|p| canon(&p.purl))); + present.extend(vendored.packages.iter().map(|p| canon(&p.purl))); } } diff --git a/crates/socket-patch-cli/src/commands/scan/discovery.rs b/crates/socket-patch-cli/src/commands/scan/discovery.rs index ebe9144d1..b83f63990 100644 --- a/crates/socket-patch-cli/src/commands/scan/discovery.rs +++ b/crates/socket-patch-cli/src/commands/scan/discovery.rs @@ -132,48 +132,89 @@ fn crawled_from_purl( }) } +/// What [`vendored_ledger_supplement`] adds to discovery, and what it +/// deliberately left out. +#[derive(Debug, Default)] +pub(crate) struct LedgerSupplement { + /// Ledger packages to discover (decoded purls, sorted). + pub(crate) packages: Vec, + /// Ledger keys whose lock provably no longer resolves through their + /// committed artifact (the dependency was upgraded or removed), so they + /// are not discoverable packages. `scan --prune` reverts them. Sorted. + pub(crate) unwired: Vec, +} + /// Vendored-ledger packages with no crawled counterpart: on a fresh clone /// the committed artifact IS the dependency, so these stay discoverable /// (updates[] detection, the table, and `scan --vendor` re-vendor/in-sync /// runs all keep working before any install). They are NOT "lockfile-only" /// — nothing needs installing; the artifact satisfies the lock. `state` is /// the ledger `run` already loaded (`vendor::load_state`). +/// +/// That holds only while the lock still wires the artifact. An entry the +/// lockfile in-use probe (the one the prune GC reverts by) answers +/// `Some(false)` for is the dependency having left the lock — bumped or +/// uninstalled — and is reported in [`LedgerSupplement::unwired`] instead: +/// re-vendoring it would fail against a lock that no longer has it. `None` +/// (no probe for the ecosystem, or no readable lock) keeps the entry. pub(crate) async fn vendored_ledger_supplement( common: &GlobalArgs, crawled: &[socket_patch_core::crawlers::types::CrawledPackage], state: &std::io::Result, -) -> Vec { +) -> LedgerSupplement { + let mut out = LedgerSupplement::default(); if common.is_global() { - return Vec::new(); + return out; } - let base_purls: Vec = match state { - Ok(state) => state - .entries - .values() - .map(|entry| strip_purl_qualifiers(&entry.base_purl).to_string()) - .collect(), - // Corrupt/unreadable ledger (a MISSING file is Ok(empty) above): - // recover the vendored set from the committed artifacts, or - // `scan --prune` (whose ledger exemption also degrades to empty) - // would delete still-vendored packages' manifest entries and blobs. - Err(_) => vendored_purls_from_artifacts(common).await, - }; + // `(ledger key, base purl, entry)`; the artifact fallback has no + // entries to probe, so it never reports unwired keys. + let candidates: Vec<(String, String, Option<&socket_patch_core::vendor::VendorEntry>)> = + match state { + Ok(state) => state + .entries + .iter() + .map(|(key, entry)| { + ( + key.clone(), + strip_purl_qualifiers(&entry.base_purl).to_string(), + Some(entry), + ) + }) + .collect(), + // Corrupt/unreadable ledger (a MISSING file is Ok(empty) above): + // recover the vendored set from the committed artifacts, or + // `scan --prune` (whose ledger exemption also degrades to empty) + // would delete still-vendored packages' manifest entries and blobs. + Err(_) => vendored_purls_from_artifacts(common) + .await + .into_iter() + .map(|base| (base.clone(), base, None)) + .collect(), + }; // Composer by release identity: a ledger `@3.0.2.0` is the crawled // `@3.0.2`, not a second package to supplement. let key = |p: &str| composer_purl_identity(p).unwrap_or_else(|| normalize_purl(p).into_owned()); let crawled_norm: HashSet = crawled.iter().map(|p| key(&p.purl)).collect(); let mut seen: HashSet = HashSet::new(); - let mut out = Vec::new(); - for base in &base_purls { + for (ledger_key, base, entry) in &candidates { let norm = key(base); - if crawled_norm.contains(&norm) || !seen.insert(norm) { + if crawled_norm.contains(&norm) || seen.contains(&norm) { continue; } + if let Some(entry) = entry { + if crate::commands::vendor::dispatch_in_use_one(entry, &common.cwd).await == Some(false) + { + out.unwired.push(ledger_key.clone()); + continue; + } + } + seen.insert(norm); if let Some(pkg) = crawled_from_purl(base, &common.cwd) { - out.push(pkg); + out.packages.push(pkg); } } - out.sort_by(|a, b| a.purl.cmp(&b.purl)); + out.packages.sort_by(|a, b| a.purl.cmp(&b.purl)); + out.unwired.sort(); out } @@ -997,7 +1038,7 @@ mod tests { ..GlobalArgs::default() }; let state = socket_patch_core::vendor::load_state(root).await; - vendored_ledger_supplement(&args, crawled, &state).await + vendored_ledger_supplement(&args, crawled, &state).await.packages } /// A ledger entry vendored as `@3.0.2.0` is the crawled composer @@ -1023,20 +1064,126 @@ mod tests { cwd: tmp.path().to_path_buf(), ..GlobalArgs::default() }; - let out = vendored_ledger_supplement(&args, &[crawled], &Ok(state.clone())).await; + let out = vendored_ledger_supplement(&args, &[crawled], &Ok(state.clone())) + .await + .packages; assert!( out.is_empty(), "{:?}", out.iter().map(|p| &p.purl).collect::>() ); - let out = vendored_ledger_supplement(&args, &[], &Ok(state)).await; + let out = vendored_ledger_supplement(&args, &[], &Ok(state)).await.packages; assert_eq!( out.iter().map(|p| p.purl.as_str()).collect::>(), vec!["pkg:composer/psr/log@3.0.2.0"] ); } + /// An npm (package-lock flavor) ledger entry for `left-pad@1.3.0` + /// vendored under [`VENDORED_UUID`], with `lock` as the project's + /// package-lock.json (`None`: no lock at all). + async fn npm_ledger_with_lock( + root: &std::path::Path, + lock: Option<&str>, + ) -> std::io::Result { + let mut state = VendorState::new(); + let entry: socket_patch_core::vendor::VendorEntry = + serde_json::from_value(serde_json::json!({ + "ecosystem": "npm", + "basePurl": "pkg:npm/left-pad@1.3.0", + "uuid": VENDORED_UUID, + "artifact": {"path": format!(".socket/vendor/npm/{VENDORED_UUID}/left-pad-1.3.0/node_modules/left-pad"), "sha256": ""}, + "wiring": [], + })) + .unwrap(); + state + .entries + .insert("pkg:npm/left-pad@1.3.0".to_string(), entry); + if let Some(lock) = lock { + std::fs::write(root.join("package-lock.json"), lock).unwrap(); + } + Ok(state) + } + + fn npm_lock_resolving(left_pad: &str) -> String { + serde_json::json!({ + "name": "app", + "lockfileVersion": 3, + "packages": { + "": {"name": "app", "dependencies": {"left-pad": "*"}}, + "node_modules/left-pad": {"version": "1.3.0", "resolved": left_pad}, + } + }) + .to_string() + } + + /// #541: once the dependency left the lock (bumped to another release, + /// or uninstalled), the ledger entry is no longer a discoverable + /// package: supplementing it made the vendor step re-vendor a package + /// the lock no longer has and fail the whole scan. + #[tokio::test] + async fn ledger_supplement_skips_entries_the_lock_no_longer_wires() { + let args = |root: &std::path::Path| GlobalArgs { + cwd: root.to_path_buf(), + ..GlobalArgs::default() + }; + // Bumped: the lock resolves left-pad from the registry again. + let tmp = tempfile::tempdir().unwrap(); + let bumped = serde_json::json!({ + "name": "app", + "lockfileVersion": 3, + "packages": { + "": {"name": "app", "dependencies": {"left-pad": "1.2.0"}}, + "node_modules/left-pad": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/left-pad/-/left-pad-1.2.0.tgz", + }, + } + }) + .to_string(); + let state = npm_ledger_with_lock(tmp.path(), Some(&bumped)).await; + let out = vendored_ledger_supplement(&args(tmp.path()), &[], &state).await; + assert!(out.packages.is_empty(), "{:?}", out.packages); + assert_eq!(out.unwired, vec!["pkg:npm/left-pad@1.3.0".to_string()]); + + // Uninstalled: the lock has no left-pad at all. + let tmp = tempfile::tempdir().unwrap(); + let removed = r#"{"name":"app","lockfileVersion":3,"packages":{"":{"name":"app"}}}"#; + let state = npm_ledger_with_lock(tmp.path(), Some(removed)).await; + let out = vendored_ledger_supplement(&args(tmp.path()), &[], &state).await; + assert!(out.packages.is_empty(), "{:?}", out.packages); + assert_eq!(out.unwired, vec!["pkg:npm/left-pad@1.3.0".to_string()]); + } + + /// The fresh-clone case the supplement exists for: the lock still + /// resolves through the committed artifact, so the entry stays + /// discoverable. With no lock at all, nothing proves the entry unused, + /// so it is kept (fail-safe, like the prune GC). + #[tokio::test] + async fn ledger_supplement_keeps_wired_and_undecidable_entries() { + for lock in [ + Some(npm_lock_resolving(&format!( + "file:.socket/vendor/npm/{VENDORED_UUID}/left-pad-1.3.0/node_modules/left-pad" + ))), + None, + ] { + let tmp = tempfile::tempdir().unwrap(); + let args = GlobalArgs { + cwd: tmp.path().to_path_buf(), + ..GlobalArgs::default() + }; + let state = npm_ledger_with_lock(tmp.path(), lock.as_deref()).await; + let out = vendored_ledger_supplement(&args, &[], &state).await; + assert_eq!( + out.packages.iter().map(|p| p.purl.as_str()).collect::>(), + vec!["pkg:npm/left-pad@1.3.0"], + "lock={lock:?}" + ); + assert!(out.unwired.is_empty(), "lock={lock:?}: {:?}", out.unwired); + } + } + #[tokio::test] async fn corrupt_ledger_recovers_vendored_purls_from_committed_artifacts() { let tmp = tempfile::tempdir().unwrap(); diff --git a/crates/socket-patch-cli/src/commands/scan/gc.rs b/crates/socket-patch-cli/src/commands/scan/gc.rs index c7c72a74b..14cd5486f 100644 --- a/crates/socket-patch-cli/src/commands/scan/gc.rs +++ b/crates/socket-patch-cli/src/commands/scan/gc.rs @@ -119,6 +119,16 @@ impl GcSummary { json } + /// The `gc` sub-object: [`Self::to_preview_json`] for a `--dry-run` + /// pass, [`Self::to_apply_json`] otherwise. + pub(super) fn to_json(&self, preview: bool) -> serde_json::Value { + if preview { + self.to_preview_json() + } else { + self.to_apply_json() + } + } + /// Serialize for a *non-mutating* GC pass (read-only preview). fn to_preview_json(&self) -> serde_json::Value { serde_json::json!({ @@ -248,6 +258,41 @@ pub(super) async fn run_apply_gc( gc } +/// The vendored-state half of the GC alone, for a `--prune` whose crawl +/// found nothing (the manifest half is skipped there: pruning against an +/// empty crawl would drop every entry). Reverting entries whose patch left +/// the manifest or whose dependency left the lock asks the manifest and +/// the lockfile, not the crawl, so it stays safe. Wet passes take the +/// apply lock like [`run_apply_gc`]; `--dry-run` previews. +pub(super) async fn run_vendor_only_gc( + common: &GlobalArgs, + manifest_path: &Path, + socket_dir: &Path, +) -> GcSummary { + if common.dry_run { + return GcSummary::vendor_only(run_vendor_gc(common, manifest_path, true).await); + } + // Same pre-lock existence gate as `run_apply_gc`: no ledger, no pass + // (and no `.socket/` created by the lock acquire). + let has_ledger = tokio::fs::metadata(common.cwd.join(VENDOR_STATE_REL)) + .await + .is_ok_and(|m| m.is_file()); + if !has_ledger { + return GcSummary::default(); + } + let timeout = Duration::from_secs(common.lock_timeout.unwrap_or(0)); + let _guard = match crate::commands::lock_cli::acquire_with_status(socket_dir, timeout) { + Ok(g) => g, + Err(e) => { + return GcSummary { + skipped: Some(lock_failure(&e, timeout)), + ..Default::default() + }; + } + }; + GcSummary::vendor_only(run_vendor_gc(common, manifest_path, false).await) +} + /// Dry-run preview of the apply-mode GC pass. Same shape as /// [`run_apply_gc`] but emits `prunable*`/`orphan*` field names and /// performs no mutation. @@ -417,10 +462,16 @@ pub(super) async fn run_human_gc( if common.silent { return; } - if let Some(line) = format_gc_line(&gc, preview) { + print_human_gc(&gc, preview); +} + +/// The human summary lines of a finished GC pass (`preview`: the +/// `--dry-run` wording). Callers handle `--silent`. +pub(super) fn print_human_gc(gc: &GcSummary, preview: bool) { + if let Some(line) = format_gc_line(gc, preview) { println!("\n{line}"); } - for line in format_gc_vendored_lines(&gc) { + for line in format_gc_vendored_lines(gc) { if preview { println!("[dry-run] {line}"); } else { diff --git a/crates/socket-patch-cli/src/commands/scan/mod.rs b/crates/socket-patch-cli/src/commands/scan/mod.rs index 479814ff4..d2621b28f 100644 --- a/crates/socket-patch-cli/src/commands/scan/mod.rs +++ b/crates/socket-patch-cli/src/commands/scan/mod.rs @@ -1624,13 +1624,28 @@ async fn run_scan( let vendor_state = &ctx.loaded().await.vendor; let ledger_supplement = vendored_ledger_supplement(&args.common, &all_crawled, vendor_state).await; - for pkg in &ledger_supplement { + for pkg in &ledger_supplement.packages { if let Some(eco) = Ecosystem::from_purl(&pkg.purl) { *eco_counts.entry(eco).or_insert(0) += 1; } supplement_purls.insert(pkg.purl.clone()); } - all_crawled.extend(ledger_supplement); + all_crawled.extend(ledger_supplement.packages); + // Ledger entries whose dependency left the lock: not discovered (see + // `vendored_ledger_supplement`). A pruning run reverts them in its GC; + // every other run says how to. + let unwired_vendored: Vec = ledger_supplement + .unwired + .into_iter() + .filter(|purl| args.common.purl_ecosystem_selected(purl)) + .collect(); + let prune_reverts_unwired = prune && !hosted; + if !unwired_vendored.is_empty() && !prune_reverts_unwired { + layout_refusals.push(( + "vendor_ledger_entry_unwired".to_string(), + render::unwired_vendored_detail(&unwired_vendored), + )); + } // Every PURL the crawl found, captured BEFORE the `--ecosystems` / // `--package` / PATH filters: prune must judge manifest entries against @@ -1763,10 +1778,24 @@ async fn run_scan( } policy.print_warnings(args.common.silent); // Hosted mode already printed its own prune-ignored warning. - if prune && !hosted { + if prune && !hosted && unwired_vendored.is_empty() { eprintln!("{}", render::PRUNE_SKIPPED_EMPTY); } } + // The manifest half of the GC is skipped on an empty crawl, but + // reverting vendored entries the lock no longer wires asks the + // lockfile, not the crawl: run that half alone, or a project whose + // last vendored dependency was removed could never reconcile. + let unwired_gc = if prune_reverts_unwired && !unwired_vendored.is_empty() { + Some(gc::run_vendor_only_gc(&args.common, &manifest_path, &socket_dir).await) + } else { + None + }; + if human { + if let Some(gc) = &unwired_gc { + gc::print_human_gc(gc, args.common.dry_run); + } + } // Telemetry: empty-scan still counts as a successful scan. spawn_patch_scanned( telemetry, @@ -1809,6 +1838,9 @@ async fn run_scan( if !layout_refusals.is_empty() { result["warnings"] = layout_refusal_json(&layout_refusals); } + if let Some(gc) = &unwired_gc { + result["gc"] = gc.to_json(args.common.dry_run); + } policy.fold_into_json(&mut result); // Hosted mode: a no-op `redirect` block keeps the envelope // schema-consistent with the ≥1-package path. diff --git a/crates/socket-patch-cli/src/commands/scan/render.rs b/crates/socket-patch-cli/src/commands/scan/render.rs index 12bfa5e8d..2f881da3e 100644 --- a/crates/socket-patch-cli/src/commands/scan/render.rs +++ b/crates/socket-patch-cli/src/commands/scan/render.rs @@ -223,6 +223,26 @@ pub(super) const PRUNE_SKIPPED_EMPTY: &str = "Warning: --prune skipped: no insta were found, and pruning every manifest entry is too destructive to do implicitly; run \ `socket-patch repair` to clean up .socket/ explicitly."; +/// The `vendor_ledger_entry_unwired` warning: vendored entries whose +/// dependency left the lockfile (upgraded or removed), so the scan no +/// longer discovers them, and `--prune` reverts them. A prune that +/// drift-keeps an entry (its lock entries were re-resolved since vendoring) +/// explains that on its own `GC: kept` line, so the detail points there +/// rather than suggesting a lock edit whose reach it cannot bound. +pub(super) fn unwired_vendored_detail(purls: &[String]) -> String { + let one = purls.len() == 1; + let them = if one { "it" } else { "them" }; + format!( + "{} no longer used by the lockfile (the dependency was upgraded or removed) and {} \ + skipped ({}); run `socket-patch scan --prune` to revert {them} (a prune that keeps \ + {them} because the lock entries were re-resolved since vendoring says so on its \ + `GC: kept` line)", + crate::ui::plural(purls.len(), "vendored entry is", "vendored entries are"), + if one { "was" } else { "were" }, + purls.join(", "), + ) +} + /// What the dry-run line says the run would do. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(super) enum Plan { diff --git a/crates/socket-patch-cli/src/commands/vendor.rs b/crates/socket-patch-cli/src/commands/vendor.rs index fcf931bbd..508aebb3a 100644 --- a/crates/socket-patch-cli/src/commands/vendor.rs +++ b/crates/socket-patch-cli/src/commands/vendor.rs @@ -257,7 +257,10 @@ pub(crate) async fn dispatch_revert_one_opts( /// dependency graph? `None` = cannot determine — callers must keep the /// entry (fail-safe): ecosystems other than npm and cargo have no in-use /// probe yet, and a missing/unreadable lockfile proves nothing. -async fn dispatch_in_use_one(entry: &VendorEntry, project_root: &Path) -> Option { +pub(crate) async fn dispatch_in_use_one( + entry: &VendorEntry, + project_root: &Path, +) -> Option { match entry.ecosystem.as_str() { "npm" => vendor::npm_flavor::vendored_entry_in_use(entry, project_root).await, // Cargo probes the lock entry's shape: detached + `[patch]` pointing diff --git a/crates/socket-patch-cli/tests/e2e_vendor_vlt_build.rs b/crates/socket-patch-cli/tests/e2e_vendor_vlt_build.rs index ef643c2f1..0e8848f2a 100644 --- a/crates/socket-patch-cli/tests/e2e_vendor_vlt_build.rs +++ b/crates/socket-patch-cli/tests/e2e_vendor_vlt_build.rs @@ -1167,6 +1167,113 @@ async fn vlt_pinned_matrix_vendored_resave_crlf_revert() { resave_revert("resave_crlf_revert", "install", true).await; } +/// #541: the vendored dependency leaves the lock (`vlt install` of another +/// release, or `vlt uninstall`). A plain vendored rescan skips the stale +/// ledger entry with a `vendor_ledger_entry_unwired` warning instead of +/// failing to re-vendor it; `--prune` reverts it and exits 0; the next run +/// is clean. The ms bystander keeps a dependency in the project (the +/// empty-crawl `--prune` path is covered by `scan_vendor_e2e`). +async fn dependency_left_lock(name: &'static str, step: &'static str) { + let Some(leg) = vendored_leg(name) else { + return; + }; + if step == "uninstall" && removed_dependency_stays_locked(leg.version()) { + return leg.skip("removed-dependency-stays-locked"); + } + let mut shape = Shape::with_bystander(); + shape.pins.push(LP_OLDER); + let fx = Fixture::build(leg, shape).await; + vendor_scan(&fx); + fx.vlt_ok(&fx.proj, &fx.leg.locked_install_args()); + match step { + "bump" => { + fx.vlt_ok(&fx.proj, &["install", "left-pad@1.2.0"]); + } + _ => { + fx.vlt_ok(&fx.proj, &["uninstall", LP.0]); + // Should `vlt uninstall` leave the `file:` spec declared, drop + // it by hand and re-lock: the end state every other release + // reaches. + let pkg_path = fx.proj.join("package.json"); + let mut pkg: Value = + serde_json::from_slice(&std::fs::read(&pkg_path).unwrap()).unwrap(); + if pkg["dependencies"].get(LP.0).is_some() { + pkg["dependencies"].as_object_mut().unwrap().remove(LP.0); + std::fs::write(&pkg_path, serde_json::to_vec_pretty(&pkg).unwrap()).unwrap(); + fx.vlt_ok(&fx.proj, &["install"]); + } + } + } + let purl = format!("pkg:npm/{}@{}", LP.0, LP.1); + let unwired = |doc: &Value| -> Vec { + doc["warnings"] + .as_array() + .into_iter() + .flatten() + .filter(|w| w["code"] == "vendor_ledger_entry_unwired") + .map(|w| w["detail"].as_str().unwrap_or_default().to_string()) + .collect() + }; + let rescan = |extra: &[&str]| { + let mut args = vec!["--vendor-source", "service"]; + args.extend_from_slice(extra); + socket_api(&fx.proj, &fx.svc, &["scan", "--mode", "vendored"], &args) + }; + + let out = rescan(&[]); + assert_eq!(out.code, 0, "{step}: rescan: {out}"); + let doc = out.json(); + let warned = unwired(&doc); + assert!( + warned.len() == 1 && warned[0].contains(&purl) && warned[0].contains("--prune"), + "{step}: {doc:#}" + ); + assert!( + uuid_dir(&fx.proj, fx.t()).exists(), + "{step}: a plain rescan reverts nothing" + ); + + let out = rescan(&["--prune"]); + assert_eq!(out.code, 0, "{step}: --prune: {out}"); + let doc = out.json(); + assert_eq!( + doc["gc"]["revertedVendoredEntries"], + json!([purl]), + "{step}: {doc:#}" + ); + assert!(unwired(&doc).is_empty(), "{step}: {doc:#}"); + assert!( + !uuid_dir(&fx.proj, fx.t()).exists(), + "{step}: --prune removes the payload" + ); + assert!( + !lock_bytes(&fx.proj) + .windows(b".socket/vendor".len()) + .any(|w| w == b".socket/vendor"), + "{step}: no vendored wiring left" + ); + + let out = rescan(&[]); + assert_eq!(out.code, 0, "{step}: after prune: {out}"); + assert!(unwired(&out.json()).is_empty(), "{step}: {out}"); + fx.leg.ran(); +} + +/// The release a [`dependency_left_lock`] bump moves to. +const LP_OLDER: (&str, &str) = ("left-pad", "1.2.0"); + +#[tokio::test(flavor = "multi_thread")] +#[ignore = "real vlt: SOCKET_PATCH_VLT_E2E_JS"] +async fn vlt_pinned_matrix_vendored_dependency_bumped_rescan() { + dependency_left_lock("dependency_bumped_rescan", "bump").await; +} + +#[tokio::test(flavor = "multi_thread")] +#[ignore = "real vlt: SOCKET_PATCH_VLT_E2E_JS"] +async fn vlt_pinned_matrix_vendored_dependency_uninstalled_rescan() { + dependency_left_lock("dependency_uninstalled_rescan", "uninstall").await; +} + // ── tamper (T33) ────────────────────────────────────────────────────────── /// Vendor, install, tamper with the committed state, then: standalone VEX diff --git a/crates/socket-patch-cli/tests/scan_vendor_e2e.rs b/crates/socket-patch-cli/tests/scan_vendor_e2e.rs index 96126cfc5..97a194e5a 100644 --- a/crates/socket-patch-cli/tests/scan_vendor_e2e.rs +++ b/crates/socket-patch-cli/tests/scan_vendor_e2e.rs @@ -1174,6 +1174,154 @@ async fn scan_prune_reverts_unused_vendored_entry() { ); } +/// #541, npm package-lock flavor: after `npm uninstall left-pad` re-locks +/// the project without the vendored dependency, a vendored rescan skips +/// the stale ledger entry with a `vendor_ledger_entry_unwired` warning +/// and exits 0. Before, the ledger supplement re-added the entry and the +/// vendor step failed to re-vendor a package the lock no longer has. +#[tokio::test] +async fn scan_vendor_skips_ledger_entry_the_lock_no_longer_wires() { + let mock = MockServer::start().await; + mount_patch_api(&mock, UUID).await; + let tmp = tempfile::tempdir().unwrap(); + write_fixture(tmp.path()); + let other = tmp.path().join("node_modules/keeper"); + std::fs::create_dir_all(&other).unwrap(); + std::fs::write( + other.join("package.json"), + br#"{"name":"keeper","version":"1.0.0"}"#, + ) + .unwrap(); + let (code, stdout, stderr) = run_scan_vendor(tmp.path(), &mock.uri(), &[]); + assert_eq!(code, 0, "stdout={stdout}; stderr={stderr}"); + + // `npm uninstall left-pad`: the lock and the installed copy are gone. + let lock = serde_json::json!({ + "name": "scan-vendor-test", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { "name": "scan-vendor-test", "version": "0.0.0" } + } + }); + std::fs::write( + tmp.path().join("package-lock.json"), + serde_json::to_vec_pretty(&lock).unwrap(), + ) + .unwrap(); + std::fs::remove_dir_all(tmp.path().join("node_modules/left-pad")).unwrap(); + // The patch API answers by requested purl: it has nothing for keeper. + Mock::given(method("POST")) + .and(path(format!("/v0/orgs/{ORG_SLUG}/patches/batch"))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "packages": [], + "canAccessPaidPatches": false, + }))) + .with_priority(1) + .mount(&mock) + .await; + + let (code, stdout, stderr) = run_scan_vendor(tmp.path(), &mock.uri(), &[]); + assert_eq!(code, 0, "stdout={stdout}; stderr={stderr}"); + let v: serde_json::Value = serde_json::from_str(stdout.trim()).expect("valid JSON"); + let warned: Vec<&serde_json::Value> = v["warnings"] + .as_array() + .into_iter() + .flatten() + .filter(|w| w["code"] == "vendor_ledger_entry_unwired") + .collect(); + assert_eq!(warned.len(), 1, "envelope={v}"); + assert!( + warned[0]["detail"].as_str().unwrap().contains(PURL), + "envelope={v}" + ); + // A plain rescan reverts nothing: the entry waits for `--prune`. + let state: serde_json::Value = serde_json::from_str( + &std::fs::read_to_string(tmp.path().join(".socket/vendor/state.json")).unwrap(), + ) + .unwrap(); + assert!(state["entries"][PURL].is_object(), "{state}"); +} + +/// #541 with no dependency left: the crawl is empty, so the manifest half +/// of the GC is skipped, but a vendored `--prune` still runs the vendored +/// half (it asks the lockfile, not the crawl) instead of skipping the +/// reconcile forever. A plain rescan of the same project warns. +#[tokio::test] +async fn scan_vendor_prune_reconciles_unwired_entry_on_an_empty_crawl() { + let mock = MockServer::start().await; + mount_patch_api(&mock, UUID).await; + let tmp = tempfile::tempdir().unwrap(); + write_fixture(tmp.path()); + let (code, stdout, stderr) = run_scan_vendor(tmp.path(), &mock.uri(), &[]); + assert_eq!(code, 0, "stdout={stdout}; stderr={stderr}"); + + // `npm uninstall left-pad` of the only dependency. + let lock = serde_json::json!({ + "name": "scan-vendor-test", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { "name": "scan-vendor-test", "version": "0.0.0" } + } + }); + std::fs::write( + tmp.path().join("package-lock.json"), + serde_json::to_vec_pretty(&lock).unwrap(), + ) + .unwrap(); + std::fs::remove_dir_all(tmp.path().join("node_modules/left-pad")).unwrap(); + let unwired = |v: &serde_json::Value| { + v["warnings"] + .as_array() + .into_iter() + .flatten() + .filter(|w| w["code"] == "vendor_ledger_entry_unwired") + .count() + }; + + let (code, stdout, stderr) = run_scan_vendor(tmp.path(), &mock.uri(), &[]); + assert_eq!(code, 0, "stdout={stdout}; stderr={stderr}"); + let v: serde_json::Value = serde_json::from_str(stdout.trim()).expect("valid JSON"); + assert_eq!(v["scannedPackages"], 0, "envelope={v}"); + assert_eq!(unwired(&v), 1, "envelope={v}"); + assert!(v.get("gc").is_none(), "no --prune, no GC: {v}"); + + let (code, stdout, stderr) = run_scan_vendor(tmp.path(), &mock.uri(), &["--prune"]); + assert_eq!(code, 0, "stdout={stdout}; stderr={stderr}"); + let v: serde_json::Value = serde_json::from_str(stdout.trim()).expect("valid JSON"); + assert_eq!(unwired(&v), 0, "the pruning run reconciles instead: {v}"); + // npm re-locked the entry away, so the wet revert drift-keeps it (see + // `scan_prune_reverts_unused_vendored_entry`): the point here is that + // the vendored GC ran at all on an empty crawl. + assert_eq!( + v["gc"]["keptVendoredEntries"], + serde_json::json!([PURL]), + "envelope={v}" + ); + + // The drift-kept entry is still unwired, so the next plain rescan warns + // again; its detail names the purl and the prune's `GC: kept` report + // instead of a lock edit that could unwire other vendored entries. + let (code, stdout, stderr) = run_scan_vendor(tmp.path(), &mock.uri(), &[]); + assert_eq!(code, 0, "stdout={stdout}; stderr={stderr}"); + let v: serde_json::Value = serde_json::from_str(stdout.trim()).expect("valid JSON"); + let detail = v["warnings"] + .as_array() + .into_iter() + .flatten() + .find(|w| w["code"] == "vendor_ledger_entry_unwired") + .and_then(|w| w["detail"].as_str()) + .unwrap_or_else(|| panic!("envelope={v}")) + .to_string(); + assert!( + detail.contains(&format!("({PURL})")) && detail.contains("`GC: kept`"), + "{detail}" + ); +} + /// Interactive (non-JSON) `scan --vendor` pre-verifies patch baselines: /// installed content matching NEITHER hash is annotated before vendoring /// starts, and the run still vendors (auto-force) with the diff --git a/crates/socket-patch-cli/tests/vlt-leg-manifest.json b/crates/socket-patch-cli/tests/vlt-leg-manifest.json index 5aa1fb3d2..09f210274 100644 --- a/crates/socket-patch-cli/tests/vlt-leg-manifest.json +++ b/crates/socket-patch-cli/tests/vlt-leg-manifest.json @@ -66,6 +66,8 @@ "resave_install_revert", "resave_uninstall_revert", "resave_crlf_revert", + "dependency_bumped_rescan", + "dependency_uninstalled_rescan", "tamper_planted_file", "tamper_file_content", "tamper_payload_package_json", @@ -198,6 +200,8 @@ "resave_install_revert", "resave_uninstall_revert", "resave_crlf_revert", + "dependency_bumped_rescan", + "dependency_uninstalled_rescan", "tamper_planted_file", "tamper_file_content", "tamper_payload_package_json", @@ -307,6 +311,21 @@ "conditions": [], "reason": "no-global-store" }, + { + "boundary": "a dependency removed from package.json leaves the lock: `vlt uninstall` keeps a `file:` spec declared and `vlt install` keeps the removed dependency's edge and node, so the vendored node stays wired (socket-patch correctly keeps the entry)", + "suite": "vendored", + "legs": [ + "dependency_uninstalled_rescan" + ], + "except": [], + "versions": { + "op": "range", + "a": "0.0.0-30", + "b": "0.0.0-32" + }, + "conditions": [], + "reason": "removed-dependency-stays-locked" + }, { "boundary": "`vlt ci`, `--frozen-lockfile`, `--expect-lockfile` exist", "suite": "hosted", diff --git a/crates/socket-patch-cli/tests/vlt_e2e_common/mod.rs b/crates/socket-patch-cli/tests/vlt_e2e_common/mod.rs index ea44c36ee..3b22114ef 100644 --- a/crates/socket-patch-cli/tests/vlt_e2e_common/mod.rs +++ b/crates/socket-patch-cli/tests/vlt_e2e_common/mod.rs @@ -177,6 +177,13 @@ pub fn npm_alias_to_public_npm(v: VltVersion) -> bool { (VltVersion::rc(30)..=VltVersion::rc(32)).contains(&v) } +/// A dependency removed from package.json stays locked: `vlt uninstall` +/// leaves a `file:` spec declared, and `vlt install` keeps the removed +/// dependency's lock edge and node. +pub fn removed_dependency_stays_locked(v: VltVersion) -> bool { + (VltVersion::zero(30)..=VltVersion::zero(32)).contains(&v) +} + /// A lockless install cannot resolve a `file:` directory dependency. pub fn lockless_file_dir_broken(v: VltVersion) -> bool { (VltVersion::zero(31)..LOCKLESS_FILE_DIR_FROM).contains(&v) @@ -1584,12 +1591,25 @@ impl PatchService { }) }) .collect(); + // Like the production API, answer only for the purls the batch asks + // about: a leg that moves a dependency to another release must not + // be offered the old release's patch. Mock::given(method("POST")) .and(path(format!("/v0/orgs/{ORG}/patches/batch"))) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "packages": packages, - "canAccessPaidPatches": false, - }))) + .respond_with(move |req: &wiremock::Request| { + let body = String::from_utf8_lossy(&req.body); + let asked: Vec<&Value> = packages + .iter() + .filter(|p| { + let purl = p["purl"].as_str().unwrap_or_default(); + body.contains(purl) || body.contains(&purl.replace("%40", "@")) + }) + .collect(); + ResponseTemplate::new(200).set_body_json(json!({ + "packages": asked, + "canAccessPaidPatches": false, + })) + }) .mount(&self.server) .await; for t in &self.targets { diff --git a/docs/testing/vlt-compatibility.md b/docs/testing/vlt-compatibility.md index 567ed045d..1deecfb54 100644 --- a/docs/testing/vlt-compatibility.md +++ b/docs/testing/vlt-compatibility.md @@ -218,7 +218,7 @@ them per OS). | Binary | Suite | Legs | |---|---|---| | `e2e_redirect_vlt_build` | `hosted` | `scan_fresh_ci`, `frozen_dead_registry`, `ordinary_install_stable`, `get_uuid_fresh_ci`, `tamper_cold_eintegrity`, `rollback_byte_exact`, `rerun_noop`, `warm_tree_invalidates`, `no_cleanup_stays_stale`, `heal_rule_b_hidden_lock_without_node`, `heal_rule_c_no_hidden_lock`, `heal_rule_c_no_record`, `scoped`, `peer_workspace_instances`, `peer_rekey_rollback`, `install_newdep_preserves`, `update_drops`, `resave_install_rollback`, `resave_crlf_rollback`, `resave_update_rollback`, `crlf_lock`, `mirror_registries_npm`, `scalar_registry`, `named_alias_untouched`, `scoped_registry_untouched`, `jsr_untouched`, `default_registry_alias`, `registry_from_env`, `registry_from_user_config`, `content_encoding_refused`, `old_lockfile_ignored`, `warm_cache_hazard`, `idempotence`, `manifestless_vex`, `ts_written_lock`, `optional_dependency_heal`, `then_vendored_optional_takeover`, `platform_optional_skipped` | -| `e2e_vendor_vlt_build` | `vendored` | `scan_fresh_ci`, `get_auto_fresh_ci`, `get_service_fresh_ci`, `durability`, `workspace_member_selfref`, `alias_selfref`, `peer_root_selfref`, `peer_member_selfref`, `single_peer_context`, `optional_warm_reinstall`, `dep_with_deps`, `hostile_gitignore`, `autocrlf_checkout`, `bin_bearing`, `package_json_devdeps_patch`, `repair_rebuilds`, `idempotency`, `revert_byte_exact`, `resave_install_revert`, `resave_uninstall_revert`, `resave_crlf_revert`, `tamper_planted_file`, `tamper_file_content`, `tamper_payload_package_json`, `tamper_symlink_outside`, `tamper_deleted_gitignore`, `tamper_lock_file_node_path`, `transitive_refused`, `legacy_lockfile_warning`, `absent_version_refused`, `lockless_reinstall`, `manifestless_vex` | +| `e2e_vendor_vlt_build` | `vendored` | `scan_fresh_ci`, `get_auto_fresh_ci`, `get_service_fresh_ci`, `durability`, `workspace_member_selfref`, `alias_selfref`, `peer_root_selfref`, `peer_member_selfref`, `single_peer_context`, `optional_warm_reinstall`, `dep_with_deps`, `hostile_gitignore`, `autocrlf_checkout`, `bin_bearing`, `package_json_devdeps_patch`, `repair_rebuilds`, `idempotency`, `revert_byte_exact`, `resave_install_revert`, `resave_uninstall_revert`, `resave_crlf_revert`, `dependency_bumped_rescan`, `dependency_uninstalled_rescan`, `tamper_planted_file`, `tamper_file_content`, `tamper_payload_package_json`, `tamper_symlink_outside`, `tamper_deleted_gitignore`, `tamper_lock_file_node_path`, `transitive_refused`, `legacy_lockfile_warning`, `absent_version_refused`, `lockless_reinstall`, `manifestless_vex` | | `mode_migration_vlt` | `migration` | `vendored_then_hosted`, `hosted_then_vendored`, `dry_run_parity`, `scoped_unwind_one_of_two`, `rollback_from_mixed`, `agent_apply_yields_to_vendored`, `agent_apply_after_hosted`, `hosted_scan_keeps_agent_patched_tree`, `agent_rollback_after_takeovers`, `pm_switch_npm_to_vlt`, `pm_switch_vlt_to_npm`, `flavor_changed`, `upgrade_hosted`, `upgrade_vendored` | | `e2e_safety_vlt` | `safety` | `linux_auto`, `explicit_hardlink`, `private_copies`, `cross_device_cache`, `agent_rollback`, `peer_fanout`, `hosted_heal`, `vendored_build`, `vendor_revert_and_repair`, `layout_note` | | `e2e_vlt` | `agent` | `scan_apply_rollback_list`, `get_and_remove`, `install_then_apply_patches_file`, `transitive_only_dep_apply_patches_store`, `lockfile_supplement`, `launcher`, `persistence_survives`, `persistence_reverted_by_reinstall`, `reruns_and_vex` | @@ -245,6 +245,7 @@ store-linker knob, `unset` when not given), `cache_root` and `upgrade` | A0 locks are refused by vendored mode | `<= 0.0.0-18` | — | migration | `*` | `a0-vendored-unsupported` | | A0 locks are refused by vendored mode | `<= 0.0.0-18` | — | production | `vendored_install_proof` | `a0-vendored-unsupported` | | the global store and `store-linker` | `< 1.2.0` | — | safety | `*` | `no-global-store` | +| a dependency removed from package.json leaves the lock: `vlt uninstall` keeps a `file:` spec declared and `vlt install` keeps the removed dependency's edge and node, so the vendored node stays wired (socket-patch correctly keeps the entry) | `0.0.0-30 … 0.0.0-32` | — | vendored | `dependency_uninstalled_rescan` | `removed-dependency-stays-locked` | | `vlt install` needs Node >= 22.7.0, above `engines` (`>=22`): the CLI is ESM without `"type": "module"`, and Node detects module syntax unflagged only from 22.7.0 (22.6.0: `SyntaxError: Cannot use import statement outside a module`); `install-proof` runs 0.0.0-30 on 22.7.0 | `0.0.0-11 … 0.0.0-30` | — | — | — | — | | `vlt install` loads `node:sqlite`, unflagged from Node 22.13.0, above `engines` (`>=22` through rc.9, `>=22.9.0` for rc.10 … rc.18; 22.12.0: `ERR_UNKNOWN_BUILTIN_MODULE`); `install-proof` runs rc.18 on 22.13.0 | `0.0.0-31 … 1.0.0-rc.18` | — | — | — | — | | `vlt ci`, `--frozen-lockfile`, `--expect-lockfile` exist | `< 0.0.0-19` | — | hosted | `frozen_dead_registry`, `optional_dependency_heal`, `then_vendored_optional_takeover` | `no-vlt-ci` |