diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index f41cd68b9..6f104d224 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -321,7 +321,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 --mode vendored` 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`) — unless the patch service has no prebuilt artifact for the newer patch yet (or at all): the package then keeps the older patch and is a `skipped` `vendor_prebuilt_pending` / `vendor_prebuilt_unavailable` event, not a failure, in every ecosystem (#954, #1235); 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). Under `--json` (v5.0, MAJOR) the download phase and the vendor engine record into the ONE scan envelope (no nested `download` / `vendor` objects): a fetched patch is a `downloaded` event (the patch metadata in `details`, plus `details.oldUuid` when the ledger holds the purl at another uuid — the re-vendor the vendor step then performs — whose human `[fetch]` line reads ` (replacing )`), a refused or failed one a `failed` event with its code, a patch the ledger already holds at the selected uuid no download event at all (reused, no fetch); then the vendor engine's events (`applied`, `rebuilt`, `skipped`, `failed`, `removed`, its uncounted advisories) follow with their warnings and sidecars. Every one of these events carries `details.mode: "vendored"`. Patch blobs are held in memory (see "Patch sources stay in memory" under the vendor contract). `--dry-run` previews per patch: `verified` (to vendor; `oldUuid` on a re-vendor), `skipped` / `already_vendored` (in sync), or `skipped` with the refusal code for an npm purl the wet run's Bun preflight (see the `get --mode vendored` bullet below) would refuse, or whose hosted pnpm pin the takeover would be refused on (see "Takeover reconciliation"), or for a PyPI purl the PyPI vendored backend's wiring guards would refuse before building anything (the project routing and lock guards a standalone `vendor --dry-run` reports, such as `pypi_no_requirements` or `pypi_uv_no_sources`, on the project lane and the PEP 723 script lane alike, #891; a PyPI purl with a live hosted pin is not judged, since the wet run restores its upstream entry before those guards run) — 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 the headline `[dry-run] Would download and vendor N patches. No changes made.`, or, when some patches are in sync or would be refused, `[dry-run] Would download and vendor K of N patches (A already vendored, R would be refused). No changes made.` with only the non-zero clauses (#1850), then a `[skip] (already vendored)` line on stderr for each in-sync patch — the wet run's line — and `[would-refuse] (): ` lines, all 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).
@@ -347,7 +347,7 @@ The rewriter reads a fixed set of candidate files from the project root: the npm
**get --mode and installed narrowing (v3.6).** `get --mode hosted|vendored` consumes the resolved patch(es) through the SAME engines as `scan --mode hosted|vendored`, so for the same selected (purl, uuid) set the on-disk result is identical by construction — the per-advisory selector for hosted/vendored (`get --save-only` then `vendor` still works). **Agent mode (v5.0 lock + residue rules)**: the download phase runs under `<.socket>/apply.lock` and hands the guard to the nested apply, so download → manifest write → apply is one lock window (the nested apply never re-acquires and inherits every caller flag — `--lock-timeout` and `--verbose` included); a failed acquire is the envelope's `status: "error"`, `error: {code: "lock_held" | "lock_io", message}`, exit 1, before any fetch (a read-only `.socket/` fails here, naming the lock path). `.socket/` and `.socket/blobs/` are created only when a record is actually persisted — an all-skipped or all-failed run leaves no `.socket/` on a fresh project — and a same-uuid `get ` re-run rewrites neither the manifest nor the blobs. Semantics:
* **Hosted** (`get GHSA-… --mode hosted`): resolves the advisory, then hands the selected (purl, uuid) pairs to scan's hosted engine — reference grants, staged cross-mode takeover, lockfile rewrite (no ledger, v5.0), gem stale-install probe, warnings, confirmation rules (cargo via `confirmed_cargo_uuids`, golang via `confirmed_golang_uuids` only) all identical to `scan --mode hosted`, and (v5.0) under the same `apply.lock` acquisition — taken around the first wet write, never on `--dry-run` or when nothing would be written; a failed acquire folds as top-level `error: {code: "lock_held" | "lock_io", message}` (exit 1), and `--dry-run` under a held lock still exits 0. **No manifest write, no blobs, no ledger** — the lockfile edits are the persistence. JSON: the same hosted events (`details.mode: "hosted"`) and `redirect` payload as scan's, after the narrowing `skipped` events (nothing is downloaded into `.socket/`). Exit codes follow scan's hosted semantics: skipped grants and rewriter warnings never flip the exit; infra errors (reference fetch, file writes) exit 1. Human prompt: `Redirect N packages to the hosted patch server?` (singular for one; `--yes`/`--json`/non-TTY auto-accept as usual). This confirm is get's alone: `scan` never prompts.
-* **Vendored** (`get GHSA-… --mode vendored`): the download phase is scan's vendored posture — **manifest-free (v5.0)**: the selected records are fetched into memory (`download_patch_records`; no blob staging; nothing under `.socket/` is written; the nested apply never runs), then scan's vendor step runs under the apply lock over exactly the selected records, like `scan --mode vendored` (no whole-manifest scope and no `[note]` about other records — that blast radius is retired with the manifest; a legacy manifest record for a vendored purl is migrated out of `.socket/manifest.json` the same way scan does it). JSON: the same events as `scan --mode vendored` (download events, then the vendor engine's, all `details.mode: "vendored"`; the human `[fetch]` line of a re-vendor reads ` (replacing )`); a vendor-step error keeps the events recorded before it and sets `status: "error"` + `error: {code, message}` (a pre-failure takeover reconcile may have already mutated the ledger — its events must reach the consumer). Exit: download failures or vendor errors → `partialFailure`/1. Human prompt: `Download and vendor N patches?`; `--dry-run` prints `[dry-run] Would download and vendor N patches. No changes made.` on both identifier paths (uuid and search). Telemetry mirrors scan's vendored arms (`track_outcomes_for_vendor` / `track_patch_vendor_failed`). **Bun vendored preflight (additive)** — shared by `get --mode vendored` on both its paths and `scan --mode vendored`: before ANY patch download, and only when the selection holds a `pkg:npm/` purl, the download phase reads `bun.lock`/`bun.lockb` once (`preflight_vendor`) and, when the vendor backend would refuse the project — a malformed, unreadable or unsupported `bun.lockb` → `vendor_bun_lockb_invalid`; an unreadable `bun.lock` → `vendor_lockfile_missing`; a `lockfileVersion` other than 0/1/2 or a non-canonical `packages` grammar → `vendor_lockfile_version_unsupported`; `workspace:` packages in a lock below version 2 → `vendor_bun_workspace_unsupported` — every `pkg:npm/` result becomes a `failed` event (`errorCode: `, `error: `) with NO fetch (the patch view is never requested) and no patch record; other ecosystems' results are untouched. **Search path** (`get --mode vendored`) and `scan --mode vendored`: the refusals are `failed` events (no `downloaded` event), the download phase writes nothing under `.socket/` (v5.0 — a pre-existing `.socket/manifest.json`, including a record seeded for another purl, is left byte-untouched), the vendor step still runs over the remaining records (no event for the refused purl), exit `partialFailure`/1. **uuid path** (`get --mode vendored`): the uuid lookup is the only fetch; the run exits 1 BEFORE the vendor step with `status: "error"`, `error: {code, message}` and the patch's one `failed` event (same code and detail) and writes nothing — no `.socket/` on a fresh project; human mode prints `Error (): ` on stderr. **Already-vendored exemption**: a purl is exempt from the workspace refusal only when every instance of its `name@version` in `bun.lock` is already a `.socket/vendor/npm/…` local tuple (any uuid; the digest-less 2-tuple counts) — the engine's own criterion — so in-sync re-runs, `repair`, and a superseding patch uuid on a project vendored before it grew a workspace member all flow to the engine (re-pinning an already-local tuple adds no workspace-relative exposure); a wiped ledger alone is not a refusal (the engine path decides). UUID equality in the ledger alone never exempts a purl: `rollback --preserve-state` retains its record after unwiring. Dry-run refusal takes priority over `already_vendored`. **Unreadable vendor ledger**: a `.socket/vendor/state.json` the preflight cannot read or parse is itself the refusal — `vendor_state_unreadable` with the io/parse detail, fail-closed (nothing is exempt) — on the uuid path, the search / `scan` path and the `--dry-run` preview alike; never a Bun lock code. **`--silent`** is "errors only" and never mutes the refusal: the code-tagged `[error] (): ` (per-patch paths) / `Error (): …` (uuid path) line stays on stderr with an empty stdout. **`--dry-run`** previews the refusal as a `skipped` event with the refusal's code (see `--dry-run` below). Agent-mode `get --save-only` is NOT preflighted (record-only intent has no consumption precondition). Pinned by `tests/vendor/in_process_vendor_bun.rs` (exact uuid-path envelope, seeded-manifest survival, `--silent`, `--dry-run`) and `tests/scan_vendor_e2e.rs`.
+* **Vendored** (`get GHSA-… --mode vendored`): the download phase is scan's vendored posture — **manifest-free (v5.0)**: the selected records are fetched into memory (`download_patch_records`; no blob staging; nothing under `.socket/` is written; the nested apply never runs), then scan's vendor step runs under the apply lock over exactly the selected records, like `scan --mode vendored` (no whole-manifest scope and no `[note]` about other records — that blast radius is retired with the manifest; a legacy manifest record for a vendored purl is migrated out of `.socket/manifest.json` the same way scan does it). JSON: the same events as `scan --mode vendored` (download events, then the vendor engine's, all `details.mode: "vendored"`; the human `[fetch]` line of a re-vendor reads ` (replacing )`); a vendor-step error keeps the events recorded before it and sets `status: "error"` + `error: {code, message}` (a pre-failure takeover reconcile may have already mutated the ledger — its events must reach the consumer). Exit: download failures or vendor errors → `partialFailure`/1. Human prompt: `Download and vendor N patches?`; `--dry-run` prints scan's vendored preview headline and lines (`[dry-run] Would download and vendor N patches. No changes made.`, with the already-vendored and would-refuse counts when non-zero; see `scan --mode vendored`) on both identifier paths (uuid and search). Telemetry mirrors scan's vendored arms (`track_outcomes_for_vendor` / `track_patch_vendor_failed`). **Bun vendored preflight (additive)** — shared by `get --mode vendored` on both its paths and `scan --mode vendored`: before ANY patch download, and only when the selection holds a `pkg:npm/` purl, the download phase reads `bun.lock`/`bun.lockb` once (`preflight_vendor`) and, when the vendor backend would refuse the project — a malformed, unreadable or unsupported `bun.lockb` → `vendor_bun_lockb_invalid`; an unreadable `bun.lock` → `vendor_lockfile_missing`; a `lockfileVersion` other than 0/1/2 or a non-canonical `packages` grammar → `vendor_lockfile_version_unsupported`; `workspace:` packages in a lock below version 2 → `vendor_bun_workspace_unsupported` — every `pkg:npm/` result becomes a `failed` event (`errorCode: `, `error: `) with NO fetch (the patch view is never requested) and no patch record; other ecosystems' results are untouched. **Search path** (`get --mode vendored`) and `scan --mode vendored`: the refusals are `failed` events (no `downloaded` event), the download phase writes nothing under `.socket/` (v5.0 — a pre-existing `.socket/manifest.json`, including a record seeded for another purl, is left byte-untouched), the vendor step still runs over the remaining records (no event for the refused purl), exit `partialFailure`/1. **uuid path** (`get --mode vendored`): the uuid lookup is the only fetch; the run exits 1 BEFORE the vendor step with `status: "error"`, `error: {code, message}` and the patch's one `failed` event (same code and detail) and writes nothing — no `.socket/` on a fresh project; human mode prints `Error (): ` on stderr. **Already-vendored exemption**: a purl is exempt from the workspace refusal only when every instance of its `name@version` in `bun.lock` is already a `.socket/vendor/npm/…` local tuple (any uuid; the digest-less 2-tuple counts) — the engine's own criterion — so in-sync re-runs, `repair`, and a superseding patch uuid on a project vendored before it grew a workspace member all flow to the engine (re-pinning an already-local tuple adds no workspace-relative exposure); a wiped ledger alone is not a refusal (the engine path decides). UUID equality in the ledger alone never exempts a purl: `rollback --preserve-state` retains its record after unwiring. Dry-run refusal takes priority over `already_vendored`. **Unreadable vendor ledger**: a `.socket/vendor/state.json` the preflight cannot read or parse is itself the refusal — `vendor_state_unreadable` with the io/parse detail, fail-closed (nothing is exempt) — on the uuid path, the search / `scan` path and the `--dry-run` preview alike; never a Bun lock code. **`--silent`** is "errors only" and never mutes the refusal: the code-tagged `[error] (): ` (per-patch paths) / `Error (): …` (uuid path) line stays on stderr with an empty stdout. **`--dry-run`** previews the refusal as a `skipped` event with the refusal's code (see `--dry-run` below). Agent-mode `get --save-only` is NOT preflighted (record-only intent has no consumption precondition). Pinned by `tests/vendor/in_process_vendor_bun.rs` (exact uuid-path envelope, seeded-manifest survival, `--silent`, `--dry-run`) and `tests/scan_vendor_e2e.rs`.
**Lock-text refusals before the download (v5.0)** — shared by `get --mode vendored` on both its paths and `scan --mode vendored`, after the Bun preflight above and the ledger's `already vendored` skip: a `pkg:npm/` result in a **pnpm, yarn classic or yarn berry** project, or a `pkg:cargo/` result, that its vendor backend refuses on the project's lock and manifest text alone is refused BEFORE its patch view is fetched — the pnpm / classic / berry gates the backend runs before it reads the package (coordinates, the lock and manifest reads and their line-ending / version / `cacheKey` / `.yarnrc.yml` gates, override and `resolutions` conflicts, the lock entry present and rewritable) and cargo's `locked_version_mismatch` (only when it is the crate's FIRST refusal; an in-tree `cargo vendor` copy still refuses in the loop as `already_vendored_in_tree`). **Scope:** only a package the vendor loop would hand to its backend is refused early — one installed on disk (the loop's own qualified-aware resolver plus the npm identity lookup), or one the lockfile inventory resolves to a verifiable registry source (a lock entry with an integrity, or the ledger-recovered pre-vendor resolution — exactly the entry the pristine fetch would use). A package absent from the lock and not installed never reached its backend and is untouched: its view is fetched, it downloads, and the vendor loop skips it `skipped` / `package_not_installed` as in v4.x (so cargo's `locked_version_mismatch` is refused early only for a crate installed at the unlocked version). The result is a `failed` event (`errorCode: `, `error: `) with the backend's exact code and detail, no view and no pristine fetch, no patch record, and therefore no vendor-engine event for it (a lockfile-only package among them gets no `vendor_fetched_missing` event: it is never fetched). Exit 1 and `status: "partialFailure"`, and when every selected package is refused this way the human `scan --mode vendored` arm prints `Nothing was vendored: N patches failed (see above).`. **Precedence:** the lock-text refusal is decided before the view, so it wins over every view-derived outcome — a package that would also have been a paid-access 403 (`[PAID]`/no access), a failed view fetch, or a no-applicable-files skip reports the lock refusal instead (the Bun refusal and the ledger's `already vendored` skip still come first). The human `[error] (): ` line is printed during the download instead of the vendor step's failure line (the human (non-`--silent`) `scan --mode vendored` arm's baseline pre-check still fetches the views it verifies; only the download, the pristine fetch and the vendor step skip the package there). A purl the lockfiles pin hosted keeps the loop's refusal (its takeover restore rewrites the lock the gates read); other flavors (package-lock, pnpm-legacy, bun) and ecosystems are untouched, and `--dry-run` is unchanged. `vendor` (manifest-driven, no view fetch) keeps its per-package `failed` events but no longer fetches the pristine source of a lockfile-only package it refuses this way — the source is deferred to the backend, which refuses before reading it (no `vendor_fetched_missing` event and no registry request; a refused package whose registry is unreachable reports the gate's code instead of `vendor_fetch_failed`); only a package the lock resolves to a verifiable source is deferred, and one it does not resolve keeps its `package_not_installed` skip. Pinned by `tests/scan_vendor_e2e.rs` (`exact_download_plan`: scan and exact-purl get, pnpm and cargo scope), `tests/e2e_yarn_legacy_cachekey_refusal_build.rs` and `tests/vendor/vendor_rerun_no_network_e2e.rs`.
* **Installed-version narrowing** (all modes, `get`'s search path): a CVE/GHSA fan-out returns one patch record per patched VERSION; get keeps only versions present here and emits calm `skipped` events (`errorCode: "package_not_installed"`) for the rest — never an error exit. Presence = installed on disk (qualified-aware resolver) ∪ already tracked in the manifest (record maintenance keeps working on hosts without an installed copy); hosted/vendored modes additionally count lockfile-resolved deps and vendor-ledger purls (mirroring scan's discovery supplements, including their `--global` gate). **Exempt** (no narrowing): UUID identifiers, exact-versioned PURL identifiers (explicit intent), `--save-only` runs (record-only has no installation precondition — the fresh-clone record→vendor flow keeps working), `--all-releases`, and the package-name path (already installed-derived). When EVERY found patch is filtered out, get exits 0 with status **`notInstalled`** (the `skipped` events, plus any warnings) — never `noMatch`, which remains pinned to the package-name path (exact names; near names are only suggested). PnP layouts are surfaced, not misreported: yarn-PnP npm results skip with `errorCode: "yarn_pnp_unsupported"` in every mode; pnpm-PnP skips carry `pnpm_pnp_unsupported` in agent/vendored modes; hosted mode — the refusal's own remedy — keeps ONLY the versions the raw `pnpm-lock.yaml` text actually resolves (boundary-anchored probe over the v5/v6/v9 key spellings, so a large fan-out never requests grants for every version ever patched), labels a JUDGED miss `package_not_installed` exactly like a non-PnP project (the layout blocked nothing — the lock was read and the version isn't resolved), and reserves the layout code for an unreadable lock (no judgment possible). When EVERY narrowed-out result is a PnP refusal, the human terminal names the layout instead of claiming "not installed" and never advises `--all-releases` (which cannot make PnP patchable); the JSON status stays `notInstalled` — consumers dispatch on each `skipped` event's `errorCode`. Hosted mode also runs the per-release VARIANT filter (`filter_to_installed_releases`) on its search path before requesting grants — agent/vendored runs get it inside the download engines — with the same keep-all-plus-warning fallbacks (surfaced as `release_narrowing` entries in `warnings[]`). An ecosystem this binary has no crawler for is likewise never judged: its results are KEPT (absence from a crawl that never looked carries no information — the same fail-safe as scan's prune GC). The human `Found N patches:` listing shows only the patches whose package version survived the narrowing (the narrowing is judged over every result, so an installed package's paid fix a free user cannot download still lists as `[PAID] (no access)`, while skip records and counts cover only accessible patches), sorted by PURL in natural version order (`4.17.2` before `4.17.10`); the narrowed-out ones are summarized on stderr in one line per reason (`Skipped N patches for M package versions not installed here (use --all-releases to include them).`), and `--verbose` adds one `[skip] ()` line per skipped version after that summary, in natural version order. When the candidates hold more patches than were selected and the pick was made without a menu (a paid user's auto-pick, `--yes`, a non-TTY run), a `Selected:` block names the patch (purl, tier, short uuid, advisories) that will be installed before the prompt. Machine output (the prompt count, the JSON envelope) uses the kept set, unchanged. The finer per-release variant narrowing (`filter_to_installed_releases`) is unchanged and still runs inside the download engines (and before an agent-mode `--dry-run` preview, so the preview names only the variants a wet run would fetch).
diff --git a/crates/socket-patch-cli/src/commands/get.rs b/crates/socket-patch-cli/src/commands/get.rs
index 1b7da1e47..517ddbdac 100644
--- a/crates/socket-patch-cli/src/commands/get.rs
+++ b/crates/socket-patch-cli/src/commands/get.rs
@@ -2290,8 +2290,7 @@ async fn run_get_vendored(
preview.record_into(&mut env);
emit(&env);
} else if !args.common.silent {
- println!("{}", format_dry_run("download and vendor", selected.len()));
- preview.print_refusals();
+ preview.print_human();
}
return 0;
}
diff --git a/crates/socket-patch-cli/src/commands/scan/mod.rs b/crates/socket-patch-cli/src/commands/scan/mod.rs
index e8281628d..dc2ed2974 100644
--- a/crates/socket-patch-cli/src/commands/scan/mod.rs
+++ b/crates/socket-patch-cli/src/commands/scan/mod.rs
@@ -3412,21 +3412,14 @@ async fn run_scan(
}
}
- // What the dry-run line offers.
- let plan = if vendor {
- render::Plan::Vendor(selected.len())
- } else {
- render::Plan::Apply(selected.len())
- };
-
// `--dry-run` is a non-mutating preview: stop here, having printed the
// table and the per-patch plan above, before the download/apply and the
// prune GC (which runs as a read-only preview instead).
if args.common.dry_run {
if !silent {
// Vendored preview: the JSON arm's ledger classification,
- // rendered as `[would-refuse]` lines so a preview never
- // advertises vendoring the wet run would refuse.
+ // rendered as `[skip]` and `[would-refuse]` lines so a preview
+ // never advertises vendoring the wet run would skip or refuse.
let preview = if vendor {
// `vendor` is never report-only, so the planning pass
// above always computed it.
@@ -3438,10 +3431,12 @@ async fn run_scan(
} else {
None
};
- let refused = preview.as_ref().map_or(0, |p| p.refused_count());
- println!("{}", render::dry_run_line(plan, refused));
- if let Some(preview) = &preview {
- preview.print_refusals();
+ match &preview {
+ Some(preview) => preview.print_human(),
+ None => println!(
+ "{}",
+ render::dry_run_line(render::Plan::Apply(selected.len()))
+ ),
}
}
print_rollout_human(&stage, true, silent);
diff --git a/crates/socket-patch-cli/src/commands/scan/render.rs b/crates/socket-patch-cli/src/commands/scan/render.rs
index d51aaec87..e4e02838b 100644
--- a/crates/socket-patch-cli/src/commands/scan/render.rs
+++ b/crates/socket-patch-cli/src/commands/scan/render.rs
@@ -249,22 +249,46 @@ pub(super) fn unwired_vendored_detail(purls: &[String]) -> String {
pub(super) enum Plan {
/// Agent mode: download `n` patches and apply them in place.
Apply(usize),
- /// Vendored mode: download `n` patches and vendor them.
- Vendor(usize),
+ /// Vendored mode: of `total` selected patches, `refused` would be
+ /// refused by the vendored preflight (listed as `[would-refuse]`
+ /// lines) and `in_sync` are held by the ledger at this uuid (listed as
+ /// `[skip] … (already vendored)` lines); the rest are downloaded and
+ /// vendored.
+ Vendor {
+ total: usize,
+ refused: usize,
+ in_sync: usize,
+ },
}
-/// The `--dry-run` headline. `refused` counts the patches the vendored
-/// preflight would refuse (listed right after as `[would-refuse]` lines),
-/// so the headline never promises to vendor what the wet run refuses.
-pub(super) fn dry_run_line(plan: Plan, refused: usize) -> String {
+/// The `--dry-run` headline. A vendored plan leaves out what the wet run
+/// refuses or skips as already vendored, so the headline never promises
+/// to vendor either (#1850).
+pub(super) fn dry_run_line(plan: Plan) -> String {
let what = match plan {
Plan::Apply(n) => format!("download and apply {}", plural(n, "patch", "patches")),
- Plan::Vendor(n) if refused > 0 => format!(
- "download and vendor {} of {} ({refused} would be refused)",
- n.saturating_sub(refused),
- plural(n, "patch", "patches")
- ),
- Plan::Vendor(n) => format!("download and vendor {}", plural(n, "patch", "patches")),
+ Plan::Vendor {
+ total,
+ refused,
+ in_sync,
+ } if refused > 0 || in_sync > 0 => {
+ let mut why = Vec::new();
+ if in_sync > 0 {
+ why.push(format!("{in_sync} already vendored"));
+ }
+ if refused > 0 {
+ why.push(format!("{refused} would be refused"));
+ }
+ format!(
+ "download and vendor {} of {} ({})",
+ total.saturating_sub(refused + in_sync),
+ plural(total, "patch", "patches"),
+ why.join(", ")
+ )
+ }
+ Plan::Vendor { total, .. } => {
+ format!("download and vendor {}", plural(total, "patch", "patches"))
+ }
};
format!("[dry-run] Would {what}. No changes made.")
}
@@ -748,26 +772,48 @@ mod tests {
// ---- dry-run / hints -------------------------------------------
+ fn vendor(total: usize, refused: usize, in_sync: usize) -> Plan {
+ Plan::Vendor {
+ total,
+ refused,
+ in_sync,
+ }
+ }
+
#[test]
fn dry_run_line_counts_refusals() {
assert_eq!(
- dry_run_line(Plan::Apply(1), 0),
+ dry_run_line(Plan::Apply(1)),
"[dry-run] Would download and apply 1 patch. No changes made."
);
assert_eq!(
- dry_run_line(Plan::Vendor(2), 0),
+ dry_run_line(vendor(2, 0, 0)),
"[dry-run] Would download and vendor 2 patches. No changes made."
);
assert_eq!(
- dry_run_line(Plan::Vendor(2), 2),
+ dry_run_line(vendor(2, 2, 0)),
"[dry-run] Would download and vendor 0 of 2 patches (2 would be refused). No changes made."
);
assert_eq!(
- dry_run_line(Plan::Vendor(1), 1),
+ dry_run_line(vendor(1, 1, 0)),
"[dry-run] Would download and vendor 0 of 1 patch (1 would be refused). No changes made."
);
}
+ #[test]
+ fn dry_run_line_counts_already_vendored() {
+ // #1850: an in-sync patch is not work the wet run would do.
+ assert_eq!(
+ dry_run_line(vendor(1, 0, 1)),
+ "[dry-run] Would download and vendor 0 of 1 patch (1 already vendored). No changes made."
+ );
+ assert_eq!(
+ dry_run_line(vendor(4, 1, 2)),
+ "[dry-run] Would download and vendor 1 of 4 patches (2 already vendored, \
+ 1 would be refused). No changes made."
+ );
+ }
+
#[test]
fn report_only_hint_names_agent_mode() {
let project = report_only_hint(&GlobalArgs::default());
diff --git a/crates/socket-patch-cli/src/commands/scan/vendor_flow.rs b/crates/socket-patch-cli/src/commands/scan/vendor_flow.rs
index 94b451426..75c82ea10 100644
--- a/crates/socket-patch-cli/src/commands/scan/vendor_flow.rs
+++ b/crates/socket-patch-cli/src/commands/scan/vendor_flow.rs
@@ -173,12 +173,43 @@ impl VendorPreview {
}
}
- /// Human rendering of the preview's refusals and symlink advisories:
- /// the count line above it still says "would download and vendor", so
- /// name what the wet run would refuse and why. Shared by `scan --mode
- /// vendored --dry-run` and `get … --mode vendored --dry-run` so the two
- /// cannot drift. Callers gate it on `--silent`.
- pub(crate) fn print_refusals(&self) {
+ /// How many rows the ledger already holds at their uuid.
+ pub(crate) fn in_sync_count(&self) -> usize {
+ self.rows
+ .iter()
+ .filter(|r| matches!(r.verdict, PreviewVerdict::AlreadyVendored))
+ .count()
+ }
+
+ /// The human `--dry-run` headline: every row, less the ones the wet
+ /// run would refuse or skip as already vendored (#1850).
+ fn dry_run_line(&self) -> String {
+ super::render::dry_run_line(super::render::Plan::Vendor {
+ total: self.rows.len(),
+ refused: self.refused_count(),
+ in_sync: self.in_sync_count(),
+ })
+ }
+
+ /// Human rendering of the preview: the headline, then a `[skip] …
+ /// (already vendored)` line for each in-sync row (stderr, the wet
+ /// run's line and stream), a `[would-refuse]` line naming what the wet
+ /// run would refuse and why, and the symlink advisories. Shared by
+ /// `scan --mode vendored --dry-run` and `get … --mode vendored
+ /// --dry-run` so the two cannot drift. Callers gate it on `--silent`.
+ pub(crate) fn print_human(&self) {
+ println!("{}", self.dry_run_line());
+ for row in &self.rows {
+ if matches!(row.verdict, PreviewVerdict::AlreadyVendored) {
+ eprintln!(
+ "{}",
+ crate::commands::agent_download::format_record_skip(
+ &row.purl,
+ "already vendored"
+ )
+ );
+ }
+ }
for row in &self.rows {
if let PreviewVerdict::WouldRefuse { code, detail } = &row.verdict {
println!(" [would-refuse] {} ({code}): {detail}", row.purl);
@@ -1480,6 +1511,50 @@ mod preview_tests {
assert_eq!(preview.refused_count(), 1);
}
+ /// #1850: the human dry-run headline, shared by `scan` and `get`,
+ /// leaves out what the JSON records as `already_vendored`, so it
+ /// never offers to vendor a patch the wet run skips as in sync.
+ #[tokio::test]
+ async fn preview_headline_leaves_out_already_vendored() {
+ let tmp = tempfile::tempdir().unwrap();
+ seed_entry(tmp.path(), NPM, UUID);
+ let in_sync =
+ preview_vendor_with(tmp.path(), &[sel(UUID, NPM)], &[], &HashMap::new()).await;
+ assert_eq!(in_sync.in_sync_count(), 1);
+ assert_eq!(
+ in_sync.dry_run_line(),
+ "[dry-run] Would download and vendor 0 of 1 patch (1 already vendored). \
+ No changes made."
+ );
+
+ let other = "pkg:npm/preview-new@1.0.0";
+ let mixed = preview_vendor_with(
+ tmp.path(),
+ &[sel(UUID, NPM), sel(UUID, other), sel(UUID, PYPI)],
+ &[],
+ &HashMap::from([(
+ PYPI.to_string(),
+ ("vendor_test_refusal", "refused".to_string()),
+ )]),
+ )
+ .await;
+ assert_eq!(
+ mixed.dry_run_line(),
+ "[dry-run] Would download and vendor 1 of 3 patches (1 already vendored, \
+ 1 would be refused). No changes made."
+ );
+
+ // Nothing in sync: the plain headline.
+ let fresh = tempfile::tempdir().unwrap();
+ let plain =
+ preview_vendor_with(fresh.path(), &[sel(UUID, NPM)], &[], &HashMap::new()).await;
+ assert_eq!(plain.in_sync_count(), 0);
+ assert_eq!(
+ plain.dry_run_line(),
+ "[dry-run] Would download and vendor 1 patch. No changes made."
+ );
+ }
+
const UUID: &str = "11111111-1111-4111-8111-111111111111";
const OLD_UUID: &str = "00000000-0000-4000-8000-000000000000";
const NPM: &str = "pkg:npm/preview-bun@1.0.0";
diff --git a/crates/socket-patch-cli/tests/docker_e2e_vendor_composer.rs b/crates/socket-patch-cli/tests/docker_e2e_vendor_composer.rs
index b36863e22..c267fa585 100644
--- a/crates/socket-patch-cli/tests/docker_e2e_vendor_composer.rs
+++ b/crates/socket-patch-cli/tests/docker_e2e_vendor_composer.rs
@@ -46,9 +46,6 @@
#![cfg(feature = "docker-e2e")]
-#[allow(clippy::duplicate_mod)]
-#[path = "common/fs_tree.rs"]
-mod fs_tree;
#[path = "common/cache_env.rs"]
mod cache_env;
#[path = "composer_e2e_common/mod.rs"]
@@ -57,6 +54,9 @@ mod composer_e2e_common;
mod docker_common;
#[path = "docker_vendor_common/mod.rs"]
mod docker_vendor_common;
+#[allow(clippy::duplicate_mod)]
+#[path = "common/fs_tree.rs"]
+mod fs_tree;
#[path = "vex_e2e_common/mod.rs"]
mod vex_e2e_common;
diff --git a/crates/socket-patch-cli/tests/get/get_render.rs b/crates/socket-patch-cli/tests/get/get_render.rs
index 09a393f8b..c43eb44b9 100644
--- a/crates/socket-patch-cli/tests/get/get_render.rs
+++ b/crates/socket-patch-cli/tests/get/get_render.rs
@@ -105,6 +105,51 @@ async fn human_vendored_uuid_dry_run_prints_line() {
assert!(!tmp.path().join(".socket").exists());
}
+/// #1850: `get --mode vendored --dry-run` on a project the ledger
+/// already holds at this uuid shares scan's preview headline: nothing to
+/// download and vendor, and the in-sync patch named as skipped.
+#[tokio::test]
+async fn human_vendored_uuid_dry_run_skips_already_vendored() {
+ let server = MockServer::start().await;
+ mount_real_view(&server, UUID, PURL).await;
+
+ let tmp = tempfile::tempdir().unwrap();
+ write_project(tmp.path());
+ let vendor_dir = tmp.path().join(".socket/vendor");
+ std::fs::create_dir_all(&vendor_dir).unwrap();
+ std::fs::write(
+ vendor_dir.join("state.json"),
+ serde_json::json!({
+ "version": 1,
+ "entries": { PURL: {
+ "ecosystem": "npm", "basePurl": PURL, "uuid": UUID,
+ "artifact": { "path": format!(".socket/vendor/npm/{UUID}/{NAME}-1.0.0.tgz") },
+ "wiring": [],
+ }}
+ })
+ .to_string(),
+ )
+ .unwrap();
+
+ let (code, stdout, stderr) = run_get_bin(
+ tmp.path(),
+ &server.uri(),
+ &[UUID, "--mode", "vendored", "--dry-run"],
+ );
+ assert_eq!(code, 0, "stdout={stdout}\nstderr={stderr}");
+ assert!(
+ stdout.contains(
+ "[dry-run] Would download and vendor 0 of 1 patch (1 already vendored). \
+ No changes made."
+ ),
+ "stdout={stdout}"
+ );
+ assert!(
+ stderr.contains(&format!(" [skip] {PURL} (already vendored)")),
+ "stderr={stderr}"
+ );
+}
+
/// Human search-path vendored dry-run line (the pluralized count flavor).
#[tokio::test]
async fn human_vendored_search_dry_run_prints_count() {
diff --git a/crates/socket-patch-cli/tests/scan/scan_render.rs b/crates/socket-patch-cli/tests/scan/scan_render.rs
index 0a718b102..fd2371ef3 100644
--- a/crates/socket-patch-cli/tests/scan/scan_render.rs
+++ b/crates/socket-patch-cli/tests/scan/scan_render.rs
@@ -674,6 +674,45 @@ async fn scan_human_vendored_dry_run_names_would_refuse_records() {
);
}
+/// #1850: on a project already vendored at this uuid, the human
+/// `--mode vendored --dry-run` says so instead of offering to download and
+/// vendor the patch: the headline leaves it out, as the JSON's
+/// `already_vendored` row and the wet run's "nothing to do" do, and the
+/// wet run's `[skip] … (already vendored)` line names it.
+#[tokio::test]
+async fn scan_human_vendored_dry_run_skips_already_vendored() {
+ let mock = MockServer::start().await;
+ mount_batch_one(&mock, MINIMIST, UUID, "free", &[], false).await;
+ mount_by_package(&mock, MINIMIST, UUID, serde_json::json!({})).await;
+ let tmp = minimist_project(b"x\n");
+ seed_vendored_entry(tmp.path(), MINIMIST);
+ let ledger = std::fs::read(tmp.path().join(".socket/vendor/state.json")).unwrap();
+
+ let (code, stdout, stderr) = run_scan_api(
+ tmp.path(),
+ &mock.uri(),
+ &["--mode", "vendored", "--dry-run"],
+ );
+ assert_eq!(code, 0, "stdout={stdout}; stderr={stderr}");
+ assert!(
+ stdout.contains(
+ "[dry-run] Would download and vendor 0 of 1 patch (1 already vendored). \
+ No changes made."
+ ),
+ "the headline must not count the in-sync patch as work; got {stdout:?}"
+ );
+ assert!(
+ stderr.contains(&format!(" [skip] {MINIMIST} (already vendored)")),
+ "the in-sync patch is named as skipped; stderr={stderr:?}"
+ );
+ assert!(!stdout.contains("[would-refuse]"), "{stdout:?}");
+ assert_eq!(
+ std::fs::read(tmp.path().join(".socket/vendor/state.json")).unwrap(),
+ ledger,
+ "a dry run writes nothing"
+ );
+}
+
// ---------------------------------------------------------------------------
// Report-only runs, agent-mode applies, GC and wiring warnings
// ---------------------------------------------------------------------------
diff --git a/crates/socket-patch-cli/tests/vendor/in_process_vendor_bun.rs b/crates/socket-patch-cli/tests/vendor/in_process_vendor_bun.rs
index 38c592217..8b2f50095 100644
--- a/crates/socket-patch-cli/tests/vendor/in_process_vendor_bun.rs
+++ b/crates/socket-patch-cli/tests/vendor/in_process_vendor_bun.rs
@@ -873,8 +873,9 @@ async fn dry_run_previews_report_would_refuse_on_refused_bun_project() {
}
}
-/// The human dry-run keeps its count line and additionally names what the
-/// wet run would refuse — under `--silent` it prints nothing at all.
+/// The human dry-run's count line leaves out what the wet run would refuse
+/// (scan's shared headline, #1850) and names it — under `--silent` it
+/// prints nothing at all.
#[tokio::test]
async fn dry_run_human_names_would_refuse_records() {
let mock = MockServer::start().await;
@@ -885,7 +886,10 @@ async fn dry_run_human_names_would_refuse_records() {
let (exit, stdout, stderr) = get_vendored(tmp.path(), &mock.uri(), PURL, &["--dry-run"]);
assert_eq!(exit, 0, "stdout={stdout}\nstderr={stderr}");
assert!(
- stdout.contains("[dry-run] Would download and vendor 1 patch. No changes made."),
+ stdout.contains(
+ "[dry-run] Would download and vendor 0 of 1 patch (1 would be refused). \
+ No changes made."
+ ),
"{stdout}"
);
assert!(