Skip to content

Commit 6879d83

Browse files
committed
Share the lock-text refusal scope with downloads
The preview and rollout plan read the lock-text refusals through their own copy of the download phase's filters, and the copies disagreed on the ledger: a same-uuid entry written by plain vendor previewed as already vendored while the wet run still refused it. Both now go through one helper, unclaimed_lock_text_refusals, so the dry run and the wet run ask the same purls. npm purls are not asked again when the project-level package-lock refusal already covers them, and the contract's dry-run error-code row names the new codes. Assisted-by: Claude Code:claude-opus-5-5
1 parent 6bf0703 commit 6879d83

3 files changed

Lines changed: 112 additions & 57 deletions

File tree

‎crates/socket-patch-cli/CLI_CONTRACT.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1618,7 +1618,7 @@ command's integration tests check its output.
16181618
| `vendor_bun_workspace_unsupported` | `failed` | vendor / scan / get `--mode vendored` (bun): the text lock holds `workspace:` packages and its `lockfileVersion` is below 2 — Bun 1.2–1.3 resolve a workspace member's local-tarball path relative to the member; a committed version-2 lock is the proof every consumer runs Bun ≥ 1.4 (deliberate over-approximation: root-only declared packages would install on version 1 too). Detail names the version integer and a version-specific remedy: delete `bun.lock` and re-lock with Bun ≥ 1.4 (an in-place `bun install` keeps the existing version) — then, for a version-1 lock, "or use `--mode hosted`, which accepts version-1 workspace locks"; for a version-0 lock, "or delete `bun.lock`, re-lock with Bun ≥ 1.2 (which writes lockfileVersion 1) and use `--mode hosted`" (hosted refuses version-0 workspace locks, so a bare hosted pointer would send the user into a second refusal). Refused before any write — in the pre-download preflight on `get`/`scan` (see `vendor_bun_lockb_invalid` for the placements); in the shared preflight that `vendor` and the vendor step run BEFORE a hosted → vendored takeover's revert (a hosted-redirected purl stays hosted-wired, ledger and lock untouched; `vendor --dry-run` previews the same `failed` code); and in the engine when the run would write a NEW local tuple. Exempt: purls the vendor ledger wires at the selected uuid, purls whose every `bun.lock` instance is already a `.socket/vendor/npm/` tuple (any uuid), in-sync re-runs and `repair` redownloads. |
16191619
| `vendor_lockfile_missing` / `vendor_lockfile_version_unsupported` (bun preflight placement) | `failed` | scan / get `--mode vendored` (bun): the pre-download preflight found `bun.lock` unreadable / at a `lockfileVersion` other than 0, 1 or 2 (a newer version: update socket-patch; no integer: re-lock with Bun ≥ 1.2 — the same text as hosted's `redirect_bun_lock_unsupported`) or outside bun's single-line `packages` grammar. Same placements as `vendor_bun_lockb_invalid`; nothing fetched, no patch record. An unreadable `.socket/vendor/state.json` met by the same preflight is `vendor_state_unreadable` (see that row), never one of these. |
16201620
| `bun_lockb_invalid` | scan `warnings[]` (run-level) | scan (every mode): the native binary inventory could not parse or read `bun.lockb`; detail names the format or filesystem error. Also printed as `Warning: …` on stderr. Exit and status remain unchanged. The warning is retained on empty and non-empty scans; valid binary locks are inventoried normally without a runtime or install. |
1621-
| (Bun / pnpm-takeover / PyPI refusal codes) | `skipped` (dry-run preview, `details.mode: "vendored"`) | scan `--mode vendored --dry-run` / get `--mode vendored --dry-run`: the wet run's Bun preflight would refuse this npm purl (`errorCode` one of the four Bun lock codes above, or `vendor_state_unreadable` for an unreadable vendor ledger), or the pnpm backend would refuse the takeover of its hosted pin (`errorCode` that backend's lock-text code, #853), or the PyPI vendored backend's wiring guards would refuse this PyPI purl (`errorCode` that backend's code, #891); `reason` carries the detail. Exit 0 / `status: "success"`, nothing written. (v5.0: replaces the `would_refuse` preview action.) |
1621+
| (Bun / pnpm-takeover / lock-text / PyPI refusal codes) | `skipped` (dry-run preview, `details.mode: "vendored"`) | scan `--mode vendored --dry-run` / get `--mode vendored --dry-run`: the wet run's Bun preflight would refuse this npm purl (`errorCode` one of the four Bun lock codes above, or `vendor_state_unreadable` for an unreadable vendor ledger), or the pnpm backend would refuse the takeover of its hosted pin (`errorCode` that backend's lock-text code, #853), or the npm package-lock / pnpm / yarn / cargo backend would refuse this purl on its lock text alone (`errorCode` that backend's code, such as `vendor_lock_entry_not_rewritable` or `vendor_workspace_member`; the download phase's "Lock-text refusals before the download" scope, #1976), or the PyPI vendored backend's wiring guards would refuse this PyPI purl (`errorCode` that backend's code, #891); `reason` carries the detail. Exit 0 / `status: "success"`, nothing written. (v5.0: replaces the `would_refuse` preview action.) |
16221622
| `cargo_wiring_migrated` | `skipped` (advisory note) | vendor / scan / get `--mode vendored` / repair (v5.0): a pre-v5 `.cargo/config.toml` / `.cargo/config` vendored `[patch.crates-io]` entry was moved into the workspace-root `Cargo.toml` (dry run: "would move"); the ledger entry is rewritten to name `Cargo.toml` (lock originals kept). A vendor re-run that migrates reports the package `applied`, not `already_vendored`. |
16231623
| `cargo_legacy_wiring_kept` | vendor: `failed`; repair: `skipped` (warning) | vendor / scan / get `--mode vendored` (v5.0): the pre-v5 config entry could not be removed after the manifest took the wiring — the run is unwound (manifest, lock and copy as before) and the package fails, since a kept entry would double-wire the crate and, on a uuid bump, point at a copy the stale sweep deletes; the code prefixes the error detail. repair: the move was refused (e.g. an unparseable `Cargo.toml`, a user entry for the crate, or an unremovable legacy entry — the manifest edit is unwound); left in place. |
16241624
| `cargo_version_tagged` | `skipped` (advisory note) | vendor / scan / get `--mode vendored` / repair (v5.0): a vendored copy and its detached Cargo.lock entry were (re)tagged `<version>+socket.<uuid>` — a copy vendored before tagged versions, or a lock entry tagged for another uuid while the wiring points at this copy (dry run: "would tag"). A vendor re-run that tags reports the package `applied`. |

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

Lines changed: 52 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -769,8 +769,9 @@ impl VendorRefusals<'_> {
769769
pub(crate) type LockRefusals = HashMap<String, (&'static str, String)>;
770770

771771
/// The lock-text refusals of the vendored download phase (see
772-
/// [`socket_patch_core::vendor::lock_text_refusals`]: the pnpm / yarn
773-
/// classic / yarn berry gates and cargo's locked-version gate), over the
772+
/// [`socket_patch_core::vendor::lock_text_refusals`]: the npm
773+
/// package-lock / pnpm / yarn classic / yarn berry gates and cargo's
774+
/// locked-version gate, through [`unclaimed_lock_text_refusals`]), over the
774775
/// patches the phase would otherwise fetch a view for — past the Bun
775776
/// refusal and the ledger's idempotency skip, which take precedence in the
776777
/// fetch loop. A purl the lockfiles pin hosted is left to the vendor loop:
@@ -808,23 +809,15 @@ pub(crate) async fn lock_text_refusals_for(
808809
.is_none()
809810
})
810811
.collect();
811-
let candidates: Vec<(&str, &str)> = fetchable
812-
.iter()
813-
.filter(|sr| !claimed.contains(&PurlKey::new(&sr.purl)))
814-
.map(|sr| (sr.purl.as_str(), sr.uuid.as_str()))
815-
.collect();
816-
let refused = socket_patch_core::vendor::lock_text_refusals(cwd, &candidates).await;
817-
let options = params.crawler_options();
818-
let mut refusals =
819-
crate::commands::vendor::lock_refusals_reaching_backend(
820-
cwd,
821-
refused,
822-
&ledger.entries,
823-
|purls| async move {
824-
crate::commands::vendor::installed_purls(&options, &purls, prior).await
825-
},
826-
)
827-
.await;
812+
let mut refusals = unclaimed_lock_text_refusals(
813+
cwd,
814+
&fetchable,
815+
&claimed,
816+
ledger,
817+
params.crawler_options(),
818+
prior,
819+
)
820+
.await;
828821
// A hosted gem the takeover will refuse (#775) is refused here too, so
829822
// its view is never fetched for a package the run cannot vendor. The
830823
// download phase only runs online (`--offline` refuses `get` and `scan`
@@ -845,6 +838,46 @@ pub(crate) async fn lock_text_refusals_for(
845838
refusals
846839
}
847840

841+
/// The lock-text half of [`lock_text_refusals_for`], shared with the
842+
/// vendored dry-run preview and rollout plan (`scan/vendor_flow.rs`) so the
843+
/// dry run and the wet run refuse exactly the same purls (#1976): the
844+
/// purls of `selected` the vendor backend refuses on the project's lock
845+
/// text alone ([`socket_patch_core::vendor::lock_text_refusals`]), leaving
846+
/// out a purl the lockfiles pin hosted (`claimed`: its takeover restores
847+
/// the upstream entry first), one the ledger's idempotency skip settles
848+
/// ([`detached_ledger_record`]), and one the vendor loop would never hand
849+
/// to its backend ([`crate::commands::vendor::lock_refusals_reaching_backend`];
850+
/// `options` and `prior` drive its installed-copy lookup).
851+
pub(crate) async fn unclaimed_lock_text_refusals(
852+
cwd: &Path,
853+
selected: &[&PatchSearchResult],
854+
claimed: &std::collections::HashSet<PurlKey>,
855+
ledger: &VendorState,
856+
options: CrawlerOptions,
857+
prior: Option<&crate::ecosystem_dispatch::NpmCrawlSnapshot>,
858+
) -> LockRefusals {
859+
let candidates: Vec<(&str, &str)> = selected
860+
.iter()
861+
.filter(|sr| !claimed.contains(&PurlKey::new(&sr.purl)))
862+
.filter(|sr| {
863+
detached_ledger_record(RecordStore::Ledger(&ledger.entries), &sr.purl, &sr.uuid)
864+
.is_none()
865+
})
866+
.map(|sr| (sr.purl.as_str(), sr.uuid.as_str()))
867+
.collect();
868+
if candidates.is_empty() {
869+
return LockRefusals::new();
870+
}
871+
let refused = socket_patch_core::vendor::lock_text_refusals(cwd, &candidates).await;
872+
crate::commands::vendor::lock_refusals_reaching_backend(
873+
cwd,
874+
refused,
875+
&ledger.entries,
876+
|purls| async move { crate::commands::vendor::installed_purls(&options, &purls, prior).await },
877+
)
878+
.await
879+
}
880+
848881
/// The purl keys the project's lockfiles pin hosted (a patch-server
849882
/// tarball, Socket's own or one of `origins`): the purls whose vendored
850883
/// run is a hosted → vendored takeover.

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

Lines changed: 59 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -186,8 +186,10 @@ impl VendorPreview {
186186
/// not predicted), and a would-refuse never flips the run's status or exit
187187
/// code. The preflights (the only disk access besides the ledger; the PyPI
188188
/// one may also probe `pipenv --version` and list Hatch environments, as
189-
/// the wet run's guards do) run only when the selection holds an npm or
190-
/// PyPI purl.
189+
/// the wet run's guards do; the lock-text gates read the lock, the
190+
/// manifest and, for a refused package the lock does not resolve to a
191+
/// registry source, the installed tree) run only when the selection holds
192+
/// an npm, PyPI or cargo purl.
191193
/// `takeover_refusals` adds the hosted→vendored takeover refusals the
192194
/// caller resolved (the gem gates of
193195
/// [`crate::commands::vendor::gem_takeover_preview_refusals`]), keyed by
@@ -209,7 +211,8 @@ pub(crate) async fn preview_vendor(
209211
let npm_lock_refusal = npm_lock_refusal(cwd, selected).await;
210212
let state = state.unwrap_or_default();
211213
let pnpm_refusals = hosted_pnpm_refusals(cwd, selected, origins, &state).await;
212-
let lock_text_refusals = lock_text_refusals(cwd, selected, origins, &state).await;
214+
let lock_text_refusals =
215+
lock_text_refusals(cwd, selected, origins, &state, npm_lock_refusal.is_some()).await;
213216
let pypi_refusals = pypi_refusals(cwd, selected, origins, takeover_refusals).await;
214217
let refuse = |code: &str, detail: &str| PreviewVerdict::WouldRefuse {
215218
code: code.to_string(),
@@ -369,7 +372,8 @@ pub(super) async fn preflight_refused_purls(
369372
let origins = crate::commands::hosted_unwind::patch_server_origins(common);
370373
let state = state.unwrap_or_default();
371374
let pnpm_refusals = hosted_pnpm_refusals(cwd, selected, &origins, &state).await;
372-
let lock_text_refusals = lock_text_refusals(cwd, selected, &origins, &state).await;
375+
let lock_text_refusals =
376+
lock_text_refusals(cwd, selected, &origins, &state, npm_lock_refusal.is_some()).await;
373377
let gem_refusals = crate::commands::vendor::gem_takeover_preview_refusals(
374378
common,
375379
selected.iter().map(|p| p.purl.as_str()),
@@ -406,54 +410,41 @@ async fn npm_lock_refusal(
406410
/// The purls of `selected` the vendor backend refuses on the project's
407411
/// lock and manifest text alone, with the backend's `(code, detail)`: the
408412
/// npm package-lock, pnpm, yarn classic and yarn berry per-entry gates and
409-
/// cargo's locked-version gate
410-
/// ([`socket_patch_core::vendor::lock_text_refusals`]). These are the
411-
/// refusals the wet run's download phase raises before it fetches a view
412-
/// ([`crate::commands::agent_download::lock_text_refusals_for`]), so the
413-
/// preview and the rollout plan must name them too (#1976): an npm package
414-
/// installed from a URL or git spec, or a workspace member, otherwise
415-
/// previews as vendorable and takes a `--max-new-patches` slot on every
416-
/// run. As in the download phase, a purl the lockfiles pin hosted is left
417-
/// to its takeover (which restores the upstream entry first, see
418-
/// [`hosted_pnpm_refusals`]), a purl the ledger already holds at this uuid
419-
/// is not asked, and only a package the vendor loop would hand to its
420-
/// backend is kept ([`crate::commands::vendor::lock_refusals_reaching_backend`]).
413+
/// cargo's locked-version gate. These are the refusals the wet run's
414+
/// download phase raises before it fetches a view, read through the same
415+
/// [`crate::commands::agent_download::unclaimed_lock_text_refusals`] (same
416+
/// hosted-pin, ledger and reaches-the-backend scope), so the preview and
417+
/// the rollout plan name them too (#1976): an npm package installed from a
418+
/// URL or git spec, or a workspace member, otherwise previews as vendorable
419+
/// and takes a `--max-new-patches` slot on every run. npm purls are not
420+
/// asked when `npm_lock_refused` (the project-level package-lock refusal
421+
/// already refuses every one of them).
421422
async fn lock_text_refusals(
422423
cwd: &Path,
423424
selected: &[PatchSearchResult],
424425
origins: &[String],
425426
state: &VendorState,
427+
npm_lock_refused: bool,
426428
) -> HashMap<String, (&'static str, String)> {
427-
let gated =
428-
|p: &&PatchSearchResult| p.purl.starts_with("pkg:npm/") || p.purl.starts_with("pkg:cargo/");
429-
if !selected.iter().any(|p| gated(&p)) {
430-
return HashMap::new();
431-
}
432-
let claimed =
433-
crate::commands::agent_download::hosted_claimed_purls(cwd, origins.to_vec()).await;
434-
let candidates: Vec<(&str, &str)> = selected
429+
let gated: Vec<&PatchSearchResult> = selected
435430
.iter()
436-
.filter(gated)
437-
.filter(|p| !claimed.contains(&PurlKey::new(&p.purl)))
438-
.filter(|p| lookup_entry(&state.entries, &p.purl).is_none_or(|e| e.uuid != p.uuid))
439-
.map(|p| (p.purl.as_str(), p.uuid.as_str()))
431+
.filter(|p| {
432+
(p.purl.starts_with("pkg:npm/") && !npm_lock_refused)
433+
|| p.purl.starts_with("pkg:cargo/")
434+
})
440435
.collect();
441-
if candidates.is_empty() {
436+
if gated.is_empty() {
442437
return HashMap::new();
443438
}
444-
let refused = socket_patch_core::vendor::lock_text_refusals(cwd, &candidates).await;
439+
let claimed =
440+
crate::commands::agent_download::hosted_claimed_purls(cwd, origins.to_vec()).await;
445441
let options = socket_patch_core::crawlers::CrawlerOptions {
446442
cwd: cwd.to_path_buf(),
447443
global: false,
448444
global_prefix: None,
449445
};
450-
crate::commands::vendor::lock_refusals_reaching_backend(
451-
cwd,
452-
refused,
453-
&state.entries,
454-
|purls| async move {
455-
crate::commands::vendor::installed_purls(&options, &purls, None).await
456-
},
446+
crate::commands::agent_download::unclaimed_lock_text_refusals(
447+
cwd, &gated, &claimed, state, options, None,
457448
)
458449
.await
459450
}
@@ -2305,6 +2296,37 @@ mod npm_lock_entry_refusal_tests {
23052296
}
23062297
}
23072298

2299+
/// The ledger's scope is the download phase's: only a detached entry
2300+
/// carrying its record settles a purl, so a same-uuid entry `vendor`
2301+
/// wrote (not detached) is still asked, and the preview agrees with the
2302+
/// wet run's refusal instead of saying `already_vendored`.
2303+
#[tokio::test]
2304+
async fn preview_asks_a_same_uuid_entry_the_wet_run_still_gates() {
2305+
let tmp = tempfile::tempdir().unwrap();
2306+
let root = tmp.path();
2307+
let code = project(root, "url");
2308+
write(
2309+
&root.join(".socket/vendor/state.json"),
2310+
&serde_json::json!({
2311+
"version": 1,
2312+
"entries": { LEFT_PAD: {
2313+
"ecosystem": "npm", "basePurl": LEFT_PAD, "uuid": UUID,
2314+
"artifact": { "path": format!(".socket/vendor/npm/{UUID}/left-pad-1.3.0.tgz") },
2315+
"wiring": [],
2316+
}}
2317+
})
2318+
.to_string(),
2319+
);
2320+
let state = load_state(root).await.expect("the seeded ledger parses");
2321+
let entry = lookup_entry(&state.entries, LEFT_PAD).expect("seeded entry");
2322+
assert!(entry.uuid == UUID && !entry.detached);
2323+
let preview = preview_vendor(root, &[sel(LEFT_PAD)], &[], &HashMap::new()).await;
2324+
match &preview.rows[0].verdict {
2325+
PreviewVerdict::WouldRefuse { code: got, .. } => assert_eq!(got, code),
2326+
other => panic!("left-pad must preview would_refuse, got {other:?}"),
2327+
}
2328+
}
2329+
23082330
/// The rollout planning pass: the refused left-pad holds no
23092331
/// `--max-new-patches` slot, so the vendorable is-number gets it.
23102332
#[tokio::test]

0 commit comments

Comments
 (0)