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 `==
): ` 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` |