Skip to content

Commit 2353511

Browse files
committed
Skip vendored entries the lock no longer uses
A vendored scan re-added every vendor-ledger entry to discovery, even after the dependency was upgraded or uninstalled. The vendor step then tried to re-vendor a package the lockfile no longer has, so every rescan failed with exit 1, including the --prune run that reverts the stale entry (#541). Ledger entries are now filtered through the same lockfile in-use check the prune GC uses. Entries the lock no longer wires are skipped with a vendor_ledger_entry_unwired warning that points at --prune. When the crawl finds nothing, a --prune run still does the lock-based vendored half of GC, so the last removed dependency can be reverted. Assisted-by: Claude Code:claude-opus-5-5
1 parent 1d9e460 commit 2353511

9 files changed

Lines changed: 374 additions & 30 deletions

File tree

‎crates/socket-patch-cli/src/commands/get.rs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1492,7 +1492,7 @@ async fn filter_to_installed_purls(
14921492
let vendored =
14931493
super::scan::project_vendored_supplement(common, &[], &ctx.loaded().await.vendor)
14941494
.await;
1495-
present.extend(vendored.iter().map(|p| canon(&p.purl)));
1495+
present.extend(vendored.packages.iter().map(|p| canon(&p.purl)));
14961496
}
14971497
}
14981498

‎crates/socket-patch-cli/src/commands/scan/discovery.rs‎

Lines changed: 169 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -132,48 +132,89 @@ fn crawled_from_purl(
132132
})
133133
}
134134

135+
/// What [`vendored_ledger_supplement`] adds to discovery, and what it
136+
/// deliberately left out.
137+
#[derive(Debug, Default)]
138+
pub(crate) struct LedgerSupplement {
139+
/// Ledger packages to discover (decoded purls, sorted).
140+
pub(crate) packages: Vec<socket_patch_core::crawlers::types::CrawledPackage>,
141+
/// Ledger keys whose lock provably no longer resolves through their
142+
/// committed artifact (the dependency was upgraded or removed), so they
143+
/// are not discoverable packages. `scan --prune` reverts them. Sorted.
144+
pub(crate) unwired: Vec<String>,
145+
}
146+
135147
/// Vendored-ledger packages with no crawled counterpart: on a fresh clone
136148
/// the committed artifact IS the dependency, so these stay discoverable
137149
/// (updates[] detection, the table, and `scan --vendor` re-vendor/in-sync
138150
/// runs all keep working before any install). They are NOT "lockfile-only"
139151
/// — nothing needs installing; the artifact satisfies the lock. `state` is
140152
/// the ledger `run` already loaded (`vendor::load_state`).
153+
///
154+
/// That holds only while the lock still wires the artifact. An entry the
155+
/// lockfile in-use probe (the one the prune GC reverts by) answers
156+
/// `Some(false)` for is the dependency having left the lock — bumped or
157+
/// uninstalled — and is reported in [`LedgerSupplement::unwired`] instead:
158+
/// re-vendoring it would fail against a lock that no longer has it. `None`
159+
/// (no probe for the ecosystem, or no readable lock) keeps the entry.
141160
pub(crate) async fn vendored_ledger_supplement(
142161
common: &GlobalArgs,
143162
crawled: &[socket_patch_core::crawlers::types::CrawledPackage],
144163
state: &std::io::Result<VendorState>,
145-
) -> Vec<socket_patch_core::crawlers::types::CrawledPackage> {
164+
) -> LedgerSupplement {
165+
let mut out = LedgerSupplement::default();
146166
if common.is_global() {
147-
return Vec::new();
167+
return out;
148168
}
149-
let base_purls: Vec<String> = match state {
150-
Ok(state) => state
151-
.entries
152-
.values()
153-
.map(|entry| strip_purl_qualifiers(&entry.base_purl).to_string())
154-
.collect(),
155-
// Corrupt/unreadable ledger (a MISSING file is Ok(empty) above):
156-
// recover the vendored set from the committed artifacts, or
157-
// `scan --prune` (whose ledger exemption also degrades to empty)
158-
// would delete still-vendored packages' manifest entries and blobs.
159-
Err(_) => vendored_purls_from_artifacts(common).await,
160-
};
169+
// `(ledger key, base purl, entry)`; the artifact fallback has no
170+
// entries to probe, so it never reports unwired keys.
171+
let candidates: Vec<(String, String, Option<&socket_patch_core::vendor::VendorEntry>)> =
172+
match state {
173+
Ok(state) => state
174+
.entries
175+
.iter()
176+
.map(|(key, entry)| {
177+
(
178+
key.clone(),
179+
strip_purl_qualifiers(&entry.base_purl).to_string(),
180+
Some(entry),
181+
)
182+
})
183+
.collect(),
184+
// Corrupt/unreadable ledger (a MISSING file is Ok(empty) above):
185+
// recover the vendored set from the committed artifacts, or
186+
// `scan --prune` (whose ledger exemption also degrades to empty)
187+
// would delete still-vendored packages' manifest entries and blobs.
188+
Err(_) => vendored_purls_from_artifacts(common)
189+
.await
190+
.into_iter()
191+
.map(|base| (base.clone(), base, None))
192+
.collect(),
193+
};
161194
// Composer by release identity: a ledger `@3.0.2.0` is the crawled
162195
// `@3.0.2`, not a second package to supplement.
163196
let key = |p: &str| composer_purl_identity(p).unwrap_or_else(|| normalize_purl(p).into_owned());
164197
let crawled_norm: HashSet<String> = crawled.iter().map(|p| key(&p.purl)).collect();
165198
let mut seen: HashSet<String> = HashSet::new();
166-
let mut out = Vec::new();
167-
for base in &base_purls {
199+
for (ledger_key, base, entry) in &candidates {
168200
let norm = key(base);
169-
if crawled_norm.contains(&norm) || !seen.insert(norm) {
201+
if crawled_norm.contains(&norm) || seen.contains(&norm) {
170202
continue;
171203
}
204+
if let Some(entry) = entry {
205+
if crate::commands::vendor::dispatch_in_use_one(entry, &common.cwd).await == Some(false)
206+
{
207+
out.unwired.push(ledger_key.clone());
208+
continue;
209+
}
210+
}
211+
seen.insert(norm);
172212
if let Some(pkg) = crawled_from_purl(base, &common.cwd) {
173-
out.push(pkg);
213+
out.packages.push(pkg);
174214
}
175215
}
176-
out.sort_by(|a, b| a.purl.cmp(&b.purl));
216+
out.packages.sort_by(|a, b| a.purl.cmp(&b.purl));
217+
out.unwired.sort();
177218
out
178219
}
179220

@@ -997,7 +1038,7 @@ mod tests {
9971038
..GlobalArgs::default()
9981039
};
9991040
let state = socket_patch_core::vendor::load_state(root).await;
1000-
vendored_ledger_supplement(&args, crawled, &state).await
1041+
vendored_ledger_supplement(&args, crawled, &state).await.packages
10011042
}
10021043

10031044
/// A ledger entry vendored as `@3.0.2.0` is the crawled composer
@@ -1023,20 +1064,126 @@ mod tests {
10231064
cwd: tmp.path().to_path_buf(),
10241065
..GlobalArgs::default()
10251066
};
1026-
let out = vendored_ledger_supplement(&args, &[crawled], &Ok(state.clone())).await;
1067+
let out = vendored_ledger_supplement(&args, &[crawled], &Ok(state.clone()))
1068+
.await
1069+
.packages;
10271070
assert!(
10281071
out.is_empty(),
10291072
"{:?}",
10301073
out.iter().map(|p| &p.purl).collect::<Vec<_>>()
10311074
);
10321075

1033-
let out = vendored_ledger_supplement(&args, &[], &Ok(state)).await;
1076+
let out = vendored_ledger_supplement(&args, &[], &Ok(state)).await.packages;
10341077
assert_eq!(
10351078
out.iter().map(|p| p.purl.as_str()).collect::<Vec<_>>(),
10361079
vec!["pkg:composer/psr/log@3.0.2.0"]
10371080
);
10381081
}
10391082

1083+
/// An npm (package-lock flavor) ledger entry for `left-pad@1.3.0`
1084+
/// vendored under [`VENDORED_UUID`], with `lock` as the project's
1085+
/// package-lock.json (`None`: no lock at all).
1086+
async fn npm_ledger_with_lock(
1087+
root: &std::path::Path,
1088+
lock: Option<&str>,
1089+
) -> std::io::Result<VendorState> {
1090+
let mut state = VendorState::new();
1091+
let entry: socket_patch_core::vendor::VendorEntry =
1092+
serde_json::from_value(serde_json::json!({
1093+
"ecosystem": "npm",
1094+
"basePurl": "pkg:npm/left-pad@1.3.0",
1095+
"uuid": VENDORED_UUID,
1096+
"artifact": {"path": format!(".socket/vendor/npm/{VENDORED_UUID}/left-pad-1.3.0/node_modules/left-pad"), "sha256": ""},
1097+
"wiring": [],
1098+
}))
1099+
.unwrap();
1100+
state
1101+
.entries
1102+
.insert("pkg:npm/left-pad@1.3.0".to_string(), entry);
1103+
if let Some(lock) = lock {
1104+
std::fs::write(root.join("package-lock.json"), lock).unwrap();
1105+
}
1106+
Ok(state)
1107+
}
1108+
1109+
fn npm_lock_resolving(left_pad: &str) -> String {
1110+
serde_json::json!({
1111+
"name": "app",
1112+
"lockfileVersion": 3,
1113+
"packages": {
1114+
"": {"name": "app", "dependencies": {"left-pad": "*"}},
1115+
"node_modules/left-pad": {"version": "1.3.0", "resolved": left_pad},
1116+
}
1117+
})
1118+
.to_string()
1119+
}
1120+
1121+
/// #541: once the dependency left the lock (bumped to another release,
1122+
/// or uninstalled), the ledger entry is no longer a discoverable
1123+
/// package: supplementing it made the vendor step re-vendor a package
1124+
/// the lock no longer has and fail the whole scan.
1125+
#[tokio::test]
1126+
async fn ledger_supplement_skips_entries_the_lock_no_longer_wires() {
1127+
let args = |root: &std::path::Path| GlobalArgs {
1128+
cwd: root.to_path_buf(),
1129+
..GlobalArgs::default()
1130+
};
1131+
// Bumped: the lock resolves left-pad from the registry again.
1132+
let tmp = tempfile::tempdir().unwrap();
1133+
let bumped = serde_json::json!({
1134+
"name": "app",
1135+
"lockfileVersion": 3,
1136+
"packages": {
1137+
"": {"name": "app", "dependencies": {"left-pad": "1.2.0"}},
1138+
"node_modules/left-pad": {
1139+
"version": "1.2.0",
1140+
"resolved": "https://registry.npmjs.org/left-pad/-/left-pad-1.2.0.tgz",
1141+
},
1142+
}
1143+
})
1144+
.to_string();
1145+
let state = npm_ledger_with_lock(tmp.path(), Some(&bumped)).await;
1146+
let out = vendored_ledger_supplement(&args(tmp.path()), &[], &state).await;
1147+
assert!(out.packages.is_empty(), "{:?}", out.packages);
1148+
assert_eq!(out.unwired, vec!["pkg:npm/left-pad@1.3.0".to_string()]);
1149+
1150+
// Uninstalled: the lock has no left-pad at all.
1151+
let tmp = tempfile::tempdir().unwrap();
1152+
let removed = r#"{"name":"app","lockfileVersion":3,"packages":{"":{"name":"app"}}}"#;
1153+
let state = npm_ledger_with_lock(tmp.path(), Some(removed)).await;
1154+
let out = vendored_ledger_supplement(&args(tmp.path()), &[], &state).await;
1155+
assert!(out.packages.is_empty(), "{:?}", out.packages);
1156+
assert_eq!(out.unwired, vec!["pkg:npm/left-pad@1.3.0".to_string()]);
1157+
}
1158+
1159+
/// The fresh-clone case the supplement exists for: the lock still
1160+
/// resolves through the committed artifact, so the entry stays
1161+
/// discoverable. With no lock at all, nothing proves the entry unused,
1162+
/// so it is kept (fail-safe, like the prune GC).
1163+
#[tokio::test]
1164+
async fn ledger_supplement_keeps_wired_and_undecidable_entries() {
1165+
for lock in [
1166+
Some(npm_lock_resolving(&format!(
1167+
"file:.socket/vendor/npm/{VENDORED_UUID}/left-pad-1.3.0/node_modules/left-pad"
1168+
))),
1169+
None,
1170+
] {
1171+
let tmp = tempfile::tempdir().unwrap();
1172+
let args = GlobalArgs {
1173+
cwd: tmp.path().to_path_buf(),
1174+
..GlobalArgs::default()
1175+
};
1176+
let state = npm_ledger_with_lock(tmp.path(), lock.as_deref()).await;
1177+
let out = vendored_ledger_supplement(&args, &[], &state).await;
1178+
assert_eq!(
1179+
out.packages.iter().map(|p| p.purl.as_str()).collect::<Vec<_>>(),
1180+
vec!["pkg:npm/left-pad@1.3.0"],
1181+
"lock={lock:?}"
1182+
);
1183+
assert!(out.unwired.is_empty(), "lock={lock:?}: {:?}", out.unwired);
1184+
}
1185+
}
1186+
10401187
#[tokio::test]
10411188
async fn corrupt_ledger_recovers_vendored_purls_from_committed_artifacts() {
10421189
let tmp = tempfile::tempdir().unwrap();

‎crates/socket-patch-cli/src/commands/scan/gc.rs‎

Lines changed: 53 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,16 @@ impl GcSummary {
119119
json
120120
}
121121

122+
/// The `gc` sub-object: [`Self::to_preview_json`] for a `--dry-run`
123+
/// pass, [`Self::to_apply_json`] otherwise.
124+
pub(super) fn to_json(&self, preview: bool) -> serde_json::Value {
125+
if preview {
126+
self.to_preview_json()
127+
} else {
128+
self.to_apply_json()
129+
}
130+
}
131+
122132
/// Serialize for a *non-mutating* GC pass (read-only preview).
123133
fn to_preview_json(&self) -> serde_json::Value {
124134
serde_json::json!({
@@ -248,6 +258,41 @@ pub(super) async fn run_apply_gc(
248258
gc
249259
}
250260

261+
/// The vendored-state half of the GC alone, for a `--prune` whose crawl
262+
/// found nothing (the manifest half is skipped there: pruning against an
263+
/// empty crawl would drop every entry). Reverting entries whose patch left
264+
/// the manifest or whose dependency left the lock asks the manifest and
265+
/// the lockfile, not the crawl, so it stays safe. Wet passes take the
266+
/// apply lock like [`run_apply_gc`]; `--dry-run` previews.
267+
pub(super) async fn run_vendor_only_gc(
268+
common: &GlobalArgs,
269+
manifest_path: &Path,
270+
socket_dir: &Path,
271+
) -> GcSummary {
272+
if common.dry_run {
273+
return GcSummary::vendor_only(run_vendor_gc(common, manifest_path, true).await);
274+
}
275+
// Same pre-lock existence gate as `run_apply_gc`: no ledger, no pass
276+
// (and no `.socket/` created by the lock acquire).
277+
let has_ledger = tokio::fs::metadata(common.cwd.join(VENDOR_STATE_REL))
278+
.await
279+
.is_ok_and(|m| m.is_file());
280+
if !has_ledger {
281+
return GcSummary::default();
282+
}
283+
let timeout = Duration::from_secs(common.lock_timeout.unwrap_or(0));
284+
let _guard = match crate::commands::lock_cli::acquire_with_status(socket_dir, timeout) {
285+
Ok(g) => g,
286+
Err(e) => {
287+
return GcSummary {
288+
skipped: Some(lock_failure(&e, timeout)),
289+
..Default::default()
290+
};
291+
}
292+
};
293+
GcSummary::vendor_only(run_vendor_gc(common, manifest_path, false).await)
294+
}
295+
251296
/// Dry-run preview of the apply-mode GC pass. Same shape as
252297
/// [`run_apply_gc`] but emits `prunable*`/`orphan*` field names and
253298
/// performs no mutation.
@@ -417,10 +462,16 @@ pub(super) async fn run_human_gc(
417462
if common.silent {
418463
return;
419464
}
420-
if let Some(line) = format_gc_line(&gc, preview) {
465+
print_human_gc(&gc, preview);
466+
}
467+
468+
/// The human summary lines of a finished GC pass (`preview`: the
469+
/// `--dry-run` wording). Callers handle `--silent`.
470+
pub(super) fn print_human_gc(gc: &GcSummary, preview: bool) {
471+
if let Some(line) = format_gc_line(gc, preview) {
421472
println!("\n{line}");
422473
}
423-
for line in format_gc_vendored_lines(&gc) {
474+
for line in format_gc_vendored_lines(gc) {
424475
if preview {
425476
println!("[dry-run] {line}");
426477
} else {

0 commit comments

Comments
 (0)