diff --git a/crates/socket-patch-bench/README.md b/crates/socket-patch-bench/README.md index faae9add7..f677e6905 100644 --- a/crates/socket-patch-bench/README.md +++ b/crates/socket-patch-bench/README.md @@ -66,7 +66,8 @@ sample: - exit code 0 and one JSON document on stdout, `status: success`; - `scannedPackages`, `lockfileOnlyPackages`, `packagesWithPatches` and `totalPatches` equal what the generated project contains; -- hosted runs: `redirect.redirected` is every patch, `rewrittenFiles` is +- hosted runs: every patch is pinned (`redirect.redirected` before v5, the + hosted `applied` events since), `rewrittenFiles` is exactly the expected set, nothing is skipped, every warning code is one the scenario expects, and each reported file really changed on disk; - dry runs: nothing on disk changed; diff --git a/crates/socket-patch-bench/src/engine.rs b/crates/socket-patch-bench/src/engine.rs index bd9487e0c..4eb6e9127 100644 --- a/crates/socket-patch-bench/src/engine.rs +++ b/crates/socket-patch-bench/src/engine.rs @@ -270,6 +270,69 @@ fn warning_codes(v: &Value) -> Vec { .unwrap_or_default() } +// The base and head binaries may straddle the v5 JSON envelope change +// (`scan --json`'s legacy counters became events), so every reading below +// accepts both shapes: the legacy key when present, else the envelope's. + +/// How many discovered packages have patches: legacy `packagesWithPatches`, +/// else the length of the `packages` discovery array. +fn packages_with_patches(v: &Value) -> Value { + match &v["packagesWithPatches"] { + Value::Null => v["packages"] + .as_array() + .map_or(Value::Null, |a| a.len().into()), + n => n.clone(), + } +} + +/// How many patches discovery found: legacy `totalPatches`, else the sum of +/// `packages[].patches[]`. +fn total_patches(v: &Value) -> Value { + match &v["totalPatches"] { + Value::Null => v["packages"].as_array().map_or(Value::Null, |a| { + a.iter() + .map(|p| p["patches"].as_array().map_or(0, Vec::len)) + .sum::() + .into() + }), + n => n.clone(), + } +} + +/// The hosted events (`details.mode: "hosted"`) of a v5 envelope. +fn hosted_events(v: &Value) -> impl Iterator { + v["events"] + .as_array() + .into_iter() + .flatten() + .filter(|e| e["details"]["mode"] == "hosted") +} + +/// How many packages a hosted run pinned (or would pin, on a dry run): +/// legacy `redirect.redirected`, else the hosted `applied` / `verified` +/// events. +fn hosted_pins(v: &Value) -> Value { + match &v["redirect"]["redirected"] { + Value::Null if v["events"].is_array() => hosted_events(v) + .filter(|e| e["action"] == "applied" || e["action"] == "verified") + .count() + .into(), + n => n.clone(), + } +} + +/// The packages a hosted run skipped: legacy `redirect.skipped[]`, else +/// the hosted `skipped` events. +fn hosted_skips(v: &Value) -> Vec { + match v["redirect"]["skipped"].as_array() { + Some(a) => a.clone(), + None => hosted_events(v) + .filter(|e| e["action"] == "skipped") + .cloned() + .collect(), + } +} + /// Check that a run did the work the fixture calls for. Any mismatch makes /// the sample meaningless, so it is an error, not a footnote. fn validate( @@ -326,12 +389,12 @@ fn validate( ), ( "packagesWithPatches", - v["packagesWithPatches"].clone(), + packages_with_patches(&v), want_patched.len().into(), ), ( "totalPatches", - v["totalPatches"].clone(), + total_patches(&v), p.fixture.patches.len().into(), ), ]; @@ -342,6 +405,7 @@ fn validate( } let mut codes = warning_codes(&v["warnings"]); let redirect = &v["redirect"]; + // Pre-v5 binaries nest the hosted warnings under `redirect`. codes.extend(warning_codes(&redirect["warnings"])); let unexpected: Vec<&String> = codes .iter() @@ -354,11 +418,11 @@ fn validate( + &serde_json::to_string(&redirect["warnings"]).unwrap_or_default() )); } - let skipped = redirect["skipped"].as_array().map(Vec::len).unwrap_or(0); - if skipped > 0 { + let skipped = hosted_skips(&v); + if !skipped.is_empty() { return Err(format!( "redirect skipped packages: {}", - redirect["skipped"] + Value::Array(skipped) )); } let rewritten: Vec = { @@ -377,10 +441,11 @@ fn validate( want_rewritten.sort(); match kind { Kind::Hosted | Kind::DryRun => { - if redirect["redirected"] != e.redirected { + let redirected = hosted_pins(&v); + if redirected != e.redirected { return Err(format!( - "redirect.redirected: got {}, want {}", - redirect["redirected"], e.redirected + "redirect.redirected: got {redirected}, want {}", + e.redirected )); } if rewritten != want_rewritten { diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index 09f62bf35..0cdef6a59 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -138,7 +138,7 @@ Beyond the globals above, each subcommand defines a small set of local arguments | `apply`, `scan`, `vendor` | `--vex` | `SOCKET_VEX` | Generate an OpenVEX 0.2.0 document at this path on a successful run; see "embedded VEX" below | | `apply`, `scan`, `vendor` | `--vex-product`, `--vex-no-verify`, `--vex-doc-id`, `--vex-compact` | `SOCKET_VEX_PRODUCT`, `SOCKET_VEX_NO_VERIFY`, `SOCKET_VEX_DOC_ID`, `SOCKET_VEX_COMPACT` | Passthrough to the embedded VEX builder; mirror the standalone `vex` knobs. Inert unless `--vex` is set | | `scan` | positional `[PATHS]...` | — | (v5.0) Meaning depends on the mode. **Hosted / vendored** (bare `scan` included): each PATH, or directory glob (`apps/*`), is a project directory scanned on its own as if it were `--cwd`. **Agent** (and a mode-less `--prune`/`--global` report): path globs scoping DISCOVERY to packages installed under matching paths (`packages/foo`, `apps/**`). See "Path-scoped scans" below | -| `scan` | `--mode ` | — | The documented selector for the three patch-application modes (v5.0 default: `hosted`, except that a `--prune` or `--global`/`--global-prefix` scan with no mode is report-only). v5.0 removes the hidden value aliases `host`/`redirect`/`vendor` (now an invalid-value usage error), and the hidden boolean spellings `--redirect`, `--vendor` and `--apply` (now unknown-argument usage errors). `--sync` selects agent mode, so `--sync` with any other `--mode` is a usage error (exit 2, enforced in `resolve_mode_flags` — clap's `conflicts_with` is value-independent); `--mode agent --sync` is accepted. `--prune` is an orthogonal GC knob and never conflicts — but hosted mode runs no GC, so `--mode hosted --prune` emits an explicit `redirect_prune_ignored` warning (JSON `redirect.warnings[]` + stderr) instead of silently dropping the flag | +| `scan` | `--mode ` | — | The documented selector for the three patch-application modes (v5.0 default: `hosted`, except that a `--prune` or `--global`/`--global-prefix` scan with no mode is report-only). v5.0 removes the hidden value aliases `host`/`redirect`/`vendor` (now an invalid-value usage error), and the hidden boolean spellings `--redirect`, `--vendor` and `--apply` (now unknown-argument usage errors). `--sync` selects agent mode, so `--sync` with any other `--mode` is a usage error (exit 2, enforced in `resolve_mode_flags` — clap's `conflicts_with` is value-independent); `--mode agent --sync` is accepted. `--prune` is an orthogonal GC knob and never conflicts — but hosted mode runs no GC, so `--mode hosted --prune` emits an explicit `redirect_prune_ignored` warning (JSON `warnings[]` + stderr) instead of silently dropping the flag | | `scan` | `--prune` / `--sync` | — | `--prune` = GC after the scan (ignored with a `redirect_prune_ignored` warning in hosted mode); `--sync` = `--mode agent --prune` | | `scan` | `--package ` (repeatable or comma-separated) | `SOCKET_SCAN_PACKAGES` | (v5.0) Only scan these packages: a name (`lodash`, `@scope/pkg`, `requests`, `group:artifact`; matched against the full name or its last segment, case-insensitively; PyPI names compare by their PEP 503 canonical form, so `typing_extensions` matches `pkg:pypi/typing-extensions`) or a purl with or without a version (`pkg:npm/lodash` matches every version, `pkg:pypi/requests@2.31.0` only that one). Qualifiers are ignored. Filters the crawl like `--ecosystems`, after the prune universe is captured, so `--prune` still judges the full crawl | | `scan` | `--mode vendored` | — | Vendor every patched dependency instead of applying in place (conflicts with `--sync`, combines with `--prune`). Vendored mode is manifest-free (v5.0): the vendor ledger embeds the patch records and `.socket/manifest.json` is never written. The former opt-in for exactly that, `--detached`, is removed in v5.0 (unknown-flag usage error) | @@ -171,11 +171,11 @@ For a **9.0 root lock**, the CLI ensures `pnpm-workspace.yaml` carries `trustLoc **vlt hosted-mode contract**: `scan` / `get --mode hosted` rewrite, in `vlt-lock.json`, every default-registry node of a granted `name@version` (the `''` / `npm` segment or a URL segment equal to the lock's scalar `registry`, both DepID grammars, every peer and modifier variant): slot [2] becomes the granted sha512 and slot [3] the hosted URL (appended to a 3-tuple); the DepID, flags and trailing slots, the line ending and every other byte stay. `options` is never edited and `vlt.json` is only read. A lock with another `lockfileVersion` (decided on the raw JSON token), a BOM, a non-object body or a `nodes` section outside vlt's one-node-per-line layout refuses the whole lock (`redirect_vlt_lock_unsupported`). **Confirmation**: vlt drives when its install state (`node_modules/.vlt-lock.json` or `node_modules/.vlt/`) is present or no other npm-family lock is; then only `vlt-lock.json` confirms a uuid. Otherwise every lock is rewritten, `redirect_vlt_sibling_lockfiles` warns, and the other locks' rules confirm, including a dep `vlt-lock.json` merely does not wire (`redirect_vlt_entry_not_found`, `redirect_vlt_entry_vendored`). Whichever lock drives, a dep the vlt rewriter refuses (`redirect_vlt_missing_sha512`, `redirect_vlt_unsupported_lock_key`) is never confirmed by any lock, although a sibling lock may already carry its rewritten URL. **Artifact preflight**: before any takeover or write (dry runs included), each granted artifact with a default-registry instance is fetched once as vlt fetches it and must verify, else the dep is withheld (`redirect_vlt_artifact_unverifiable`, see the tag table). **Heal**: stale installed copies of Socket-owned nodes are removed so the next `vlt install` extracts the patched bytes, and `rollback` / `remove` do the same for the registry bytes (`--no-vlt-install-cleanup` keeps them; optional dependencies' copies are always kept); `redirect_vlt_reinstall_required` says what happened and what to run. The same-run `--vex` never attests a vlt package whose installed copy is stale or unchecked, whose lock a vlt release may ignore (`redirect_vlt_lockfile_version_missing`, `redirect_vlt_old_lockfile_ignored`, `redirect_vlt_scalar_registry_ignored`), or which also resolves from a non-default registry (`redirect_vlt_custom_registry_skipped`). `vlt.json` or vlt install state without `vlt-lock.json` warns `redirect_vlt_no_lockfile` instead of `redirect_npm_no_lockfile`. `rollback` / `remove` restore each hosted node's slots [2] and [3] from the version document of the registry the node resolves against (slot [3] is its `dist.tarball`, as vlt writes it; `upstream_registry_fallback` when that registry can't be read), following the lock's own slot-[3] convention (see "Hosted unwind coverage"). Tested releases: `docs/testing/vlt-compatibility.md`. -**Takeover reconciliation (every hosted ecosystem, v5.0)**: vendoring over a hosted pin (`vendor`, `scan --mode vendored`, `get --mode vendored`) first RESTORES that purl's lock entries to their default upstream registry entry — the same restore `rollback` runs (core `patch::redirect::upstream::restore_upstream`; see "Hosted unwind coverage"), over the hosted pins lockfile discovery finds (v5 keeps no hosted ledger) — and then vendors, so the vendor ledger records the PRISTINE registry entry as its wiring `original` and `vendor --revert` lands back on upstream registry state, never on hosted. The run that takes over records a `vendor_takeover_reverted_redirect` advisory event (`skipped` action beside the purl's genuine outcome; detail ` was hosted; restored its upstream registry entry () before vendoring (mode takeover)`; the human path prints `Warning: …`), plus any advisory the restore raised (`npm_allow_remote_left`, …). **A takeover the vendored backend does not carry through keeps the hosted pin (#853, #944)**: the wet run holds the restore in its group commit, and when the backend then refuses the purl — whatever the code: a pnpm `catalog:` dependency (`vendor_lock_entry_unsupported`), a CRLF `pnpm-lock.yaml` (`vendor_lockfile_crlf_unsupported`), a conflicting `pnpm-workspace.yaml` override — a range, another version or a `>` selector (`vendor_override_conflict`), a uv inline `[tool.uv] sources` table, a prebuilt download that fails, … — or its apply fails with nothing recorded, the restore is rolled back before anything reaches disk: the purl is reported `failed ` with the backend's own code and detail, neither `vendor_takeover_reverted_redirect` nor the restore's advisories are recorded for it, and the hosted wiring stays byte-for-byte (exit 1 / `partial_failure`), so the package stays hosted-patched instead of being un-hosted and then refused. `vendor --dry-run` previews that same `failed ` by running the backend's dry run over the restored project staged in memory (nothing written). A restore that writes a file outside the group commit's captured set (`.socket/gradle/hosted-index.tsv`) is not rolled back. The `scan` / `get --mode vendored --dry-run` preview, which stages no restore (it never touches the registry), predicts the pnpm (lockfileVersion 9) part of this (#853): a hosted pin the pnpm vendored backend refuses on lock or manifest text (a `catalog:` dependency, a CRLF `pnpm-lock.yaml` / `pnpm-workspace.yaml`, a conflicting override, …) is listed `would_refuse` with the wet run's `errorCode` and the backend's own `error` detail, evaluated on the still-hosted project (the pnpm restore only rewrites the entry's `resolution:`, which none of those gates reads); a hosted pin of any other lock flavor or ecosystem — a legacy pnpm 7/8 lock (`lockfileVersion` 5.4 / 6.0) included, whose CRLF, override-conflict and entry refusals are not lock-text gated — still previews `would_vendor` even when the wet takeover refuses it (the wet refusal keeps the hosted pin), except for the gem preflight below. **Gem preflight before the takeover**: `scan` / `get --mode vendored` ask the gem vendored backend's own refusals BEFORE the upstream restore — the manifest gate (`gemfile_not_loaded`: a `gems.rb` twin or a `BUNDLE_GEMFILE`-configured manifest) and the Gemfile declaration gate evaluated on the Gemfile and Gemfile.lock text the restore would leave (`gemfile_declaration_not_editable`: a declaration inside a `group` / `platforms` / conditional block, a parenthesized or duplicate declaration, …) — so a hosted gem vendored mode cannot wire is reported `failed ` with the hosted `Gemfile` / `Gemfile.lock` byte-untouched (exit 1 / `partial_failure`), and their `--dry-run` preview reports that gem as `would_refuse` with the same `errorCode` (exit 0, like the Bun / vlt `would_refuse` rows), never `would_vendor`. Pinned against real Bundler by `tests/e2e_redirect_gem_build.rs`. `--dry-run` resolves the same restore without writing (registry lookups included): a pin that would restore reports `vendor_would_revert_redirect`, and one that would be refused surfaces in the preview with the wet run's `redirect_revert_failed` code and detail (for bun, whose hosted rewrite replaces the entry's `name@version` spec, the preview first runs the Bun vendored preflight described below and then stops at the advisory instead of reading the still-hosted lock — a lock the vendored backend would refuse is previewed as the wet run's `failed `, never as `vendor_would_revert_redirect`). A purl whose upstream entry cannot be restored — `--offline`, a registry that does not answer, a lock the restore refuses (see "Hosted unwind coverage"; a hosted binary `bun.lockb` pin IS restored for the takeover — its npm registry record is rebuilt natively — while `rollback` / `remove` refuse it) — fails `redirect_revert_failed` with the detail `cannot vendor over the live hosted pin: cannot restore to its upstream registry entry: ; restore it from version control instead (`git checkout -- `)` (for `bun.lock` / `bun.lockb` the detail also adds `, then run \`bun install --force\` (a plain \`bun install\` keeps the patched copy)`) (exit 1 / `partial_failure`, nothing vendored for it, the hosted wiring left in place). The cargo backend's `hosted_redirect_live` refusal backstops a crate whose hosted residue is still in place when it is reached; its detail names `socket-patch rollback` and `git checkout -- Cargo.toml Cargo.lock`. **Bun vendored preflight before the takeover**: `vendor` — like `scan` / `get --mode vendored`, whose pre-download preflight runs earlier — checks `bun.lock` / `bun.lockb` with the shared Bun vendored preflight BEFORE the upstream restore, so a hosted purl on a lock the vendored backend refuses (a pre-version-2 `workspace:` lock → `vendor_bun_workspace_unsupported`; a malformed or unsupported binary lock → `vendor_bun_lockb_invalid`; an unsupported text-lock version → its code) is reported `failed ` with the hosted wiring and active Bun lock byte-untouched (exit 1 / `partial_failure`): the package stays hosted-patched instead of being un-hosted and then refused. `vendor --dry-run` previews that same `failed` code (exit-code parity with the wet run, nothing written) instead of promising `vendor_would_revert_redirect`. Pinned by `tests/in_process_vendor_bun_takeover.rs` and, against real Bun, `tests/mode_migration_bun.rs`. The npm package-lock backend's lock gate gets the same placement: a hosted pin in a project whose `npm-shrinkwrap.json` / `package-lock.json` is not a v2/v3 lock (npm 6's lockfileVersion 1) is refused `failed vendor_lockfile_version_unsupported` BEFORE the restore, in `vendor`, `scan --mode vendored` and `get --mode vendored` alike, so the package stays hosted-patched; the vendored dry-run preview lists every npm purl of such a project as `would_refuse` with that code. Pinned by `tests/in_process_vendor_npm_v1_takeover.rs`. Hosted → vendored and vendored → hosted (`redirect_takeover_reverted_vendored` in `redirect.warnings[]`) both work in place on the locks the target mode accepts. **Removed in v5.0**: the run-level `vendor_supersedes_redirect` warning and its reconcile of the redirect ledger (a live lock that already proved vendored won over a stale hosted ledger record) — once the lock routes a package to `.socket/vendor/`, no hosted state is left to go stale. Which way the live lock points is decided by the same lockfile discovery rules `vex` gates attestations on (see "Manifest-less VEX (lockfile discovery)"), for `redirect_supersedes_vendored` and `hosted_wiring_retained` alike. +**Takeover reconciliation (every hosted ecosystem, v5.0)**: vendoring over a hosted pin (`vendor`, `scan --mode vendored`, `get --mode vendored`) first RESTORES that purl's lock entries to their default upstream registry entry — the same restore `rollback` runs (core `patch::redirect::upstream::restore_upstream`; see "Hosted unwind coverage"), over the hosted pins lockfile discovery finds (v5 keeps no hosted ledger) — and then vendors, so the vendor ledger records the PRISTINE registry entry as its wiring `original` and `vendor --revert` lands back on upstream registry state, never on hosted. The run that takes over records a `vendor_takeover_reverted_redirect` advisory event (`skipped` action beside the purl's genuine outcome; detail ` was hosted; restored its upstream registry entry () before vendoring (mode takeover)`; the human path prints `Warning: …`), plus any advisory the restore raised (`npm_allow_remote_left`, …). **A takeover the vendored backend does not carry through keeps the hosted pin (#853, #944)**: the wet run holds the restore in its group commit, and when the backend then refuses the purl — whatever the code: a pnpm `catalog:` dependency (`vendor_lock_entry_unsupported`), a CRLF `pnpm-lock.yaml` (`vendor_lockfile_crlf_unsupported`), a conflicting `pnpm-workspace.yaml` override — a range, another version or a `>` selector (`vendor_override_conflict`), a uv inline `[tool.uv] sources` table, a prebuilt download that fails, … — or its apply fails with nothing recorded, the restore is rolled back before anything reaches disk: the purl is reported `failed ` with the backend's own code and detail, neither `vendor_takeover_reverted_redirect` nor the restore's advisories are recorded for it, and the hosted wiring stays byte-for-byte (exit 1 / `partialFailure`), so the package stays hosted-patched instead of being un-hosted and then refused. `vendor --dry-run` previews that same `failed ` by running the backend's dry run over the restored project staged in memory (nothing written). A restore that writes a file outside the group commit's captured set (`.socket/gradle/hosted-index.tsv`) is not rolled back. The `scan` / `get --mode vendored --dry-run` preview, which stages no restore (it never touches the registry), predicts the pnpm (lockfileVersion 9) part of this (#853): a hosted pin the pnpm vendored backend refuses on lock or manifest text (a `catalog:` dependency, a CRLF `pnpm-lock.yaml` / `pnpm-workspace.yaml`, a conflicting override, …) is listed `would_refuse` with the wet run's `errorCode` and the backend's own `error` detail, evaluated on the still-hosted project (the pnpm restore only rewrites the entry's `resolution:`, which none of those gates reads); a hosted pin of any other lock flavor or ecosystem — a legacy pnpm 7/8 lock (`lockfileVersion` 5.4 / 6.0) included, whose CRLF, override-conflict and entry refusals are not lock-text gated — still previews `would_vendor` even when the wet takeover refuses it (the wet refusal keeps the hosted pin), except for the gem preflight below. **Gem preflight before the takeover**: `scan` / `get --mode vendored` ask the gem vendored backend's own refusals BEFORE the upstream restore — the manifest gate (`gemfile_not_loaded`: a `gems.rb` twin or a `BUNDLE_GEMFILE`-configured manifest) and the Gemfile declaration gate evaluated on the Gemfile and Gemfile.lock text the restore would leave (`gemfile_declaration_not_editable`: a declaration inside a `group` / `platforms` / conditional block, a parenthesized or duplicate declaration, …) — so a hosted gem vendored mode cannot wire is reported `failed ` with the hosted `Gemfile` / `Gemfile.lock` byte-untouched (exit 1 / `partialFailure`), and their `--dry-run` preview reports that gem as `would_refuse` with the same `errorCode` (exit 0, like the Bun / vlt `would_refuse` rows), never `would_vendor`. Pinned against real Bundler by `tests/e2e_redirect_gem_build.rs`. `--dry-run` resolves the same restore without writing (registry lookups included): a pin that would restore reports `vendor_would_revert_redirect`, and one that would be refused surfaces in the preview with the wet run's `redirect_revert_failed` code and detail (for bun, whose hosted rewrite replaces the entry's `name@version` spec, the preview first runs the Bun vendored preflight described below and then stops at the advisory instead of reading the still-hosted lock — a lock the vendored backend would refuse is previewed as the wet run's `failed `, never as `vendor_would_revert_redirect`). A purl whose upstream entry cannot be restored — `--offline`, a registry that does not answer, a lock the restore refuses (see "Hosted unwind coverage"; a hosted binary `bun.lockb` pin IS restored for the takeover — its npm registry record is rebuilt natively — while `rollback` / `remove` refuse it) — fails `redirect_revert_failed` with the detail `cannot vendor over the live hosted pin: cannot restore to its upstream registry entry: ; restore it from version control instead (`git checkout -- `)` (for `bun.lock` / `bun.lockb` the detail also adds `, then run \`bun install --force\` (a plain \`bun install\` keeps the patched copy)`) (exit 1 / `partialFailure`, nothing vendored for it, the hosted wiring left in place). The cargo backend's `hosted_redirect_live` refusal backstops a crate whose hosted residue is still in place when it is reached; its detail names `socket-patch rollback` and `git checkout -- Cargo.toml Cargo.lock`. **Bun vendored preflight before the takeover**: `vendor` — like `scan` / `get --mode vendored`, whose pre-download preflight runs earlier — checks `bun.lock` / `bun.lockb` with the shared Bun vendored preflight BEFORE the upstream restore, so a hosted purl on a lock the vendored backend refuses (a pre-version-2 `workspace:` lock → `vendor_bun_workspace_unsupported`; a malformed or unsupported binary lock → `vendor_bun_lockb_invalid`; an unsupported text-lock version → its code) is reported `failed ` with the hosted wiring and active Bun lock byte-untouched (exit 1 / `partialFailure`): the package stays hosted-patched instead of being un-hosted and then refused. `vendor --dry-run` previews that same `failed` code (exit-code parity with the wet run, nothing written) instead of promising `vendor_would_revert_redirect`. Pinned by `tests/in_process_vendor_bun_takeover.rs` and, against real Bun, `tests/mode_migration_bun.rs`. The npm package-lock backend's lock gate gets the same placement: a hosted pin in a project whose `npm-shrinkwrap.json` / `package-lock.json` is not a v2/v3 lock (npm 6's lockfileVersion 1) is refused `failed vendor_lockfile_version_unsupported` BEFORE the restore, in `vendor`, `scan --mode vendored` and `get --mode vendored` alike, so the package stays hosted-patched; the vendored dry-run preview lists every npm purl of such a project as `would_refuse` with that code. Pinned by `tests/in_process_vendor_npm_v1_takeover.rs`. Hosted → vendored and vendored → hosted (`redirect_takeover_reverted_vendored` in `warnings[]`) both work in place on the locks the target mode accepts. **Removed in v5.0**: the run-level `vendor_supersedes_redirect` warning and its reconcile of the redirect ledger (a live lock that already proved vendored won over a stale hosted ledger record) — once the lock routes a package to `.socket/vendor/`, no hosted state is left to go stale. Which way the live lock points is decided by the same lockfile discovery rules `vex` gates attestations on (see "Manifest-less VEX (lockfile discovery)"), for `redirect_supersedes_vendored` and `hosted_wiring_retained` alike. ### Scan modes (v5.0) -**Mode resolution (`resolve_mode_flags`, MAJOR in v5.0).** `--mode`, or `--sync` (agent mode), picks the mode. With neither, `scan` and `get` keep the mode the project's patch state already records (`mode_from_project_state`, #1088; both stores are read from the project the resolved manifest belongs to, so a `--manifest-path` into another project consults that project's ledger): a non-empty vendor ledger (`.socket/vendor/state.json`; also an unreadable or malformed one, so the vendored flow reports it) means **vendored**, a manifest holding patches the ledger does not already cover (by key or base purl), or an unreadable or malformed manifest (so the agent flow reports it), means **agent** — a record the ledger covers, as `get --save-only` then `vendor` leaves, is vendored state — and a project with neither runs **hosted** mode — JSON and human alike; the hosted result nests under the JSON `redirect` sub-object (see the hosted paragraph below). A project holding both agent and vendored state is a usage error (exit 2, code `mode_ambiguous`, naming the three `--mode` values) and writes nothing. A human-mode run that kept vendored or agent mode says so on stderr (`Note: using --mode vendored because … already holds vendored patches; pass --mode explicitly to switch modes`); `--json` and `--silent` print no note. **Switching modes is always explicit**: the in-place vendored → hosted takeover (see **Staged takeover**) runs only under `--mode hosted`, the hosted → vendored takeover only under `--mode vendored`, and a bare `scan`/`get` never converts a project. With hosted/vendored PATH arguments and no `--mode`, each project directory takes its mode from its own state. The one exception to the state-derived default: a `--prune` or `--global`/`--global-prefix` scan with no mode has no project lockfile to rewire, so it is **report-only** — discovery, the table, the `updates` array and the `redirectState` block below, plus the `--prune` GC — and, in human mode, ends with the hint `To apply these patches in place, run:` / ` socket-patch scan --mode agent [PATHS]` / ` socket-patch get `. A global scan's hint carries the run's scope, so it can be run verbatim: `-g` (`scan --mode agent -g` / `get -g <…>`), or `--global-prefix ` when a prefix was given (the directory shell-quoted when it needs it). An explicit `--mode hosted` or `--mode vendored` with `--global`/`--global-prefix` is a usage error (exit 2: global installs have no project lockfile to redirect, or to wire vendored artifacts into); `get` enforces the same rule with the same wording. +**Mode resolution (`resolve_mode_flags`, MAJOR in v5.0).** `--mode`, or `--sync` (agent mode), picks the mode. With neither, `scan` and `get` keep the mode the project's patch state already records (`mode_from_project_state`, #1088; both stores are read from the project the resolved manifest belongs to, so a `--manifest-path` into another project consults that project's ledger): a non-empty vendor ledger (`.socket/vendor/state.json`; also an unreadable or malformed one, so the vendored flow reports it) means **vendored**, a manifest holding patches the ledger does not already cover (by key or base purl), or an unreadable or malformed manifest (so the agent flow reports it), means **agent** — a record the ledger covers, as `get --save-only` then `vendor` leaves, is vendored state — and a project with neither runs **hosted** mode — JSON and human alike (see the hosted paragraph below and [`scan` and `get` JSON](#scan-and-get-json-v50-major)). A project holding both agent and vendored state is a usage error (exit 2, code `mode_ambiguous`, naming the three `--mode` values) and writes nothing. A human-mode run that kept vendored or agent mode says so on stderr (`Note: using --mode vendored because … already holds vendored patches; pass --mode explicitly to switch modes`); `--json` and `--silent` print no note. **Switching modes is always explicit**: the in-place vendored → hosted takeover (see **Staged takeover**) runs only under `--mode hosted`, the hosted → vendored takeover only under `--mode vendored`, and a bare `scan`/`get` never converts a project. With hosted/vendored PATH arguments and no `--mode`, each project directory takes its mode from its own state. The one exception to the state-derived default: a `--prune` or `--global`/`--global-prefix` scan with no mode has no project lockfile to rewire, so it is **report-only** — discovery, the table, the `updates` array and the `redirectState` block below, plus the `--prune` GC — and, in human mode, ends with the hint `To apply these patches in place, run:` / ` socket-patch scan --mode agent [PATHS]` / ` socket-patch get `. A global scan's hint carries the run's scope, so it can be run verbatim: `-g` (`scan --mode agent -g` / `get -g <…>`), or `--global-prefix ` when a prefix was given (the directory shell-quoted when it needs it). An explicit `--mode hosted` or `--mode vendored` with `--global`/`--global-prefix` is a usage error (exit 2: global installs have no project lockfile to redirect, or to wire vendored artifacts into); `get` enforces the same rule with the same wording. **Global scope never touches the project's state (v5.0).** A `--global`/`--global-prefix` run that starts inside a project acts on the global installs only. The `--cwd` project's hosted pins and vendor ledger are not its target: `rollback` and `remove` run no hosted or vendored leg (they restore the global copies and drop their manifest records; `rollback` keeps the manifest records of purls the project vendors), a pre-v5 hosted ledger is never retired, and the project's vendor ledger does not own the global copies, so `apply` and `scan --mode agent` patch the global copy of a purl the project vendors (no `vendored` skip, no `vendored_ownership_retained` warning). The standalone `vendor` command acts only on the project, so every form of it (plain, `--revert`, `--check`) is a usage error under global scope: exit 2, human `Error: cannot be used with vendor[ --revert| --check]: global installs have no project lockfile to …`, JSON `{status: "error", error: {code: "global_scope_unsupported", message}}`, checked before the project is read or locked (#498). @@ -183,17 +183,17 @@ For a **9.0 root lock**, the CLI ensures `pnpm-workspace.yaml` carries `trustLoc **scan never prompts, in any mode** (v5.0): no confirm, no free-tier patch menu (it always takes the top-ranked downloadable patch; see "Which patch gets selected"), and no `Non-interactive mode detected` note. `--yes` does not change a scan. `get` (agent mode only — hosted/vendored `get` never prompts either, v5.0), `rollback`, `remove` and `--update` keep their prompts. -**Hosted-state visibility (`redirectState`, additive/MINOR).** Every non-hosted-mode, non-vendored-mode `scan --json` SUCCESS envelope (report-only, `--mode agent`/`--sync`, and the zero-discovery envelope) carries an additive top-level `redirectState` object whenever the project's lockfiles pin ≥ 1 hosted patch: `{ mode, records: [{purl, uuid}], wiringLive: [purl] }`. It is a descriptive STATE block, not a warning. `mode` is the constant `"hosted"`. **v5.0 (MAJOR shape change)**: hosted mode keeps no ledger, so `records` lists the hosted pins lockfile discovery finds (one per `(purl, uuid)`, the same discovery `vex` uses: a hosted URL counts only on `https://patch.socket.dev` or the `--patch-server-url` origin), and the v4 `ledger` and `records[].ledgerKey` keys are gone. Each record's `purl` is CANONICALIZED (qualifiers stripped, percent-decoded — e.g. `pkg:npm/@scope/pkg@1.0.0`, `pkg:gem/nokogiri@1.13.3`) to the same spelling `wiringLive` carries, so the join is a plain string compare. `wiringLive` is the subset of those pins among this run's *counted* purls (post-`--ecosystems`-filter) — computed once per run, the same set that feeds `hosted_wiring_retained`. A record missing from `wiringLive` is still wired; it just was not crawled/queried this run (an `--ecosystems` filter, a zero discovery). The key is omitted when no lockfile pins a hosted patch (and under `--global`), and error envelopes (the `--offline` refusal, all-batches-failed) are deliberately minimal and never carry it. A pre-v5 `.socket/vendor/redirect-state.json` is not read. Hosted-mode runs carry the `redirect` sub-object instead (the run's own result), and vendored-mode runs carry the takeover warnings (their takeovers may restore pins mid-run) — neither duplicates a pre-run snapshot that could go stale. +**Hosted-state visibility (`redirectState`, additive/MINOR).** Every non-hosted-mode, non-vendored-mode `scan --json` SUCCESS envelope (report-only, `--mode agent`/`--sync`, and the zero-discovery envelope) carries an additive top-level `redirectState` object whenever the project's lockfiles pin ≥ 1 hosted patch: `{ mode, records: [{purl, uuid}], wiringLive: [purl] }`. It is a descriptive STATE block, not a warning. `mode` is the constant `"hosted"`. **v5.0 (MAJOR shape change)**: hosted mode keeps no ledger, so `records` lists the hosted pins lockfile discovery finds (one per `(purl, uuid)`, the same discovery `vex` uses: a hosted URL counts only on `https://patch.socket.dev` or the `--patch-server-url` origin), and the v4 `ledger` and `records[].ledgerKey` keys are gone. Each record's `purl` is CANONICALIZED (qualifiers stripped, percent-decoded — e.g. `pkg:npm/@scope/pkg@1.0.0`, `pkg:gem/nokogiri@1.13.3`) to the same spelling `wiringLive` carries, so the join is a plain string compare. `wiringLive` is the subset of those pins among this run's *counted* purls (post-`--ecosystems`-filter) — computed once per run, the same set that feeds `hosted_wiring_retained`. A record missing from `wiringLive` is still wired; it just was not crawled/queried this run (an `--ecosystems` filter, a zero discovery). The key is omitted when no lockfile pins a hosted patch (and under `--global`), and error envelopes (the `--offline` refusal, all-batches-failed) are deliberately minimal and never carry it. A pre-v5 `.socket/vendor/redirect-state.json` is not read. Hosted-mode runs carry their own `redirect` payload and `details.mode: "hosted"` events instead, and vendored-mode runs carry the takeover warnings (their takeovers may restore pins mid-run) — neither duplicates a pre-run snapshot that could go stale. -**Agent-flow run-level warnings (additive).** An agent-mode apply (`--mode agent` / `--sync`, `--json`) may add a top-level `warnings[]` array of `{code, detail}` entries to the scan envelope (absent when none fired; each is also mirrored to stderr unless `--silent`). They surface cross-mode state the apply cannot change — never a status or exit-code change (hosted refusals set the precedent: exit 0 + warning). Codes (stable; new codes are additive/MINOR): `vendored_ownership_retained` — vendor-owned package(s) were skipped before download (the per-patch `skipped`/`vendored` records in `apply.patches[]` are unchanged); the detail names the purls and the migration path (`remove `, or `vendor --revert` which unwinds every vendored package, then re-run). `hosted_wiring_retained` — the lockfiles still pin scanned package(s) to a hosted patch (the agent run does not unwind hosted wiring — as of v5.0 that is `socket-patch rollback`'s job, which restores the upstream registry entries, or `remove ` per package); the detail names the purls and the options (stay `--mode hosted`, migrate via `scan --mode vendored`, or `socket-patch rollback`). The warning keys on the hosted pins lockfile discovery finds at scan time, so a flow that restored the upstream entries retires it. The human path prints the same `hosted_wiring_retained` text to stderr after an apply; the vendored counterpart is already covered by its per-package `[skip] … (vendored …)` lines. `ownership_not_restored` (v5.0; `apply` and `rollback` `warnings[]` alike) — a file WAS patched (or restored) but its ownership could not be put back to the original uid/gid (the mode is still restored last); the detail is `: : patched, but ownership could not be restored to uid N gid M: ` and the human line `Warning: ` (stderr, muted by `--silent`); never a status or exit change. `cargo_build_cache_stale` (v5.0; `apply` and `rollback` `warnings[]` alike) — a cargo crate's bytes changed but its compiled copy in a project build directory could not be invalidated (a fingerprint directory that could not be removed, or a `build.build-dir` with a `{workspace-path-hash}` template); the detail names the crates to `cargo clean -p` before the next build, which may otherwise link the stale code; never a status or exit change. +**Agent-flow run-level warnings (additive).** An agent-mode apply (`--mode agent` / `--sync`, `--json`) may add a top-level `warnings[]` array of `{code, detail}` entries to the scan envelope (absent when none fired; each is also mirrored to stderr unless `--silent`). They surface cross-mode state the apply cannot change — never a status or exit-code change (hosted refusals set the precedent: exit 0 + warning). Codes (stable; new codes are additive/MINOR): `vendored_ownership_retained` — vendor-owned package(s) were skipped before download (each is also a `skipped` / `vendored` event); the detail names the purls and the migration path (`remove `, or `vendor --revert` which unwinds every vendored package, then re-run). `hosted_wiring_retained` — the lockfiles still pin scanned package(s) to a hosted patch (the agent run does not unwind hosted wiring — as of v5.0 that is `socket-patch rollback`'s job, which restores the upstream registry entries, or `remove ` per package); the detail names the purls and the options (stay `--mode hosted`, migrate via `scan --mode vendored`, or `socket-patch rollback`). The warning keys on the hosted pins lockfile discovery finds at scan time, so a flow that restored the upstream entries retires it. The human path prints the same `hosted_wiring_retained` text to stderr after an apply; the vendored counterpart is already covered by its per-package `[skip] … (vendored …)` lines. `ownership_not_restored` (v5.0; `apply` and `rollback` `warnings[]` alike) — a file WAS patched (or restored) but its ownership could not be put back to the original uid/gid (the mode is still restored last); the detail is `: : patched, but ownership could not be restored to uid N gid M: ` and the human line `Warning: ` (stderr, muted by `--silent`); never a status or exit change. `cargo_build_cache_stale` (v5.0; `apply` and `rollback` `warnings[]` alike) — a cargo crate's bytes changed but its compiled copy in a project build directory could not be invalidated (a fingerprint directory that could not be removed, or a `build.build-dir` with a `{workspace-path-hash}` template); the detail names the crates to `cargo clean -p` before the next build, which may otherwise link the stale code; never a status or exit change. -`scan --prune` opts into garbage collection. When set, `scan` removes manifest entries for packages no longer present in the crawl, then deletes orphan blob files, and every obsolete diff and package archive, from `.socket/`. A blob is an orphan when no patch left in the manifest references it: the afterHash and beforeHash blobs of every remaining patch are kept, the same retention policy `repair` uses (the beforeHash blobs are an offline rollback's only restore data, and `repair` downloads afterHash blobs only). Diff and legacy package archives are never kept, even for a remaining patch: nothing reads them any more. Off by default (v3.0) so a temporary uninstall doesn't silently destroy manifest state. Only entries whose ecosystem this run actually crawled are eligible: a `pkg:/` with no crawler in this build (a newer CLI's ecosystem in the committed manifest) is exempt — the crawl never looked for them, so their absence is not evidence of removal (same fail-safe as the `--ecosystems` filter, which narrows the query but never the prune's installed set). A Cargo entry is also exempt while its agent-mode copy is still patched: the project crawl looks up only the crates `Cargo.lock` resolves, but a crate the lock bumped or dropped keeps its patched copy in the machine-wide `$CARGO_HOME/registry/src` cache (nothing deletes it), and the entry holds the only blobs that can restore that copy. The wet pass keeps it with a `cargo_cache_patch_kept` warning naming the `socket-patch rollback ` that restores the copy and drops the entry; the `--dry-run` preview leaves it out of `prunedManifestEntries` (and, like every `warnings` entry, reports no warning for it). The pass also reconciles vendored state (runs FIRST, under ONE apply-lock acquisition shared with the manifest prune — lock contention skips the whole pass without failing the scan; `--lock-timeout` is honored and a lock I/O error is reported rather than swallowed; the existence gate — a manifest file OR a vendor ledger file, both cheap stats; an emptied ledger is deleted on save, so its presence is its content proxy — runs BEFORE the lock, so a bare project never gets a `.socket/`; in the vendored scan arms the pass runs AFTER the vendor step): (a) ledger entries still tracked by a manifest record (manifest-mode entries written by standalone `vendor`) whose patch is gone from the manifest are reverted — `detached` entries (every `scan`/`get --mode vendored` entry, v5.0) have no manifest record to lose and are exempt from this leg; (b) EVERY ledger entry whose dependency is no longer in the lockfile graph is reverted and any manifest entry it still had dropped (v5.0: the check is about the lockfile, not the manifest, so embedded-record entries are no longer exempt; a missing or undeterminable lockfile keeps the entry, fail-safe); and (c) orphan `.socket/vendor//` dirs with no ledger entry are swept. The prune never deletes a zero-patch `.socket/manifest.json` (its `{"patches": {}}` + `setup` block stay). The JSON `gc` sub-object gains `revertedVendoredEntries` + `removedVendorOrphanDirs` (on a `--dry-run` preview, what the pass would revert and sweep, under the same keys — see "One GC shape") + the wet-only `keptVendoredEntries` + `failedVendoredEntries`, plus two ADDITIVE wet-only keys: `skipped: {code, message}` — present exactly when the pass was skipped at the lock (`lock_held` | `lock_io`; every count is then zero) — and `warnings: [{code, detail}]` — `vendor_state_write_failed` / `manifest_write_failed` (entries were reverted but the ledger or manifest rewrite failed), `cleanup_failed` (an orphan sweep failed mid-way), `cargo_cache_patch_kept` (see above), and the reinstall advisories of the vendored reverts (`vendor_bun_reinstall_required`, `vendor_vlt_reinstall_required`, `vendor_pypi_reinstall_required`; a revert's other warnings, such as `vendor_lock_entry_removed` or a drift keep's, are not repeated here). Human mode prints `GC: skipped (): .`, one `GC: .` line per warning, and `GC: failed to revert N vendored entries: …` (singular for one) for `failedVendoredEntries`. `keptVendoredEntries` lists drift-kept entries the revert deliberately preserved (`vendor_artifact_kept` — undo the drift and re-run `vendor --revert` to finish); the preview cannot see drift (backends return before the wiring replay on dry runs), so the preview's `revertedVendoredEntries` may over-promise what a wet run will actually reclaim. +`scan --prune` opts into garbage collection. When set, `scan` removes manifest entries for packages no longer present in the crawl, then deletes orphan blob files, and every obsolete diff and package archive, from `.socket/`. A blob is an orphan when no patch left in the manifest references it: the afterHash and beforeHash blobs of every remaining patch are kept, the same retention policy `repair` uses (the beforeHash blobs are an offline rollback's only restore data, and `repair` downloads afterHash blobs only). Diff and legacy package archives are never kept, even for a remaining patch: nothing reads them any more. Off by default (v3.0) so a temporary uninstall doesn't silently destroy manifest state. Only entries whose ecosystem this run actually crawled are eligible: a `pkg:/` with no crawler in this build (a newer CLI's ecosystem in the committed manifest) is exempt — the crawl never looked for them, so their absence is not evidence of removal (same fail-safe as the `--ecosystems` filter, which narrows the query but never the prune's installed set). A Cargo entry is also exempt while its agent-mode copy is still patched: the project crawl looks up only the crates `Cargo.lock` resolves, but a crate the lock bumped or dropped keeps its patched copy in the machine-wide `$CARGO_HOME/registry/src` cache (nothing deletes it), and the entry holds the only blobs that can restore that copy. The wet pass keeps it with a `cargo_cache_patch_kept` warning naming the `socket-patch rollback ` that restores the copy and drops the entry; the `--dry-run` preview reports no event (and no warning) for it. The pass also reconciles vendored state (runs FIRST, under ONE apply-lock acquisition shared with the manifest prune — lock contention skips the whole pass without failing the scan; `--lock-timeout` is honored and a lock I/O error is reported rather than swallowed; the existence gate — a manifest file OR a vendor ledger file, both cheap stats; an emptied ledger is deleted on save, so its presence is its content proxy — runs BEFORE the lock, so a bare project never gets a `.socket/`; in the vendored scan arms the pass runs AFTER the vendor step): (a) ledger entries still tracked by a manifest record (manifest-mode entries written by standalone `vendor`) whose patch is gone from the manifest are reverted — `detached` entries (every `scan`/`get --mode vendored` entry, v5.0) have no manifest record to lose and are exempt from this leg; (b) EVERY ledger entry whose dependency is no longer in the lockfile graph is reverted and any manifest entry it still had dropped (v5.0: the check is about the lockfile, not the manifest, so embedded-record entries are no longer exempt; a missing or undeterminable lockfile keeps the entry, fail-safe); and (c) orphan `.socket/vendor//` dirs with no ledger entry are swept. The prune never deletes a zero-patch `.socket/manifest.json` (its `{"patches": {}}` + `setup` block stay). Under `--json` (v5.0, MAJOR) the pass is recorded into the scan envelope: each pruned manifest entry is a `removed` event with `details.manifest: true`, each reverted vendored entry a `removed` event (`errorCode: "vendor_reverted"`, `details.mode: "vendored"`), each drift-kept entry a `skipped` / `vendor_revert_kept` event and each entry whose revert failed a `skipped` / `vendor_revert_failed` event (a GC revert failure never fails the run); a `--dry-run` preview reports the removals as `verified`. The artifact sweep is the envelope's `gc` (`summary.bytesFreed` mirrors it) and the orphan vendor dirs swept are the top-level `removedVendorOrphanDirs` count. The pass's warnings join the top-level `warnings[]`: `vendor_state_write_failed` / `manifest_write_failed` (entries were reverted but the ledger or manifest rewrite failed), `cleanup_failed` (an orphan sweep failed mid-way), `cargo_cache_patch_kept` (see above), and the reinstall advisories of the vendored reverts (`vendor_bun_reinstall_required`, `vendor_vlt_reinstall_required`, `vendor_pypi_reinstall_required`; a revert's other warnings, such as `vendor_lock_entry_removed` or a drift keep's, are not repeated). A pass skipped at the lock (`lock_held` | `lock_io`) has no `gc` and no events, only a `gc_skipped` warning whose detail ends with the code. Human mode prints `GC: skipped: .`, one `GC: .` line per warning, and `GC: failed to revert N vendored entries: …` (singular for one). A drift keep is an entry the revert deliberately preserved (`vendor_artifact_kept` — undo the drift and re-run `vendor --revert` to finish); the preview cannot see drift (backends return before the wiring replay on dry runs), so its `verified` reverts may over-promise what a wet run will actually reclaim. `scan` queries the patch API in `--batch-size` chunks. Authenticated runs POST `/v0/orgs/{slug}/patches/batch`; token-less runs POST `{proxy}/patch/batch` on the public proxy and degrade to per-package `GET /patch/by-package/:purl` requests in two cases: the deployed proxy predates the batch endpoint (legacy proxies answer the POST with their `400 "Unsupported endpoint"` catch-all), or the all-or-nothing batch validation rejects the chunk (e.g. a crawled PURL type the server doesn't recognize, such as `pkg:jsr/…` — the per-package path tolerates those individually, preserving the pre-batch scan semantics). Rate limits and over-capacity 503s surface instead of silently degrading. **Throttling: bounded retry, then a reported failure.** Every patch-API JSON call (the batch query, the per-package patch lists, patch views and VEX record fetches, hosted package references) retries an HTTP `429` or `503` answer up to 3 times (`SOCKET_API_MAX_RETRIES=`, `0`-`10`; `0` = no retry). The wait honors `Retry-After` (delta-seconds or HTTP-date); a `Retry-After` over 30 s is not waited out — the answer is final at once — and one under the jittered first backoff step (`0`, a past date) waits that step instead. Without one it backs off 0.5 s / 1 s / 2 s (each step up to 8 s, with jitter in its upper half). All retries in one run share a 60 s wall-clock window that opens with the run's first retry: a retry whose wait would end after it closes is refused and the answer is final. Parallel requests wait in parallel, so each still gets its retries while the run adds at most about 60 s. Nothing else is retried (401/403 still drive the proxy fallback on the first answer; the public proxy's permanent `503 "Patch API is not configured"` is never retried on any path — the batch query still degrades to the per-package path at once, and a per-package lookup or patch view answering it is the same non-throttle failure it always was, so the legacy per-package path still skips that package), and a retried answer folds exactly where the first attempt's would have, so output is identical to an unthrottled run's. A request still throttled after that is a failure in the channel its siblings use: a failed batch is the human `Warning: API batch of failed: ` line and, under `--json`, a run-level `warnings[]` entry `{code: "api_batch_failed", detail: "API batch of failed: "}` (additive; `status` stays `success`, exit 0 — the other batches' packages are reported); a failed per-package patch-list query in the agent / hosted / vendored flows is the human `Warning: could not fetch details for : ` line and, under `--json`, `{code: "patch_details_failed", detail: "could not fetch details for : "}`. When every batch (or every patch-list query) fails, the existing all-failed error envelope and exit 1 apply. The error names the exhausted retry: `Rate limit exceeded (HTTP 429, gave up after 3 retries). Please try again later.` / `API request failed with status 503: (gave up after 3 retries)` (or `(Retry-After s exceeds the 30 s retry cap)` / `(the run's 60 s retry window has closed)`); with retries off it is the pre-retry text. On the token-less legacy per-package proxy path (a proxy without `POST /patch/batch`), a package still throttled (429 / over-capacity 503) after its retries fails its whole batch query, so every package in that batch goes unchecked and is reported through the batch-failure channel above (an unresolvable PURL, or a "not configured" 503, is still skipped individually). Pinned by `tests/scan_api_retry_e2e.rs` and the core crate's `tests/api_retry_e2e.rs`. -**Lockfile supplement (v3.4)**: `scan` discovery is no longer limited to installed trees. The project's lockfiles (`package-lock.json`/`npm-shrinkwrap.json`, `pnpm-lock.yaml` v9, `yarn.lock` classic + berry, `bun.lock`, `vlt-lock.json` (registry nodes, Socket-hosted pins included; vendored `file` nodes are left to the vendor ledger), `Cargo.lock`, `go.sum`, `composer.lock`, `Gemfile.lock`, `uv.lock`/`poetry.lock`/pinned `requirements.txt`) are inventoried and dependencies with NO installed copy join discovery — counts, the API lookup, the table (flagged ` [NOT INSTALLED]`, plus a stderr note), and the prune "scanned" set (a wiped node_modules no longer prunes lockfile-listed entries). JSON gains a top-level `lockfileOnlyPackages` count and an additive `notInstalled: true` on matching `packages[]` entries. Agent mode partitions lockfile-only patches out BEFORE download (calm `skipped`/`package_not_installed` records — never an error exit, never a manifest write); vendored mode passes them through to the vendor engine's server download. Vendored-ledger entries likewise stay discoverable on a fresh clone (the committed artifact is the dependency). Global scans (`--global`) get no supplement. **Rush monorepos** (no root lockfile, `rush.json` present): the npm-lock inventory falls back to the Rush source-of-truth locks — `common/config/rush/pnpm-lock.yaml` plus every `common/config/subspaces/*/pnpm-lock.yaml` (`read_dir`-sorted, repo-relative paths preserved) — so a Rush repo's dependencies still join discovery. **Plug'n'Play layouts are an explicit refusal, not an empty inventory**: a `.pnp.*` loader means the npm packages are structurally unreachable in EVERY mode (under yarn PnP the installed-tree crawl is empty too — no `node_modules/`), so `scan` surfaces an additive top-level `warnings[]` array (`{code, detail}` objects, omitted when empty) carrying `yarn_pnp_unsupported` (same code as apply's refusal; remedy `yarn patch `) or `pnpm_pnp_unsupported` (pnpm's `node-linker=pnp` twin; pnpm remedies), plus a stderr `Warning: …` line on the human path. Exit code and `status` are deliberately unchanged (exit 0 / `success` — the same posture as hosted refusals, which exit 0 with `redirected: 0`); the warning is the machine-readable signal that nothing was checked. Pinned by `tests/e2e_safety_yarn_pnp.rs`. **A Bundler lock socket-patch cannot read is likewise a warning, not an empty inventory**: when the project holds gem files but bundler loads no `Gemfile.lock` / `gems.locked` socket-patch reads (`BUNDLE_GEMFILE` or bundler 4's `BUNDLE_LOCKFILE` naming another file, or a `Gemfile` + `gems.rb` twin, whose loaded pair depends on the bundler major that runs), `warnings[]` carries the additive `gem_lock_unsupported` (detail names the setting or the twin) and its lockfile-only gems are not discovered. Same exit-0 / `success` posture. +**Lockfile supplement (v3.4)**: `scan` discovery is no longer limited to installed trees. The project's lockfiles (`package-lock.json`/`npm-shrinkwrap.json`, `pnpm-lock.yaml` v9, `yarn.lock` classic + berry, `bun.lock`, `vlt-lock.json` (registry nodes, Socket-hosted pins included; vendored `file` nodes are left to the vendor ledger), `Cargo.lock`, `go.sum`, `composer.lock`, `Gemfile.lock`, `uv.lock`/`poetry.lock`/pinned `requirements.txt`) are inventoried and dependencies with NO installed copy join discovery — counts, the API lookup, the table (flagged ` [NOT INSTALLED]`, plus a stderr note), and the prune "scanned" set (a wiped node_modules no longer prunes lockfile-listed entries). JSON gains a top-level `lockfileOnlyPackages` count and an additive `notInstalled: true` on matching `packages[]` entries. Agent mode partitions lockfile-only patches out BEFORE download (calm `skipped`/`package_not_installed` events — never an error exit, never a manifest write); vendored mode passes them through to the vendor engine's server download. Vendored-ledger entries likewise stay discoverable on a fresh clone (the committed artifact is the dependency). Global scans (`--global`) get no supplement. **Rush monorepos** (no root lockfile, `rush.json` present): the npm-lock inventory falls back to the Rush source-of-truth locks — `common/config/rush/pnpm-lock.yaml` plus every `common/config/subspaces/*/pnpm-lock.yaml` (`read_dir`-sorted, repo-relative paths preserved) — so a Rush repo's dependencies still join discovery. **Plug'n'Play layouts are an explicit refusal, not an empty inventory**: a `.pnp.*` loader means the npm packages are structurally unreachable in EVERY mode (under yarn PnP the installed-tree crawl is empty too — no `node_modules/`), so `scan` surfaces an additive top-level `warnings[]` array (`{code, detail}` objects, omitted when empty) carrying `yarn_pnp_unsupported` (same code as apply's refusal; remedy `yarn patch `) or `pnpm_pnp_unsupported` (pnpm's `node-linker=pnp` twin; pnpm remedies), plus a stderr `Warning: …` line on the human path. Exit code and `status` are deliberately unchanged (exit 0 / `success` — the same posture as hosted refusals, which exit 0 with no `applied` event); the warning is the machine-readable signal that nothing was checked. Pinned by `tests/e2e_safety_yarn_pnp.rs`. **A Bundler lock socket-patch cannot read is likewise a warning, not an empty inventory**: when the project holds gem files but bundler loads no `Gemfile.lock` / `gems.locked` socket-patch reads (`BUNDLE_GEMFILE` or bundler 4's `BUNDLE_LOCKFILE` naming another file, or a `Gemfile` + `gems.rb` twin, whose loaded pair depends on the bundler major that runs), `warnings[]` carries the additive `gem_lock_unsupported` (detail names the setting or the twin) and its lockfile-only gems are not discovered. Same exit-0 / `success` posture. **Server artifact acquisition (v5.0)**: vendoring downloads the patched artifact for the selected UUID, including on a fresh checkout with no installed package. The CLI no longer downloads pristine packages, stages patch blobs, applies patches to vendor copies, or constructs archives. Backend lock and package-identity checks still run before acquisition; git and custom-registry Cargo sources are refused with `vendor_source_unsupported`. Healthy committed artifacts are reused offline. A changed UUID downloads a fresh server artifact; an unhealthy artifact at the recorded UUID uses the exact redownload procedure below, except that a directory copy whose ledger entry has no file inventory (vendored before v5.0) is rebuilt by its backend from a fresh verified download, and a missing or stale Bun workspace mirror over a healthy canonical tarball is rewritten from that tarball by the backend; those cases report the backend's own failure codes. @@ -208,15 +208,15 @@ For a **9.0 root lock**, the CLI ensures `pnpm-workspace.yaml` carries `trustLoc **Path-scoped scans (`scan [PATHS]...`, v5.0)**: what a PATH means depends on the mode. * **Hosted and vendored mode (bare `scan` included) — project directories** (`run_project_dirs`). Each PATH is a directory, or a glob (`*?[`) matching directories, relative to `--cwd`; the set is sorted and deduplicated, and each directory is scanned on its own exactly as if it were `--cwd` (its own lockfiles, ledgers and `.socket/`). With more than one directory, each run is headed `== ==` on stdout (unless `--silent`), and the exit code is the worst of the runs. Usage errors (exit 2, stderr only, before any scan): a PATH that is not a directory (`` `X` is not a directory``), a glob matching no directory (`` `X` matches no directory``), an invalid glob, and `--json` with more than one directory (`--json takes one project directory (N given); run one scan per directory`), so stdout stays one document. Likewise `--vex` with more than one directory (`--vex takes one project directory (N given); run one scan per directory`): the one output path would be overwritten by each run. -* **Agent mode (and a mode-less `--prune`/`--global` report) — installed-path globs** scoping DISCOVERY at the **purl level**: a package is in scope iff ANY of its crawled installed copies sits under a matching path (every copy, enumerated the way `rollback`'s path targets enumerate them: a pnpm workspace member's link into the root `.pnpm` store and a nested member copy count, not just the one copy the crawl records per purl), and a selected package is then handled with ALL its copies (scoping selects which packages are considered, never which copies). Glob semantics (shared with `rollback`'s path targets, `src/path_scope.rs`): Unix-shell globs with `require_literal_separator` — `*`/`?` never cross a `/`, `**` spans directories; a pattern matching any **ancestor** directory of the copy path also matches, so a bare `scan packages/foo` scopes the whole subtree without `/**`; relative patterns match against the copy path relativized to `--cwd`, absolute patterns against the absolute path (the ONLY way to reach paths outside the project tree, e.g. `--global` stores — a relative pattern never matches outside `--cwd`); leading `./` and trailing `/` are normalized away, matching is purely textual (no filesystem access or symlink resolution), case-sensitive except on Windows (whose filesystems are not); an unparseable or empty pattern is a usage error (exit 2). **The prune universe is never narrowed**: the path filter is applied strictly AFTER the `scanned_purls` capture (and after `--ecosystems`), so `scan PATHS --prune` prunes exactly what an unscoped `scan --prune` would — a scoped scan can never treat an out-of-scope package as uninstalled (the same fail-safe as the `--ecosystems` filter). Lockfile-only and vendor-ledger supplement records have no installed path and are EXCLUDED from a path-scoped scan, surfaced as one run-level `path_scope_excluded_supplements` warning carrying the count. A scope matching nothing is a normal empty scan — exit 0, zero packages, **no GC** (the zero-package early return fires before any GC). `PATHS` combine with `--mode agent`/`--sync`/`--prune`/`--global`. Every scan JSON shape (success, zero-package, and error alike) carries an always-present `paths` key echoing the patterns verbatim (empty array when unscoped; a hosted/vendored per-directory run is unscoped, so it is `[]`). One-sentence duality rule: **a target that selects nothing is an error on `rollback` (exit 1) and an empty scan on an agent-mode `scan` (exit 0)**. +* **Agent mode (and a mode-less `--prune`/`--global` report) — installed-path globs** scoping DISCOVERY at the **purl level**: a package is in scope iff ANY of its crawled installed copies sits under a matching path (every copy, enumerated the way `rollback`'s path targets enumerate them: a pnpm workspace member's link into the root `.pnpm` store and a nested member copy count, not just the one copy the crawl records per purl), and a selected package is then handled with ALL its copies (scoping selects which packages are considered, never which copies). Glob semantics (shared with `rollback`'s path targets, `src/path_scope.rs`): Unix-shell globs with `require_literal_separator` — `*`/`?` never cross a `/`, `**` spans directories; a pattern matching any **ancestor** directory of the copy path also matches, so a bare `scan packages/foo` scopes the whole subtree without `/**`; relative patterns match against the copy path relativized to `--cwd`, absolute patterns against the absolute path (the ONLY way to reach paths outside the project tree, e.g. `--global` stores — a relative pattern never matches outside `--cwd`); leading `./` and trailing `/` are normalized away, matching is purely textual (no filesystem access or symlink resolution), case-sensitive except on Windows (whose filesystems are not); an unparseable or empty pattern is a usage error (exit 2). **The prune universe is never narrowed**: the path filter is applied strictly AFTER the `scanned_purls` capture (and after `--ecosystems`), so `scan PATHS --prune` prunes exactly what an unscoped `scan --prune` would — a scoped scan can never treat an out-of-scope package as uninstalled (the same fail-safe as the `--ecosystems` filter). Lockfile-only and vendor-ledger supplement records have no installed path and are EXCLUDED from a path-scoped scan, surfaced as one run-level `path_scope_excluded_supplements` warning carrying the count. A scope matching nothing is a normal empty scan — exit 0, zero packages, **no GC** (the zero-package early return fires before any GC). `PATHS` combine with `--mode agent`/`--sync`/`--prune`/`--global`. Every scan JSON envelope past the argument checks (success, zero-package, all-batches-failed) carries a `paths` key echoing the patterns verbatim (empty array when unscoped; a hosted/vendored per-directory run is unscoped, so it is `[]`). One-sentence duality rule: **a target that selects nothing is an error on `rollback` (exit 1) and an empty scan on an agent-mode `scan` (exit 0)**. -`scan --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): an npm-family package then keeps the older patch and is a `skipped` `vendor_prebuilt_pending` / `vendor_prebuilt_unavailable` event, not a failure (#954); 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, or whose hosted pnpm pin the takeover would be refused on (see "Takeover reconciliation") — without network downloads or disk writes; the preview never flips status or exit (the human path — `scan` and `get` alike, through one shared printer — prints `[would-refuse] (): ` lines behind the `--silent` gate). Interactive mode prompts "Download and vendor N patches?" (singular for one). +`scan --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): an npm-family package then keeps the older patch and is a `skipped` `vendor_prebuilt_pending` / `vendor_prebuilt_unavailable` event, not a failure (#954); 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") — 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). -`scan --mode hosted` swaps the in-place apply for the registry-redirect pipeline: discover → resolve hosted-patch references (grant token + integrity + per-dep registry override) → rewrite ONLY the patched dependencies' lockfile / registry-config entries to point at the hosted packages. A dep counts as **redirected** only when its hosted-artifact URL (or per-dep registry index URL) actually landed in a project file — a granted reference whose rewriter found nothing to edit is neither counted nor attested. **No ledger (v5.0)**: hosted mode writes ONLY the lockfile / registry-config edits — `.socket/vendor/redirect-state.json` is never written (on success or failure), and a pre-v5 one on disk is ignored (never read for planning, never quarantined, left byte-identical). The lockfiles are the only record of a hosted patch: `list`, `vex`, `rollback`, `remove`, `vendor` and `repair` all discover the hosted pins from them (a hosted URL counts only on `https://patch.socket.dev` or the `--patch-server-url` / `SOCKET_PATCH_SERVER_URL` origin), and commit-ready output is just the lockfile / config changes. Cargo and golang are confirmed only by their rewriter's own report (`confirmed_cargo_uuids` / `confirmed_golang_uuids`): a golang dep counts only when its go.mod `replace M V => patch.socket.dev/gopatch/ ` and both go.sum lines are in place, never because the patch-server origin or leftover go.sum lines appear somewhere. Gradle is confirmed the same way (`confirmed_gradle_uuids`): only when the final files hold the owned script, the index row, the live apply line in every build's settings file and the suffixed version in every lock entry of the GA (see [Gradle builds](#gradle-builds-v50)). A golang module that go.mod does not require and go.sum does not list at the patched version is outside the build graph and is refused with `redirect_golang_not_in_module_graph` (nothing written). Only the exact module `patch.socket.dev/gopatch/` is socket-owned; any other module path is refused with `redirect_golang_untrusted_module_path`. A vendored golang module is taken over like cargo and the npm family: its vendor wiring, committed copy and ledger entry are reverted first (`redirect_takeover_reverted_vendored`). A vendored PyPI package (requirements.txt, Poetry, Pipenv, uv, Hatch, PDM, pylock) is taken over the same way: its vendored wiring is restored to the recorded registry entry, its ledger entry and wheel are removed, and it is redirected in the same commit. The Python rewriters treat any non-registry source as user-authored, so without the revert they refused socket-patch's own vendored source and left the project vendored. A takeover revert that leaves vendored wiring in place is refused with `redirect_vendored_revert_failed`. That covers a drift-skipped record (`vendor_lock_entry_drifted`) and a reverted file that still references the artifact (`vendor_revert_residual_reference`). The ledger entry and artifact are kept, and the package stays vendored and skipped. `--dry-run` predicts the same refusal from the same signals instead of previewing `redirect_would_revert_vendored`. The hosted requirements.txt rewriter only rewrites an existing pin in the root `requirements.txt`, so a vendored requirements.txt package whose wiring is a pin in a `-r` include or a `(transitive)` line vendored mode appended is kept vendored, wet and `--dry-run` alike, with `redirect_requirements_takeover_unreachable` (`redirect.warnings[]`, and `redirect.skipped[].reason`). Its wiring, ledger entry and wheel are kept, so it stays vendored and patched (exit 0). The uv and Poetry rewriters are covered the same way: a vendored uv package whose recorded pre-vendor `uv.lock` entry is at another version than the patch (vendored uv pins the entry down to the patch's version; the revert brings the lock's own version back, and hosted mode only pins the version the lock resolves) is kept vendored with `redirect_uv_takeover_version_unreachable`, and a vendored Poetry package on a Poetry 0.x lock (which hosted mode refuses outright) is kept vendored with `redirect_poetry_lock_unsupported`. **Staged takeover (v5.0)**: the takeover is atomic. Each purl's vendored revert is staged in memory (the run's group commit, the same journaled commit vendored mode uses), the hosted rewrite plans against the reverted project, and a staged purl the rewrite does not pin is retracted: every staged revert is undone, that purl keeps its vendored wiring, ledger entry and artifact byte-identical, and the rest are staged and rewritten again. A retracted purl is skipped (`redirect.skipped[].reason`) with the cause: its existing skip reason (unavailable wheel metadata, …), the rewriter warning that names the package (`redirect_yarn_berry_missing_checksum`, `redirect_pypi_platform_wheel`, …), `redirect_requirements_takeover_unreachable`, the rewrite's lock-level refusal (`redirect_yarn_berry_mixed_line_endings`, `redirect_bun_lock_unsupported`, a Gradle planner refusal, …) or `redirect_takeover_not_pinned`; that warning is reported in `redirect.warnings[]`, followed by `redirect_takeover_kept_vendored` naming the package. Exit 0: the package stays vendored and patched, never unpatched in both modes. The reverts, the hosted pins and the vendored ledger then reach the disk in one commit, and the reverted artifacts are deleted only after it: a refusal or write failure before the commit writes nothing, a failed commit puts back the files it replaced, and a commit interrupted after its journal was written is finished by the next command that takes the apply lock. `--dry-run` runs the same steps and drops the staged state instead of committing, so its `redirected` count and warnings are the wet run's. A hosted run with no takeover stages its writes the same way but commits them without the journal (it writes nothing under `.socket/`): a file that fails to be replaced puts back the ones already replaced, so a failed run leaves no lock half-redirected. Re-runs over already-rewritten output plan from the current lock text and are idempotent (exit 0, lock unchanged). **Lock (v5.0)**: the hosted engine acquires `<.socket>/apply.lock` before its first wet write (the staged takeover reverts) — not on `--dry-run`, and not when the run would write nothing (zero redirects, all skipped) — so previews and no-op runs never create `.socket/`; contention is `lock_held` and a lock-file I/O fault (a read-only project root, a file squatting on `.socket/`) is `lock_io` — both exit 1, refused BEFORE any project file is written, and rendered like every other lock holder: human `Error (): ` on stderr (+ the `--lock-timeout` hint for a live holder); JSON keeps the hosted shape — top-level `status: "error"`, `error: {code: "lock_held" | "lock_io", message}` (v5.0: the same object every command uses; no top-level `error.code`), and `redirect: {mode: "hosted"}` retained. **Takeover symlink pre-check (v5.0)**: a vendored→hosted takeover whose recorded wiring file is a symlink is refused up front with `redirect_symlinked_file_unsupported` — wet and `--dry-run` alike, before any revert — so "nothing was written" holds. **Human mode (v5.0)**: hosted `scan` prints the results table and update detection like the other modes, then rewrites without a prompt (scan never prompts); `--dry-run` previews through the engine, and a detail fetch that leaves nothing to redirect enters the engine as a no-op (`Redirected 0 packages; rewrote 0 files.`, no lock, no `.socket/`). The detail fetch prints the same progress counter and per-package `Warning: could not fetch details for …` lines as the agent arm. An EMPTY hosted discovery prints `No patches available for installed packages.` and exits 0 without entering the engine; a discovery whose every offer is paid-tier for an org without paid access prints the table's paid nudge, then `No downloadable patches (paid subscription required).`, and exits 0 without entering the engine (parity with the agent/vendored arms). JSON output gains a `redirect` sub-object: `{ mode: "hosted", redirected, rewrittenFiles, skipped, patches, warnings, dryRun }` (`mode` is additive so consumers can dispatch without inferring it). `patches` (additive, v5.0) is the per-purl outcome of every selected patch, sorted by purl: `{purl, uuid, action}` with `action` `pinned` (`would_pin` under `--dry-run`; `redirected` counts these), `skipped` (`errorCode` = the `skipped[]` reason, `error` = its detail when it has one), or `unpinned` (`errorCode: redirect_unconfirmed` — the patch was granted but no lockfile entry pinning it could be rewritten; the human output's `Not hosted : …` line). An `unpinned` or `skipped` row does not change `status` or the exit code (the hosted exit policy is an open decision, #704). Rewriter warnings carry stable `redirect_*` codes (e.g. `redirect_npm_no_lockfile`, `redirect_gradle_manual_snippet`, `redirect_golang_unsupported`); new codes are additive (MINOR). v5.0 additive codes: `redirect_composer_no_lockfile` / `redirect_gem_no_gemfile` (composer / gem: neither manifest nor lock present — once per run, after the intake gates), `redirect_gem_bundle_gemfile_unsupported` (gem: `BUNDLE_GEMFILE` — `BUNDLE_GEMFILE:` in the bundler app config, which outranks the environment variable as in `Bundler::Settings`, else the environment variable — names a manifest other than the project's `Gemfile` / `gems.rb`, so no gem is redirected or attested; a value naming one of those two selects that pair even when the other spelling is present), `redirect_gem_bundle_lockfile_unsupported` (gem: bundler 4's custom lockfile — the `BUNDLE_LOCKFILE` environment variable, else `BUNDLE_LOCKFILE:` in the bundler app config, else in the global config — names a lock other than the loaded pair's own `Gemfile.lock` / `gems.locked`, so no gem is redirected or attested rather than pinning a lock bundler ignores), `redirect_gem_twin_manifest_ambiguous` (gem: a `Gemfile` + `gems.rb` twin under default discovery; bundler 1.x loads the `Gemfile` and bundler ≥ 2 loads `gems.rb`, and a lock's `BUNDLED WITH` records which bundler wrote it, not which one installs it, so neither pair is wired or attested — remove the unused spelling or set `BUNDLE_GEMFILE` to the one in use), `redirect_gem_mirror_overrides_source` (gem: Bundler's all-source, exact patch-source or patch-hostname mirror can route the per-dep `source` block to an unpatched upstream gem. Intake reads the app config (`BUNDLE_APP_CONFIG`, where a set-but-empty value selects `/config`, honoring `BUNDLE_IGNORE_CONFIG`) and all `BUNDLE_MIRROR__...` variables visible to the scan; app config overrides the environment per encoded key, then `mirror.all` takes precedence over exact source, which takes precedence over hostname. URI matching follows Bundler's whole-URI case folding, default-port/trailing-slash normalization and single slash key alias, not URL prefixes. An exact-source fallback-timeout key without a mirror URL shadows the hostname mirror and fetches that source directly; a configured URL is conservatively refused even if a timeout could bypass an unreachable mirror at install time. Like `redirect_gem_bundle_gemfile_unsupported`, the gate leaves the Gemfile pair byte-identical and confirms no gem redirect. On an embedded `scan --vex`, rediscovered older hosted gem pins may attest only from verified installed bytes: a missing tree is not excused by the lockfile, and `--vex-no-verify` omits those hosted gems with `mirror_overrides_source` rather than trusting their intercepted source. Agent/vendored evidence, unrelated ecosystems and standalone VEX behavior are unchanged. Details identify the setting form and its app/environment origin without printing mirror values or source URLs, which may contain credentials. Remove the applicable all/source/hostname setting (including any slash alias) from that origin and reuse its existing mirror URL under `mirror.https://rubygems.org` to clear the refusal; an environment setting must be unset in the scan/install environment. User-global Bundler config and mirrors set only in a later install environment are not inspected; keep those mirrors scoped to the upstream source too), `redirect_maven_no_pom` (no `pom.xml` and no Gradle build), `redirect_nuget_lock_unparseable` (a present-but-corrupt `packages.lock.json` — warned once, nothing mutated; an absent lock still proceeds; a leading UTF-8 BOM, which dotnet reads past, is not corrupt and is kept on rewrite), `redirect_nuget_lock_other_version` (the lock also resolves the patched id at another version in some target framework: the exact-id `packageSourceMapping` would route that framework to a feed that serves only the patched version, so the dep is skipped with nothing written; only lock entries at the patched version are ever re-pinned, and `resolved` is never rewritten), `redirect_nuget_mapping_set_aside` (v5.0 #462: another source's `packageSourceMapping` also named the patched id exactly — Visual Studio's mapping UI writes such lists — which ties with the Socket source so NuGet would take the package from whichever feed answers first; that pattern, or its whole `` when it was the only one, is commented out in a `` comment while the patch is wired, and `remove` / `rollback` put it back byte-exact), `redirect_nuget_mapping_conflict` (such a pattern sits in markup that cannot go in a comment; the dep is skipped, nothing written). v5.0 #354: when the rewriter creates the `packageSourceMapping`, its `*` catch-all also names the sources NuGet merges in from the user config and every parent directory's config (honoring ``), since NuGet drops every source no pattern names; a fresh config only seeds `nuget.org` when those inherited configs have it (a parent that cleared it for a mirror keeps that choice); when an inherited config already has a `packageSourceMapping` (NuGet merges those too), no catch-all is written at all — only the Socket pattern — so a source the parent restricts is never widened; the exclusivity set-aside exempts only the source the run wires, so a `socket-patch-*` lookalike key or a stale uuid naming the id is set aside too, `redirect_cargo_lock_pkg_ambiguous` (several same-name+version `[[package]]` blocks and none carries the index `source` — transactional skip), `redirect_cargo_dep_overridden` (the crate does not resolve to crates.io because the user overrides it — its one `Cargo.lock` block carries a git / other-registry `source`, as a `[patch]` git or registry override in the root manifest or any cargo config leaves it, or has no `source` while the root `Cargo.toml` / project `.cargo/config*` holds a `[patch]` path entry for it; with no `Cargo.lock`, the same `[patch.crates-io]` / `[patch."https://github.com/rust-lang/crates.io-index"]` entry for the crate, by key or `package = "…"`, in the root `Cargo.toml` or the project `.cargo/config*` — transactional skip, nothing rewritten: a Socket pin would silently replace the user's fork and the then-unused `[patch]` entry breaks `cargo --locked`). Also additive: `redirect_gem_version_not_locked` (gem: no `GEM` section of the lock lists the crawled `name (version)`, for example a version another project installed into the shared gem home; the gem is skipped with nothing written, so the user's declared constraint and the locked version are never overwritten), `redirect_gem_no_lockfile` (gem: the project has a `Gemfile` / `gems.rb` but no `Gemfile.lock` / `gems.locked`, so nothing records which version it resolves and the crawled version may be another project's copy in the shared gem home; the gem is skipped with nothing written, never pinned over the user's constraint or appended as a new dependency, and the detail asks for `bundle lock` (or `bundle install`) and a re-run). Also v5.0: a registry override of the wrong kind (or none at all) warns the arm's missing-override code for nuget/gem/golang. Refusals stay fail-closed with a diagnosis that names the actual cause: a yarn-berry lock entry resolving through a non-`npm:` protocol keeps `redirect_yarn_berry_unsupported_protocol` with the entry's ACTUAL protocol in the detail — except socket-patch's OWN vendored wiring (a `file:` range into `.socket/vendor/`), which gets the distinct `redirect_yarn_berry_vendored_entry` code whose detail names the retirement path (`remove ` per package, or `vendor --revert` which unwinds every vendored package, then re-run `scan --mode hosted`). Both leave the entry byte-identical; neither changes exit code or status. **yarn berry line endings (v5.0)**: yarn writes a NEW `yarn.lock` with the OS line ending (`os.EOL` — CRLF on Windows) and keeps an existing lock's majority ending on every later write, and a `core.autocrlf` checkout turns an LF lock CRLF on any OS — so a uniformly CRLF lock is rewritten in its own ending: every untouched byte (a leading BOM included) round-trips (and `rollback`'s upstream restore keeps the lock's own ending). A lock that MIXES CRLF and LF (or holds a bare CR) has no single ending to keep — yarn's own `--immutable` check rejects it too (YN0028) — so it is refused untouched with `redirect_yarn_berry_mixed_line_endings` (the detail names `yarn install`, which normalizes it). The root `package.json`, which the rewrite re-renders to add `resolutions`, gets the same gate: a mixed one is refused untouched with the same code — the decision vendored mode takes with `vendor_yarn_berry_mixed_line_endings`, from the same shared berry gate set. This replaces v4's `redirect_yarn_berry_crlf_unsupported`, which refused every CRLF lock and is no longer emitted. A vendored→hosted takeover of a yarn-berry entry is checked against these project gates (mixed `yarn.lock` / `package.json` line endings, unsupported `cacheKey`, a non-zero `.yarnrc.yml` `compressionLevel`) on the project as it is before any revert, because the revert re-renders `package.json` in its majority ending — wet and `--dry-run` alike. A refused purl keeps its vendored wiring, ledger entry and artifact byte-identical, is skipped with the gate's code (reported in `redirect.warnings[]`, followed by `redirect_takeover_kept_vendored`) and is never announced as `redirect_takeover_reverted_vendored`. +`scan --mode hosted` swaps the in-place apply for the registry-redirect pipeline: discover → resolve hosted-patch references (grant token + integrity + per-dep registry override) → rewrite ONLY the patched dependencies' lockfile / registry-config entries to point at the hosted packages. A dep counts as **redirected** only when its hosted-artifact URL (or per-dep registry index URL) actually landed in a project file — a granted reference whose rewriter found nothing to edit is neither counted nor attested. **No ledger (v5.0)**: hosted mode writes ONLY the lockfile / registry-config edits — `.socket/vendor/redirect-state.json` is never written (on success or failure), and a pre-v5 one on disk is ignored (never read for planning, never quarantined, left byte-identical). The lockfiles are the only record of a hosted patch: `list`, `vex`, `rollback`, `remove`, `vendor` and `repair` all discover the hosted pins from them (a hosted URL counts only on `https://patch.socket.dev` or the `--patch-server-url` / `SOCKET_PATCH_SERVER_URL` origin), and commit-ready output is just the lockfile / config changes. Cargo and golang are confirmed only by their rewriter's own report (`confirmed_cargo_uuids` / `confirmed_golang_uuids`): a golang dep counts only when its go.mod `replace M V => patch.socket.dev/gopatch/ ` and both go.sum lines are in place, never because the patch-server origin or leftover go.sum lines appear somewhere. Gradle is confirmed the same way (`confirmed_gradle_uuids`): only when the final files hold the owned script, the index row, the live apply line in every build's settings file and the suffixed version in every lock entry of the GA (see [Gradle builds](#gradle-builds-v50)). A golang module that go.mod does not require and go.sum does not list at the patched version is outside the build graph and is refused with `redirect_golang_not_in_module_graph` (nothing written). Only the exact module `patch.socket.dev/gopatch/` is socket-owned; any other module path is refused with `redirect_golang_untrusted_module_path`. A vendored golang module is taken over like cargo and the npm family: its vendor wiring, committed copy and ledger entry are reverted first (`redirect_takeover_reverted_vendored`). A vendored PyPI package (requirements.txt, Poetry, Pipenv, uv, Hatch, PDM, pylock) is taken over the same way: its vendored wiring is restored to the recorded registry entry, its ledger entry and wheel are removed, and it is redirected in the same commit. The Python rewriters treat any non-registry source as user-authored, so without the revert they refused socket-patch's own vendored source and left the project vendored. A takeover revert that leaves vendored wiring in place is refused with `redirect_vendored_revert_failed`. That covers a drift-skipped record (`vendor_lock_entry_drifted`) and a reverted file that still references the artifact (`vendor_revert_residual_reference`). The ledger entry and artifact are kept, and the package stays vendored and skipped. `--dry-run` predicts the same refusal from the same signals instead of previewing `redirect_would_revert_vendored`. The hosted requirements.txt rewriter only rewrites an existing pin in the root `requirements.txt`, so a vendored requirements.txt package whose wiring is a pin in a `-r` include or a `(transitive)` line vendored mode appended is kept vendored, wet and `--dry-run` alike, with `redirect_requirements_takeover_unreachable` (a top-level `warnings[]` entry, and the `errorCode` of the package's `skipped` event). Its wiring, ledger entry and wheel are kept, so it stays vendored and patched (exit 0). The uv and Poetry rewriters are covered the same way: a vendored uv package whose recorded pre-vendor `uv.lock` entry is at another version than the patch (vendored uv pins the entry down to the patch's version; the revert brings the lock's own version back, and hosted mode only pins the version the lock resolves) is kept vendored with `redirect_uv_takeover_version_unreachable`, and a vendored Poetry package on a Poetry 0.x lock (which hosted mode refuses outright) is kept vendored with `redirect_poetry_lock_unsupported`. **Staged takeover (v5.0)**: the takeover is atomic. Each purl's vendored revert is staged in memory (the run's group commit, the same journaled commit vendored mode uses), the hosted rewrite plans against the reverted project, and a staged purl the rewrite does not pin is retracted: every staged revert is undone, that purl keeps its vendored wiring, ledger entry and artifact byte-identical, and the rest are staged and rewritten again. A retracted purl is a `skipped` event whose `errorCode` is the cause: its existing skip reason (unavailable wheel metadata, …), the rewriter warning that names the package (`redirect_yarn_berry_missing_checksum`, `redirect_pypi_platform_wheel`, …), `redirect_requirements_takeover_unreachable`, the rewrite's lock-level refusal (`redirect_yarn_berry_mixed_line_endings`, `redirect_bun_lock_unsupported`, a Gradle planner refusal, …) or `redirect_takeover_not_pinned`; that warning is reported in `warnings[]`, followed by `redirect_takeover_kept_vendored` naming the package. Exit 0: the package stays vendored and patched, never unpatched in both modes. The reverts, the hosted pins and the vendored ledger then reach the disk in one commit, and the reverted artifacts are deleted only after it: a refusal or write failure before the commit writes nothing, a failed commit puts back the files it replaced, and a commit interrupted after its journal was written is finished by the next command that takes the apply lock. `--dry-run` runs the same steps and drops the staged state instead of committing, so its `verified` events and warnings are the wet run's `applied` events and warnings. A hosted run with no takeover stages its writes the same way but commits them without the journal (it writes nothing under `.socket/`): a file that fails to be replaced puts back the ones already replaced, so a failed run leaves no lock half-redirected. Re-runs over already-rewritten output plan from the current lock text and are idempotent (exit 0, lock unchanged). **Lock (v5.0)**: the hosted engine acquires `<.socket>/apply.lock` before its first wet write (the staged takeover reverts) — not on `--dry-run`, and not when the run would write nothing (zero redirects, all skipped) — so previews and no-op runs never create `.socket/`; contention is `lock_held` and a lock-file I/O fault (a read-only project root, a file squatting on `.socket/`) is `lock_io` — both exit 1, refused BEFORE any project file is written, and rendered like every other lock holder: human `Error (): ` on stderr (+ the `--lock-timeout` hint for a live holder); JSON prints the envelope with `status: "error"` and `error: {code: "lock_held" | "lock_io", message}` (no `redirect` payload: nothing was rewritten). **Takeover symlink pre-check (v5.0)**: a vendored→hosted takeover whose recorded wiring file is a symlink is refused up front with `redirect_symlinked_file_unsupported` — wet and `--dry-run` alike, before any revert — so "nothing was written" holds. **Human mode (v5.0)**: hosted `scan` prints the results table and update detection like the other modes, then rewrites without a prompt (scan never prompts); `--dry-run` previews through the engine, and a detail fetch that leaves nothing to redirect enters the engine as a no-op (`Redirected 0 packages; rewrote 0 files.`, no lock, no `.socket/`). The detail fetch prints the same progress counter and per-package `Warning: could not fetch details for …` lines as the agent arm. An EMPTY hosted discovery prints `No patches available for installed packages.` and exits 0 without entering the engine; a discovery whose every offer is paid-tier for an org without paid access prints the table's paid nudge, then `No downloadable patches (paid subscription required).`, and exits 0 without entering the engine (parity with the agent/vendored arms). Under `--json` (v5.0, MAJOR) the outcome is recorded into the scan envelope: one event per selected patch, sorted by purl, each with `details.mode: "hosted"` — `applied` for a confirmed pin (`verified` under `--dry-run`), `skipped` with the skip reason as `errorCode` (and its detail as `reason`), or `skipped` / `redirect_unconfirmed` for a patch that was granted but no lockfile entry pinning it could be rewritten (the human output's `Not hosted : …` line) — plus a `redirect: {mode: "hosted", rewrittenFiles}` payload (the files rewritten, or that would be). A `skipped` event does not change `status` or the exit code (the hosted exit policy is an open decision, #704). Rewriter warnings carry stable `redirect_*` codes (e.g. `redirect_npm_no_lockfile`, `redirect_gradle_manual_snippet`, `redirect_golang_unsupported`); new codes are additive (MINOR). v5.0 additive codes: `redirect_composer_no_lockfile` / `redirect_gem_no_gemfile` (composer / gem: neither manifest nor lock present — once per run, after the intake gates), `redirect_gem_bundle_gemfile_unsupported` (gem: `BUNDLE_GEMFILE` — `BUNDLE_GEMFILE:` in the bundler app config, which outranks the environment variable as in `Bundler::Settings`, else the environment variable — names a manifest other than the project's `Gemfile` / `gems.rb`, so no gem is redirected or attested; a value naming one of those two selects that pair even when the other spelling is present), `redirect_gem_bundle_lockfile_unsupported` (gem: bundler 4's custom lockfile — the `BUNDLE_LOCKFILE` environment variable, else `BUNDLE_LOCKFILE:` in the bundler app config, else in the global config — names a lock other than the loaded pair's own `Gemfile.lock` / `gems.locked`, so no gem is redirected or attested rather than pinning a lock bundler ignores), `redirect_gem_twin_manifest_ambiguous` (gem: a `Gemfile` + `gems.rb` twin under default discovery; bundler 1.x loads the `Gemfile` and bundler ≥ 2 loads `gems.rb`, and a lock's `BUNDLED WITH` records which bundler wrote it, not which one installs it, so neither pair is wired or attested — remove the unused spelling or set `BUNDLE_GEMFILE` to the one in use), `redirect_gem_mirror_overrides_source` (gem: Bundler's all-source, exact patch-source or patch-hostname mirror can route the per-dep `source` block to an unpatched upstream gem. Intake reads the app config (`BUNDLE_APP_CONFIG`, where a set-but-empty value selects `/config`, honoring `BUNDLE_IGNORE_CONFIG`) and all `BUNDLE_MIRROR__...` variables visible to the scan; app config overrides the environment per encoded key, then `mirror.all` takes precedence over exact source, which takes precedence over hostname. URI matching follows Bundler's whole-URI case folding, default-port/trailing-slash normalization and single slash key alias, not URL prefixes. An exact-source fallback-timeout key without a mirror URL shadows the hostname mirror and fetches that source directly; a configured URL is conservatively refused even if a timeout could bypass an unreachable mirror at install time. Like `redirect_gem_bundle_gemfile_unsupported`, the gate leaves the Gemfile pair byte-identical and confirms no gem redirect. On an embedded `scan --vex`, rediscovered older hosted gem pins may attest only from verified installed bytes: a missing tree is not excused by the lockfile, and `--vex-no-verify` omits those hosted gems with `mirror_overrides_source` rather than trusting their intercepted source. Agent/vendored evidence, unrelated ecosystems and standalone VEX behavior are unchanged. Details identify the setting form and its app/environment origin without printing mirror values or source URLs, which may contain credentials. Remove the applicable all/source/hostname setting (including any slash alias) from that origin and reuse its existing mirror URL under `mirror.https://rubygems.org` to clear the refusal; an environment setting must be unset in the scan/install environment. User-global Bundler config and mirrors set only in a later install environment are not inspected; keep those mirrors scoped to the upstream source too), `redirect_maven_no_pom` (no `pom.xml` and no Gradle build), `redirect_nuget_lock_unparseable` (a present-but-corrupt `packages.lock.json` — warned once, nothing mutated; an absent lock still proceeds; a leading UTF-8 BOM, which dotnet reads past, is not corrupt and is kept on rewrite), `redirect_nuget_lock_other_version` (the lock also resolves the patched id at another version in some target framework: the exact-id `packageSourceMapping` would route that framework to a feed that serves only the patched version, so the dep is skipped with nothing written; only lock entries at the patched version are ever re-pinned, and `resolved` is never rewritten), `redirect_nuget_mapping_set_aside` (v5.0 #462: another source's `packageSourceMapping` also named the patched id exactly — Visual Studio's mapping UI writes such lists — which ties with the Socket source so NuGet would take the package from whichever feed answers first; that pattern, or its whole `` when it was the only one, is commented out in a `` comment while the patch is wired, and `remove` / `rollback` put it back byte-exact), `redirect_nuget_mapping_conflict` (such a pattern sits in markup that cannot go in a comment; the dep is skipped, nothing written). v5.0 #354: when the rewriter creates the `packageSourceMapping`, its `*` catch-all also names the sources NuGet merges in from the user config and every parent directory's config (honoring ``), since NuGet drops every source no pattern names; a fresh config only seeds `nuget.org` when those inherited configs have it (a parent that cleared it for a mirror keeps that choice); when an inherited config already has a `packageSourceMapping` (NuGet merges those too), no catch-all is written at all — only the Socket pattern — so a source the parent restricts is never widened; the exclusivity set-aside exempts only the source the run wires, so a `socket-patch-*` lookalike key or a stale uuid naming the id is set aside too, `redirect_cargo_lock_pkg_ambiguous` (several same-name+version `[[package]]` blocks and none carries the index `source` — transactional skip), `redirect_cargo_dep_overridden` (the crate does not resolve to crates.io because the user overrides it — its one `Cargo.lock` block carries a git / other-registry `source`, as a `[patch]` git or registry override in the root manifest or any cargo config leaves it, or has no `source` while the root `Cargo.toml` / project `.cargo/config*` holds a `[patch]` path entry for it; with no `Cargo.lock`, the same `[patch.crates-io]` / `[patch."https://github.com/rust-lang/crates.io-index"]` entry for the crate, by key or `package = "…"`, in the root `Cargo.toml` or the project `.cargo/config*` — transactional skip, nothing rewritten: a Socket pin would silently replace the user's fork and the then-unused `[patch]` entry breaks `cargo --locked`). Also additive: `redirect_gem_version_not_locked` (gem: no `GEM` section of the lock lists the crawled `name (version)`, for example a version another project installed into the shared gem home; the gem is skipped with nothing written, so the user's declared constraint and the locked version are never overwritten), `redirect_gem_no_lockfile` (gem: the project has a `Gemfile` / `gems.rb` but no `Gemfile.lock` / `gems.locked`, so nothing records which version it resolves and the crawled version may be another project's copy in the shared gem home; the gem is skipped with nothing written, never pinned over the user's constraint or appended as a new dependency, and the detail asks for `bundle lock` (or `bundle install`) and a re-run). Also v5.0: a registry override of the wrong kind (or none at all) warns the arm's missing-override code for nuget/gem/golang. Refusals stay fail-closed with a diagnosis that names the actual cause: a yarn-berry lock entry resolving through a non-`npm:` protocol keeps `redirect_yarn_berry_unsupported_protocol` with the entry's ACTUAL protocol in the detail — except socket-patch's OWN vendored wiring (a `file:` range into `.socket/vendor/`), which gets the distinct `redirect_yarn_berry_vendored_entry` code whose detail names the retirement path (`remove ` per package, or `vendor --revert` which unwinds every vendored package, then re-run `scan --mode hosted`). Both leave the entry byte-identical; neither changes exit code or status. **yarn berry line endings (v5.0)**: yarn writes a NEW `yarn.lock` with the OS line ending (`os.EOL` — CRLF on Windows) and keeps an existing lock's majority ending on every later write, and a `core.autocrlf` checkout turns an LF lock CRLF on any OS — so a uniformly CRLF lock is rewritten in its own ending: every untouched byte (a leading BOM included) round-trips (and `rollback`'s upstream restore keeps the lock's own ending). A lock that MIXES CRLF and LF (or holds a bare CR) has no single ending to keep — yarn's own `--immutable` check rejects it too (YN0028) — so it is refused untouched with `redirect_yarn_berry_mixed_line_endings` (the detail names `yarn install`, which normalizes it). The root `package.json`, which the rewrite re-renders to add `resolutions`, gets the same gate: a mixed one is refused untouched with the same code — the decision vendored mode takes with `vendor_yarn_berry_mixed_line_endings`, from the same shared berry gate set. This replaces v4's `redirect_yarn_berry_crlf_unsupported`, which refused every CRLF lock and is no longer emitted. A vendored→hosted takeover of a yarn-berry entry is checked against these project gates (mixed `yarn.lock` / `package.json` line endings, unsupported `cacheKey`, a non-zero `.yarnrc.yml` `compressionLevel`) on the project as it is before any revert, because the revert re-renders `package.json` in its majority ending — wet and `--dry-run` alike. A refused purl keeps its vendored wiring, ledger entry and artifact byte-identical, is skipped with the gate's code (reported in `warnings[]`, followed by `redirect_takeover_kept_vendored`) and is never announced as `redirect_takeover_reverted_vendored`. -**Attribution gate (v5.0).** A hosted run never leaves wiring that lockfile discovery calls contested: before the rewrite writes any lockfile, the same discovery `vex`, `list`, `rollback`, `remove` and `vendor` read runs over the project as the rewrite would leave it. A candidate whose pin discovery reads but cannot attribute to one package version (a requirements `-r` include resolving the same version from the registry beside a rewired `Pipfile.lock`, #567; a Maven pin in a ``, #260) is left out of the rewrite and reported in `redirect.skipped[]` as `redirect_unattributable` (nothing written for it; exit code unchanged). Unchanged: a pin in a file discovery does not read (a pre-2.6 bundler `Gemfile`, locked by the next `bundle install`) keeps the rewriter's verdict, and so does a deliberate partial redirect the run already reports (a bundled or `bun patch`-ed copy left on the registry, a yarn `npm:` alias entry left on the registry with `redirect_yarn_classic_alias_skipped` / `redirect_yarn_berry_alias_skipped` while the package's direct entry is pinned, a dep withheld from the vlt rewrite while a sibling lock takes it), and so does a vendored→hosted takeover: its vendored wiring is reverted in the run's staged overlay before the rewrite plans, and it is redirected when the rewriters pin it (otherwise it is retracted and stays vendored), in a `--dry-run` preview the same. A lockless NuGet / Cargo pin (an exclusive Socket source mapping without `packages.lock.json`, a Cargo registry pin without `Cargo.lock`) is still written, with a `redirect_pin_lockless` warning: no lockfile records its version, so `vex` cannot attest it and `rollback` / `remove` / `vendor` refuse it until the lockfile exists (whether such pins should be written at all is an open decision). The rollout's recorded view uses the same discovery: a uuid a file merely mentions (a stale `package.json` field, an inactive `pdm.lock`, a comment) is not a pin and does not count as already patched. +**Attribution gate (v5.0).** A hosted run never leaves wiring that lockfile discovery calls contested: before the rewrite writes any lockfile, the same discovery `vex`, `list`, `rollback`, `remove` and `vendor` read runs over the project as the rewrite would leave it. A candidate whose pin discovery reads but cannot attribute to one package version (a requirements `-r` include resolving the same version from the registry beside a rewired `Pipfile.lock`, #567; a Maven pin in a ``, #260) is left out of the rewrite and reported as a `skipped` event with `errorCode` `redirect_unattributable` (nothing written for it; exit code unchanged). Unchanged: a pin in a file discovery does not read (a pre-2.6 bundler `Gemfile`, locked by the next `bundle install`) keeps the rewriter's verdict, and so does a deliberate partial redirect the run already reports (a bundled or `bun patch`-ed copy left on the registry, a yarn `npm:` alias entry left on the registry with `redirect_yarn_classic_alias_skipped` / `redirect_yarn_berry_alias_skipped` while the package's direct entry is pinned, a dep withheld from the vlt rewrite while a sibling lock takes it), and so does a vendored→hosted takeover: its vendored wiring is reverted in the run's staged overlay before the rewrite plans, and it is redirected when the rewriters pin it (otherwise it is retracted and stays vendored), in a `--dry-run` preview the same. A lockless NuGet / Cargo pin (an exclusive Socket source mapping without `packages.lock.json`, a Cargo registry pin without `Cargo.lock`) is still written, with a `redirect_pin_lockless` warning: no lockfile records its version, so `vex` cannot attest it and `rollback` / `remove` / `vendor` refuse it until the lockfile exists (whether such pins should be written at all is an open decision). The rollout's recorded view uses the same discovery: a uuid a file merely mentions (a stale `package.json` field, an inactive `pdm.lock`, a comment) is not a pin and does not count as already patched. The rewriter reads a fixed set of candidate files from the project root: the npm-family locks (`package-lock.json`, `npm-shrinkwrap.json`, `pnpm-lock.yaml`, `shrinkwrap.yaml`, `yarn.lock`, plus `.yarnrc.yml` for the berry cache-config gate, `bun.lock` / `bun.lockb`, and `vlt-lock.json` with `vlt.json` and `node_modules/.vlt-lock.json` read only), `requirements.txt` / `uv.lock` / `Pipfile.lock` (pipfile-spec 6; see the Pipenv section below) / `poetry.lock` (every Poetry lock generation from 1.0 on — the 0.12 `[metadata.hashes]` layout is refused because that installer ignores URL sources; a Poetry < 1.4 writer additionally gets `redirect_poetry_stale_install_risk`, see `docs/testing/poetry-compatibility.md`) / `pdm.lock` (PDM lock formats `2` and `4.3`–`4.5.1`; the identity-losing `3.1` / `4.0`–`4.2` formats and unknown future formats are refused with `redirect_pdm_refused`, and a lock-format-`2` writer additionally gets `redirect_pdm_legacy_sync_required`, see `docs/testing/pdm-compatibility.md`; when `uv.lock` or `poetry.lock` sits beside it they drive and `pdm.lock` is left alone), `Cargo.toml` / `Cargo.lock` / `.cargo/config.toml` (plus the legacy extensionless `.cargo/config` — cargo reads that spelling in preference when both exist, so the managed `[registries.…]` block is written into whichever one is present; **cargo also reads every workspace-member manifest** — the `[workspace] members` globs minus `exclude` — and every in-root path-dependency manifest, recursively, reached without crossing a symbolic link and never under `.socket/`, and pins the crate in each one that declares it, so those `/Cargo.toml` files can appear in `rewrittenFiles`. A crate is redirected only when every declaration pins and every other `Cargo.lock` package depending on it is a planned member: one a registry or git crate — or a path package outside the root or behind a link — also depends on is refused `redirect_cargo_transitive_dependents` (a pin reaches only the declarations it sits on), a crate no manifest declares keeps `redirect_cargo_toml_dep_not_found` with a transitive-only detail naming `--mode vendored`, a crate every declaration of which requires another version (no requirement accepts the patched version) is refused `redirect_cargo_toml_dep_unrewritable`, and so is a requirement that also matches another locked version of the crate — each a transactional skip, never recorded or attested. With NO `Cargo.lock` there is no resolved graph to ask, so the dependents question is answered from the manifests instead: a crate declared beside any other dependency — anything but a path dependency on a manifest this run also pins, or a `workspace = true` inheritor of a table it scans — or beside a workspace member this run did not read (a `members` glob, or a member outside the project or behind a symbolic link, which member discovery drops) is refused `redirect_cargo_lockless_dependents`, whose detail names the remedies (commit a lockfile, or `--mode vendored`); a project whose only dependency is the patched crate has nothing that could pull it in and still redirects. All-CRLF manifests, locks and configs are rewritten with CRLF kept (mixed endings keep refusing where the grammar does not match), and `remove` / rollback match the recorded fragments across a later CRLF↔LF checkout conversion), `composer.lock`, `nuget.config` / `packages.lock.json` (plus, v5.0 #353/#514, every lock a project under the root restores into: a member project's `packages.lock.json`, a per-project `packages..lock.json`, a literal `NuGetLockFilePath` — each pinned with the root config; a `NuGetLockFilePath` it cannot evaluate warns `redirect_nuget_lock_path_unresolved` and a project tree it cannot list warns `redirect_nuget_lock_unreadable`, both skipping the nuget redirect with nothing written), `Gemfile` / `Gemfile.lock`, `pom.xml` (+ `.mvn/maven.config` / `.mvn/checksums/checksums.sha256` for maven Trusted Checksums merge, and, for a Gradle build, every settings, build, `buildSrc`, included-build, applied and plugin-source script, version catalog and lock file the script graph reaches, plus `gradle/verification-metadata.xml`, `gradle/wrapper/gradle-wrapper.properties` and the owned `.socket/gradle/` files), and the sbt build files (`socket-patch.sbt`, `socket-patch-vendor.sbt`, `build.sbt`, `project/build.properties`, `.sbtopts`, `.jvmopts`; `build.sbt.lock` and the Mill / scala-cli build files `build.mill`, `build.mill.yaml`, `build.sc`, `.mill-version`, `project.scala` for their presence only) — read, never edited; `socket-patch.sbt` is the only sbt file hosted mode writes (see **Hosted sbt** below). **npm-family flavor coverage**: package-lock / npm-shrinkwrap (a package the project patches itself with npm ≥ 12.1's native `npm patch` — a root `patchedDependencies` key, or the lock entry's `patched` record — is left on its registry entry in every npm lock, `redirect_npm_patched_dependency_skipped`), pnpm (root OR any nested `*/pnpm-lock.yaml`), yarn classic (a yarn 2+ install migrates a v1 `yarn.lock` and drops its pins, so a run whose v1 lock carries a hosted pin warns `redirect_yarn_classic_berry_migration_risk` — the hosted twin of the vendored `yarn_classic_berry_migration_risk` — unless the root `package.json`, read as advisory input, declares `"packageManager": "yarn@1…"`), **yarn berry** (the pin yarn writes for a root `resolutions` entry: the root `package.json` — edited only beside a berry `yarn.lock` — gains one `"@npm:": ""` selector per locked range (`redirect_yarn_berry_resolution` edits), and only that `yarn.lock` entry is re-keyed `"@"` with the same `resolution:` + `yarnBerry10c0` checksum (`redirect_yarn_berry_entry`), moved to yarn's key order; never an `npm:` locator, whose fetcher sends npm registry auth to the patch host, nor a tarball locator under an `npm:` key, which hardened mode rejects (YN0078). An older release's `npm:::__archiveUrl=` pin is still recognized and is re-pinned on the next run; rollback rebuilds the key from the selectors and drops them. Refused, nothing written: a user-authored `resolutions` entry for the package `redirect_yarn_berry_resolutions_conflict`, no root manifest `redirect_yarn_berry_manifest_missing`, a builtin `patch:` entry wrapping the same descriptor `redirect_yarn_berry_shared_descriptor`, an artifact URL yarn cannot fetch as a tarball `redirect_yarn_berry_artifact_url_unsupported`; cacheKey `10c0` and `.yarnrc.yml compressionLevel 0` gated by `redirect_yarn_berry_cache_unsupported`), and **bun** (text `bun.lock` lockfileVersion 0, 1 or 2 — 0 is the `--save-text-lockfile` opt-in lock of Bun 1.1.39–1.1.45, 1 the 1.2–1.3 default, 2 the 1.4+ default; all three emit one `packages` grammar, so the registry 4-tuple → URL 3-tuple rewrite is version-independent and the lock's own version line is kept. Any other or missing version, or a `packages` section outside bun's single-line grammar, is refused `redirect_bun_lock_unsupported` — the detail is the shared version gate's text (a newer version: update socket-patch, re-locking would reproduce it; no integer: re-lock with Bun ≥ 1.2), identical to the vendored refusal. A version-0 lock holding `workspace:` packages is refused `redirect_bun_workspace_unsupported` (its 2-tuple workspace grammar cannot keep the hosted tuple through a frozen install); the remedy is to delete `bun.lock` and re-run `bun install` with Bun ≥ 1.2, which writes lockfileVersion 1 (accepted). A plain in-place `bun install` bumps the version only when a workspace depends on another workspace (e.g. root → member — the shape the matrix measured); otherwise Bun 1.2.0 keeps version 0 and Bun 1.2.23+ fail to resolve, so the in-place bump is not the documented remedy. Bun lock version, grammar and workspace compatibility are checked before a vendored takeover, including during dry-run: these refusals preserve the existing lock, artifact and vendor ledger. Version-1 and version-2 workspace locks are rewritten, nested versions included. A granted dep with no rewritable entry warns `redirect_bun_entry_not_found` (or `redirect_bun_non_registry_entry_skipped` when the only same-version entry is a user URL / `file:` tarball, #497), a grant without a sha512 `redirect_bun_missing_sha512`; a CRLF lock keeps `\r\n` on the rewritten line, and a hosted URL left by an earlier grant of the same `name@version` is re-pinned in place. **Digest-less re-saves (Bun 1.1.39–1.3.9)**: every text-lock Bun below 1.3.10 re-saves a URL tuple WITHOUT its `sha512` whenever the lock is re-saved for another reason (`bun add`, `bun install` after a package.json or workspace change), leaving the 2-tuple `["name@", {meta}]` — the spec Bun installs from is intact. The CLI treats that spelling as its own wiring: a repeat hosted run counts the dep as redirected (no `redirect_bun_entry_not_found`) and HEALS the line back to the 3-tuple with the current `sha512`, recording the heal as a further `redirect_bun_lock_package` edit whose `original` is the 2-tuple (a stale URL is re-pinned from either spelling); `rollback`, scoped `rollback ` / `remove ` and the vendored takeover accept the digest-less spelling of a recorded `new` line (same key, spec and meta, only the trailing `"sha512-…"` missing) and restore the recorded original over it, so the chain always unwinds to the pristine registry line. Anything else — another uuid/token, another version, a re-laid meta object — is still drift. **Native `bun.lockb`**: when no text `bun.lock` exists, binary format versions 1, 2 and 3 are read and rewritten directly. Socket Patch does not invoke Bun or convert the project to a text lockfile. Exact matching package records are rewritten to hosted tarballs with the granted integrity, preserving dependency resolution IDs, workspace/dependency topology and unrelated package metadata; binary pointers and the package metadata hash are updated. Per-package `redirect_bun_lockb_package` snapshots support scoped rollback, repeat runs, superseding grants and hosted ↔ vendored takeover. A regular binary lock is discoverable even with no Bun runtime or `node_modules`; a dry run previews the same binary edits without writing them. A malformed, unreadable, unsupported or unverified binary structure is `redirect_bun_lockb_invalid` (exit 0, `redirected: 0`), and it refuses the npm rewrite before any takeover or sibling npm-family lock mutation. A symlinked binary write target is `redirect_symlinked_file_unsupported` (exit 1, including dry-run). `bun.lock` wins when both spellings exist. Binary-only projects do not receive `redirect_npm_no_lockfile`. Measured boundaries and the real-Bun matrix: `docs/testing/bun-compatibility.md`), and **vlt** (`vlt-lock.json` without `lockfileVersion`, `0` or `1`; see the vlt hosted-mode contract below). **Rush monorepos**: when `rush.json` is present the rewriter also reads `common/config/rush/pnpm-lock.yaml` and each `common/config/subspaces//pnpm-lock.yaml` (sorted for determinism) under their repo-relative keys and repoints them in place; editing them emits `redirect_rush_repo_state_stale` when the `repo-state.json` beside a rewritten lock exists (`common/config/rush/repo-state.json` for the common lock, `common/config/subspaces//repo-state.json` for a subspace lock; the `pnpmShrinkwrapHash` desync is refreshed by `rush update`, which the redirect survives), and `redirect_pnpm_trust_lockfile` carries the Rush pnpm >=11 install remedy (see the trust paragraph above). **maven** is fail-closed via version suffixing: a `mavenSuffixedVersion` + `mavenPomSha256` override pins the Socket-only `-socket.` by rewriting the literal `` (`redirect_maven_dep_version`) or adding a `` entry (`redirect_maven_dep_management_added`), plus optional Trusted Checksums (`redirect_maven_trusted_checksums`, conflicts as `redirect_maven_trusted_checksums_conflict`; when `.mvn/wrapper/maven-wrapper.properties` pins a Maven older than 3.9.4, which ignores those files, the additive warning `redirect_maven_trusted_checksums_unenforced`); a `${property}` version is refused (`redirect_maven_dep_unpinned`), a reactor root (a `` / `` declaration, a profile's included) is refused whole with `redirect_maven_multimodule_unsupported`: `pom.xml` and `.mvn/` are left untouched and the dep is not counted as redirected (a Gradle build beside it is still planned by the Gradle rewriter, but a dep in a root with a `pom.xml` is confirmed only when the pom pins it too), since a module's own literal `` would shadow a root pin (use `--mode vendored`; `vex` omits a hosted pin in a reactor root as `vex_maven_reactor_root`), a non-matching literal skipped (`redirect_maven_dep_version_mismatch`), a `` variant (sources, tests, a native build) left at its version (`redirect_maven_classifier_unsupported`; the grant serves the main jar only, and the dep is skipped when a variant other than `sources` / `javadoc` names the patched release), a versioned declaration inside `` (a literal or a `${property}`) left as-is (`redirect_maven_profile_dependency_unpatched`; the dep is skipped when a profile holds its only declaration), and a pom not readable as one left untouched (`redirect_maven_pom_unreadable`). Matches and anchors skip comments, CDATA, ``, ``, ``, `` and `` (the scope `vex` reads), and an existing self-closed `` / `` is expanded in place, never duplicated; an override without a suffixed version falls back to same-GAV repository injection (`redirect_maven_same_gav_fallback`, NOT fail-closed). **gradle** (v5.0) is automated wiring, no longer a pasted snippet: the owned settings script `.socket/gradle/socket-patch.hosted.settings.gradle` with its index `.socket/gradle/hosted-index.tsv`, one apply line per build's settings file, every lock entry of the GA moved to the suffixed version, and the suffixed component in an existing `gradle/verification-metadata.xml`. A refused dep writes nothing and keeps `redirect_gradle_manual_snippet` as its fallback; same-GAV grants are refused (`redirect_gradle_same_gav_unsupported`). Rules, refusals and codes: [Gradle builds](#gradle-builds-v50). @@ -224,7 +224,7 @@ The rewriter reads a fixed set of candidate files from the project root: the npm **Non-UTF-8 candidate files (#721)**: the rewriters edit UTF-8 text only. A candidate file that exists but is not UTF-8 (for example a UTF-16 `requirements.txt`, which is what Windows PowerShell 5.1's `pip freeze >` writes and which pip installs from) is never read as absent. When a candidate of its ecosystem could rewrite it, the run is refused with `candidate_file_unreadable` (exit 1, `--dry-run` included), the message names the file, and nothing is written; the remedy is to re-save the file as UTF-8. Two exceptions keep their own refusals. A Gradle build file the Gradle planner reaches gets that planner's per-build refusal (`redirect_gradle_build_file_unreadable`, exit 0) and the rest of the run goes ahead, and with no readable Gradle build a stray Gradle file (a lock, a nested script) is never rewritten, so it does not refuse the run, while a non-UTF-8 root `settings.gradle(.kts)` or `build.gradle(.kts)` still refuses it (it may be the build itself). An unreadable `socket-patch.sbt` is refused with `redirect_sbt_owned_file_unreadable`. A vendored→hosted takeover checks this before it reverts anything, so a refused run leaves the vendored wiring, ledger entry and artifact byte-identical. Vendored mode likewise refuses a non-UTF-8 `requirements.txt` or `-r` include by name (`pypi_no_requirements`) instead of wiring around it. Lock-only discovery reads `requirements.txt` and its in-root `-r` includes the way pip decodes them (a UTF-16 or UTF-32 byte-order mark selects that encoding), so such a project's pins are still found instead of reporting "No packages found". -**Gem stale-install guard (additive warning — the canonical narrative; other mentions point here)**: the gem hosted rewrite is pure Gemfile/lock text, so a gem ALREADY materialized under the project's bundle paths keeps its upstream bytes — the next `bundle install` prints `Using ` and never refetches, on **every** bundler major (live-verified 2026-08-19 on 1.17.3 / 2.7.2 / 4.0.18: bundler 4's CHECKSUMS verify at download time only, and nothing is downloaded; `bundle install --force`/`--redownload` re-install from the stale cached `.gem` instead of re-fetching — bundler 1 silently, bundler 4 with an exit-37 checksum refusal that still leaves the upstream bytes installed; the **verified** remedy is removing the installed dir + cache `.gem` + `specifications` entry, then `bundle install`). After the rewrite, a hosted run therefore probes the installed-gem discovery paths (the same ruby-crawler discovery `apply` uses, honoring `--global`/`--global-prefix` like scan's own discovery, plus — read-only — a `.bundle/config` bundle path refused as a write root because it resolves outside the project, which takes the project-local remedy; the `gem env` homes count only when Bundler uses system gems, i.e. no deployment store under `vendor/bundle`, and the first settings tier (app config, environment, global config) that sets `path`, `path.system` or `disable_shared_gems` doesn't set a non-empty `path` without `path.system: true` or `disable_shared_gems: false`, and, when no tier sets any of those, no truthy `deployment` (the first tier that sets it wins; it makes `vendor/bundle` the path), since with such a `path` `bundle install` fetches non-default gems into it and never reuses a system copy; the `.bundle` default that `default_install_uses_path` (Bundler 2.x) or `simulate_version 5` (Bundler 4.x) selects depends on the Bundler that runs, so those flags keep the `gem env` homes judged) for each confirmed gem redirect and judges the materialization against the patch record's `afterHash` file map. Judgment rules: records are found **by uuid** among this run's fetched records (v5.0: hosted mode persists no records, so a purl whose `/patches/view` fetch failed this run is not judged; the warning re-fires on every re-scan whose fetch succeeds, until the stale materialization is gone); a materialization with every file at `afterHash` is already patched and never warns (an agent→hosted migration stays quiet by construction), and when several confirmed variant purls resolve to one installed dir, ANY of them judging it patched keeps it quiet; staleness needs **positive evidence** — at least one record file whose bytes were actually read and hash to neither state's expectation — so missing or unreadable files never produce a warning. Warnings emit `redirect_gem_stale_install` (JSON `redirect.warnings[]` + a code-tagged stderr line) in three flavors: a PROJECT-LOCAL dir (under the project root, compared on absolute paths so the default `--cwd .` counts, or under the project's own refused `.bundle/config` path) gets the verified delete-list remedy (installed dir, cache `.gem`, `specifications` entry — plus the project's committed `/.gem` when present and not proven to be the patched artifact, since bundler installs from its cache dir in preference to fetching); a SHARED gem-env home gets a caveat that the home is shared machine-wide and prefers migrating the project to a local bundle path over deleting shared files; and a committed cache-dir archive whose sha256 differs from the patched artifact's warns standalone even with no installed dir at all (a fresh checkout with a committed stale cache re-materializes the upstream bytes forever). A stale-flagged purl is additionally **excluded from the same run's `--vex` `assume_applied` set** — the envelope must never attest a CVE its own warning says is live; the purl falls back to normal installed-tree verification (a patched install still attests, a stale one is omitted). Standalone `vex` and `apply --check` judge gem copies by the same rule (#1098): when Bundler doesn't use system gems, a copy in a `gem env` home is not one the project loads and is not verified, unless it is a default gem (its spec under `specifications/default/`), which Bundler loads from the system home under any path. The cache dir is bundler's `cache_path` setting (`Bundler.app_cache`), resolved in `Bundler::Settings` priority: `BUNDLE_CACHE_PATH:` in the bundler app config (`$BUNDLE_APP_CONFIG/config`, else `.bundle/config`) first, then the `BUNDLE_CACHE_PATH` environment variable, then `BUNDLE_CACHE_PATH:` in the global config (`bundle config set --global`: `$BUNDLE_CONFIG`, else `$BUNDLE_USER_CONFIG`, else `$BUNDLE_USER_HOME/config`, else `~/.bundle/config`), else `vendor/cache`; a relative value is read against the project root. The same global tier, below the app config and the environment, applies to `BUNDLE_GEMFILE:` and, for agent-mode install-root discovery, to `BUNDLE_PATH:`. A present local or environment `path`, `path.system`, or `disable_shared_gems` setting (including an empty string or false flag) shadows the global path tier, matching the tested Bundler 2.6/4 behavior; Bundler 1.x's legacy global-path shortcut is not modeled. An empty higher-tier `gemfile` setting also shadows the global value but leaves an existing nonempty `BUNDLE_GEMFILE` environment value in effect, or uses default manifest discovery when there is none. With `BUNDLE_IGNORE_CONFIG` set (any value) bundler reads no config file, so the app and global configs are skipped here too and only the environment and the default count — the same holds for the `BUNDLE_GEMFILE:` app-config setting. The probe is read-only (nothing is deleted) and skipped on `--dry-run` — deliberately explicit, since nothing was rewritten. Exit code and `status` are unchanged (warning-only, the hosted-refusal posture); a same-run `--vex` may still fail on "nothing to attest" per the embedded-VEX contract. +**Gem stale-install guard (additive warning — the canonical narrative; other mentions point here)**: the gem hosted rewrite is pure Gemfile/lock text, so a gem ALREADY materialized under the project's bundle paths keeps its upstream bytes — the next `bundle install` prints `Using ` and never refetches, on **every** bundler major (live-verified 2026-08-19 on 1.17.3 / 2.7.2 / 4.0.18: bundler 4's CHECKSUMS verify at download time only, and nothing is downloaded; `bundle install --force`/`--redownload` re-install from the stale cached `.gem` instead of re-fetching — bundler 1 silently, bundler 4 with an exit-37 checksum refusal that still leaves the upstream bytes installed; the **verified** remedy is removing the installed dir + cache `.gem` + `specifications` entry, then `bundle install`). After the rewrite, a hosted run therefore probes the installed-gem discovery paths (the same ruby-crawler discovery `apply` uses, honoring `--global`/`--global-prefix` like scan's own discovery, plus — read-only — a `.bundle/config` bundle path refused as a write root because it resolves outside the project, which takes the project-local remedy; the `gem env` homes count only when Bundler uses system gems, i.e. no deployment store under `vendor/bundle`, and the first settings tier (app config, environment, global config) that sets `path`, `path.system` or `disable_shared_gems` doesn't set a non-empty `path` without `path.system: true` or `disable_shared_gems: false`, and, when no tier sets any of those, no truthy `deployment` (the first tier that sets it wins; it makes `vendor/bundle` the path), since with such a `path` `bundle install` fetches non-default gems into it and never reuses a system copy; the `.bundle` default that `default_install_uses_path` (Bundler 2.x) or `simulate_version 5` (Bundler 4.x) selects depends on the Bundler that runs, so those flags keep the `gem env` homes judged) for each confirmed gem redirect and judges the materialization against the patch record's `afterHash` file map. Judgment rules: records are found **by uuid** among this run's fetched records (v5.0: hosted mode persists no records, so a purl whose `/patches/view` fetch failed this run is not judged; the warning re-fires on every re-scan whose fetch succeeds, until the stale materialization is gone); a materialization with every file at `afterHash` is already patched and never warns (an agent→hosted migration stays quiet by construction), and when several confirmed variant purls resolve to one installed dir, ANY of them judging it patched keeps it quiet; staleness needs **positive evidence** — at least one record file whose bytes were actually read and hash to neither state's expectation — so missing or unreadable files never produce a warning. Warnings emit `redirect_gem_stale_install` (JSON `warnings[]` + a code-tagged stderr line) in three flavors: a PROJECT-LOCAL dir (under the project root, compared on absolute paths so the default `--cwd .` counts, or under the project's own refused `.bundle/config` path) gets the verified delete-list remedy (installed dir, cache `.gem`, `specifications` entry — plus the project's committed `/.gem` when present and not proven to be the patched artifact, since bundler installs from its cache dir in preference to fetching); a SHARED gem-env home gets a caveat that the home is shared machine-wide and prefers migrating the project to a local bundle path over deleting shared files; and a committed cache-dir archive whose sha256 differs from the patched artifact's warns standalone even with no installed dir at all (a fresh checkout with a committed stale cache re-materializes the upstream bytes forever). A stale-flagged purl is additionally **excluded from the same run's `--vex` `assume_applied` set** — the envelope must never attest a CVE its own warning says is live; the purl falls back to normal installed-tree verification (a patched install still attests, a stale one is omitted). Standalone `vex` and `apply --check` judge gem copies by the same rule (#1098): when Bundler doesn't use system gems, a copy in a `gem env` home is not one the project loads and is not verified, unless it is a default gem (its spec under `specifications/default/`), which Bundler loads from the system home under any path. The cache dir is bundler's `cache_path` setting (`Bundler.app_cache`), resolved in `Bundler::Settings` priority: `BUNDLE_CACHE_PATH:` in the bundler app config (`$BUNDLE_APP_CONFIG/config`, else `.bundle/config`) first, then the `BUNDLE_CACHE_PATH` environment variable, then `BUNDLE_CACHE_PATH:` in the global config (`bundle config set --global`: `$BUNDLE_CONFIG`, else `$BUNDLE_USER_CONFIG`, else `$BUNDLE_USER_HOME/config`, else `~/.bundle/config`), else `vendor/cache`; a relative value is read against the project root. The same global tier, below the app config and the environment, applies to `BUNDLE_GEMFILE:` and, for agent-mode install-root discovery, to `BUNDLE_PATH:`. A present local or environment `path`, `path.system`, or `disable_shared_gems` setting (including an empty string or false flag) shadows the global path tier, matching the tested Bundler 2.6/4 behavior; Bundler 1.x's legacy global-path shortcut is not modeled. An empty higher-tier `gemfile` setting also shadows the global value but leaves an existing nonempty `BUNDLE_GEMFILE` environment value in effect, or uses default manifest discovery when there is none. With `BUNDLE_IGNORE_CONFIG` set (any value) bundler reads no config file, so the app and global configs are skipped here too and only the environment and the default count — the same holds for the `BUNDLE_GEMFILE:` app-config setting. The probe is read-only (nothing is deleted) and skipped on `--dry-run` — deliberately explicit, since nothing was rewritten. Exit code and `status` are unchanged (warning-only, the hosted-refusal posture); a same-run `--vex` may still fail on "nothing to attest" per the embedded-VEX contract. **Pipenv hosted redirect (`Pipfile.lock`, pipfile-spec 6)**: every category other than `_meta` (`default`, `develop`, and Pipenv 2022+ named categories) that pins the package at the patched version is rewritten to the hosted reference — `{"file" | "path": "#sha256=", "hashes": ["sha256:"]}` with `markers`/`extras`/`index` kept exactly as Pipenv wrote them (present or absent: whether Pipenv records `index` depends on its release, the Pipfile spelling and the locking environment, so only the entry itself knows) and `version` dropped; `_meta` (the Pipfile content hash) and the Pipfile itself are never touched, so `pipenv install --deploy`/`sync`/`verify` keep passing. The reference KEY depends on the installing Pipenv: releases 7–11 only install `path` references, 2018 and later `file` ones (0–6 write pipfile-spec < 6 and are refused). The release is probed once per command with `pipenv --version`, resolved on ABSOLUTE `PATH` entries only (a relative entry would run a `pipenv` planted in the scanned repository; `.bat`/`.cmd` shims are found through `PATHEXT` on Windows), only when a pypi patch actually targets an entry of the lock, and `SOCKET_PIPENV_MAJOR=` pins the answer without spawning anything. An unknown installer selects `file` and warns `redirect_pipenv_installer_unknown` only when the lock was rewritten. **Refusal scope**: a pin/source CONFLICT (another version pinned, a foreign `file`/`path` source, a VCS/editable dependency) refuses the whole dependency atomically across categories as `redirect_pipenv_refused` AND vetoes the sibling Python rewriters (requirements.txt / uv.lock / pyproject) for that patch — the project's Pipenv install could not pick the patch up, so a half-redirected checkout is refused; anything else (no entry for the package, an old pipfile-spec, an unparseable lock, a digest-less patch) is `redirect_pipenv_skipped` and leaves the siblings alone (a stale Pipfile.lock in a uv/Poetry/requirements project must not block them). The veto applies to a LIVE lock only: a `Pipfile.lock` with no `Pipfile` beside it is abandoned, so its conflict refuses that file but never the siblings. Hash enforcement at install time is split by era — the `#sha256=` URL fragment is what Pipenv 2023+ verifies, the `hashes` list what 2018–2022 verify, Pipenv 11 either — so both are load-bearing. **Pipenv stale-install guard**: Pipenv never reinstalls a release that is already present (`pipenv install`, `install --deploy` and `sync` all exit 0 and keep the installed bytes — measured on 11.10.4, 2018.11.26 and 2026.8.0, hosted and vendored), so after the rewrite the run probes the Python crawler's site-packages (VIRTUAL_ENV, `./.venv`, `./venv`, Pipenv's out-of-tree `WORKON_HOME` venv; `--global`/`--global-prefix` honoured) for each confirmed Pipfile.lock redirect with the same rules as the gem guard (records by uuid from this run's fetch, PATCHED = `verify_patch_record` Ok, STALE needs positive evidence, read-only, skipped on `--dry-run`, stale purls excluded from the same-run `--vex` `assume_applied` set) and the Python stale-install guard (`redirect_pypi_stale_install`, see above) names the site-packages dir and the Pipenv-specific verified remedy: `pipenv run pip uninstall -y && pipenv sync` (or `pipenv --rm && pipenv sync`), with the `sync` arguments following the lock, since plain `pipenv sync` installs only `default`: the targeted form re-syncs the categories that pin the package (`--dev` for `develop`, `--categories ""` for a named category) and the `--rm` form re-syncs every non-empty category — NOT `pipenv uninstall`, which rewrites the Pipfile and re-locks the patch away. The vendored backend emits the twin `pypi_pipenv_stale_install` (`skipped` warning event). **Rollback** (v5.0, upstream restore): each hosted entry gets its registry shape back — `"version": "=="`, the entry's own `index` carried back unchanged (refused unless it — and the Pipfile's explicit `index`, if any — names a PyPI source in `_meta.sources`), and every release file's sha256 from PyPI's JSON API (`SOCKET_PYPI_JSON_API`), sorted by filename as Pipenv records them; an entry that pins another version beside the hosted reference is refused with the `git checkout` remedy (see "Hosted unwind coverage"). A Pipfile names no project, so a same-run `--vex` on a Pipenv project needs `--vex-product` (or a git remote) to detect a product purl. **Discovery**: `Pipfile.lock` is part of the lockfile inventory (every category's `==` pins, with the lock's digest set as `Sha256AnyOf` integrity so a lock-only checkout can be vendored by fetching the pure wheel through PyPI's JSON API — only when `_meta.sources` name the public index; a private-index lock stays discovery-only and never reaches pypi.org), and Socket's own hosted / vendored references stay discoverable as the package they replace, so a re-scan of an already-redirected or already-vendored lock-only checkout re-confirms it (`--vex` attests, vendored reports `already_vendored`) instead of finding nothing. @@ -233,20 +233,20 @@ The rewriter reads a fixed set of candidate files from the project root: the npm * `.socket/vendor/state.json` — the **vendored**-mode ledger (see "Ownership, state, and reversal" below): wiring edits with verbatim pre-vendor originals, artifact fingerprints, and the embedded patch `record` — for every entry written by `scan`/`get --mode vendored` beside `detached: true` (the record is that entry's only source), and for standalone `vendor` fed by an agent-mode manifest as a fallback copy without `detached` (the manifest record stays authoritative while the manifest covers the entry, by ledger key or base purl; `vex`, `list` and `setup --check` fall back to the embedded copy when it does not, `repair` only with no manifest at all). Entries written before 5.0 by standalone `vendor` carry no `record`; readers tolerate its absence. **Schema version 2 (v5.0)**: the `new` of a whole-file wiring record (kinds `maven_pom_repository`, `nuget_config_source`, `python_lock_document`, `python_script_metadata`, `hatch_document`) of 1 KiB or more, when its `original` is a string, is stored as an edit of that same record's `original`: `{"snapshot": "", "ops": [[start, len] | "inserted text", …]}` (the text is the ops concatenated in order: a `[start, len]` byte range copied from the `original`, a string inserted as is), and the ledger's `version` is `2`; the `original` stays a plain string, no other record kind is touched, and a ledger without such a record keeps the version-1 bytes. Both versions are read; a version-2 edit is rebuilt and checked against its hash (a mismatch, a missing `original`, an out-of-range copy, or any other `{"snapshot": …}` value is `vendor_state_unreadable`), so every consumer sees the same full texts as with an inline version-1 ledger. Records are self-contained, so an older socket-patch re-saving a version-2 ledger (it keeps `original` / `new` verbatim and drops unknown fields) loses nothing. * `.socket/vendor/redirect-state.json` — the **pre-v5 hosted**-mode ledger (`RedirectState` in `socket-patch-core/src/patch/redirect/state.rs`: `{ version, mode, edits[], records{} }`). **Retired in v5.0**: no command writes it, and `scan` / `get --mode hosted` ignore it. It is read for migration only — `list` and `vex` take a record from it for a hosted pin with the same purl and uuid (the lockfiles still decide what is hosted; its `edits` are never replayed), and a malformed one is only the `redirect_ledger_corrupt` warning there — and `rollback` / `remove` delete it once no lockfile pins a hosted patch any more (a `rollback` in a project whose ONLY state is this file removes it and exits 0, JSON `legacyRedirectLedgerRemoved: true`). A project scanned in hosted mode by v5 commits only its lockfile / config edits. -**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 `{status: "error", error: {code: "lock_held" | "lock_io", message}}` on get's legacy envelope, 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: +**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: get's legacy envelope gains the same nested `redirect` sub-object as scan's (`{mode:"hosted", redirected, rewrittenFiles, skipped, warnings, dryRun}`); the top-level shape is `{status, found, patches:[], warnings?}` — `downloaded`/`applied` are absent (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: get's envelope takes the detached download envelope's shape — `{status, found, downloaded, skipped, failed, detached: true, patches: [{purl, uuid, action: "downloaded" | "skipped" | "failed", …}], warnings?}` (`applied` is absent; `detached: true` is pinned; a `downloaded` record for a purl the vendor ledger holds at another uuid carries the additive `oldUuid`, derived from the ledger — the human `[fetch]` line reads ` (replacing )`) — and gains the nested `vendor` Envelope exactly like scan's `result["vendor"]`; a vendor-step error folds the partial envelope + `{status:"error", error:{code,message}}` in (a pre-failure takeover reconcile may have already mutated the ledger — its events must reach the consumer). Exit: download failures or vendor `has_errors` → `partial_failure`/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 `{action:"failed", 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 records ride `patches[]` / `download.patches[]` with `downloaded: 0`, 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 `partial_failure`/1. **uuid path** (`get --mode vendored`): the uuid lookup is the only fetch; the run exits 1 BEFORE the vendor step with exactly `{status:"error", found:1, downloaded:0, skipped:0, failed:1, error:{code, message}, patches:[{purl, uuid, action:"failed", errorCode, error}]}` (the `error` OBJECT is the vendored-mode error shape of the vendor-step fold-in above) 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 the additive `would_refuse` action (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`. +* **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`. -**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 becomes `{action:"failed", errorCode:, error:}` in `download.patches[]` / `patches[]` with the backend's exact code and detail, no view and no pristine fetch, no patch record, and therefore no vendor event: compared with v4.x, `download.downloaded` drops and `download.failed` rises by the number of such packages, `vendor.summary.failed` and `vendor.events` lose their `failed` events, and a lockfile-only package among them loses its `vendor_fetched_missing` event (it is never fetched). Exit code and top-level `status` are unchanged (`partial_failure`/1); the nested `vendor.status` becomes `success` when those refusals were the vendor step's only failures (observed on the depscan fixture: 3 refusals, `partialFailure` → `success`), 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` records (`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 the additive status **`not_installed`** (`{status:"not_installed", found:N, downloaded:0, applied:0, patches:[], warnings?}`) — never `no_match`, 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 `not_installed` — consumers dispatch on the per-record `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)`-prefixed strings 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). -* **Deliberate divergences from scan** (documented, not drift): agent-mode get keeps its `selection_required` JSON posture for free multi-patch PURLs (scan and, v5.0, hosted/vendored get auto-pick); get has no `--vex` (an ambient `SOCKET_VEX` is ignored by get's modes), no `--prune`; get does not run scan's pre-vendor baseline annotation; and an all-narrowed-out run exits `not_installed` without entering the vendor step (heal-after-wipe re-vendoring stays `scan --mode vendored`'s job). Agent-mode `get` honors `--dry-run` too (v5.0): the search and uuid paths classify each selected patch against the manifest (read-only; an unreadable manifest fails closed like the wet run) and stop before the prompt, the download, any `.socket/` write and the apply — human `[would-add]` / `[would-update] … (replacing )` / `[skip] … (already in manifest)` lines then `[dry-run] Would download and apply N patches. No changes made.`; JSON `{status:"success", dryRun:true, found, downloaded:0, skipped, applied:0, patches:[{purl, uuid, action:"would_add"|"would_update"(+oldUuid)|"skipped"}, ], warnings?}`, exit 0. +**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). +* **Deliberate divergences from scan** (documented, not drift): agent-mode get keeps its `selectionRequired` JSON posture for free multi-patch PURLs (scan and, v5.0, hosted/vendored get auto-pick); get has no `--vex` (an ambient `SOCKET_VEX` is ignored by get's modes), no `--prune`; get does not run scan's pre-vendor baseline annotation; and an all-narrowed-out run exits `notInstalled` without entering the vendor step (heal-after-wipe re-vendoring stays `scan --mode vendored`'s job). Agent-mode `get` honors `--dry-run` too (v5.0): the search and uuid paths classify each selected patch against the manifest (read-only; an unreadable manifest fails closed like the wet run) and stop before the prompt, the download, any `.socket/` write and the apply — human `[would-add]` / `[would-update] … (replacing )` / `[skip] … (already in manifest)` lines then `[dry-run] Would download and apply N patches. No changes made.`; JSON `dryRun: true` with a `verified` event per patch a wet run would record (`oldUuid` on a would-be update), `skipped` / `already_in_manifest` for one already recorded, plus the narrowing `skipped` events; exit 0. -`--dry-run` previews what `apply` / `rollback` / `scan --mode agent` / `repair` / `remove` — and `get` in every mode (hosted/vendored since v3.6, agent since v5.0) — would do without mutating disk. `get --mode hosted --dry-run` flows through the hosted engine's dry-run contract (no lock, no `.socket/`, no lockfile writes, `redirect.dryRun: true`); `get --mode vendored --dry-run` emits the same ledger-classification preview as scan's (`would_vendor` / `already_vendored` / `would_revendor`+`oldUuid` under the nested `vendor` key — plus, additive, `would_refuse` + `errorCode` + `error` for npm purls the wet run's Bun preflight would refuse (or, see "Takeover reconciliation", whose hosted pnpm pin the takeover would be refused on): an in-sync `already_vendored` entry is exempt, as is a `would_revendor` entry whose `bun.lock` instances are all already local tuples; a purl the lock still resolves from the registry is refused like a fresh one, and the preview stays exit 0 / `status: "success"` with nothing written) before any download, and both skip the confirm prompt (nothing to confirm). In JSON mode, the envelope is populated with would-be actions and counts (`remove --dry-run` skips the confirmation prompt — there is nothing to confirm — and flips its would-be `Removed` events to `Verified` previews, so `summary.removed` stays "entries actually deleted"). `rollback --dry-run` (v5.0) previews every leg — the in-place restore verification, the vendored unwire (`Would revert/unwire vendoring for …`), the hosted upstream restore (every pin is resolved exactly like a wet run — registry lookups included, so a pin the wet run would refuse is previewed as that refusal — and nothing is flushed to disk), the manifest removals (simulated in memory), and the blob/archive GC — with no writes and no prompt. +`--dry-run` previews what `apply` / `rollback` / `scan --mode agent` / `repair` / `remove` — and `get` in every mode (hosted/vendored since v3.6, agent since v5.0) — would do without mutating disk. `get --mode hosted --dry-run` flows through the hosted engine's dry-run contract (no lock, no `.socket/`, no lockfile writes, top-level `dryRun: true`, `verified` events); `get --mode vendored --dry-run` emits the same ledger-classification preview as scan's (`verified` to vendor, `oldUuid` on a re-vendor, `skipped` / `already_vendored` in sync — plus `skipped` with the refusal code for npm purls the wet run's Bun preflight would refuse (or, see "Takeover reconciliation", whose hosted pnpm pin the takeover would be refused on): an in-sync `already_vendored` entry is exempt, as is a re-vendor whose `bun.lock` instances are all already local tuples; a purl the lock still resolves from the registry is refused like a fresh one, and the preview stays exit 0 / `status: "success"` with nothing written) before any download, and both skip the confirm prompt (nothing to confirm). In JSON mode, the envelope is populated with would-be actions and counts (`remove --dry-run` skips the confirmation prompt — there is nothing to confirm — and flips its would-be `Removed` events to `Verified` previews, so `summary.removed` stays "entries actually deleted"). `rollback --dry-run` (v5.0) previews every leg — the in-place restore verification, the vendored unwire (`Would revert/unwire vendoring for …`), the hosted upstream restore (every pin is resolved exactly like a wet run — registry lookups included, so a pin the wet run would refuse is previewed as that refusal — and nothing is flushed to disk), the manifest removals (simulated in memory), and the blob/archive GC — with no writes and no prompt. v5.0 removed the v4 spellings `scan --apply` (use `--mode agent`), `scan --vendor` (use `--mode vendored`), `get --no-apply` (use `--save-only`), and the `download` (use `get`) and `gc` (use `repair`) subcommand aliases, with no deprecation release. Each is now an ordinary clap usage error (exit 2). -**Python stale-install guard**: after a hosted redirect, `scan` / `get` use the Python crawler to inspect every matching installed package, including Poetry's out-of-tree virtualenvs, the project's Hatch environments (Hatch's data dir / `HATCH_DATA_DIR`, `[dirs.env] virtual`, explicit env `path`s) and `--global-prefix`. A readable file that differs from the patch's `afterHash` emits `redirect_pypi_stale_install` in JSON `redirect.warnings[]` and human stderr. The probe changes no installed files, re-runs on idempotent scans, and falls back to persisted patch records when fresh record fetching fails. Missing/unreadable files alone do not prove staleness; lock-only checkouts stay quiet. Dry runs skip the probe. Same-run VEX excludes positively stale Python packages (qualifier-insensitive), even with `--vex-no-verify` or a healthy copy in another interpreter; if nothing remains to attest, the command exits 1 with `no_applicable_patches`. Reinstall from the rewritten lock in the affected interpreter and verify with `socket-patch vex`. A stale Hatch environment instead names `hatch env remove ` / `hatch env prune`: Hatch's pip installer (and uv before Hatch 1.16) keeps a same-version release, so only a recreated env picks up the patch. The same Hatch envs are what agent mode patches and `vex` judges for a Hatch project. +**Python stale-install guard**: after a hosted redirect, `scan` / `get` use the Python crawler to inspect every matching installed package, including Poetry's out-of-tree virtualenvs, the project's Hatch environments (Hatch's data dir / `HATCH_DATA_DIR`, `[dirs.env] virtual`, explicit env `path`s) and `--global-prefix`. A readable file that differs from the patch's `afterHash` emits `redirect_pypi_stale_install` in JSON `warnings[]` and human stderr. The probe changes no installed files, re-runs on idempotent scans, and falls back to persisted patch records when fresh record fetching fails. Missing/unreadable files alone do not prove staleness; lock-only checkouts stay quiet. Dry runs skip the probe. Same-run VEX excludes positively stale Python packages (qualifier-insensitive), even with `--vex-no-verify` or a healthy copy in another interpreter; if nothing remains to attest, the command exits 1 with `no_applicable_patches`. Reinstall from the rewritten lock in the affected interpreter and verify with `socket-patch vex`. A stale Hatch environment instead names `hatch env remove ` / `hatch env prune`: Hatch's pip installer (and uv before Hatch 1.16) keeps a same-version release, so only a recreated env picks up the patch. The same Hatch envs are what agent mode patches and `vex` judges for a Hatch project. ### socket.yml patch policy (v5.0) @@ -367,7 +367,7 @@ Supersession is judged on the by-package records the selection itself uses, so a } ``` -`maxNewPatches.value` is `null` for unlimited; `source` is `flag`, `env`, `file`, `default` or `cap` (the in-memory `maxNewPatchesCap` tightened it; the in-memory `maxNewPatches` option reports `flag`). `counts.new` / `counts.deferred` count base purls, `counts.upgrade` / `counts.already` count `(project, purl)` rows. `deferred[]` is in rank order: `purl` is the base purl, `uuids` the distinct selected uuids across its rows, `projects` the repo-relative project directories (`""` is the scanned directory), `rank` 1-based among eligible NEW base purls. Deferred rows are never written, downloaded or vendored: hosted mode mirrors each into `redirect.skipped[]` as `{purl, uuid, reason: "rollout_deferred", detail}`, the in-memory engine lists them in `ProjectResult.deferred[]` (`{purl, uuid, severity, rank}`) and `skipped[]`, and agent / vendored mode leave them out of `apply.patches[]` / `vendor`. Warnings (`rollout_incomplete_lookup`, `rollout_reference_failed`) go to the top-level `warnings[]`. Exit codes are unchanged: deferring is not a failure. +`maxNewPatches.value` is `null` for unlimited; `source` is `flag`, `env`, `file`, `default` or `cap` (the in-memory `maxNewPatchesCap` tightened it; the in-memory `maxNewPatches` option reports `flag`). `counts.new` / `counts.deferred` count base purls, `counts.upgrade` / `counts.already` count `(project, purl)` rows. `deferred[]` is in rank order: `purl` is the base purl, `uuids` the distinct selected uuids across its rows, `projects` the repo-relative project directories (`""` is the scanned directory), `rank` 1-based among eligible NEW base purls. Deferred rows are never written, downloaded or vendored: hosted mode mirrors each into `redirect.skipped[]` as `{purl, uuid, reason: "rollout_deferred", detail}`, the in-memory engine lists them in `ProjectResult.deferred[]` (`{purl, uuid, severity, rank}`) and `skipped[]`, and agent / vendored mode record no event for them. Warnings (`rollout_incomplete_lookup`, `rollout_reference_failed`) go to the top-level `warnings[]`. Exit codes are unchanged: deferring is not a failure. Human output adds, when a cap is set, `Rollout: 3 of 9 new patches applied (maxNewPatches=3 from --max-new-patches); 0 upgrades, 0 already applied.` (with several project directories: `…, shared by this run's directories, 1 left)`) and next steps naming the deferred patches (`6 new patches deferred; commit these changes and run scan again to apply the next 3.`, `Next up: minimist@1.2.5 (critical), …`; a dry run says `would be deferred`, and an incomplete lookup says so instead). Hosted mode prints them on stdout after its own next steps, unindented; agent and vendored mode under a `Next steps:` heading. The `rollout` block is left out of error envelopes (`status: "error"`). @@ -724,7 +724,7 @@ package then fails, exactly the files the eject wrote are put back (the pins' fi restore's files, and every file the vendored apply committed, each only when its bytes changed) — the project stays hosted exactly as before, and every other file (a `--json > report.json` redirect target, a log another process appends to) is left alone, with the -`eject_rolled_back` warning (printed to stderr on a human run, which prints no "Vendored N packages" summary and no "Next steps:") and `partial_failure`, exit 1 — each package the vendored apply had vendored is re-reported as a `skipped` event with `errorCode: "eject_rolled_back"`, never `applied`, and the run's other `skipped` advisories for it (`vendor_prebuilt_downloaded`, `vendor_takeover_reverted_redirect`, …) are dropped (#1005); if putting the snapshot back itself fails, +`eject_rolled_back` warning (printed to stderr on a human run, which prints no "Vendored N packages" summary and no "Next steps:") and `partialFailure`, exit 1 — each package the vendored apply had vendored is re-reported as a `skipped` event with `errorCode: "eject_rolled_back"`, never `applied`, and the run's other `skipped` advisories for it (`vendor_prebuilt_downloaded`, `vendor_takeover_reverted_redirect`, …) are dropped (#1005); if putting the snapshot back itself fails, the error is `eject_rollback_failed` naming the files to `git checkout`. The eject does not emit the per-purl `vendor_takeover_reverted_redirect` warning (the restore is its own planned step). `--offline` (or `SOCKET_OFFLINE`) refuses the eject up front with `offline_eject_unavailable` — @@ -991,7 +991,7 @@ worse, lets a warm cache silently serve unpatched bytes): ## Rollback command contract (v5.0) -> **Semver note.** v5.0 changes `rollback`'s DEFAULT behavior (a default-value/behavior change → **MAJOR** per the [semver policy](#semver-policy)) and narrows the meaning of the existing `vendored: []` JSON key (**MAJOR**). Every new envelope key, flag, and warning code below is additive on top of that. **Hosted leg (v5.0)**: hosted mode keeps no ledger, so the hosted leg restores each hosted pin to its default upstream registry entry (re-resolved from the registry) instead of replaying recorded fragments; a pin that cannot be restored is refused with the `git checkout -- ` remedy. +> **Semver note.** v5.0 changes `rollback`'s DEFAULT behavior (a default-value/behavior change → **MAJOR** per the [semver policy](#semver-policy)) and prints `--json` as the unified envelope (**MAJOR**, see "JSON envelope" below). Every flag and warning code below is additive on top of that. **Hosted leg (v5.0)**: hosted mode keeps no ledger, so the hosted leg restores each hosted pin to its default upstream registry entry (re-resolved from the registry) instead of replaying recorded fragments; a pin that cannot be restored is refused with the `git checkout -- ` remedy. `rollback` and `scan` are now the batch-level duals — `scan` moves the project toward "fully patched", `rollback` toward "fully unpatched" — the way `get` and `remove` are the single-patch duals. `rollback` needs no `--mode`: it infers what to undo from three sources (`.socket/manifest.json` = agent/in-place, `.socket/vendor/state.json` = vendored, and the hosted pins lockfile discovery finds in the project's lockfiles = hosted — v5.0 hosted mode keeps no ledger). @@ -1009,11 +1009,11 @@ worse, lets a warm cache silently serve unpatched bytes): A bare `rollback` (or a scoped one, for its scope) restores the SYSTEM to unpatched and cleans up the local state, in phases under one `apply.lock` acquisition: -1. **State discovery.** A missing manifest is no longer fatal when the vendor ledger or the lockfiles' hosted pins hold work (`rollback` runs manifest-less on hosted-only / vendored projects — every `scan`/`get --mode vendored` and v5 `scan --mode hosted` project is manifest-less). The **truly-empty** project — no manifest, no vendor ledger, no hosted pin — keeps the legacy "Manifest not found" exit 1 (JSON: the legacy `{status: "error", error: {code: "manifest_not_found", message: "Manifest not found"}, path}` shape), with one v5.0 exception: when a pre-v5 `.socket/vendor/redirect-state.json` is the only thing left, nothing pins it any more, so a wet run deletes it and exits 0 (human `Removed the pre-v5 hosted ledger .socket/vendor/redirect-state.json: no lockfile pins a hosted patch.`, `Would remove …` on `--dry-run`, which deletes nothing; JSON `{status: "success", rolledBack: 0, alreadyOriginal: 0, failed: 0, dryRun, warnings, legacyRedirectLedgerRemoved}` — a minimal envelope without the keys below; a failed delete is the `legacy_redirect_ledger_kept` warning, still exit 0). A project whose lockfiles still reference `.socket/vendor/` artifacts but whose vendor ledger is missing errors asking for `.socket/vendor/state.json` to be restored from version control first (v5.0: `repair` no longer reconstructs the ledger). **Corrupt-ledger containment**: an unreadable vendor ledger fails ONLY the legs that need it — the vendored leg, manifest cleanup, and GC are skipped fail-closed (`vendor_state_unreadable` warning) while the agent and hosted legs still run; it drives `partial_failure` exit 1, and an emergency restore is never blocked by it. When the ONLY state on disk is an unreadable vendor ledger, the run fails closed naming the store. A pre-v5 redirect ledger is never read by rollback (v4's `redirect_state_unreadable` is no longer emitted). Under `--global`/`--global-prefix` the project's hosted pins and vendor ledger are not discovered at all, so the vendored and hosted legs below do not run (see "Global scope never touches the project's state"). +1. **State discovery.** A missing manifest is no longer fatal when the vendor ledger or the lockfiles' hosted pins hold work (`rollback` runs manifest-less on hosted-only / vendored projects — every `scan`/`get --mode vendored` and v5 `scan --mode hosted` project is manifest-less). The **truly-empty** project — no manifest, no vendor ledger, no hosted pin — keeps the "Manifest not found" exit 1 (JSON: the error envelope with `error.code: "manifest_not_found"` plus a top-level `path`), with one v5.0 exception: when a pre-v5 `.socket/vendor/redirect-state.json` is the only thing left, nothing pins it any more, so a wet run deletes it and exits 0 (human `Removed the pre-v5 hosted ledger .socket/vendor/redirect-state.json: no lockfile pins a hosted patch.`, `Would remove …` on `--dry-run`, which deletes nothing; JSON: a `success` envelope with no events plus `legacyRedirectLedgerRemoved` (bool); a failed delete is the `legacy_redirect_ledger_kept` warning, still exit 0). A project whose lockfiles still reference `.socket/vendor/` artifacts but whose vendor ledger is missing errors asking for `.socket/vendor/state.json` to be restored from version control first (v5.0: `repair` no longer reconstructs the ledger). **Corrupt-ledger containment**: an unreadable vendor ledger fails ONLY the legs that need it — the vendored leg, manifest cleanup, and GC are skipped fail-closed (`vendor_state_unreadable` warning) while the agent and hosted legs still run; it drives `partialFailure` exit 1, and an emergency restore is never blocked by it. When the ONLY state on disk is an unreadable vendor ledger, the run fails closed naming the store. A pre-v5 redirect ledger is never read by rollback (v4's `redirect_state_unreadable` is no longer emitted). Under `--global`/`--global-prefix` the project's hosted pins and vendor ledger are not discovered at all, so the vendored and hosted legs below do not run (see "Global scope never touches the project's state"). 2. **Agent leg** — the existing in-place restore machinery, unchanged (v5.0 presentation: the human `No patches found in manifest` line prints only for an unscoped run with no work in ANY leg — a run whose work is all vendored/hosted stays quiet about the manifest): multi-copy restore, release-variant narrowing, the before-blob gate (+ on-demand download; a gate abort still exits 1 with per-package `missing_blob` failure results **and** skips manifest cleanup + GC entirely — nothing was restored, and the retry's revert data must survive), local-go redirect drop, and the `not_installed` exit-0 asymmetry verbatim. Vendor-owned purls are still excluded here (see the vendored-mode section) — they are handled by the next leg instead of being punted to other commands — with one exception: a vendored Cargo crate whose copy in the shared `$CARGO_HOME/registry/src` cache (or a `cargo vendor` dir) still carries an earlier agent-mode patch is restored here too (vendoring never touches that copy, and the manifest record holds the only blobs that can restore it); its record leaves the manifest only when both this leg and the vendored leg succeeded. `remove` restores it the same way. -3. **Vendored leg** — each in-scope ledger entry (embedded-record entries included) is reverted through the vendor backends: lockfile wiring restored, artifact dir deleted (and its emptied `.socket/vendor//` husk pruned, v5.0), ledger entry dropped + persisted per purl (crash-consistent, like `vendor --revert`). A **drift-keep** (the backend refused a drifted lock) keeps the entry, the artifact, AND the manifest record (`vendoredKept`, exit 1 — the system is still patched); a failure is recorded and other entries proceed. +3. **Vendored leg** — each in-scope ledger entry (embedded-record entries included) is reverted through the vendor backends: lockfile wiring restored, artifact dir deleted (and its emptied `.socket/vendor//` husk pruned, v5.0), ledger entry dropped + persisted per purl (crash-consistent, like `vendor --revert`). A **drift-keep** (the backend refused a drifted lock) keeps the entry, the artifact, AND the manifest record (a `failed` `vendor_revert_kept` event, exit 1 — the system is still patched); a failure is recorded and other entries proceed. 4. **Hosted leg** — each in-scope hosted pin is restored to its default upstream registry entry; see "Hosted unwind coverage" below. After a hosted leg with no failure, a wet run deletes a pre-v5 `redirect-state.json` once no lockfile pins a hosted patch any more (a failed delete is the `legacy_redirect_ledger_kept` warning). -5. **Manifest cleanup** — entries are removed ONLY for in-scope purls whose legs fully succeeded, were not-installed, or were release-variant siblings narrowed away by an attempted variant that succeeded (half a variant group never lingers — `remove` parity); drift-kept and failed purls keep their records, and a failed variant holds its whole group. A manifest record that a live hosted pin has superseded (the lockfile wires the same package release to a different patch uuid, e.g. an agent → hosted migration after the patch was replaced) and whose installed copy holds neither side of the record is not restored in place and does not fail the run: the hosted leg's lock restore and the next install unwind it, and the record leaves the manifest with the `rollback_record_superseded` warning (`remove` does the same instead of aborting before its hosted leg). A copy still holding the record's patched bytes is restored as usual. No-op removals never rewrite the file. A failed write surfaces as `manifest_write_failed` (warning + `partial_failure` exit 1; GC still runs against the unchanged manifest). +5. **Manifest cleanup** — entries are removed ONLY for in-scope purls whose legs fully succeeded, were not-installed, or were release-variant siblings narrowed away by an attempted variant that succeeded (half a variant group never lingers — `remove` parity); drift-kept and failed purls keep their records, and a failed variant holds its whole group. A manifest record that a live hosted pin has superseded (the lockfile wires the same package release to a different patch uuid, e.g. an agent → hosted migration after the patch was replaced) and whose installed copy holds neither side of the record is not restored in place and does not fail the run: the hosted leg's lock restore and the next install unwind it, and the record leaves the manifest with the `rollback_record_superseded` warning (`remove` does the same instead of aborting before its hosted leg). A copy still holding the record's patched bytes is restored as usual. No-op removals never rewrite the file. A failed write surfaces as `manifest_write_failed` (warning + `partialFailure` exit 1; GC still runs against the unchanged manifest). 6. **GC** — blob, diff and legacy package-archive sweeps against the post-removal manifest, using the same artifact-reference policy as `remove`, retaining beforeHash blobs for (a) removed-but-not-installed entries (a crawler miss must not destroy the only local revert data — `remove` parity) and (b) EVERY entry remaining in the post-removal manifest — still-active patches (failed, drift-kept, eco-/path-excluded) keep their revert data, so a scoped or failed run never destroys the blobs a later rollback needs; only blobs referenced solely by genuinely-removed entries are swept. GC errors warn (`cleanup_failed`) and continue — they never affect the exit (repair's posture). **Confirmation prompt.** A wet, non-preserve run with work prompts once, remove-style, composing only the clauses that apply into one English list (`a and b`, `a, b, and c`) with counted nouns: `Roll back N patches`, `remove them from the local manifest`, `delete M vendored artifacts and their ledger records`, `restore H hosted packages to the upstream registry` (e.g. `Roll back 1 patch, remove it from the local manifest, and restore 1 hosted package to the upstream registry?`) — default yes, auto-accepted under `--yes`/`--json`/non-TTY (the shared `confirm` semantics; CI unaffected). Decline prints `Cancelled; no changes made.` (stdout) and exits 0. `--dry-run` and `--preserve-state` runs are prompt-free (they delete no local state). @@ -1040,30 +1040,30 @@ v5.0 replaces v4's per-purl reverts and whole-ledger reverse replay (`revert_rem * **maven** — `pom.xml` (the `-socket.` version suffix, the added `` / `` entry) and the `.mvn/maven.config` / `.mvn/checksums/checksums.sha256` lines hosted mode writes: **no network**, so it restores under `--offline` too. `.mvn` files holding anything else keep the resolver lines (`maven_trusted_checksums_left`). * **nuget** — `nuget.config` loses the `socket-patch-` source and its exact-id mapping; every lock entry of the id at the pinned version, in every lock the root config governs (member projects' and `packages..lock.json` included), gets nuget.org's `contentHash` back (`SOCKET_NUGET_URL`). Refused when the restored config would not resolve the id from nuget.org alone. A config hosted mode created from scratch is kept (`nuget_default_config_left`). * Any other file wiring a pin refuses it (`socket-patch cannot re-derive the upstream entry in `). -* **Refusals.** `--offline` refuses every pin whose restore needs a registry lookup (all but maven), as does a registry that does not answer or no longer describes the entry. A refused pin writes nothing; its message is `cannot restore to its upstream registry entry: ; restore it from version control instead (`git checkout -- `)` — for a `bun.lock` / `bun.lockb` followed by `, then run `bun install --force` (a plain `bun install` keeps the patched copy)`, since Bun's hoisted linker does not re-extract a package whose entry returns to the registry copy of the same `name@version` (#764) — human `Error: Cannot restore …` on stderr (even under `--silent`), JSON `hosted.failed[{purl, error}]`, and `partial_failure` exit 1 (`remove`: the `hosted_revert_failed` error). A write failure after every pin resolved is one `hosted.failed` entry with the pseudo-purl `files`. +* **Refusals.** `--offline` refuses every pin whose restore needs a registry lookup (all but maven), as does a registry that does not answer or no longer describes the entry. A refused pin writes nothing; its message is `cannot restore to its upstream registry entry: ; restore it from version control instead (`git checkout -- `)` — for a `bun.lock` / `bun.lockb` followed by `, then run `bun install --force` (a plain `bun install` keeps the patched copy)`, since Bun's hoisted linker does not re-extract a package whose entry returns to the registry copy of the same `name@version` (#764) — human `Error: Cannot restore …` on stderr (even under `--silent`), JSON a `failed` `hosted_restore_refused` event, and exit 1 (`remove`: the `hosted_revert_failed` error). A write failure after every pin resolved is one artifact-level (no `purl`) `failed` `hosted_write_failed` event. * **Output.** Human `Restored to its upstream registry entry` / `Would restore to its upstream registry entry` (`--dry-run`). vlt: the stale installed copies of restored nodes are removed afterwards, as before (`--no-vlt-install-cleanup` keeps them). -### JSON envelope (legacy shape + additive always-present keys) +### JSON envelope (v5.0, MAJOR: the unified envelope) -`rollback --json` keeps its legacy top-level shape (`status` — `"success"` \| `"partial_failure"` \| `"error"` — `rolledBack`, `alreadyOriginal`, `failed`, `dryRun`, `results[]`) and adds these keys, ALL always present so consumers never null-check (except `error`, present only on `status: "error"`): +`rollback --json` prints the shared [envelope](#envelope-shape) (`command: "rollback"`). The legacy keys `rolledBack`, `alreadyOriginal`, `failed`, `results[]`, `vendored`, `vendoredReverted`, `vendoredPreserved`, `vendoredKept`, `vendoredFailed`, `manifest`, `hosted.{reverted,failed,unsupported}` and `gc.skipped` are gone: every outcome is one event and `summary` counts them. -| Key | Shape | Meaning | -|---|---|---| -| `error` | `{code, message}` | Only on `status: "error"` (v5.0, MAJOR: was a string). Codes: `manifest_not_found`, `manifest_invalid`, `manifest_unreadable`, `patch_not_found`, `path_glob_no_match`, `hosted_wiring_contested`, `vendor_ledger_missing`, `rollback_failed`, `lock_held` / `lock_io`, and `path_glob_invalid` (a usage error, exit 2). Per-result `results[*].error` stays a string. | -| `warnings` | `[{code, detail}]` | Run-level warnings, now populated (previously always empty): `reinstall_required`, `hosted_state_not_preservable`, `out_of_scope_copies_restored`, `vendor_state_unreadable`, `cleanup_failed`, `manifest_write_failed`, `legacy_redirect_ledger_kept`, the upstream-restore advisories (`npm_allow_remote_left`, `pnpm_trust_lockfile_left`, `maven_trusted_checksums_left`, `nuget_default_config_left`, `upstream_uv_override_removed`, `upstream_registry_fallback`, `upstream_pnpm_tarball_setting_guessed`, `upstream_gem_stale_cache`, `yarn_berry_node_gyp_unresolved`, `hosted_resolution_orphaned`), `ownership_not_restored` (a restored file whose ownership could not be put back — see the apply warnings), `cargo_build_cache_stale` (see the apply warnings), `rollback_record_superseded` (a manifest record superseded by a live hosted pin, left to the hosted leg — see Manifest cleanup), plus vendored/hosted leg advisories. New codes are additive (MINOR) | -| `vendored` | `[purl]` | **Meaning narrowed (MAJOR)**: vendor-owned purls the run did NOT act on — today exactly the corrupt-vendor-ledger skip. | -| `vendoredReverted` | `[purl]` | Ledger entries cleanly reverted this run (unwired + artifact deleted + entry dropped; previewed on dry-run) | -| `vendoredPreserved` | `[purl]` | `--preserve-state`: unwired with artifact + ledger entry kept | -| `vendoredKept` | `[{purl, reason}]` | Drift-keeps — wiring drifted, vendored state (and the manifest entry) left untouched; drives exit 1 | -| `vendoredFailed` | `[{purl, error}]` | Vendored reverts that errored — entry, artifact, and manifest record all survive for a retry; drives exit 1 | -| `hosted` | `{reverted: [purl], failed: [{purl, error}], unsupported: [purl], editedFiles: N}` | The hosted leg (v5.0: the upstream restore). `reverted` lists the pins restored (would-be on dry-run); `failed` the refused pins with the version-control remedy in `error` (the pseudo-purl `files` for a write failure); `unsupported` is kept for shape and is always empty (every ecosystem has a restore); `editedFiles` counts distinct files rewritten | -| `manifest` | `{removedEntries: [purl], preserved: bool}` | Entries removed from the manifest (would-be removals on dry-run); `preserved` mirrors `--preserve-state` | -| `gc` | `{skipped: true}` \| `{removedBlobs, removedDiffArchives, removedPackageArchives, bytesFreed}` | The shared GC shape (see "One GC shape"). Skipped under `--preserve-state`, after a blob-gate abort, and under a corrupt vendor ledger | -| `paths` | `[string]` | The path-glob targets verbatim (empty when none) | - -**Counters (v5.0, #1066)**: `rolledBack` and `failed` span every leg. `rolledBack` counts agent results that restored files, plus `vendoredReverted`, `vendoredPreserved` and `hosted.reverted`; `failed` counts failed agent results, plus `vendoredKept`, `vendoredFailed`, `hosted.failed` and `hosted.unsupported`. A package wired through two legs counts once per leg. `alreadyOriginal` stays agent-only. When something failed and nothing was rolled back or already original, the run failed as a whole: `status: "error"` with `error: {code: "rollback_failed", message}` (exit 1) instead of `partial_failure`. - -**Exit rules**: not-installed entries never flip the exit (the documented apply/rollback asymmetry — even an all-not-installed run exits 0 `success`). Everything that leaves the system still patched DOES flip it to `partial_failure` exit 1: agent-leg failures, vendored drift-keeps and revert failures, hosted refusals, a corrupt vendor ledger, and a failed manifest write. GC failures never affect the exit. +| Outcome | Event | +|---|---| +| Agent leg: a copy restored | `rolledBack` (`verified` on `--dry-run`); `files` = the restored (would-be restored) files, `details.path` = the installed copy. One event per copy | +| Agent leg: already original | `skipped` `already_original` | +| Agent leg: patch record lists no files | `skipped` `no_files` | +| Agent leg: copy failed | `failed`; `errorCode` from the first blocking file (`hash_mismatch`, `file_not_found`, `missing_blob`), else `rollback_failed`; `details.filesVerified[{file, status, message, currentHash, expectedHash, targetHash}]` with camelCase `status` (`ready`, `alreadyOriginal`, `hashMismatch`, `notFound`, `missingBlob`). A before-blob gate abort is one `missing_blob` failure per gated package | +| In-scope entry not installed | `skipped` `package_not_installed` (never flips status or exit) | +| Vendored leg: reverted / preserved (`--preserve-state`) | `rolledBack` (`verified` on `--dry-run`), `details.mode: "vendored"`; preserved adds `details.preserved: true` | +| Vendored leg: drift-keep | `failed` `vendor_revert_kept`, `details.mode: "vendored"` (exit 1: the system is still patched) | +| Vendored leg: revert failed | `failed` `vendor_revert_failed` (`vendor_state_write_failed` when the ledger save failed), `details.mode: "vendored"` | +| Hosted leg: pin restored | `rolledBack` (`verified` on `--dry-run`), `details.mode: "hosted"` | +| Hosted leg: pin refused / write failed / contested wiring | `failed` `hosted_restore_refused` / artifact-level `hosted_write_failed` / artifact-level `hosted_wiring_contested`, `details.mode: "hosted"` | +| Manifest entry dropped | `removed` (`verified` on `--dry-run`) with `details.manifest: true` | + +Events carry the patch `uuid` when one is recorded. A package wired through two legs gets one event per leg. Cargo's `.cargo-checksum.json` resync rides the envelope's `sidecars[]`. Rollback's own top-level keys: `hosted: {editedFiles: N}` (distinct files the hosted leg rewrote) and `paths` (the path-glob targets verbatim, `[]` when none); `path` on `manifest_not_found` and `legacyRedirectLedgerRemoved` on the pre-v5-ledger exit. `gc` is the shared GC object (via `summary.bytesFreed` too); it is absent under `--preserve-state`, and absent with a `gc_skipped` warning after a before-blob gate abort or under a corrupt vendor ledger. `warnings[]` (omitted when empty): `reinstall_required`, `hosted_state_not_preservable`, `out_of_scope_copies_restored`, `vendor_state_unreadable`, `cleanup_failed`, `manifest_write_failed`, `gc_skipped`, `legacy_redirect_ledger_kept`, `ownership_not_restored`, `rollback_record_superseded`, `gradle_m2_copy_not_restored`, `cargo_build_cache_stale` (see the apply warnings), the upstream-restore advisories (including `hosted_resolution_orphaned`) and the vendored/hosted leg advisories. + +**Status and exit**: `success` (exit 0) unless something leaves the system still patched — an agent failure, a vendored drift-keep or failure, a hosted refusal, contested wiring under an unscoped run, a corrupt vendor ledger, a failed manifest write — which is `partialFailure` (exit 1). When something failed and nothing was rolled back, previewed, already original or not installed, the run failed as a whole: `status: "error"` with `error: {code: "rollback_failed"}` (exit 1; the `failed` events stay). Not-installed entries never flip the exit (the apply/rollback asymmetry). GC failures never affect the exit. A dry run's would-be refusals stay `failed` events (not `skipped`): a dry run that cannot roll something back exits 1, as before. Every early error (`manifest_not_found`, `manifest_invalid`, `manifest_unreadable`, `patch_not_found`, `ambiguous_target`, `path_glob_no_match`, `hosted_wiring_contested`, `vendor_ledger_missing`, `lock_held` / `lock_io`, the catch-all `rollback_failed`, and the exit-2 usage error `path_glob_invalid`) is a full envelope with empty `events`. ## Self-update contract (`socket-patch --update`) @@ -1250,14 +1250,15 @@ The v3.0 legacy names `SOCKET_PATCH_PROXY_URL`, `SOCKET_PATCH_DEBUG` and `SOCKET Every `--json` invocation emits a single JSON object that follows the **unified envelope** below. The envelope was introduced in v3.0; older per-command shapes are deprecated. See `src/json_envelope.rs` for the source of truth; its unit tests pin the serialized names, and each command's e2e tests assert the envelope it emits. The `tests/cli_parse_*.rs` files pin the parsed clap arguments, not this shape (a few, such as `cli_parse_list.rs`, also spot-check `list`'s envelope). -**One error shape (v5.0, MAJOR).** On every command, every `--json` failure carries its top-level `error` as a `{code, message}` object — the envelope's `EnvelopeError` — including on the legacy shapes `scan`, `get` and `rollback` still print. `code` is a stable snake_case tag (see [Top-level `EnvelopeError` codes](#top-level-envelopeerror-codes)); `message` is for humans. No command prints a top-level `errorCode` any more (it moved into `error.code`). Per-record keys are unchanged: `patches[*].error` / `patches[*].errorCode`, `events[*].error` / `events[*].errorCode` and rollback's `results[*].error` stay strings. +**One error shape (v5.0, MAJOR).** On every command, every `--json` failure is a full envelope (`command`, `status: "error"`, `dryRun`, `events`, `summary`) whose top-level `error` is a `{code, message}` object — the envelope's `EnvelopeError`. `code` is a stable snake_case tag (see [Top-level `EnvelopeError` codes](#top-level-envelopeerror-codes)); `message` is for humans. No command prints a top-level `errorCode` any more (it moved into `error.code`). Per-event keys are unchanged: `events[*].error` / `events[*].errorCode` stay strings. ### Envelope shape ```jsonc { "command": "scan" | "apply" | "vex" | "vendor" | "rollback" | "get" | "list" | "remove" | "repair" | "update", - "status": "success" | "partialFailure" | "error" | "noManifest" | "notFound", + "status": "success" | "partialFailure" | "error" | "noManifest" | "paidRequired" | "notFound" + | "notInstalled" | "noMatch" | "noPackages" | "selectionRequired", "dryRun": false, "events": [ , ... ], "summary": { @@ -1269,10 +1270,11 @@ Every `--json` invocation emits a single JSON object that follows the **unified "failed": 0, "removed": 0, "verified": 0, - "rebuilt": 0, // omitted while zero (repair / vendor only) + "rebuilt": 0, + "rolledBack": 0, "bytesFreed": 0 // = gc.bytesFreed; 0 when no GC ran }, - "gc": { // only when the run swept .socket/ (repair, remove) + "gc": { // only when the run swept .socket/ (repair, remove, rollback) "removedBlobs": 0, "removedDiffArchives": 0, "removedPackageArchives": 0, @@ -1284,13 +1286,19 @@ Every `--json` invocation emits a single JSON object that follows the **unified `events` is the load-bearing payload. `summary` is pre-computed from `events` so consumers don't have to walk the array; its action counters count patch-level events, so a GC carrier event (the artifact-level `removed`, or `verified` on a dry run, that `repair` and `remove` emit for a sweep) bumps none of them. The sweep itself is reported once, in `gc`. `error` is set only on top-level failures (e.g. `manifest_not_found`); per-patch failures appear as `events[*]` with `action: "failed"`. -**One GC shape (v5.0).** Every command that sweeps orphan artifacts from `.socket/blobs`, `.socket/diffs` and `.socket/packages` reports the pass as the same `gc` object, `{removedBlobs, removedDiffArchives, removedPackageArchives, bytesFreed}`: the envelope's `gc` (`repair`, `remove`), rollback's `gc` and the `gc` of `scan --prune` / `--sync` (which adds its manifest and vendored keys beside them). On a dry run the counts are what the pass would remove, under the same keys (v5.0, MAJOR: scan's `--dry-run` preview no longer prints `prunableManifestEntries` / `orphanBlobs` / `orphanDiffArchives` / `orphanPackageArchives` / `revertableVendoredEntries` / `vendorOrphanDirs` / `bytesReclaimable`; it prints `prunedManifestEntries`, `removedBlobs`, `removedDiffArchives`, `removedPackageArchives`, `revertedVendoredEntries`, `removedVendorOrphanDirs` and `bytesFreed`, and leaves out only the keys a real pass alone can fill: `keptVendoredEntries`, `failedVendoredEntries`, `skipped`, `warnings`). `summary.bytesFreed` mirrors `gc.bytesFreed` on the envelope commands (0 when no GC ran: `repair --download-only`, `remove --preserve-state`, and every command without a GC pass). `gc` is absent when no sweep ran. v5.0 removes `summary.bytesDownloaded`, which no command ever emitted. +**One GC shape (v5.0).** Every command that sweeps orphan artifacts from `.socket/blobs`, `.socket/diffs` and `.socket/packages` reports the pass as the same `gc` object, `{removedBlobs, removedDiffArchives, removedPackageArchives, bytesFreed}`: the envelope's `gc` (`repair`, `remove`, `rollback`) and the `gc` of `scan --prune` / `--sync` (which adds its manifest and vendored keys beside them). On a dry run the counts are what the pass would remove, under the same keys (v5.0, MAJOR: scan's `--dry-run` preview no longer prints `prunableManifestEntries` / `orphanBlobs` / `orphanDiffArchives` / `orphanPackageArchives` / `revertableVendoredEntries` / `vendorOrphanDirs` / `bytesReclaimable`; it prints `prunedManifestEntries`, `removedBlobs`, `removedDiffArchives`, `removedPackageArchives`, `revertedVendoredEntries`, `removedVendorOrphanDirs` and `bytesFreed`, and leaves out only the keys a real pass alone can fill: `keptVendoredEntries`, `failedVendoredEntries`, `skipped`, `warnings`). `summary.bytesFreed` mirrors `gc.bytesFreed` on the envelope commands (0 when no GC ran: `repair --download-only`, `remove --preserve-state`, and every command without a GC pass). `gc` is absent when no sweep ran. v5.0 removes `summary.bytesDownloaded`, which no command ever emitted. + +**GC carrier event (v5.0, MAJOR).** `repair` and `remove` add one artifact-level event (no `purl`) for a sweep that removed something: `removed` (`verified` on a dry run) with `bytes` = bytes freed and `details: {count, checked}` — `count` the artifacts swept (= `gc.removedBlobs + gc.removedDiffArchives + gc.removedPackageArchives`), `checked` the artifacts the sweep examined. It bumps no `summary` counter. `remove`'s carrier no longer carries `details.blobsRemoved` / `details.archivesRemoved` (read `gc`) or `details.rolledBack` (see `remove`'s `rolledBack` events). + +**`details.mode`.** An event about a vendored-mode or hosted-mode patch carries `details.mode: "vendored" | "hosted"` (`list`; v5.0 also `remove`'s vendored/hosted legs and `repair`'s vendored phase); an agent-mode (manifest) event carries none. `repair`'s download carrier names its artifact kind `details.downloadMode` (`"diff"` / `"file"`; v5.0, MAJOR: was `details.mode`). + +**`sidecars[]` value tags (v5.0).** `files[].action` (`rewritten` / `deleted`) and `advisory.severity` (`info` / `warning` / `error`) follow the envelope's camelCase enum convention; `advisory.code` is a routing tag and stays snake_case (`pypi_record_stale`, `sidecar_fixup_failed`, …). ### `PatchEvent` shape ```jsonc { - "action": "discovered" | "downloaded" | "applied" | "updated" | "skipped" | "failed" | "removed" | "verified" | "rebuilt", + "action": "discovered" | "downloaded" | "applied" | "updated" | "skipped" | "failed" | "removed" | "verified" | "rebuilt" | "rolledBack", "purl": "pkg:npm/foo@1.2.3", // omitted on artifact-level events "uuid": "", // optional "oldUuid": "", // only when action=updated @@ -1309,7 +1317,7 @@ Every `--json` invocation emits a single JSON object that follows the **unified } ``` -`files[].path` is the manifest file key (`package/index.js`). A pnpm or vlt package installed as more than one peer-variant store copy is patched (and rolled back) in every copy; a file of a copy other than the one the package resolved to is listed under that copy's on-disk path (`…/.pnpm/foo@1.0.0_react@18.3.1/node_modules/foo/index.js`). So a run that wrote only such a copy reports `applied` with that file, not `already_patched`, and rollback's `filesRolledBack` / `filesVerified` carry the same paths (it counts the package in `rolledBack`, not `alreadyOriginal`). Copies are counted by real location: a pnpm / Bun workspace member's `packages/a/node_modules/foo` link into the root store is the same copy as the store entry, so `apply` / `rollback` visit it once (one event, no phantom `already_patched` / `alreadyOriginal`), while path targets still match it through the member's link (#633). +`files[].path` is the manifest file key (`package/index.js`). A pnpm or vlt package installed as more than one peer-variant store copy is patched (and rolled back) in every copy; a file of a copy other than the one the package resolved to is listed under that copy's on-disk path (`…/.pnpm/foo@1.0.0_react@18.3.1/node_modules/foo/index.js`). So a run that wrote only such a copy reports `applied` with that file, not `already_patched`, and rollback's `rolledBack` event lists the same paths in `files` (not `already_original`). Copies are counted by real location: a pnpm / Bun workspace member's `packages/a/node_modules/foo` link into the root store is the same copy as the store entry, so `apply` / `rollback` visit it once (one event, no phantom `already_patched` / `already_original`), while path targets still match it through the member's link (#633). `details` is intentionally schemaless — different subcommands attach different keys. Consumers MUST treat unknown keys as best-effort metadata and must not break on absence. @@ -1318,16 +1326,16 @@ Every `--json` invocation emits a single JSON object that follows the **unified | Action | Emitted by | Meaning | |--------------|---------------------------------------|---------| | `discovered` | `list` | Patch recorded in the manifest, the vendor ledger or a hosted lockfile pin — no work taken. | -| `downloaded` | `repair`, `--update` | `repair`: artifacts were fetched (one aggregate event, `details.count`). `--update`: the release archive was fetched (`bytes` = archive size). | -| `applied` | `apply`, `vendor` | Patch was written to disk (`vendor`: vendored). `files` enumerates what changed. | -| `updated` | `--update` | The binary was replaced (`details.from` / `details.to`). | +| `downloaded` | `repair`, `--update`, `get`, `scan` | `get` / `scan --mode agent\|vendored`: the patch was fetched (patch metadata in `details`; vendored: `details.mode`, and `details.oldUuid` on a re-vendor). `repair`: artifacts were fetched (one aggregate event, `details.count`). `--update`: the release archive was fetched (`bytes` = archive size). | +| `applied` | `apply`, `vendor`, `get`, `scan` | Patch was written to disk (`vendor`, vendored `get`/`scan`: vendored; hosted `get`/`scan`: the lockfile pin was written, `details.mode: "hosted"`; agent `get`/`scan`: the nested apply patched it, no `files`). `files` enumerates what changed. | +| `updated` | `--update`, `get`, `scan` | `--update`: the binary was replaced (`details.from` / `details.to`). Agent `get` / `scan`: a manifest record was replaced by a newer patch (`oldUuid`, metadata in `details`). | | `skipped` | every envelope command | No-op — already patched, not in scope, filtered, etc. `errorCode` carries the reason. | -| `failed` | `apply`, `repair`, `vendor` | A specific attempt failed. `errorCode` + `error` set. | -| `removed` | `remove`, `repair`, `vendor` | A manifest entry or vendored state was removed, or (artifact-level, no `purl`) `.socket/` artifacts were swept — that GC carrier sets `bytes` and is not counted in `summary.removed`; the sweep's totals are the envelope's `gc`. | -| `verified` | `apply`, `remove`, `repair`, `vendor` (dry run); `vex`; `--update --dry-run` | The action *would* succeed cleanly (`files` lists previewed changes); `vex`: the patch verified and was attested. | -| `rebuilt` | `repair`, `vendor` | A missing/corrupt vendored artifact was restored from its exact server download (v5.0: never a lost ledger entry — see `vendor_ledger_missing`). `summary.rebuilt` counts these (the field is omitted while zero). | +| `failed` | `apply`, `repair`, `vendor`, `rollback`, `get`, `scan` | A specific attempt failed. `errorCode` + `error` set. | +| `removed` | `remove`, `repair`, `vendor`, `rollback`, `scan --prune` (manifest entries carry `details.manifest: true`) | A manifest entry or vendored state was removed, or (artifact-level, no `purl`) `.socket/` artifacts were swept — that GC carrier sets `bytes` and is not counted in `summary.removed`; the sweep's totals are the envelope's `gc`. | +| `verified` | `apply`, `remove`, `repair`, `vendor`, `rollback`, `get`, `scan` (dry run); `vex`; `--update --dry-run` | The action *would* succeed cleanly (`files` lists previewed changes); `vex`: the patch verified and was attested. | +| `rebuilt` | `repair`, `vendor` | A missing/corrupt vendored artifact was restored from its exact server download (v5.0: never a lost ledger entry — see `vendor_ledger_missing`). `summary.rebuilt` counts these. | +| `rolledBack` | `rollback`, `remove` | A patched package was restored to its original files (`files` lists them), or (`rollback`) a vendored / hosted wiring was unwound (`details.mode`). `remove`: one per restored installed copy, `details.path` naming it — v5.0, MAJOR: was the GC carrier's `details.rolledBack`. | -`scan`, `get` and `rollback` print their legacy shapes, not events (see [Migration status](#migration-status-v30)). ### Stable `errorCode` tags @@ -1338,20 +1346,25 @@ Every `--json` invocation emits a single JSON object that follows the **unified | `apply_failed` | `failed` | apply: hash mismatch, write error, archive read error. | | `no_local_source` | `skipped`/`failed` | Agent patch application cannot obtain the required local or downloaded patch source. Vendored mode consumes complete server artifacts and no longer stages patch blobs. | | `offline_missing_sources` / `sources_download_failed` | apply run-level `warnings[]` | apply (additive): the patch sources were unavailable — `--offline` with no local source, or the download left a patch with no source — so nothing was attempted. The envelope keeps its pinned shape (`partialFailure`, empty `events[]`, zero summary, no top-level `error`); the warning is its machine-readable reason (the human path prints the staging `Error:` line on stderr instead, even under `--silent`). | -| `paid_required` | — (top-level `status` of `get`'s legacy JSON, not an event tag) | `get` only: every matching patch needs a paid plan the caller's token isn't entitled to. `get --json` prints `{"status": "paid_required", "found": N, "downloaded": 0, "applied": 0, "patches": [{"purl", "uuid", "tier"}, …]}` (plus any narrowing `skipped`/`warnings`), with no `events` and no `error`, and exits 0. `get ` on the public proxy reports it both for a `tier: "paid"` view and for the proxy's 403 refusal, whose record then carries only `uuid` + `tier` (the proxy never named the purl). `scan` never reports it: it leaves paid patches out of the selection and counts them in `paidPatches` / `canAccessPaidPatches`. | -| `download_failed` | `failed` | repair/get: network or 404 on patch fetch. | -| `cleanup_failed` | `skipped` (warning) | repair: an orphan-sweep pass (blobs, diff or package archives) failed mid-way (e.g. permission error). The run continues and exits 0; human mode carries the warning on stderr (not muted by `--silent`). v5.0: `rollback`'s default GC surfaces the same condition in its run-level `warnings[]` (and `remove`'s extended archive GC on stderr) — same posture, never affects the exit. | -| `rollback_failed` | `failed` | remove/rollback: file restore could not complete. | -| `vendored` | `skipped` | apply (every ecosystem) + scan `--mode agent`: the package is managed by `socket-patch vendor`; the command yields ownership (scan also skips the download). v5.0: rollback no longer yields — its vendored leg reverts these entries by default, and its `vendored: []` array is reserved-empty (a corrupt vendor ledger surfaces via the `vendor_state_unreadable` warning + exit 1 — the skip cannot name purls, since naming them needs the ledger). Scan `--mode agent --json` additionally surfaces one run-level `vendored_ownership_retained` warning naming the skipped purls (additive; exit/status unchanged). | -| `vendor_reverted` | `removed` | remove: vendoring reverted (lock fragments restored, artifact + ledger entry gone) as part of removing the patch. | -| `vendor_revert_failed` | top-level error | remove: the vendor revert failed; the manifest was NOT modified. | +| `paid_required` | `skipped` + status=`paidRequired` | get: every patch found needs a paid plan and the caller's token isn't entitled (exit 0); one `skipped` event per patch, `details.tier`. `get ` on the public proxy reports it both for a `tier: "paid"` view and for the proxy's 403 refusal, whose event then carries only `uuid` (no `purl`: the proxy never named it). `scan` never reports it: it leaves paid patches out of the selection and counts them in `paidPatches` / `canAccessPaidPatches`. | +| `download_failed` | `failed` | repair/get/scan: network or 404 on patch fetch. | +| `cleanup_failed` | `skipped` (warning) | repair: an orphan-sweep pass (blobs, diff or package archives) failed mid-way (e.g. permission error). The run continues and exits 0; human mode carries the warning on stderr (not muted by `--silent`). v5.0: `rollback`'s default GC and `remove`'s GC surface the same condition in their run-level `warnings[]` (`remove`: also on stderr in human mode) — same posture, never affects the exit. | +| `rollback_failed` | `failed` | remove/rollback: file restore could not complete (rollback: no blocking file names a more specific code). | +| `already_original` | `skipped` | rollback (v5.0): every file of the copy already matches its `beforeHash`. | +| `hash_mismatch` / `file_not_found` / `missing_blob` | `failed` | rollback (v5.0): the copy's first blocking file drifted from both hashes, is missing, or its before-blob is unavailable (`details.filesVerified` names each file). | +| `no_files` | `skipped` | rollback (v5.0): the patch record lists no files, so nothing was restored. | +| `hosted_restore_refused` / `hosted_write_failed` | `failed` (`details.mode: "hosted"`) | rollback (v5.0): a hosted pin's upstream restore was refused (detail names the `git checkout` remedy), or writing the restored lockfiles failed (artifact-level, no `purl`). Exit 1. | +| `gc_skipped` | rollback `warnings[]` | rollback (v5.0): the artifact GC could not run (a before-blob gate abort, an unreadable vendor ledger); `gc` is absent. Not raised under `--preserve-state`, which never asks for one. | +| `vendored` | `skipped` | apply (every ecosystem) + scan `--mode agent`: the package is managed by `socket-patch vendor`; the command yields ownership (scan also skips the download). v5.0: rollback no longer yields — its vendored leg reverts these entries by default (a corrupt vendor ledger surfaces via the `vendor_state_unreadable` warning + exit 1 — the skip cannot name purls, since naming them needs the ledger). Scan `--mode agent --json` additionally surfaces one run-level `vendored_ownership_retained` warning naming the skipped purls (additive; exit/status unchanged). | +| `vendor_reverted` | `removed` | remove: vendoring reverted (lock fragments restored, artifact + ledger entry gone) as part of removing the patch. v5.0: carries `details.mode: "vendored"` (as do the vendored leg's `skipped` events). | +| `vendor_revert_failed` | top-level error; rollback `failed` | remove: the vendor revert failed; the manifest was NOT modified. Rollback (v5.0): a `failed` event with `details.mode: "vendored"` (`vendor_state_write_failed` when the revert landed but the ledger could not be saved); the entry, artifact and manifest record survive for a retry, exit 1. | | `vendor_state_retained` | `skipped` | remove `--skip-rollback`: vendor wiring + artifact deliberately left in place (the next `vendor` run reconciles the dropped entry). Also the top-level error code when `--skip-rollback` targets a vendored patch with no manifest record (every `scan`/`get --mode vendored` entry — and, v5.0, the ledger-only leftover of an earlier `remove --skip-rollback` of a manifest-tracked vendored patch). | | `hosted_state_retained` | (top-level error) | remove `--skip-rollback` targeting a hosted-only patch (no manifest entry): restoring the pin's upstream registry entry is the only possible removal, so the combination is refused (exit 1), mirroring the manifest-less vendored refusal above. | -| `vendor_state_preserved` | `skipped` | remove `--preserve-state` (v5.0): lockfile unwired; artifact, ledger entry, and manifest entry all kept for a later re-apply. Rollback's counterpart is the `vendoredPreserved: []` envelope array. | -| `vendor_revert_kept` | `skipped` + top-level error | remove (v5.0): the vendored revert drift-kept (`kept_artifact`), so the ledger entry AND the manifest entry were both kept. ANY drift-keep makes the run a `partialFailure` (exit 1) — part of the requested removal did not happen; when EVERY matching entry drift-kept, the top-level error carries this code (`summary.removed` stays 0; the identifier DID match, so never `not_found`). Remedy: re-run `scan --mode vendored` to normalize, then remove. A PyPI revert that restored its recorded wiring but kept the wheel because another project file still installs from it (`vendor_revert_residual_reference`, e.g. a `pipenv requirements` / `uv export` / `poetry export` requirements file) is a keep too, but not a drift (#1184): its skip reason, the per-entry warning, the top-level message and rollback's `vendoredKept[].reason` say that a project file still installs from the vendored artifact, and the remedy is to point the file named by `vendor_revert_residual_reference` back at the registry release (or re-export it from the restored lock), then remove (or roll back) again. Its `vendor_artifact_kept` advisory says the same, and a hosted takeover refused for it (`redirect_vendored_revert_failed`) names the file instead of a wiring edit. Rollback's counterpart is the `vendoredKept: []` envelope array (also exit 1). | -| `hosted_reverted` | `removed` | remove (v5.0): a hosted lockfile pin was restored to its upstream registry entry as part of removing the patch (`verified` on dry-run). Beside a manifest entry it bypasses `summary.removed` like `vendor_reverted`. | -| `hosted_revert_failed` | top-level error | remove (v5.0): a matched hosted pin could not be restored to its upstream registry entry (`--offline`, a registry that does not answer, `bun.lockb`, a lock shape the restore refuses — see "Hosted unwind coverage"), or writing the restored files failed; the message names the `git checkout -- ` remedy. The manifest was not modified, exit 1. Rollback's counterpart is a `hosted.failed[]` entry (also `partial_failure` exit 1). v4's `hosted_revert_unsupported` is no longer emitted (every ecosystem has a restore). | -| `cargo_cache_patch_kept` | `scan --prune`/`--sync` `gc.warnings[]` (human: `GC: …`) | a Cargo manifest entry the crawl no longer reports (the lock bumped or dropped the crate) was NOT pruned because its copy in the shared `$CARGO_HOME/registry/src` cache still carries the agent-mode patch; the record holds the only blobs that can restore it. The detail names the purl and `socket-patch rollback ` (with `--global` when a `cargo vendor` dir hides the registry cache from the project crawl), which restores the copy and drops the entry. Never affects the exit. | +| `vendor_state_preserved` | `skipped` | remove `--preserve-state` (v5.0): lockfile unwired; artifact, ledger entry, and manifest entry all kept for a later re-apply. Rollback's counterpart is a `rolledBack` event with `details.preserved: true`. | +| `vendor_revert_kept` | `skipped` + top-level error | remove (v5.0): the vendored revert drift-kept (`kept_artifact`), so the ledger entry AND the manifest entry were both kept. ANY drift-keep makes the run a `partialFailure` (exit 1) — part of the requested removal did not happen; when EVERY matching entry drift-kept, the top-level error carries this code (`summary.removed` stays 0; the identifier DID match, so never `not_found`). Remedy: re-run `scan --mode vendored` to normalize, then remove. A PyPI revert that restored its recorded wiring but kept the wheel because another project file still installs from it (`vendor_revert_residual_reference`, e.g. a `pipenv requirements` / `uv export` / `poetry export` requirements file) is a keep too, but not a drift (#1184): its skip reason, the per-entry warning, the top-level message and the message of rollback's `failed` / `vendor_revert_kept` event say that a project file still installs from the vendored artifact, and the remedy is to point the file named by `vendor_revert_residual_reference` back at the registry release (or re-export it from the restored lock), then remove (or roll back) again. Its `vendor_artifact_kept` advisory says the same, and a hosted takeover refused for it (`redirect_vendored_revert_failed`) names the file instead of a wiring edit. Rollback reports a drift-keep as a `failed` event with this code (exit 1). | +| `hosted_reverted` | `removed` | remove (v5.0): a hosted lockfile pin was restored to its upstream registry entry as part of removing the patch (`verified` on dry-run). Beside a manifest entry it bypasses `summary.removed` like `vendor_reverted`. v5.0: carries `details.mode: "hosted"`. | +| `hosted_revert_failed` | top-level error | remove (v5.0): a matched hosted pin could not be restored to its upstream registry entry (`--offline`, a registry that does not answer, `bun.lockb`, a lock shape the restore refuses — see "Hosted unwind coverage"), or writing the restored files failed; the message names the `git checkout -- ` remedy. The manifest was not modified, exit 1. Rollback's counterpart is a `failed` `hosted_restore_refused` event (exit 1). v4's `hosted_revert_unsupported` is no longer emitted (every ecosystem has a restore). | +| `cargo_cache_patch_kept` | `scan --prune`/`--sync` top-level `warnings[]` (human: `GC: …`) | a Cargo manifest entry the crawl no longer reports (the lock bumped or dropped the crate) was NOT pruned because its copy in the shared `$CARGO_HOME/registry/src` cache still carries the agent-mode patch; the record holds the only blobs that can restore it. The detail names the purl and `socket-patch rollback ` (with `--global` when a `cargo vendor` dir hides the registry cache from the project crawl), which restores the copy and drops the entry. Never affects the exit. | | `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. When the run also emits a Bun advisory (`vendor_bun_reinstall_required` / `redirect_bun_reinstall_required`) the detail and the human note add " (Bun: a plain `bun install` keeps them; run `bun install --force`)". When it emits a PyPI advisory (`vendor_pypi_reinstall_required` / `redirect_pypi_reinstall_required`) they add " (PDM, uv and Pipenv keep a same-version install through a plain sync: run the reinstall the pypi_reinstall_required warning names)". | | `hosted_state_not_preservable` | rollback / remove `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` reports the same code in its `warnings[]` — manifest-backed and hosted-only alike — and prints it as a `Note:` on stderr in human mode.) | | `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. | @@ -1359,9 +1372,9 @@ Every `--json` invocation emits a single JSON object that follows the **unified | `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`); also `failed` (per package) | 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). As for `redirect_symlinked_file_unsupported` (#898), the artifact dirs the run added under `.socket/vendor/` are removed (any that cannot be are named in the detail), each package the run vendored is a `failed` event with this code, not `applied`, its `skipped` advisories from the run are dropped, and the human run prints no "Vendored N packages" count for them and no "Next steps:". When putting a partially-applied commit back fails too, the journal is kept instead, the detail says the next socket-patch command in the project finishes the commit, and the per-package events stand (that commit does complete). | | `redirect_symlinked_file_unsupported` (vendored) | top-level error (`vendor`, and the nested vendor envelope of `scan` / `get --mode vendored`) | v5.0 group commit (#627): a file the run would rewrite — a lockfile, `package.json`, `pnpm-workspace.yaml`, `nuget.config`, … — is a symbolic link. The commit stages each file and renames it over the path, which would replace the link with a detached copy and leave its target (the lock other checkouts read) unpatched, so it refuses before writing anything — the same code and message as the hosted guard. Exit 1; the link, its target and `.socket/vendor/state.json` are left as they were, the artifact dirs the run added under `.socket/vendor/` are removed (any that cannot be are named in the message, with `vendor --revert` as the remedy), and each package the run vendored is a `failed` event with this code, not `applied` (`summary.applied` does not count it); the `skipped` advisories the run recorded for it (`vendor_prebuilt_downloaded`, `vendor_artifact_reused`, …) are dropped, since they describe the vendoring that was refused. The human run prints no "Vendored N packages" count for them and no "Next steps:" (#898). Backends that check their own targets first (Hatch, uv, Poetry, PDM, Pipenv, requirements, PEP 751 locks, Cargo) refuse per package with this same code (see the per-package row below); a symlinked `bun.lockb` keeps `vendor_bun_lockb_invalid`. | -| `vendor_would_refuse_symlinked_file` | `skipped` (advisory event) under `vendor --dry-run`; a `warnings: [{code, detail}]` entry on the `would_vendor` / `would_revendor` row of the `vendor` preview under `scan` / `get --mode vendored --dry-run` (human: an `[warning] (): ` line) | dry run (#627): a dry run captures no writes, so for each package it would vendor (not one previewed as in sync, whose re-run writes nothing) it names every symlinked file of that package's ecosystem a vendored run may rewrite (the registry's vendored rewrite targets plus `pnpm-workspace.yaml`, `nuget.config`, `packages.lock.json`, the root `pom.xml`, `.mvn/maven.config` and `hatch.toml`; files a vendored run only reads, such as `.yarnrc.yml` or `vlt.json`, are never named); the wet run refuses with `redirect_symlinked_file_unsupported` if it must rewrite one. Does not change the exit code. | -| `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.) | -| `manifest_write_failed` | rollback `warnings[]` | rollback (v5.0): the post-rollback manifest update could not be written; no entries were removed (`manifest.removedEntries: []`) and the run exits `partial_failure` 1. | +| `vendor_would_refuse_symlinked_file` | `skipped` (advisory event) under `vendor --dry-run`; a top-level `warnings[]` entry (detail prefixed `: `) for each package the `scan` / `get --mode vendored --dry-run` preview would vendor (human: an `[warning] (): ` line) | dry run (#627): a dry run captures no writes, so for each package it would vendor (not one previewed as in sync, whose re-run writes nothing) it names every symlinked file of that package's ecosystem a vendored run may rewrite (the registry's vendored rewrite targets plus `pnpm-workspace.yaml`, `nuget.config`, `packages.lock.json`, the root `pom.xml`, `.mvn/maven.config` and `hatch.toml`; files a vendored run only reads, such as `.yarnrc.yml` or `vlt.json`, are never named); the wet run refuses with `redirect_symlinked_file_unsupported` if it must rewrite one. Does not change the exit code. | +| `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 `partialFailure` 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 (the `errorCode` of the package's event, 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.) | +| `manifest_write_failed` | rollback `warnings[]` | rollback (v5.0): the post-rollback manifest update could not be written; no entries were removed (no `removed` event) and the run exits `partialFailure` 1. | | `npm_allow_remote_left` / `pnpm_trust_lockfile_left` | rollback/remove `warnings[]`; vendor advisory event (takeover) | upstream restore (v5.0): no npm-family lock entry is hosted any more, but the project `.npmrc` keeps a top-level `allow-remote=all` (resp. `pnpm-workspace.yaml` keeps `trustLockfile: true`) in a file that is not exactly what hosted mode creates; the file is left untouched (v5 records no provenance), remove the line if nothing else needs it. A file that is exactly hosted mode's own is deleted silently, and a file that is a keyless document plus exactly the `packages:` scaffold and `trustLockfile: true` lines hosted mode splices into one (#1096) gets those lines removed, restoring it byte for byte. | | `maven_trusted_checksums_left` / `nuget_default_config_left` / `upstream_uv_override_removed` | rollback/remove `warnings[]`; vendor advisory event (takeover) | upstream restore (v5.0): `.mvn` config keeps the trusted-checksums resolver lines because it holds more than hosted mode writes; `nuget.config` now holds only the nuget.org source (delete it if hosted mode created it); a transitive `override-dependencies` entry hosted mode added to `pyproject.toml` (the one under its `# socket-patch hosted` comment) was removed; a user-authored override is never removed and never reported. | | `hosted_resolution_orphaned` | rollback/remove `warnings[]`; list `warnings[]` (human: `Warning: …` on stderr) | yarn berry (#1203): the root `package.json` keeps a Socket-hosted `resolutions` selector that no live `yarn.lock` entry resolves and no lock descriptor matches (left behind by `yarn remove` / `yarn up`). `rollback` / `remove` removed it (the lock is untouched; an emptied `resolutions` object is dropped); `list` only reports it and names the scoped `remove ` that retires it alone. Never flips the exit. | @@ -1386,7 +1399,7 @@ Every `--json` invocation emits a single JSON object that follows the **unified | `wiring_conflict` | `skipped` | vex (manifest-less): the lockfiles wire one package to two or more different patches (e.g. a stale sibling lock); which one the build installs is undecidable, so none is attested. | | `hash_mismatch` / `not_applied` / `file_not_found` / `package_not_found` / `no_files` / `vendor_*` | `skipped` | vex: verification omissions — the installed copy (agent / hosted) or the committed artifact (`vendor_hash_mismatch`, `vendor_artifact_missing`, `vendor_artifact_unreadable`, `vendor_inventory_mismatch`, `vendor_uuid_mismatch`, `vendor_path_unsafe`) does not carry the patched bytes, or nothing is installed. `vendor_manifest_unverifiable`: a vendored vlt directory verified without its vendor ledger (from `vlt-lock.json` alone) holds a `package.json` with its devDependencies stripped, and the patched `package.json` blob is not in `.socket/blobs`, so it cannot be checked. A lockfile-pinned hosted reference with nothing installed attests instead of `package_not_found` (see "Manifest-less VEX"). | | `not_applied` / `hash_mismatch` / `file_not_found` / `no_matching_variant` | `failed` | `apply --check` (v5.0): an installed copy of the patch does not verify (still unpatched; neither the original nor the patched bytes; a patched file missing; a copy of a release-variant base that holds none of the manifest's variants, keyed by the base purl). Exit 1, status `partialFailure`. | -| `redirect_unconfirmed` | `redirect.patches[]` `unpinned` row | hosted `scan` / `get` (v5.0, additive): the patch was granted but no lockfile entry pinning it could be rewritten. The status and exit code are unchanged for now, pending the open hosted exit-policy decision (#704); `--silent` hides the human line. | +| `redirect_unconfirmed` | `skipped` (`details.mode: "hosted"`) | hosted `scan` / `get` (v5.0, additive): the patch was granted but no lockfile entry pinning it could be rewritten. The status and exit code are unchanged for now, pending the open hosted exit-policy decision (#704); `--silent` hides the human line. | | `lockfile_unreadable` / `lockfile_unparseable` / `patched_ref_invalid` / `patched_ref_unattributable` | run-level `warnings[]` | vex (every form): lockfile-discovery diagnostics — see "Manifest-less VEX (lockfile discovery)". Never flip the exit on their own. | | `vex_npm_shrinkwrap_only` | run warning and `failed[].reason` | vex (every form, #899): the patch is wired only in a root `npm-shrinkwrap.json` with no `package-lock.json` twin, which npm >= 12 never reads; no statement until the twin exists. `list` / `rollback` / `remove` still manage the wiring. | | `vendor_multiple_lockfiles` / `pypi_multiple_lockfiles` | `skipped` (warning) | vendor: a sibling lockfile of another package manager (for PyPI, also a root `requirements.txt` that pins the package beside the wired tool lock) will still install UNPATCHED bytes; names the wired winner + the ignored locks. Beside `Pipfile.lock`, an exact registry pin of the package in `requirements.txt` or an in-root `-r` include (`pipenv requirements` output) is wired with the lock instead and never named here (#612): both files then refer to the committed wheel, the ledger entry records both, every revert restores both, and a re-run over a project whose `Pipfile.lock` is already wired (a vendor from before, or an export made since) wires the export too (`pypi_requirements_sibling_wired`). A pin the requirements wiring cannot rewrite (a range, extras, an include outside the root) stays a loser. | @@ -1394,11 +1407,11 @@ Every `--json` invocation emits a single JSON object that follows the **unified | `vendor_workspace_member_skipped` | `skipped` (warning) | vendor / scan / get `--mode vendored` (npm, #688): a lock entry with the package's `name@version` is the project's own source (a workspace member or a `file:` directory), so it is left alone while the lock's registry copies are vendored; patch that source directly if it needs the fix. When it is the only instance, vendoring refuses instead. | | `vendor_npm_allow_file` | `skipped` (warning, printed as `Warning (vendor_npm_allow_file)`) | vendor / scan / get `--mode vendored` (package-lock, #969): the effective npm `allow-file` setting (env > project `.npmrc` > user > global > builtin) is not `all`, so npm >= 11.14 refuses the vendored `file:` tarball (EALLOWFILE) on install. The setting is respected, never rewritten; the detail names where it comes from and the remedy (`allow-file=all`). `vendor --check` fails the entry for the same reason. | | `vendor_yarn_berry_unsupported` | `failed` | vendor (npm): yarn-berry Plug'n'Play layout; use its native `yarn patch` workflow. | -| `vendor_bun_lockb_invalid` | `failed` | vendor / scan / get `--mode vendored`: the binary lock is malformed, unreadable, unsupported or cannot be rewritten safely. The detail names the parser, hash or filesystem error. Refused before patch downloads and before hosted takeover; `patches[]` / `download.patches[]` carry `errorCode` and `error`, while `get ` also carries top-level `error.code`. Dry-run predicts the same refusal. | +| `vendor_bun_lockb_invalid` | `failed` | vendor / scan / get `--mode vendored`: the binary lock is malformed, unreadable, unsupported or cannot be rewritten safely. The detail names the parser, hash or filesystem error. Refused before patch downloads and before hosted takeover; the package's `failed` event carries `errorCode` and `error`, while `get ` also carries top-level `error.code`. Dry-run predicts the same refusal. | | `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. | | `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. | | `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. | -| `would_refuse` | dry-run preview action (`vendor.patches[]`) | 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); the record carries `errorCode` + `error`. Exit 0 / `status: "success"`, nothing written. | +| (Bun / pnpm-takeover 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); `reason` carries the detail. Exit 0 / `status: "success"`, nothing written. (v5.0: replaces the `would_refuse` preview action.) | | `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`. | | `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. | | `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 `+socket.` — 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`. | @@ -1408,13 +1421,13 @@ Every `--json` invocation emits a single JSON object that follows the **unified | `cargo_wiring_restored` | `skipped` (advisory note) | repair (v5.0): a vendored crate's Cargo.lock entry was detached with no Socket-owned `[patch]` pointing at its committed copy (a pre-v5 release overwrote its crate-named config key when a second version was vendored); the manifest entry is written back and the ledger updated (dry run: "would restore"). A `vendor` re-run heals the same state as a plain re-vendor. | | `cargo_manifest_unreadable` / `cargo_manifest_unparseable` / `cargo_manifest_not_workspace_root` / `cargo_manifest_patch_source_alias` | `failed` | vendor / scan / get `--mode vendored` (cargo, v5.0): the workspace-root `Cargo.toml` cannot carry the vendored `[patch.crates-io]` entry (or cargo would ignore it there) — see the cargo caveat under "Vendored mode". Refused before any write. | | `vendor_would_revert_redirect` / `vendor_takeover_reverted_redirect` | `skipped` (advisory event) | vendor / scan / get `--mode vendored` over a hosted pin (every ecosystem, v5.0): dry run — the upstream restore was resolved (registry lookups included) and would succeed (for bun, only after the Bun vendored preflight accepted the lock; a refused lock is previewed as the wet run's `failed ` instead) / wet run — the pin's lock entries were restored to their upstream registry entry before vendoring (mode takeover; detail ` was hosted; restored its upstream registry entry () before vendoring (mode takeover)`), so `vendor --revert` later returns to upstream. Fires on the run that takes over, not on re-runs, and not for a purl whose takeover was rolled back because the backend refused it (see "Takeover reconciliation"). | -| `redirect_revert_failed` | `failed` | vendor / scan / get `--mode vendored` (dry and wet): the upstream restore of a hosted pin was refused (`--offline`, a registry that does not answer, a lock shape the restore refuses — for `bun.lockb`, a record the codec cannot rebuild) — detail `cannot vendor over the live hosted pin: cannot restore to its upstream registry entry: ; restore it from version control instead (`git checkout -- `)` (for `bun.lock` / `bun.lockb` the detail also adds `, then run \`bun install --force\` (a plain \`bun install\` keeps the patched copy)`); nothing vendored for the purl, hosted wiring left in place, exit 1 `partial_failure`. | +| `redirect_revert_failed` | `failed` | vendor / scan / get `--mode vendored` (dry and wet): the upstream restore of a hosted pin was refused (`--offline`, a registry that does not answer, a lock shape the restore refuses — for `bun.lockb`, a record the codec cannot rebuild) — detail `cannot vendor over the live hosted pin: cannot restore to its upstream registry entry: ; restore it from version control instead (`git checkout -- `)` (for `bun.lock` / `bun.lockb` the detail also adds `, then run \`bun install --force\` (a plain \`bun install\` keeps the patched copy)`); nothing vendored for the purl, hosted wiring left in place, exit 1 `partialFailure`. | | `patch_fetch_failed` (eject) | `failed` | vendor eject (v5.0): a hosted pin's patch record could not be fetched from `…/patches/view/`; the whole eject is refused (`eject_refused`), nothing touched, exit 1. | | `redirect_pnpm_lockfile_elsewhere` / `redirect_workspace_lockfile_elsewhere` / `cargo_manifest_not_workspace_root` (hosted) | top-level `error.code` (`status: "error"`) | scan / get `--mode hosted` (v5.0): the project directory is a workspace member whose lock lives in another directory, so the rewriters, which read only the project directory, would pin nothing (pnpm: no npm-family lock here, and the nearest ancestor `pnpm-workspace.yaml` or the project's `lockfile-dir` (`.npmrc`) / `lockfileDir` (`pnpm-workspace.yaml`) puts `pnpm-lock.yaml` elsewhere; npm / yarn / Bun, `redirect_workspace_lockfile_elsewhere`: no npm-family lock here, and the nearest ancestor `package.json` whose `workspaces` (array, or the object form's `packages`) matches the directory holds `package-lock.json`, `npm-shrinkwrap.json`, `yarn.lock`, `bun.lock` or `bun.lockb`; a matching root with none of them that is itself listed by an outer root's `workspaces` hands the check to that root; vlt, same code: the nearest ancestor `vlt.json` whose `workspaces` (a string, an array, or an object of groups) matches the directory holds `vlt-lock.json`, or, as vlt falls back to it when `vlt.json` has no `workspaces` field, the `package.json` `workspaces` root above holds `vlt-lock.json`, and the nearer of a `vlt.json` and a `package.json` root is named; `workspaces` patterns use the glob grammar the package managers share: `*`, `?`, `**`, brace sets and sequences (`{a,b}`, `{1..3}`) and character classes (`[a-c]`, `[!a]`); when a pnpm workspace also governs the directory, the nearer root is named and a tie goes to `redirect_pnpm_lockfile_elsewhere`; uv (pypi, #1138), `redirect_workspace_lockfile_elsewhere`: the directory holds a `pyproject.toml`, and the nearest ancestor `pyproject.toml` declaring `[tool.uv.workspace]` lists it in `members` (and not in `exclude`), with no standalone project (`[project]`, no workspace) in between, so uv installs it from that root's `uv.lock` (a `uv.lock`, `poetry.lock`, `pdm.lock`, `Pipfile.lock` or pylock left in the member is never read and does not exempt it); the message says uv workspaces are not patched from their root yet either, and vendored refuses the same layout with `pypi_uv_workspace_unsupported` instead of rewriting a member with Hatch configuration as a lockless Hatch project; a directory whose own locks are all ones its manager never reads inside a workspace member is refused the same way, naming the ignored locks: `package-lock.json` / `npm-shrinkwrap.json` when its `package.json` `workspaces` root holds `package-lock.json` or `npm-shrinkwrap.json` (npm, #1094), `bun.lock` / `bun.lockb` when that root holds `bun.lock` or `bun.lockb` (Bun, #1101), and `vlt-lock.json` in a directory with no `vlt.json` of its own when its vlt workspace root (as above) holds `vlt-lock.json` (vlt, #1134); vendored refuses it with `vendor_lockfile_missing`, and `vex` reads the ignored lock as absent, with one `patched_ref_unattributable` warning naming it when it holds Socket references) or rewrite the member as a lockless project (cargo: the vendored workspace-root check). Refused before any takeover or write, `--dry-run` included; the message names the directory to run from; exit 1. Disk runs only (an in-memory project has no ancestors). | | `redirect_pnpm_settings_elsewhere` | top-level `error.code` (`status: "error"`) | scan / get `--mode hosted`: the project directory is a pnpm workspace member (listed by the `packages:` globs of the nearest ancestor `pnpm-workspace.yaml`) with its own v9 `pnpm-lock.yaml` (`sharedWorkspaceLockfile: false`) and no `pnpm-workspace.yaml` of its own, so its pnpm settings come from that ancestor file, which pnpm reads alone (a member's own file is ignored). A directory those globs do not list (no `packages:`, an empty list, a non-matching or `!`-excluded path) is a standalone project on pnpm 11.28+/12 that reads only its own file: it is pinned and gets its own `pnpm-workspace.yaml` like any single project. A root file that does not parse, or whose patterns use braces, classes or extglobs, counts as listing the project. When that file neither carries `trustLockfile: true` nor explicitly sets another value, the trust auto-config has nowhere to go: refused before any takeover or write, `--dry-run` included; the message names the root file to add `trustLockfile: true` to (or `--no-trust-lockfile-config` pins without it); exit 1. Once the root file trusts the lock (or opts out), the member is pinned and no nested `pnpm-workspace.yaml` is created; the `redirect_pnpm_trust_lockfile` warning names the root file. In memory, a member whose lock is demoted into its workspace root (#492) is never refused; one whose lock is not (the workspace root's files do not confirm it pins or ignores that lock, or socket.yml leaves the root out) is refused with this code as its project error, nothing written for it, whenever its lock is v9, the trust auto-config is on and that file may list it (listed, unreadable, or not readable as globs), whatever it says about `trustLockfile`. | | `eject_refused` | top-level `error.code` (`status: "error"`) | vendor eject (v5.0): a record fetch failed or a pin's upstream restore was refused while planning; nothing was changed, exit 1. | | `eject_planned` | `applied` (reason) | vendor eject `--dry-run` (v5.0): the pin would be restored upstream and vendored; nothing written. | -| `eject_rolled_back` | warning; `skipped` event | vendor eject (v5.0): a package failed after the restore began; every touched file was put back from the pre-eject snapshot, so the project is still hosted; `partial_failure`, exit 1. Also the `errorCode` of the `skipped` event re-reporting each package the eject had vendored before it was rolled back (not counted in `summary.applied`). | +| `eject_rolled_back` | warning; `skipped` event | vendor eject (v5.0): a package failed after the restore began; every touched file was put back from the pre-eject snapshot, so the project is still hosted; `partialFailure`, exit 1. Also the `errorCode` of the `skipped` event re-reporting each package the eject had vendored before it was rolled back (not counted in `summary.applied`). | | `eject_rollback_failed` | top-level `error.code` | vendor eject (v5.0): putting the pre-eject snapshot back failed; the detail names the files to `git checkout --`; exit 1. | | `offline_eject_unavailable` | top-level `error.code` | vendor eject under `--offline` / `SOCKET_OFFLINE` (v5.0): records and registry entries cannot be fetched offline; zero network requests, nothing touched, exit 1. | | `hosted_wiring_contested` | top-level `error.code` (list: warning when it can still list) | rollback / remove / vendor eject / list (v5.0): a lockfile mentions a recognized hosted patch uuid that discovery rejected (or a pin with no lockfile), so the hosted set is not known exactly; refused with nothing touched, exit 1. Remedy: fix or `git checkout` the named lockfile. | @@ -1432,12 +1445,12 @@ Every `--json` invocation emits a single JSON object that follows the **unified | `vendor_vlt_lock_out_of_sync` | `failed` | vendor (vlt): an importer's `package.json` is missing, unparseable, or declares a spec for the dependency that differs from the lock's importer edge. Remedy: `vlt install` first. Refused before any write. | | `vendor_vlt_build_scripts_unsupported` | `failed` | vendor (vlt): the package declares a `preinstall`, `install`, `postinstall` or `prepare` script, or ships a `binding.gyp`. vlt builds a registry copy in the untracked store, but a vendored `file:` dependency in place, so `vlt build` would rewrite the committed artifact (a platform binary over a JS shim, say) and every later vendor, repair and `vex` would treat it as tampered. Remedy: `--mode hosted`. Refused before any write. | | `vendor_vlt_legacy_lockfile` | `skipped` (warning) | vendor (vlt): an era-A lock (vlt 0.0.0-19 … 1.0.0-rc.8): a `··` default-registry id, or default-registry ids that are URL segments equal to a scalar `options.registry` with no `·npm·` id (era B writes `·npm·` whatever the scalar). vlt 0.0.0-31 … 1.0.0-rc.5 install the vendored lock but fail to reinstall the vendored `file:` dependency if `vlt-lock.json` is deleted and re-created (the other era-A releases reinstall it; the lock does not say which release reads it). The package is still vendored; remedy: upgrade vlt. | -| `vendor_vlt_reinstall_required` | `skipped` (advisory; human: `Warning: …`) | vendor / scan / get `--mode vendored` (vlt), wet and dry runs, and in-sync reruns: (a) the run rewires an optional dependency, or an importer's `node_modules/` of an optional dependency still resolves into `node_modules/.vlt/`: from vlt 0.0.0-30 a plain `vlt install` (1.2.0: also `--force`) keeps that installed upstream copy linked; the detail says to run `vlt ci` (or delete `node_modules` and run `vlt install`) to link the vendored copy, and that vlt 0.0.0-30 … 1.0.4 install no optional dependency from the lock of a project that declares only optional dependencies (upgrade to 1.0.5 or later first); (b) otherwise, an importer's link of the dependency still resolves into `node_modules/.vlt/`: the detail names the links (`node_modules/`, `/node_modules/`) and says `vlt install` (or `vlt ci`) links the vendored copy — on a warm tree after a plain `vlt install` that is true of every vendored direct dependency; (c) an importer's link resolves into the vendored dir of the patch this run replaces (a new patch uuid), which the run removes: the detail names the links and says `vlt install` (or `vlt ci`) links the new vendored copy; (d) a redownload of the payload (vendor, or `repair` after a corrupt or missing payload) could not keep vlt's links to the package's own dependencies (its old `node_modules/` held more than links): the detail says to run `vlt ci` (or delete `node_modules` and run `vlt install`), since a plain `vlt install` does not re-link them. `repair` moves those links back into the downloaded payload when they are only links. The package is vendored either way; a run whose patch fails to apply emits neither. A wet `vendor --revert` (and the revert a vendored → hosted takeover runs, whose advisory joins `redirect.warnings[]`): (a) the revert moves an `optionalDependencies` spec back from the `file:` dir, or an optional importer's `node_modules/` still resolves into the vendored uuid dir: from vlt 0.0.0-30 a plain `vlt install` keeps that link (dangling once the dir is removed), so the detail says to run `vlt ci` (or delete `node_modules` and run `vlt install`) to link the restored copy, with the same vlt 1.0.5 note; (b) otherwise, an importer's link still resolves into the vendored uuid dir: the detail names the links and says `vlt install` (or `vlt ci`) links the restored copy. A dry-run revert emits neither. | -| `vendor_bun_reinstall_required` | `skipped` (advisory; human: `Warning: …`); rollback/remove `warnings[]`; `scan --prune` `gc.warnings[]` (human: `GC: …`) | a wet Bun revert (`vendor --revert`, rollback / remove of a vendored entry, `--preserve-state` included) that restored the lock entry while `node_modules/` is a real directory, or the tree has no `node_modules/.bun/` (a hoisted install): Bun's hoisted linker does not re-extract a package whose lock entry moves from the vendored tarball back to the registry record of the same `name@version`, so a plain `bun install` (also `--frozen-lockfile`) reports no changes and keeps the vendored bytes (measured on 1.1.45 … 1.4.2). The detail names `name@version` and says to run `bun install --force` (or delete `node_modules` and run `bun install`); the human revert hint names `bun install --force` too. An isolated install (a link into `node_modules/.bun/`) relinks and a project without `node_modules/` has nothing installed: neither warns, and neither does a dry run, a drift-kept revert, or a revert that restored nothing (`vendor_lockfile_missing`, or `vendor_lock_entry_removed` after `bun remove`, whose copy a plain `bun install` prunes). | +| `vendor_vlt_reinstall_required` | `skipped` (advisory; human: `Warning: …`) | vendor / scan / get `--mode vendored` (vlt), wet and dry runs, and in-sync reruns: (a) the run rewires an optional dependency, or an importer's `node_modules/` of an optional dependency still resolves into `node_modules/.vlt/`: from vlt 0.0.0-30 a plain `vlt install` (1.2.0: also `--force`) keeps that installed upstream copy linked; the detail says to run `vlt ci` (or delete `node_modules` and run `vlt install`) to link the vendored copy, and that vlt 0.0.0-30 … 1.0.4 install no optional dependency from the lock of a project that declares only optional dependencies (upgrade to 1.0.5 or later first); (b) otherwise, an importer's link of the dependency still resolves into `node_modules/.vlt/`: the detail names the links (`node_modules/`, `/node_modules/`) and says `vlt install` (or `vlt ci`) links the vendored copy — on a warm tree after a plain `vlt install` that is true of every vendored direct dependency; (c) an importer's link resolves into the vendored dir of the patch this run replaces (a new patch uuid), which the run removes: the detail names the links and says `vlt install` (or `vlt ci`) links the new vendored copy; (d) a redownload of the payload (vendor, or `repair` after a corrupt or missing payload) could not keep vlt's links to the package's own dependencies (its old `node_modules/` held more than links): the detail says to run `vlt ci` (or delete `node_modules` and run `vlt install`), since a plain `vlt install` does not re-link them. `repair` moves those links back into the downloaded payload when they are only links. The package is vendored either way; a run whose patch fails to apply emits neither. A wet `vendor --revert` (and the revert a vendored → hosted takeover runs, whose advisory joins `warnings[]`): (a) the revert moves an `optionalDependencies` spec back from the `file:` dir, or an optional importer's `node_modules/` still resolves into the vendored uuid dir: from vlt 0.0.0-30 a plain `vlt install` keeps that link (dangling once the dir is removed), so the detail says to run `vlt ci` (or delete `node_modules` and run `vlt install`) to link the restored copy, with the same vlt 1.0.5 note; (b) otherwise, an importer's link still resolves into the vendored uuid dir: the detail names the links and says `vlt install` (or `vlt ci`) links the restored copy. A dry-run revert emits neither. | +| `vendor_bun_reinstall_required` | `skipped` (advisory; human: `Warning: …`); rollback/remove `warnings[]`; `scan --prune` top-level `warnings[]` (human: `GC: …`) | a wet Bun revert (`vendor --revert`, rollback / remove of a vendored entry, `--preserve-state` included) that restored the lock entry while `node_modules/` is a real directory, or the tree has no `node_modules/.bun/` (a hoisted install): Bun's hoisted linker does not re-extract a package whose lock entry moves from the vendored tarball back to the registry record of the same `name@version`, so a plain `bun install` (also `--frozen-lockfile`) reports no changes and keeps the vendored bytes (measured on 1.1.45 … 1.4.2). The detail names `name@version` and says to run `bun install --force` (or delete `node_modules` and run `bun install`); the human revert hint names `bun install --force` too. An isolated install (a link into `node_modules/.bun/`) relinks and a project without `node_modules/` has nothing installed: neither warns, and neither does a dry run, a drift-kept revert, or a revert that restored nothing (`vendor_lockfile_missing`, or `vendor_lock_entry_removed` after `bun remove`, whose copy a plain `bun install` prunes). | | `vendor_flavor_changed` | `failed` | vendor (npm): the purl's vendor ledger entry was written for another lockfile `flavor` than the one the router now detects (for example `npm` → `vlt` after switching package managers). Remedy: `socket-patch vendor --revert` it first, then re-vendor. Refused before any write. | | `vendor_dep_manifest_unlocked` | refused | vendor (yarn classic): the patch rewrites the package's own `package.json` to depend on a descriptor (`name@range`) no `yarn.lock` block is keyed by — an added dependency, or an existing one moved to a new range. yarn 1 builds its install graph from the lock, so the rewired block would name a dependency it never resolves: online frozen installs fetch it unpinned, `--offline` installs fail and every plain `yarn install` re-saves the lock (#591). Refused after staging and before any wiring is written (the staged uuid dir is removed); the detail names the descriptors. Remedy: lock them first (for example `yarn add `), then re-run. A dry run, which stages nothing, does not foresee it. | -| `redirect_yarn_classic_dep_manifest_unlocked` | `redirect.warnings[]` (warning) | scan/get `--mode hosted` (yarn classic): the served tarball's own `package.json` depends on a descriptor no `yarn.lock` block is keyed by. yarn 1 installs only what the lock names, so a pin would install the patched package without that dependency (#591). The dep is not pinned and never confirmed; the lock is left as it was. Same remedy as `vendor_dep_manifest_unlocked`. | -| `redirect_yarn_classic_dep_manifest_rewritten` | `redirect.warnings[]` (warning) | scan/get `--mode hosted` (yarn classic): the served tarball's own `package.json` declares other dependencies than the pinned block's sub-maps, every descriptor already locked; the block's `dependencies:` / `optionalDependencies:` sub-maps are rewritten to match (#591). | +| `redirect_yarn_classic_dep_manifest_unlocked` | `warnings[]` (warning) | scan/get `--mode hosted` (yarn classic): the served tarball's own `package.json` depends on a descriptor no `yarn.lock` block is keyed by. yarn 1 installs only what the lock names, so a pin would install the patched package without that dependency (#591). The dep is not pinned and never confirmed; the lock is left as it was. Same remedy as `vendor_dep_manifest_unlocked`. | +| `redirect_yarn_classic_dep_manifest_rewritten` | `warnings[]` (warning) | scan/get `--mode hosted` (yarn classic): the served tarball's own `package.json` declares other dependencies than the pinned block's sub-maps, every descriptor already locked; the block's `dependencies:` / `optionalDependencies:` sub-maps are rewritten to match (#591). | | `vendor_artifact_gitignored` | `failed` | vendor (vlt, the npm-family tarball flavors — npm, pnpm, bun, yarn classic, yarn berry — NuGet, and the JVM trees of Maven, Gradle, sbt and scala-cli): inside a git work tree, `git check-ignore --no-index` reports the new artifact's uuid directory (JVM: its tree root, such as `.socket/vendor/maven2`) as ignored by a rule its own `.gitignore` cannot override (such as a root `.socket/` or `vendor/` rule; the detail names the rule). Remedy: drop that rule for `.socket/vendor/`. Refused before any write, dry run included. A file rule such as `*.tgz`, `*.nupkg` or `*.jar` is overridden by the `!*` `.gitignore` vendoring writes into the uuid dir or tree root; if a written tarball, directory payload or nupkg still reads as ignored, the run refuses and removes the uuid dir it created. | | `vendor_artifact_gitignore_unchecked` | warning | vendor (vlt, the npm-family tarball flavors and NuGet): git is installed but could not answer the ignore check for the written vendored directory (it failed to start, ran past 30 s, or `rev-parse` / `check-ignore` exited with an error); the package is vendored and the detail names what failed. Remedy: make sure no ignore rule covers `.socket/` before committing. Git absent, or a project outside any work tree, raises nothing. | | `vendor_ledger_entry_missing` | `failed` | vendor (vlt): the only installed copy is vlt's link to a committed vendored directory, but the vendor ledger has no entry for the package; restore `.socket/vendor/state.json` from version control (v5.0: `repair` no longer re-synthesizes it). Replaces the `package_not_installed` skip. | @@ -1451,17 +1464,17 @@ Every `--json` invocation emits a single JSON object that follows the **unified | `vendor_uuid_mismatch` | `skipped` | repair: the manifest's patch uuid moved past the vendored artifact — a re-vendor (`vendor` / `scan --mode vendored`) is pending; repair does not cross patch generations. | | `content_mismatch_overwritten` | `skipped` (warning) | apply (default policy): a file matched NEITHER beforeHash nor afterHash and was overwritten with the full verified patched content. This includes a file the patch adds (empty beforeHash) that already exists with other content. `--strict` turns this case into a `failed` event instead. Agent-mode `get` / `scan --json` carry it as a `(content_mismatch_overwritten) …` `warnings[]` entry (#1004). | | `vendor_lock_checksums_unsupported` / `vendor_stale_lock_checksum` | `failed` | vendor (gem): an ambiguous/platform CHECKSUMS entry, or a v1-wired lock whose stale token blocks the hot path (run `vendor --revert` + re-vendor). | -| `redirect_unattributable` | `redirect.skipped[].reason` | scan/get `--mode hosted`: the rewriters would pin the patch, but lockfile discovery over the result reads that pin as contested (another lock or requirements file resolves the same version elsewhere, or the pin is not one the package manager consumes), so `vex`, `rollback`, `remove` and `vendor` would refuse it. The candidate is left out of the rewrite, so nothing is written for it; the detail carries discovery's findings. Exit code unchanged. | -| `redirect_pin_lockless` | `redirect.warnings[]` (warning) | scan/get `--mode hosted` (nuget, cargo): the pin was written without a lockfile that records its version, so `vex` cannot attest it and `rollback` / `remove` / `vendor` refuse it as unattributable. The detail names the lockfile to create (`dotnet restore --use-lock-file`, `cargo generate-lockfile`) before re-running the hosted scan. | -| `redirect_pypi_stale_install` | `redirect.warnings[]` (warning) | Hosted Python redirect: readable installed files differ from patched hashes. Read-only, repeated on re-scan, and excludes the package from same-run VEX. See the "Python stale-install guard" section. | -| `redirect_nuget_stale_global_package` / `vendor_nuget_stale_global_package` | `redirect.warnings[]` / the vendor result's `warnings` (warning) | scan `--mode hosted` / `vendor` / `scan --mode vendored` (nuget, v5.0 #352): NuGet's global packages folder (`NUGET_PACKAGES`, else `~/.nuget/packages`) already holds the patched package extracted from other bytes (its `.nupkg.metadata` `contentHash` is not the patched one). A patch keeps the upstream id and version and NuGet restores a package already in that folder without asking any source, so `dotnet restore` would keep the upstream bytes (silently without a lock, NU1403 against the re-pinned lock with one). The detail names the directory and the remedy: delete it, then `dotnet restore` (`dotnet nuget locals global-packages --clear` also works but empties the whole machine-wide folder, and the detail says so); CI caches of that folder must drop it too. Read-only like the gem guard (the folder is shared machine-wide), re-fired on every re-run until the copy is gone, skipped on `--dry-run`; a hosted purl it flags is excluded from the same run's `--vex` `assume_applied` and reported stale. A dir without `.nupkg.metadata` (a legacy `packages/` folder) is never judged. | -| `redirect_gem_stale_install` | `redirect.warnings[]` (warning) | scan `--mode hosted` (gem): a stale UNPATCHED materialization (installed gem, or committed archive in bundler's cache dir — `vendor/cache` unless `cache_path` moves it) that `bundle install` will reuse instead of fetching the redirected patch; the detail carries the verified remedy. Full rules and flavors: the "Gem stale-install guard" section. | -| `redirect_gem_version_not_locked` | `redirect.warnings[]` (warning) | scan `--mode hosted` (gem): the crawled gem version is installed on the machine but no `GEM` section of the project's lock resolves it (another project's copy in the shared gem home). The gem is skipped and the Gemfile and lock stay byte-identical. | -| `redirect_gem_no_lockfile` | `redirect.warnings[]` (warning) | scan `--mode hosted` (gem): the project has a `Gemfile` / `gems.rb` but no lock, so the version it resolves is unknown (#1125). Every gem is skipped and the Gemfile stays byte-identical; run `bundle lock` (or `bundle install`), commit the lock, and re-run. | -| `redirect_pipenv_refused` | `redirect.warnings[]` (warning) | scan `--mode hosted` (pipenv): the Pipfile.lock pins another version or a non-registry / foreign source for the package — refused atomically across categories, and the patch is vetoed from the sibling Python rewriters (see the "Pipenv hosted redirect" section). | -| `redirect_pipenv_skipped` | `redirect.warnings[]` (warning) | scan `--mode hosted` (pipenv): no entry for the package, pipfile-spec < 6, an unparseable lock or a digest-less patch — nothing rewritten here; the sibling rewriters proceed. | -| `redirect_pipenv_installer_unknown` | `redirect.warnings[]` (warning) | scan `--mode hosted` (pipenv): the lock was rewritten with the modern `file` reference because no `pipenv` answered on PATH; Pipenv 7–11 projects need `path` — put that pipenv on PATH or set `SOCKET_PIPENV_MAJOR`. | -| `redirect_pypi_platform_wheel` | `redirect.warnings[]` (warning) | scan / get `--mode hosted` (pypi, every lane: uv.lock, PEP 723 script locks, pylock.toml, Pipfile.lock, poetry.lock, pdm.lock, requirements.txt, Hatch): the patch service granted the patch as a platform-, ABI- or interpreter-tagged wheel (any tag triple other than `-none-any` whose python tag set holds a generic Python 3 tag, `py3` or `py3`; e.g. `cp311-cp311-manylinux…`, or `cp311-none-any`, which pip installs on CPython 3.11 only). A hosted pin would narrow the cross-platform lock entry to that one wheel, so installs on any other interpreter, OS or architecture would fail and hosted rollback could not derive the upstream wheels to restore. The patch is withheld from every PyPI rewriter (nothing is written or confirmed for it, and a same-run `--vex` does not attest it); exit 0, like every hosted refusal. The tags are read as vendored mode reads them for `vendor_platform_locked`. | +| `redirect_unattributable` | `skipped` (hosted event) | scan/get `--mode hosted`: the rewriters would pin the patch, but lockfile discovery over the result reads that pin as contested (another lock or requirements file resolves the same version elsewhere, or the pin is not one the package manager consumes), so `vex`, `rollback`, `remove` and `vendor` would refuse it. The candidate is left out of the rewrite, so nothing is written for it; the detail carries discovery's findings. Exit code unchanged. | +| `redirect_pin_lockless` | `warnings[]` (warning) | scan/get `--mode hosted` (nuget, cargo): the pin was written without a lockfile that records its version, so `vex` cannot attest it and `rollback` / `remove` / `vendor` refuse it as unattributable. The detail names the lockfile to create (`dotnet restore --use-lock-file`, `cargo generate-lockfile`) before re-running the hosted scan. | +| `redirect_pypi_stale_install` | `warnings[]` (warning) | Hosted Python redirect: readable installed files differ from patched hashes. Read-only, repeated on re-scan, and excludes the package from same-run VEX. See the "Python stale-install guard" section. | +| `redirect_nuget_stale_global_package` / `vendor_nuget_stale_global_package` | `warnings[]` (warning, hosted) / `skipped` (warning, vendor advisory event) | scan `--mode hosted` / `vendor` / `scan --mode vendored` (nuget, v5.0 #352): NuGet's global packages folder (`NUGET_PACKAGES`, else `~/.nuget/packages`) already holds the patched package extracted from other bytes (its `.nupkg.metadata` `contentHash` is not the patched one). A patch keeps the upstream id and version and NuGet restores a package already in that folder without asking any source, so `dotnet restore` would keep the upstream bytes (silently without a lock, NU1403 against the re-pinned lock with one). The detail names the directory and the remedy: delete it, then `dotnet restore` (`dotnet nuget locals global-packages --clear` also works but empties the whole machine-wide folder, and the detail says so); CI caches of that folder must drop it too. Read-only like the gem guard (the folder is shared machine-wide), re-fired on every re-run until the copy is gone, skipped on `--dry-run`; a hosted purl it flags is excluded from the same run's `--vex` `assume_applied` and reported stale. A dir without `.nupkg.metadata` (a legacy `packages/` folder) is never judged. | +| `redirect_gem_stale_install` | `warnings[]` (warning) | scan `--mode hosted` (gem): a stale UNPATCHED materialization (installed gem, or committed archive in bundler's cache dir — `vendor/cache` unless `cache_path` moves it) that `bundle install` will reuse instead of fetching the redirected patch; the detail carries the verified remedy. Full rules and flavors: the "Gem stale-install guard" section. | +| `redirect_gem_version_not_locked` | `warnings[]` (warning) | scan `--mode hosted` (gem): the crawled gem version is installed on the machine but no `GEM` section of the project's lock resolves it (another project's copy in the shared gem home). The gem is skipped and the Gemfile and lock stay byte-identical. | +| `redirect_gem_no_lockfile` | `warnings[]` (warning) | scan `--mode hosted` (gem): the project has a `Gemfile` / `gems.rb` but no lock, so the version it resolves is unknown (#1125). Every gem is skipped and the Gemfile stays byte-identical; run `bundle lock` (or `bundle install`), commit the lock, and re-run. | +| `redirect_pipenv_refused` | `warnings[]` (warning) | scan `--mode hosted` (pipenv): the Pipfile.lock pins another version or a non-registry / foreign source for the package — refused atomically across categories, and the patch is vetoed from the sibling Python rewriters (see the "Pipenv hosted redirect" section). | +| `redirect_pipenv_skipped` | `warnings[]` (warning) | scan `--mode hosted` (pipenv): no entry for the package, pipfile-spec < 6, an unparseable lock or a digest-less patch — nothing rewritten here; the sibling rewriters proceed. | +| `redirect_pipenv_installer_unknown` | `warnings[]` (warning) | scan `--mode hosted` (pipenv): the lock was rewritten with the modern `file` reference because no `pipenv` answered on PATH; Pipenv 7–11 projects need `path` — put that pipenv on PATH or set `SOCKET_PIPENV_MAJOR`. | +| `redirect_pypi_platform_wheel` | `warnings[]` (warning) | scan / get `--mode hosted` (pypi, every lane: uv.lock, PEP 723 script locks, pylock.toml, Pipfile.lock, poetry.lock, pdm.lock, requirements.txt, Hatch): the patch service granted the patch as a platform-, ABI- or interpreter-tagged wheel (any tag triple other than `-none-any` whose python tag set holds a generic Python 3 tag, `py3` or `py3`; e.g. `cp311-cp311-manylinux…`, or `cp311-none-any`, which pip installs on CPython 3.11 only). A hosted pin would narrow the cross-platform lock entry to that one wheel, so installs on any other interpreter, OS or architecture would fail and hosted rollback could not derive the upstream wheels to restore. The patch is withheld from every PyPI rewriter (nothing is written or confirmed for it, and a same-run `--vex` does not attest it); exit 0, like every hosted refusal. The tags are read as vendored mode reads them for `vendor_platform_locked`. | | `pypi_pipenv_installer_unsupported` | `failed` | vendor (pipenv): the installed Pipenv is older than 2018 and cannot consume vendored wheel references — upgrade Pipenv or use hosted mode. | | `pypi_pipenv_version_mismatch` | `failed` | vendor (pipenv): a category pins a different version than the patch — refused before any write. (`pypi_pipenv_invalid_wheel` retired in v5.0: the backend takes the orchestrator's resolved version instead of parsing the wheel filename.) | | `redirect_symlinked_file_unsupported` (per package) | `failed` | vendor / scan / get `--mode vendored` (pypi and cargo, v5.0): a file the backend checks before its first write — `pyproject.toml` / `uv.lock`, `poetry.lock`, `pdm.lock`, `Pipfile.lock`, a planned `requirements*.txt`, a PEP 751 / script lock, `hatch.toml`, or the workspace-root `Cargo.toml` — is a symlink. Refused before any write on wire AND on revert (the pypi revert keeps the artifact, `kept_artifact`). The same code (and message) as the hosted guard and the group commit's top-level refusal: one code for "a file this run would rewrite is a symbolic link" in every mode. Until v5.0-rc these were per-backend codes (`pypi_uv_symlink_unsupported`, `pypi_poetry_symlink_unsupported`, `pypi_pipenv_symlink_unsupported`, `pypi_pdm_symlink_unsupported`, `pypi_requirements_symlink_unsupported`, `pypi_lock_symlink_unsupported`, `pypi_hatch_symlink`, `cargo_manifest_symlink_unsupported`). | @@ -1473,35 +1486,35 @@ Every `--json` invocation emits a single JSON object that follows the **unified | `vendor_lock_entry_pruned_kept` | revert `warnings[]` | vendor `--revert` / rollback (yarn berry, #737): vendoring dropped a lock entry only the patched package's implicit `node-gyp` dependency reached (or one descriptor of a shared entry), and the lock has re-added or changed that entry since; it is left as it is (not drift: the artifact is still removed). Run `yarn install` if the lock is incomplete. | | `pypi_{poetry,pdm,pipenv}_no_lockfile` | `failed` | vendor (pypi): a lock-less tool marker with no `requirements.txt` fallback — run ` lock`. | | `pypi_poetry_integrity_unverified` | `skipped` (warning) | vendor (pypi / poetry): the lock was written by Poetry < 1.4 (0.12 `[metadata.hashes]`, lock 1.0/1.1, or a 2.0 lock without a `@generated by Poetry X.Y.Z` header — 1.3 wrote those). That installer does not verify local wheel hashes (the committed wheel bytes are the protection) and does not replace an already-installed package at the same version; recreate the virtualenv or `pip uninstall` the package before `poetry install`, or upgrade Poetry. | -| `redirect_poetry_stale_install_risk` | `redirect.warnings[]` (warning) | scan `--mode hosted` (poetry): same writer test as above — a warm virtualenv keeps the upstream package after the redirect on Poetry < 1.4 (1.4+ re-installs from the new source); fresh installs pick up the patched wheel. Emitted once per rewritten lock, only on the run that rewrites it. | -| `redirect_poetry_entry_not_found` / `redirect_poetry_missing_sha256` / `redirect_poetry_lock_unsupported` | `redirect.warnings[]` (warning) | scan `--mode hosted` (poetry): the lock has no `[[package]]` at the granted version (uv-parity twin of `redirect_uv_entry_not_found`); the grant carries no SHA-256 (gated once per dep, not per lock); the lock is refused — Poetry 0.12 layout (URL sources ignored), an unsupported `lock-version`, a forked package listed at several versions, a user-authored `[package.source]` on another origin (an earlier Socket URL for the same wheel is superseded in place), a malformed `[metadata.files]`/`[metadata.hashes]`, or a wheel whose filename does not match the locked package. Exit code and `status` unchanged (hosted-refusal posture). A vendored → hosted takeover over a Poetry 0.x lock is retracted with this code (wet and `--dry-run`, see **Staged takeover**): the purl stays vendored and patched and is skipped with this code as `redirect.skipped[].reason`. The detail names the remedy and its reach: run `socket-patch vendor --revert` (it reverts EVERY vendored package in the project, not just this one), upgrade to Poetry >= 1.0 and re-lock, then re-run `scan --mode hosted`. | -| `redirect_pdm_refused` / `redirect_pdm_legacy_sync_required` | `redirect.warnings[]` (warning) | scan `--mode hosted` (pdm): the `pdm.lock` rewrite was refused — an unsupported `[metadata] lock_version` (the identity-losing `3.1` / `4.0`–`4.2` formats or an untested future format), an unsupported `strategy`, a package listed at several versions (fork) or absent, a user-authored `url`/`path`/VCS/`editable` source, hash-less or malformed `files`, or a wheel whose filename does not match the locked package (`redirect_pdm_refused`); or the lock was written in format `2` (PDM 0.12–1.4), whose upstream freshness bug lets `pdm install` regenerate the lock — use `pdm sync` (`redirect_pdm_legacy_sync_required`). A refused uuid is withheld from every other PyPI rewriter when `pdm.lock` is the install driver, and its patch is not confirmed. Exit code and `status` unchanged (hosted-refusal posture). | -| `redirect_bun_lock_unsupported` | `redirect.warnings[]` (warning) | scan/get `--mode hosted` (bun): the text lock's `lockfileVersion` is not 0, 1 or 2 (a newer version: update socket-patch, re-locking would reproduce it; no integer: re-lock with Bun ≥ 1.2 — the shared gate's text, identical to vendored's `vendor_lockfile_version_unsupported`), or its `packages` section is not bun's single-line grammar. Nothing rewritten; exit 0 (hosted-refusal posture). | -| `redirect_bun_workspace_unsupported` | `redirect.warnings[]` (warning) | scan/get `--mode hosted` (bun): a lockfileVersion-0 lock (Bun 1.1.39–1.1.45 `--save-text-lockfile`) holds `workspace:` packages; frozen installs of that grammar cannot keep the hosted tuple. Detail: "Bun version-0 workspace locks cannot preserve hosted tarballs on frozen installs; delete bun.lock and re-run `bun install` with Bun >= 1.2 (which writes lockfileVersion 1, accepted by hosted mode) — a plain in-place `bun install` bumps the version only when a workspace depends on another workspace (e.g. root -> member); otherwise it keeps version 0 or fails to resolve" (measured: Bun 1.2.0 keeps 0, 1.2.23–1.4.2 exit 1 "failed to resolve" on a root that does not depend on its members). Version-1/2 workspace locks are rewritten. Exit 0. | -| `redirect_bun_lockb_invalid` | `redirect.warnings[]` (warning) | scan/get `--mode hosted`: the native binary lock is malformed, unreadable, unsupported or cannot be rewritten safely. No installer is spawned and no binary or sibling npm lock edit or takeover occurs; dry-run reports the same format error. Exit 0, `redirected: 0`. | -| `redirect_bun_entry_not_found` / `redirect_bun_missing_sha512` | `redirect.warnings[]` (warning) | scan/get `--mode hosted` (bun): the lock has no rewritable entry at the granted version (re-resolved, or occupied by an unowned URL/file spec whose leaf names no version or another version; a same-version user tarball reports `redirect_bun_non_registry_entry_skipped` instead) / the grant carries no sha512 integrity. Per-dep; nothing rewritten for it; exit 0. NOT emitted for the digest-less 2-tuple Bun 1.1.39–1.3.9 re-save our URL tuple as — that entry counts as redirected and is healed. | -| `redirect_bun_patched_dependency_skipped` | `redirect.warnings[]` (warning) | scan/get `--mode hosted` (bun, `bun.lock` and `bun.lockb`): the project patches the granted `name@version` itself with `bun patch` (a `patchedDependencies` key for `name@version`, or the bare name, in the root `package.json` or mirrored in `bun.lock`). Bun applies that patch only to the registry resolution, so the entry is left on its registry tuple instead of silently losing the user's patch (#367). Per-dep; the detail names the key and the remedy (fold the Socket fix into the user's patch, or drop the `patchedDependencies` entry and re-run); the in-run VEX never assumes the uuid applied. Vendored mode refuses the same package `vendor_lock_entry_unsupported` before any write or download. Exit 0. | -| `redirect_bun_default_trust_lost` / `vendor_bun_default_trust_lost` | `redirect.warnings[]` / vendor `warnings[]` (warning) | scan/get `--mode hosted` and vendored mode (bun, `bun.lock` and `bun.lockb`): the rewired package is on Bun's built-in default trusted list (better-sqlite3, esbuild, sharp, …) and the project declares no `trustedDependencies` (root `package.json`, or the copy mirrored in `bun.lock`). Bun 1.3.5 and later apply that list only to packages resolved from the npm registry, so on a hosted URL or a local tarball the package's install scripts are skipped with exit 0 (#371). The package is still rewired; the detail tells the user to add it to `trustedDependencies` (which replaces Bun's default list, so other default-trusted dependencies whose scripts matter must be listed too). Repeated on every run while the pin stays and no list is declared. Exit 0. | -| `redirect_bun_non_registry_entry_skipped` / `vendor_non_registry_entry_skipped` | `redirect.warnings[]` / vendor warnings (warning) | scan/get `--mode hosted` and vendored mode (bun, `bun.lock` and `bun.lockb`): the lock also installs the granted `name@version` from a user URL or `file:` tarball (its `-.tgz` leaf names that version). Bun installs it from that spec, so the copy stays unpatched beside any rewired registry copy; it is left untouched, the in-run VEX does not assume the patch, and `vex` attests nothing for that `name@version` (#497). With no registry copy left, vendoring refuses with `vendor_lock_entry_not_rewritable`. A same-name URL / `file:` tarball whose leaf names no version, or a git, folder or `link:` copy, has no version in the lock: these codes do not fire for it, but `vex` attests no ref of that package from that lock (`patched_ref_unattributable`), since the copy may be the wired version. | -| `redirect_npm_patched_dependency_skipped` | `redirect.warnings[]` (warning) | scan/get `--mode hosted` (`package-lock.json` / `npm-shrinkwrap.json`): the project patches the granted `name@version` itself with npm ≥ 12.1's native `npm patch` (a root `package.json` `patchedDependencies` key for `name@version` or the bare name, or a `packages` entry carrying npm's `patched` record, which npm writes in a lockfileVersion 4 lock). npm applies the user's diff on top of whatever tarball the lock names and fails the install `EPATCHFAILED` when it no longer applies, so a hosted pin would break every later `npm ci` / `npm install` (or install bytes VEX can never attest); the dep is left on its registry entry in every present npm lock instead (#711). Per-dep; the detail names the key or lock entry and the remedy (fold the Socket fix into the user's patch, or drop the `patchedDependencies` entry and re-run); when an earlier hosted run already pinned the entry, the detail says so and names `socket-patch rollback ` to restore the registry entry instead of claiming it is unchanged; the in-run VEX never assumes the uuid applied, and no sibling lock confirms it. Vendored mode refuses the lockfileVersion 4 lock `vendor_lockfile_version_unsupported`, its detail naming `npm patch` / `patchedDependencies`. Exit 0. | -| `redirect_vlt_lock_unsupported` | `redirect.warnings[]` (warning) | scan/get `--mode hosted` (vlt): `vlt-lock.json` has a `lockfileVersion` other than absent, `0` or `1` (decided on the raw JSON token), is not a JSON object, starts with a UTF-8 BOM, or its `nodes` section is not vlt's one-node-per-line layout. Nothing rewritten; also refuses a vendored → hosted takeover of a `flavor: "vlt"` entry before its revert (`redirect.skipped[].reason`). Exit 0. | -| `redirect_takeover_kept_vendored` | `redirect.warnings[]` (warning) | scan/get `--mode hosted`: a vendored → hosted takeover the hosted rewrite would not pin was retracted (see **Staged takeover**): the package keeps its vendored wiring, ledger entry and artifact byte-identical and stays patched. The detail names the cause code, which is also the purl's `redirect.skipped[].reason`. Exit 0. Replaces v5.0-pre `redirect_takeover_unpatched`, which reported a package left unpatched in both modes and is no longer emitted. | -| `redirect_takeover_not_pinned` | `redirect.skipped[].reason` | scan/get `--mode hosted`: the skip reason of a retracted takeover when no rewriter warning names the cause. | -| `redirect_hatch_lock_regenerated` | `redirect.warnings[]` (warning) | scan/get `--mode hosted` (pypi / Hatch 1.17+): the project's Hatch environments are locked (`lock-envs = true`, an environment's `locked = true` or `lock-filename`), so its `pylock*.toml` is derived from pyproject and Hatch regenerates it on the next sync. The package is wired in the Hatch declarations as well as in the lock (rewriting only the lock was discarded by Hatch, #479); run `hatch dep lock` to refresh the lock (`hatch dep lock --check` reports it stale until then). Vendored routes such a project to the Hatch lane and its pip-installer refusal. | -| `redirect_requirements_direct_reference` | `redirect.warnings[]` (warning) | scan/get `--mode hosted` (pypi / requirements.txt): the root `requirements.txt` installs the patched release from a PEP 508 direct reference the user wrote (`name @ ` whose archive names that release: a `files.pythonhosted.org` file, a private mirror, an internal fork build, a `file://` path), not from socket-patch's own hosted artifact. It is the user's own source choice, so the line is left byte-for-byte and the package is not redirected (#542). Before v5.0 the line was swapped for the hosted build, and rollback then wrote a plain `name==version` index pin. | -| `redirect_requirements_takeover_unreachable` | `redirect.warnings[]` (warning) | scan/get `--mode hosted` (pypi / requirements.txt): a vendored → hosted takeover of a package that vendored mode wired through a pin in a `-r` include, or through a `(transitive)` line it appended to the root `requirements.txt`. Hosted mode only rewrites an existing pin in the root `requirements.txt`, so the staged takeover is retracted (wet and `--dry-run`): the vendored wiring, ledger entry and wheel stay byte-identical, the purl is skipped with this code as `redirect.skipped[].reason`, and nothing is redirected for it. Exit 0. The detail names the remedy and its reach: run `socket-patch vendor --revert` (it reverts EVERY vendored package in the project, not just this one), move the pin from the include into the root `requirements.txt` and delete it from the include (or, for a `(transitive)` line, add an exact `==` pin to the root file), then re-run `scan --mode hosted`. | -| `redirect_uv_takeover_version_unreachable` | `redirect.warnings[]` (warning) | scan/get `--mode hosted` (pypi / uv): a vendored → hosted takeover of a package whose recorded pre-vendor `uv.lock` entry is at another version than the patch (vendored uv pins the lock entry down to the patch's version, for example when the lock resolved a newer release). Reverting would bring the lock's own version back, and hosted mode only pins the version the lock resolves, so the staged takeover is retracted (wet and `--dry-run`): the vendored wiring, ledger entry and wheel stay byte-identical, the purl is skipped with this code as `redirect.skipped[].reason`, and nothing is redirected for it. Exit 0. The detail names the remedy and its reach: run `socket-patch vendor --revert` (it reverts EVERY vendored package in the project, not just this one), make the project resolve the patch's version (for example an exact `==` requirement) and re-lock, then re-run `scan --mode hosted`. | -| `redirect_vlt_missing_sha512` / `redirect_vlt_entry_not_found` / `redirect_vlt_entry_vendored` / `redirect_vlt_unsupported_lock_key` | `redirect.warnings[]` (warning) | scan/get `--mode hosted` (vlt): the grant has no sha512 / the lock has no default-registry node for `name@version` / the only match is a vendored `file` node under `.socket/vendor/npm//` / a default-registry instance is outside vlt's node-line grammar or still unpatched after the splice. Per dep; none of the dep's instances is written. `redirect_vlt_missing_sha512` and `redirect_vlt_unsupported_lock_key` refuse the dep: it is never confirmed, whichever lock drives (a sibling lock may still carry its rewritten URL). `redirect_vlt_entry_not_found` and `redirect_vlt_entry_vendored` only say `vlt-lock.json` does not wire it: while vlt drives it is not confirmed; otherwise a sibling lock's rules may confirm it. Exit 0. | -| `redirect_vlt_custom_registry_skipped` | `redirect.warnings[]` (warning) | scan/get `--mode hosted` (vlt): same-`name@version` nodes under a named alias, a scoped registry or jsr, or git, remote-tarball or local-directory nodes of the same package name (vlt records no version for those; a remote tarball whose `-.tgz` leaf names another version does not count), were left untouched (hosted mode only redirects vlt's default registry). The dep is still redirected, but the run's `--vex` does not attest it, and neither does a later `vex` from the lock alone. | -| `redirect_vlt_lockfile_version_missing` / `redirect_vlt_old_lockfile_ignored` / `redirect_vlt_scalar_registry_ignored` | `redirect.warnings[]` (warning) | scan/get `--mode hosted` (vlt): the lock has no `lockfileVersion` (vlt ≥ 1.0.0-rc.15 re-resolves it) / a legacy default-registry id without `"modifiers"` in `vlt.json` (vlt 0.0.0-16 … 0.0.0-24 ignore the lock) / a scalar `registry` option that vlt 1.0.0-rc.7 … rc.29 honor over the lock. The deps stay redirected, but the run's `--vex` does not attest them. | -| `redirect_vlt_sibling_lockfiles` | `redirect.warnings[]` (warning) | scan/get `--mode hosted` (vlt): `vlt-lock.json` and another npm-family lock are both present and vlt's install state (`node_modules/.vlt-lock.json` or `node_modules/.vlt/`) is not, so both locks were rewritten and the other lock's rules confirm. | -| `redirect_npm_replace_registry_host` | `redirect.warnings[]` (warning) | scan/get `--mode hosted` (npm): the effective npm `replace-registry-host` (env var, project `.npmrc`, or user / global / builtin config) is `always` or a pinned hosted hostname, so npm rewrites the hosted pins to the configured registry and every install fails E404; the detail names the layer and the remedies (`replace-registry-host=npmjs` in the project `.npmrc`, or vendored mode). See the npm `replace-registry-host` contract. | -| `redirect_vlt_no_lockfile` | `redirect.warnings[]` (warning) | scan/get `--mode hosted` (vlt): `vlt.json` or vlt's install state is present without `vlt-lock.json`; replaces `redirect_npm_no_lockfile` for vlt projects. | -| `redirect_npm_shrinkwrap_only` | `redirect.warnings[]` (warning) | scan/get `--mode hosted` (npm, #899): the root `npm-shrinkwrap.json` is the only npm lock and carries a hosted redirect (this run's or an earlier one's — every run repeats it until the project gains a `package-lock.json`). npm >= 12 never reads the shrinkwrap and installs the unpatched registry bytes; npm <= 11 installs the patch. Rename the lock to `package-lock.json` (or commit a copy under that name) and re-run. | -| `redirect_vlt_artifact_unverifiable` | `redirect.warnings[]` (warning), `redirect.skipped[].reason` | scan/get `--mode hosted` (vlt): before any takeover or rewrite (dry runs included), each granted artifact with a default-registry instance in `vlt-lock.json` (or, for a purl a `flavor: "vlt"` vendored entry claims, its vendored node, probed before the takeover reverts it) is fetched once as vlt fetches it (`accept-encoding: gzip;q=1.0, identity;q=0.5`, no `Authorization`, up to 10 redirects) and must return 200 with no content encoding (or `identity`) and the granted sha512. On failure (`content-encoding `, `sha512 mismatch`, `http `, `fetch error `, `offline`) the dep is withheld from every rewriter when vlt drives or it is vlt-vendored (which also keeps it vendored), and from the vlt rewrite only otherwise (detail "…; vlt-lock.json was not changed for {purl}"; only the sibling lock this run rewrote can confirm it). A lock already pinned by an earlier run is left pinned, and neither confirmed nor attested. Projects without `vlt-lock.json` make no such request. The detail quotes the artifact URL (and any fetch error that echoes it) with its grant-token path level, the one just before the patch uuid, spelled ``; host, uuid and leaf stay. The in-memory hosted engine (`hosted-bundle`, the Node addon) has no network for this fetch, so it judges every in-scope artifact as `--offline` does (withheld, never pinned; the vendored takeover it refuses anyway). Exit 0. | -| `redirect_vlt_reinstall_required` | `redirect.warnings[]` (advisory); rollback/remove `warnings[]` (+ human stderr) | vlt: `vlt-lock.json` pins (or, after rollback/remove, no longer pins) Socket-patched packages, and vlt never refreshes an installed copy. The heal removes `node_modules/.vlt-lock.json` and each stale `node_modules/.vlt/` of a Socket-owned node (never a link's target, never outside the project, never a copy it cannot judge) unless `--no-vlt-install-cleanup` or `--dry-run`. It never removes an optional node's copy (lock flags 1 or 3, or flags it cannot read): `vlt install` does not put a removed optional dependency back (its link dangles) unless the same install also reinstalls a non-optional node, so such a copy is left stale and the detail says to run `vlt ci` (or delete `node_modules` and run `vlt install`); vlt 0.0.0-30 … 1.0.4 install no optional dependency from the lock of a project that declares only optional dependencies, so there both commands remove the installed copy and the detail says to upgrade vlt to 1.0.5 or later first. The detail says whether copies were removed, left stale by a skipped cleanup, could not be checked, or none were stale, and adds how many optional copies were kept whenever there are any. The kept optional copies are named by what they are: `unpatched copies of optional dependencies` after `scan`/`get`, `patched copies of optional dependencies` after `rollback`/`remove`, and `installed copies of the vendored optional dependencies` after a hosted → vendored takeover (the copy the hosted pin left installed, which may still be the registry bytes). Stale or unchecked copies are not attested by the run's `--vex`, nor is a confirmed vlt pin the heal did not check (a URL on a host other than patch.socket.dev and the configured `--patch-server-url`/`--api-url`). A hidden lock that cannot be removed keeps every store entry. Invalidation failures only warn. | +| `redirect_poetry_stale_install_risk` | `warnings[]` (warning) | scan `--mode hosted` (poetry): same writer test as above — a warm virtualenv keeps the upstream package after the redirect on Poetry < 1.4 (1.4+ re-installs from the new source); fresh installs pick up the patched wheel. Emitted once per rewritten lock, only on the run that rewrites it. | +| `redirect_poetry_entry_not_found` / `redirect_poetry_missing_sha256` / `redirect_poetry_lock_unsupported` | `warnings[]` (warning) | scan `--mode hosted` (poetry): the lock has no `[[package]]` at the granted version (uv-parity twin of `redirect_uv_entry_not_found`); the grant carries no SHA-256 (gated once per dep, not per lock); the lock is refused — Poetry 0.12 layout (URL sources ignored), an unsupported `lock-version`, a forked package listed at several versions, a user-authored `[package.source]` on another origin (an earlier Socket URL for the same wheel is superseded in place), a malformed `[metadata.files]`/`[metadata.hashes]`, or a wheel whose filename does not match the locked package. Exit code and `status` unchanged (hosted-refusal posture). A vendored → hosted takeover over a Poetry 0.x lock is retracted with this code (wet and `--dry-run`, see **Staged takeover**): the purl stays vendored and patched and is skipped with this code as `skipped` (hosted event). The detail names the remedy and its reach: run `socket-patch vendor --revert` (it reverts EVERY vendored package in the project, not just this one), upgrade to Poetry >= 1.0 and re-lock, then re-run `scan --mode hosted`. | +| `redirect_pdm_refused` / `redirect_pdm_legacy_sync_required` | `warnings[]` (warning) | scan `--mode hosted` (pdm): the `pdm.lock` rewrite was refused — an unsupported `[metadata] lock_version` (the identity-losing `3.1` / `4.0`–`4.2` formats or an untested future format), an unsupported `strategy`, a package listed at several versions (fork) or absent, a user-authored `url`/`path`/VCS/`editable` source, hash-less or malformed `files`, or a wheel whose filename does not match the locked package (`redirect_pdm_refused`); or the lock was written in format `2` (PDM 0.12–1.4), whose upstream freshness bug lets `pdm install` regenerate the lock — use `pdm sync` (`redirect_pdm_legacy_sync_required`). A refused uuid is withheld from every other PyPI rewriter when `pdm.lock` is the install driver, and its patch is not confirmed. Exit code and `status` unchanged (hosted-refusal posture). | +| `redirect_bun_lock_unsupported` | `warnings[]` (warning) | scan/get `--mode hosted` (bun): the text lock's `lockfileVersion` is not 0, 1 or 2 (a newer version: update socket-patch, re-locking would reproduce it; no integer: re-lock with Bun ≥ 1.2 — the shared gate's text, identical to vendored's `vendor_lockfile_version_unsupported`), or its `packages` section is not bun's single-line grammar. Nothing rewritten; exit 0 (hosted-refusal posture). | +| `redirect_bun_workspace_unsupported` | `warnings[]` (warning) | scan/get `--mode hosted` (bun): a lockfileVersion-0 lock (Bun 1.1.39–1.1.45 `--save-text-lockfile`) holds `workspace:` packages; frozen installs of that grammar cannot keep the hosted tuple. Detail: "Bun version-0 workspace locks cannot preserve hosted tarballs on frozen installs; delete bun.lock and re-run `bun install` with Bun >= 1.2 (which writes lockfileVersion 1, accepted by hosted mode) — a plain in-place `bun install` bumps the version only when a workspace depends on another workspace (e.g. root -> member); otherwise it keeps version 0 or fails to resolve" (measured: Bun 1.2.0 keeps 0, 1.2.23–1.4.2 exit 1 "failed to resolve" on a root that does not depend on its members). Version-1/2 workspace locks are rewritten. Exit 0. | +| `redirect_bun_lockb_invalid` | `warnings[]` (warning) | scan/get `--mode hosted`: the native binary lock is malformed, unreadable, unsupported or cannot be rewritten safely. No installer is spawned and no binary or sibling npm lock edit or takeover occurs; dry-run reports the same format error. Exit 0, `redirected: 0`. | +| `redirect_bun_entry_not_found` / `redirect_bun_missing_sha512` | `warnings[]` (warning) | scan/get `--mode hosted` (bun): the lock has no rewritable entry at the granted version (re-resolved, or occupied by an unowned URL/file spec whose leaf names no version or another version; a same-version user tarball reports `redirect_bun_non_registry_entry_skipped` instead) / the grant carries no sha512 integrity. Per-dep; nothing rewritten for it; exit 0. NOT emitted for the digest-less 2-tuple Bun 1.1.39–1.3.9 re-save our URL tuple as — that entry counts as redirected and is healed. | +| `redirect_bun_patched_dependency_skipped` | `warnings[]` (warning) | scan/get `--mode hosted` (bun, `bun.lock` and `bun.lockb`): the project patches the granted `name@version` itself with `bun patch` (a `patchedDependencies` key for `name@version`, or the bare name, in the root `package.json` or mirrored in `bun.lock`). Bun applies that patch only to the registry resolution, so the entry is left on its registry tuple instead of silently losing the user's patch (#367). Per-dep; the detail names the key and the remedy (fold the Socket fix into the user's patch, or drop the `patchedDependencies` entry and re-run); the in-run VEX never assumes the uuid applied. Vendored mode refuses the same package `vendor_lock_entry_unsupported` before any write or download. Exit 0. | +| `redirect_bun_default_trust_lost` / `vendor_bun_default_trust_lost` | `warnings[]` / vendor `warnings[]` (warning) | scan/get `--mode hosted` and vendored mode (bun, `bun.lock` and `bun.lockb`): the rewired package is on Bun's built-in default trusted list (better-sqlite3, esbuild, sharp, …) and the project declares no `trustedDependencies` (root `package.json`, or the copy mirrored in `bun.lock`). Bun 1.3.5 and later apply that list only to packages resolved from the npm registry, so on a hosted URL or a local tarball the package's install scripts are skipped with exit 0 (#371). The package is still rewired; the detail tells the user to add it to `trustedDependencies` (which replaces Bun's default list, so other default-trusted dependencies whose scripts matter must be listed too). Repeated on every run while the pin stays and no list is declared. Exit 0. | +| `redirect_bun_non_registry_entry_skipped` / `vendor_non_registry_entry_skipped` | `warnings[]` / vendor warnings (warning) | scan/get `--mode hosted` and vendored mode (bun, `bun.lock` and `bun.lockb`): the lock also installs the granted `name@version` from a user URL or `file:` tarball (its `-.tgz` leaf names that version). Bun installs it from that spec, so the copy stays unpatched beside any rewired registry copy; it is left untouched, the in-run VEX does not assume the patch, and `vex` attests nothing for that `name@version` (#497). With no registry copy left, vendoring refuses with `vendor_lock_entry_not_rewritable`. A same-name URL / `file:` tarball whose leaf names no version, or a git, folder or `link:` copy, has no version in the lock: these codes do not fire for it, but `vex` attests no ref of that package from that lock (`patched_ref_unattributable`), since the copy may be the wired version. | +| `redirect_npm_patched_dependency_skipped` | `warnings[]` (warning) | scan/get `--mode hosted` (`package-lock.json` / `npm-shrinkwrap.json`): the project patches the granted `name@version` itself with npm ≥ 12.1's native `npm patch` (a root `package.json` `patchedDependencies` key for `name@version` or the bare name, or a `packages` entry carrying npm's `patched` record, which npm writes in a lockfileVersion 4 lock). npm applies the user's diff on top of whatever tarball the lock names and fails the install `EPATCHFAILED` when it no longer applies, so a hosted pin would break every later `npm ci` / `npm install` (or install bytes VEX can never attest); the dep is left on its registry entry in every present npm lock instead (#711). Per-dep; the detail names the key or lock entry and the remedy (fold the Socket fix into the user's patch, or drop the `patchedDependencies` entry and re-run); when an earlier hosted run already pinned the entry, the detail says so and names `socket-patch rollback ` to restore the registry entry instead of claiming it is unchanged; the in-run VEX never assumes the uuid applied, and no sibling lock confirms it. Vendored mode refuses the lockfileVersion 4 lock `vendor_lockfile_version_unsupported`, its detail naming `npm patch` / `patchedDependencies`. Exit 0. | +| `redirect_vlt_lock_unsupported` | `warnings[]` (warning) | scan/get `--mode hosted` (vlt): `vlt-lock.json` has a `lockfileVersion` other than absent, `0` or `1` (decided on the raw JSON token), is not a JSON object, starts with a UTF-8 BOM, or its `nodes` section is not vlt's one-node-per-line layout. Nothing rewritten; also refuses a vendored → hosted takeover of a `flavor: "vlt"` entry before its revert (`skipped` (hosted event)). Exit 0. | +| `redirect_takeover_kept_vendored` | `warnings[]` (warning) | scan/get `--mode hosted`: a vendored → hosted takeover the hosted rewrite would not pin was retracted (see **Staged takeover**): the package keeps its vendored wiring, ledger entry and artifact byte-identical and stays patched. The detail names the cause code, which is also the purl's `skipped` (hosted event). Exit 0. Replaces v5.0-pre `redirect_takeover_unpatched`, which reported a package left unpatched in both modes and is no longer emitted. | +| `redirect_takeover_not_pinned` | `skipped` (hosted event) | scan/get `--mode hosted`: the skip reason of a retracted takeover when no rewriter warning names the cause. | +| `redirect_hatch_lock_regenerated` | `warnings[]` (warning) | scan/get `--mode hosted` (pypi / Hatch 1.17+): the project's Hatch environments are locked (`lock-envs = true`, an environment's `locked = true` or `lock-filename`), so its `pylock*.toml` is derived from pyproject and Hatch regenerates it on the next sync. The package is wired in the Hatch declarations as well as in the lock (rewriting only the lock was discarded by Hatch, #479); run `hatch dep lock` to refresh the lock (`hatch dep lock --check` reports it stale until then). Vendored routes such a project to the Hatch lane and its pip-installer refusal. | +| `redirect_requirements_direct_reference` | `warnings[]` (warning) | scan/get `--mode hosted` (pypi / requirements.txt): the root `requirements.txt` installs the patched release from a PEP 508 direct reference the user wrote (`name @ ` whose archive names that release: a `files.pythonhosted.org` file, a private mirror, an internal fork build, a `file://` path), not from socket-patch's own hosted artifact. It is the user's own source choice, so the line is left byte-for-byte and the package is not redirected (#542). Before v5.0 the line was swapped for the hosted build, and rollback then wrote a plain `name==version` index pin. | +| `redirect_requirements_takeover_unreachable` | `warnings[]` (warning) | scan/get `--mode hosted` (pypi / requirements.txt): a vendored → hosted takeover of a package that vendored mode wired through a pin in a `-r` include, or through a `(transitive)` line it appended to the root `requirements.txt`. Hosted mode only rewrites an existing pin in the root `requirements.txt`, so the staged takeover is retracted (wet and `--dry-run`): the vendored wiring, ledger entry and wheel stay byte-identical, the purl is skipped with this code as `skipped` (hosted event), and nothing is redirected for it. Exit 0. The detail names the remedy and its reach: run `socket-patch vendor --revert` (it reverts EVERY vendored package in the project, not just this one), move the pin from the include into the root `requirements.txt` and delete it from the include (or, for a `(transitive)` line, add an exact `==` pin to the root file), then re-run `scan --mode hosted`. | +| `redirect_uv_takeover_version_unreachable` | `warnings[]` (warning) | scan/get `--mode hosted` (pypi / uv): a vendored → hosted takeover of a package whose recorded pre-vendor `uv.lock` entry is at another version than the patch (vendored uv pins the lock entry down to the patch's version, for example when the lock resolved a newer release). Reverting would bring the lock's own version back, and hosted mode only pins the version the lock resolves, so the staged takeover is retracted (wet and `--dry-run`): the vendored wiring, ledger entry and wheel stay byte-identical, the purl is skipped with this code as `skipped` (hosted event), and nothing is redirected for it. Exit 0. The detail names the remedy and its reach: run `socket-patch vendor --revert` (it reverts EVERY vendored package in the project, not just this one), make the project resolve the patch's version (for example an exact `==` requirement) and re-lock, then re-run `scan --mode hosted`. | +| `redirect_vlt_missing_sha512` / `redirect_vlt_entry_not_found` / `redirect_vlt_entry_vendored` / `redirect_vlt_unsupported_lock_key` | `warnings[]` (warning) | scan/get `--mode hosted` (vlt): the grant has no sha512 / the lock has no default-registry node for `name@version` / the only match is a vendored `file` node under `.socket/vendor/npm//` / a default-registry instance is outside vlt's node-line grammar or still unpatched after the splice. Per dep; none of the dep's instances is written. `redirect_vlt_missing_sha512` and `redirect_vlt_unsupported_lock_key` refuse the dep: it is never confirmed, whichever lock drives (a sibling lock may still carry its rewritten URL). `redirect_vlt_entry_not_found` and `redirect_vlt_entry_vendored` only say `vlt-lock.json` does not wire it: while vlt drives it is not confirmed; otherwise a sibling lock's rules may confirm it. Exit 0. | +| `redirect_vlt_custom_registry_skipped` | `warnings[]` (warning) | scan/get `--mode hosted` (vlt): same-`name@version` nodes under a named alias, a scoped registry or jsr, or git, remote-tarball or local-directory nodes of the same package name (vlt records no version for those; a remote tarball whose `-.tgz` leaf names another version does not count), were left untouched (hosted mode only redirects vlt's default registry). The dep is still redirected, but the run's `--vex` does not attest it, and neither does a later `vex` from the lock alone. | +| `redirect_vlt_lockfile_version_missing` / `redirect_vlt_old_lockfile_ignored` / `redirect_vlt_scalar_registry_ignored` | `warnings[]` (warning) | scan/get `--mode hosted` (vlt): the lock has no `lockfileVersion` (vlt ≥ 1.0.0-rc.15 re-resolves it) / a legacy default-registry id without `"modifiers"` in `vlt.json` (vlt 0.0.0-16 … 0.0.0-24 ignore the lock) / a scalar `registry` option that vlt 1.0.0-rc.7 … rc.29 honor over the lock. The deps stay redirected, but the run's `--vex` does not attest them. | +| `redirect_vlt_sibling_lockfiles` | `warnings[]` (warning) | scan/get `--mode hosted` (vlt): `vlt-lock.json` and another npm-family lock are both present and vlt's install state (`node_modules/.vlt-lock.json` or `node_modules/.vlt/`) is not, so both locks were rewritten and the other lock's rules confirm. | +| `redirect_npm_replace_registry_host` | `warnings[]` (warning) | scan/get `--mode hosted` (npm): the effective npm `replace-registry-host` (env var, project `.npmrc`, or user / global / builtin config) is `always` or a pinned hosted hostname, so npm rewrites the hosted pins to the configured registry and every install fails E404; the detail names the layer and the remedies (`replace-registry-host=npmjs` in the project `.npmrc`, or vendored mode). See the npm `replace-registry-host` contract. | +| `redirect_vlt_no_lockfile` | `warnings[]` (warning) | scan/get `--mode hosted` (vlt): `vlt.json` or vlt's install state is present without `vlt-lock.json`; replaces `redirect_npm_no_lockfile` for vlt projects. | +| `redirect_npm_shrinkwrap_only` | `warnings[]` (warning) | scan/get `--mode hosted` (npm, #899): the root `npm-shrinkwrap.json` is the only npm lock and carries a hosted redirect (this run's or an earlier one's — every run repeats it until the project gains a `package-lock.json`). npm >= 12 never reads the shrinkwrap and installs the unpatched registry bytes; npm <= 11 installs the patch. Rename the lock to `package-lock.json` (or commit a copy under that name) and re-run. | +| `redirect_vlt_artifact_unverifiable` | `warnings[]` (warning), `skipped` (hosted event) | scan/get `--mode hosted` (vlt): before any takeover or rewrite (dry runs included), each granted artifact with a default-registry instance in `vlt-lock.json` (or, for a purl a `flavor: "vlt"` vendored entry claims, its vendored node, probed before the takeover reverts it) is fetched once as vlt fetches it (`accept-encoding: gzip;q=1.0, identity;q=0.5`, no `Authorization`, up to 10 redirects) and must return 200 with no content encoding (or `identity`) and the granted sha512. On failure (`content-encoding `, `sha512 mismatch`, `http `, `fetch error `, `offline`) the dep is withheld from every rewriter when vlt drives or it is vlt-vendored (which also keeps it vendored), and from the vlt rewrite only otherwise (detail "…; vlt-lock.json was not changed for {purl}"; only the sibling lock this run rewrote can confirm it). A lock already pinned by an earlier run is left pinned, and neither confirmed nor attested. Projects without `vlt-lock.json` make no such request. The detail quotes the artifact URL (and any fetch error that echoes it) with its grant-token path level, the one just before the patch uuid, spelled ``; host, uuid and leaf stay. The in-memory hosted engine (`hosted-bundle`, the Node addon) has no network for this fetch, so it judges every in-scope artifact as `--offline` does (withheld, never pinned; the vendored takeover it refuses anyway). Exit 0. | +| `redirect_vlt_reinstall_required` | `warnings[]` (advisory); rollback/remove `warnings[]` (+ human stderr) | vlt: `vlt-lock.json` pins (or, after rollback/remove, no longer pins) Socket-patched packages, and vlt never refreshes an installed copy. The heal removes `node_modules/.vlt-lock.json` and each stale `node_modules/.vlt/` of a Socket-owned node (never a link's target, never outside the project, never a copy it cannot judge) unless `--no-vlt-install-cleanup` or `--dry-run`. It never removes an optional node's copy (lock flags 1 or 3, or flags it cannot read): `vlt install` does not put a removed optional dependency back (its link dangles) unless the same install also reinstalls a non-optional node, so such a copy is left stale and the detail says to run `vlt ci` (or delete `node_modules` and run `vlt install`); vlt 0.0.0-30 … 1.0.4 install no optional dependency from the lock of a project that declares only optional dependencies, so there both commands remove the installed copy and the detail says to upgrade vlt to 1.0.5 or later first. The detail says whether copies were removed, left stale by a skipped cleanup, could not be checked, or none were stale, and adds how many optional copies were kept whenever there are any. The kept optional copies are named by what they are: `unpatched copies of optional dependencies` after `scan`/`get`, `patched copies of optional dependencies` after `rollback`/`remove`, and `installed copies of the vendored optional dependencies` after a hosted → vendored takeover (the copy the hosted pin left installed, which may still be the registry bytes). Stale or unchecked copies are not attested by the run's `--vex`, nor is a confirmed vlt pin the heal did not check (a URL on a host other than patch.socket.dev and the configured `--patch-server-url`/`--api-url`). A hidden lock that cannot be removed keeps every store entry. Invalidation failures only warn. | | `redirect_bun_reinstall_required` | rollback/remove `warnings[]` (+ human stderr), `vendor --revert` `warnings[]` | a wet hosted unwind that restored `bun.lock` / `bun.lockb` pins to the registry record while their installed copy may be kept (the `vendor_bun_reinstall_required` rule: Bun's hoisted linker keeps it through a plain `bun install`). One run-level warning naming every such `name@version` and `bun install --force` (or deleting `node_modules`). `vendor --revert` leaves it out when the vendored revert already advised `vendor_bun_reinstall_required` for every package it re-hosted. | -| `vendor_pypi_reinstall_required` | `skipped` (advisory; human: `Warning: …`); rollback/remove `warnings[]`; `scan --prune` `gc.warnings[]` (human: `GC: …`) | a PyPI vendored revert (`vendor --revert`, rollback / remove of a vendored entry, the manifest reconcile, `--preserve-state` and `--dry-run` included) of a PDM, uv or Pipenv entry (#477). These tools reinstall a same-version package only when the LOCKED candidate is a URL or file that differs from the installed one, so once the lock is back on the registry release a plain `pdm sync` / `pdm install`, `uv sync` or `pipenv sync` reports nothing to do and keeps the vendored build. The detail names the purl and the reinstall that restores the upstream bytes: `pdm sync --reinstall` (or recreating the virtualenv / `__pypackages__`), `uv sync --reinstall-package `, or Pipenv's `pipenv run pip uninstall -y && pipenv sync` (the categories from `Pipfile.lock`; `pipenv --rm && pipenv sync` for a clean virtualenv). Poetry, requirements.txt, Hatch and PEP 751 entries do not get it. | +| `vendor_pypi_reinstall_required` | `skipped` (advisory; human: `Warning: …`); rollback/remove `warnings[]`; `scan --prune` top-level `warnings[]` (human: `GC: …`) | a PyPI vendored revert (`vendor --revert`, rollback / remove of a vendored entry, the manifest reconcile, `--preserve-state` and `--dry-run` included) of a PDM, uv or Pipenv entry (#477). These tools reinstall a same-version package only when the LOCKED candidate is a URL or file that differs from the installed one, so once the lock is back on the registry release a plain `pdm sync` / `pdm install`, `uv sync` or `pipenv sync` reports nothing to do and keeps the vendored build. The detail names the purl and the reinstall that restores the upstream bytes: `pdm sync --reinstall` (or recreating the virtualenv / `__pypackages__`), `uv sync --reinstall-package `, or Pipenv's `pipenv run pip uninstall -y && pipenv sync` (the categories from `Pipfile.lock`; `pipenv --rm && pipenv sync` for a clean virtualenv). Poetry, requirements.txt, Hatch and PEP 751 entries do not get it. | | `redirect_pypi_reinstall_required` | rollback/remove `warnings[]` (+ human stderr), `vendor --revert` `warnings[]` | a hosted unwind (wet or `--dry-run`) that restored PyPI pins in `pdm.lock`, `uv.lock` or `Pipfile.lock` to their registry entry: the `vendor_pypi_reinstall_required` rule for hosted pins (the patched build's `direct_url.json` still names the patch server). One run-level warning naming each purl and its reinstall. | | `vendor_prebuilt_stub_invalid` | `failed` | RubyGems: the server stub lacks required attributes or is otherwise invalid; no local stub fallback is permitted. | | `vendor_*` / `pypi_*` / `gemfile_*` / `lock_*` / `locked_version_mismatch` / `user_authored_*` / `native_extensions_unsupported` / `platform_gem_unsupported` | `failed`/`skipped` | vendor: per-ecosystem refusal + drift vocabulary; see the Vendor command contract section. New tags are additive (MINOR). | @@ -1510,15 +1523,15 @@ Every `--json` invocation emits a single JSON object that follows the **unified | Code | Subcommands | Meaning | |-----------------------|----------------------------------|---------| -| `manifest_not_found` | remove, repair, rollback, vex (not `list` since v5.0: a missing manifest is an empty list) | `.socket/manifest.json` doesn't exist. For `vex` (and `scan --vex`) it fires only when, in addition, NOTHING else names a patch — no vendor-ledger entry, no lockfile reference (hosted or vendored) — and the message says so (exit 2 standalone; `apply`/`vendor --vex` treat it as their calm no-op). v3.5: `repair` proceeds anyway (vendored phase only) when a vendor ledger or vendor-path lockfile references exist, and exits 0 with a `redirect_only_project` skip (not this error) when the project's only patch state is hosted pins in its lockfiles (v5.0; or a pre-v5 `redirect-state.json`). `list` likewise no longer fires this on a hosted-only project: v5.0 lists every hosted pin the lockfiles wire (exit 0, labeled `details.mode: "hosted"` + `details.lockfiles: []` — no `details.ledger`, since hosted mode keeps none; when the manifest exists too, both are shown, purl-sorted with the manifest entry first on a tie). A pin carries its uuid and empty details unless a pre-v5 redirect ledger records the same purl and uuid (read for migration only: its record supplies the vulnerabilities / tier / description); a pre-v5 ledger record whose pin is in no lockfile is not listed. v5.0: `list` reads the vendor ledger the same way — a vendored-only project (every `scan`/`get --mode vendored` project) lists its ledger entries' embedded records labeled `Mode: vendored (recorded in .socket/vendor/state.json)` in human mode — the twin of the hosted `Mode: hosted (wired in )` line — (`details.mode: "vendored"` + `details.ledger: ".socket/vendor/state.json"` in JSON), exit 0. A standalone-`vendor` entry's fallback `record` lists the same way once no manifest entry covers it (by ledger key or base purl) — the copy manifest-less `vex` attests from, so `list` never reports `manifest_not_found` for a tree whose VEX document attests a patch; while the manifest covers it, only the manifest entry is listed. All sources always come from the SAME project (see the `--manifest-path` flag row), so `--manifest-path` into another project reads that project's state, never the local one. The error still fires when NONE of the three sources has a patch, and a present-but-broken manifest still reports `manifest_invalid`/`manifest_unreadable` regardless (corruption is never masked). A malformed pre-v5 redirect ledger degrades to "nothing to consult" with a stderr warning, muted by `--silent` (the pins still list); `list --json` carries it in the run-level `warnings[]` as `redirect_ledger_corrupt` instead of on stderr. v5.0: `rollback` likewise proceeds manifest-less when the vendor ledger or the lockfiles' hosted pins hold work (its error is the legacy `{status: "error", error: {code: "manifest_not_found", message: "Manifest not found"}, path}` shape); only the truly-empty project — no manifest, no vendor ledger, no hosted pin (a lone pre-v5 redirect ledger is deleted, exit 0) — keeps the exit-1 error, and a project whose lockfiles still reference `.socket/vendor/` artifacts with NO vendor ledger gets a distinct error naming `socket-patch repair`. `remove` (v5.0) proceeds manifest-less whenever a vendor ledger file exists or the lockfiles pin a hosted patch (an existence probe and the read-only hosted-pin discovery before the lock; the vendor ledger loads under it): ANY vendor-ledger entry matching the identifier — detached or not — is removed through the ledger path (`--preserve-state` and drift-keeps behave exactly as on the manifest path), a hosted-only match restores its upstream registry entry, and when that state exists but holds nothing for the identifier the error is `not_found` (exit 1), not this code — `manifest_not_found` fires from `remove` only when all three sources are empty. Manifest entries are removed in sorted purl order. | +| `manifest_not_found` | remove, repair, rollback, vex, apply (only when the manifest vanishes between apply's existence probe and its locked read) (not `list` since v5.0: a missing manifest is an empty list) | `.socket/manifest.json` doesn't exist. For `vex` (and `scan --vex`) it fires only when, in addition, NOTHING else names a patch — no vendor-ledger entry, no lockfile reference (hosted or vendored) — and the message says so (exit 2 standalone; `apply`/`vendor --vex` treat it as their calm no-op). v3.5: `repair` proceeds anyway (vendored phase only) when a vendor ledger or vendor-path lockfile references exist, and exits 0 with a `redirect_only_project` skip (not this error) when the project's only patch state is hosted pins in its lockfiles (v5.0; or a pre-v5 `redirect-state.json`). `list` likewise no longer fires this on a hosted-only project: v5.0 lists every hosted pin the lockfiles wire (exit 0, labeled `details.mode: "hosted"` + `details.lockfiles: []` — no `details.ledger`, since hosted mode keeps none; when the manifest exists too, both are shown, purl-sorted with the manifest entry first on a tie). A pin carries its uuid and empty details unless a pre-v5 redirect ledger records the same purl and uuid (read for migration only: its record supplies the vulnerabilities / tier / description); a pre-v5 ledger record whose pin is in no lockfile is not listed. v5.0: `list` reads the vendor ledger the same way — a vendored-only project (every `scan`/`get --mode vendored` project) lists its ledger entries' embedded records labeled `Mode: vendored (recorded in .socket/vendor/state.json)` in human mode — the twin of the hosted `Mode: hosted (wired in )` line — (`details.mode: "vendored"` + `details.ledger: ".socket/vendor/state.json"` in JSON), exit 0. A standalone-`vendor` entry's fallback `record` lists the same way once no manifest entry covers it (by ledger key or base purl) — the copy manifest-less `vex` attests from, so `list` never reports `manifest_not_found` for a tree whose VEX document attests a patch; while the manifest covers it, only the manifest entry is listed. All sources always come from the SAME project (see the `--manifest-path` flag row), so `--manifest-path` into another project reads that project's state, never the local one. The error still fires when NONE of the three sources has a patch, and a present-but-broken manifest still reports `manifest_invalid`/`manifest_unreadable` regardless (corruption is never masked). A malformed pre-v5 redirect ledger degrades to "nothing to consult" with a stderr warning, muted by `--silent` (the pins still list); `list --json` carries it in the run-level `warnings[]` as `redirect_ledger_corrupt` instead of on stderr. v5.0: `rollback` likewise proceeds manifest-less when the vendor ledger or the lockfiles' hosted pins hold work (its error is the legacy `{status: "error", error: {code: "manifest_not_found", message: "Manifest not found"}, path}` shape); only the truly-empty project — no manifest, no vendor ledger, no hosted pin (a lone pre-v5 redirect ledger is deleted, exit 0) — keeps the exit-1 error, and a project whose lockfiles still reference `.socket/vendor/` artifacts with NO vendor ledger gets a distinct error naming `socket-patch repair`. `remove` (v5.0) proceeds manifest-less whenever a vendor ledger file exists or the lockfiles pin a hosted patch (an existence probe and the read-only hosted-pin discovery before the lock; the vendor ledger loads under it): ANY vendor-ledger entry matching the identifier — detached or not — is removed through the ledger path (`--preserve-state` and drift-keeps behave exactly as on the manifest path), a hosted-only match restores its upstream registry entry, and when that state exists but holds nothing for the identifier the error is `not_found` (exit 1), not this code — `manifest_not_found` fires from `remove` only when all three sources are empty. Manifest entries are removed in sorted purl order. | | `ambiguous_target` | remove, get, rollback | The identifier is a package name whose last-segment rule selects several packages across the manifest, the vendor ledger and the hosted pins (`core` → `@angular/core` and `@babel/core`). Nothing is changed (exit 1); the message names each package. Use the full name or a purl. See **Target grammar**. | -| `manifest_invalid` | list, remove, rollback | Manifest exists but is unparseable. | -| `manifest_unreadable` | list, remove, vex, get, rollback | I/O error reading manifest (vex: also an unparseable manifest; exit 2). | +| `manifest_invalid` | list, remove, apply (incl. `--check`), repair, vendor (incl. `--check`), vex, rollback, get, scan (agent mode) | Manifest exists but is unparseable (malformed JSON or a schema violation). **v5.0, MAJOR (#931)**: one mapping on every command — `apply` used to say `apply_failed`, `repair` `repair_failed`, `vendor` the undocumented `invalid_manifest`, and `apply --check` / `vendor --check` / `vex` / `get` / `rollback` `manifest_unreadable`. Exit codes unchanged (`vex`: 2, every other command 1). Agent-mode `scan` fails with it (exit 1); the other scan modes, which don't read the manifest, add a warning with the same code and go on. | +| `manifest_unreadable` | list, remove, apply (incl. `--check`), repair, vendor (incl. `--check`), vex, rollback, get, scan (agent mode) | Manifest exists but cannot be read (an I/O error: permission denied, a directory at the path, …). `vex`: exit 2. Other scan modes warn, as for `manifest_invalid`. | | `manifest_write_failed` | get | The manifest write after a download failed; the blobs that run wrote are removed again. | | `patch_fetch_failed` | get | The patch search or view request failed. | | `patch_no_applicable_files` | get | The fetched patch has no file it could record; nothing written. | -| `blob_write_failed` | get | A patch blob could not be decoded or written; the per-patch record keeps its string `error`. | -| `selection_required` | get | Several patches match and `--json` cannot prompt; `status` stays `selection_required`, `options[]` lists them. | +| `blob_write_failed` | get | A patch blob could not be decoded or written; the patch's `failed` event carries the same `errorCode` (search path: a per-patch `failed` event only). | +| `selection_required` | get | Several patches match and `--json` cannot prompt; `status` is `selectionRequired` (exit 1), the top-level `purl` names the package and `options[]` lists the candidates. | | `offline_unsupported` | scan, get | `--offline` / `SOCKET_OFFLINE`: the command needs the patch API. | | `patch_details_failed` | scan | Every patch-detail query failed. | | `api_batch_failed` | scan | Every batch query failed. | @@ -1528,7 +1541,7 @@ Every `--json` invocation emits a single JSON object that follows the **unified | `patch_not_found` | rollback | No manifest, ledger or hosted patch matches the identifier. | | `path_glob_no_match` | rollback, scan | rollback: a path target matched no patched package (exit 1). scan: a PATH glob matched no directory (usage error, exit 2). | | `vendor_ledger_missing` | rollback | Lockfiles reference `.socket/vendor/` artifacts but the vendor ledger is missing. | -| `rollback_failed` | rollback | The rollback pipeline failed before any per-package result. | +| `rollback_failed` | rollback | The rollback pipeline failed before any per-package result, or (#1066) something failed and nothing was rolled back, previewed, already original or not installed (the `failed` events stay in `events`). | | `lock_held` / `lock_io` | every lock-taking command | Another live run holds `apply.lock`, or the lock file could not be opened. | | `invalid_args` | scan, get, remove, repair | Usage error (exit 2): flags that cannot be combined. | | `global_scope_unsupported` | scan, get, vendor | Usage error (exit 2): `--global`/`--global-prefix` with hosted or vendored mode. | @@ -1544,9 +1557,21 @@ Every `--json` invocation emits a single JSON object that follows the **unified | `vendor_ledger_corrupt` | vex (every form) | `.socket/vendor/state.json` exists but is malformed or unreadable. The vendor ledger is an attestation input (records and liveness), so attesting from a partial view is refused (exit 2 standalone; the host command fails). A missing ledger is simply empty. | | `redirect_ledger_corrupt` | vex, list (`warnings[]`) | v5.0: a WARNING, no longer an error — a pre-v5 `.socket/vendor/redirect-state.json` exists but is malformed or unreadable. v5 hosted mode keeps no ledger (hosted references come from the lockfiles, their records from the API), so the file is only an optional migration record source: its records are not consulted and the run continues. Delete the file or restore it from version control. | | `serialize_failed` | vex | The built document could not be serialized (exit 2). | -| `apply_failed` | apply | apply pipeline error before any patch ran. | -| `repair_failed` | repair | repair pipeline error. | +| `apply_failed` | apply | apply pipeline error before any patch ran (never a manifest load failure — see `manifest_invalid` / `manifest_unreadable`), or the run-level code of a run whose patches failed. | +| `yarn_pnp_unsupported` | apply | A yarn-berry Plug'n'Play layout with an npm patch in scope: refused, nothing written (exit 1). | +| `repair_failed` | repair | repair pipeline error (an invalid `--download-mode`); never a manifest load failure. | | `remove_failed` | remove | Could not write the modified manifest. | +| `not_found` | remove | `status: "notFound"`: no manifest entry, vendor-ledger entry or hosted pin matches the identifier (exit 1). | +| `rollback_failed` / `vendor_revert_failed` / `hosted_revert_failed` | remove | The in-place rollback, the vendored revert or the hosted upstream restore failed before the manifest was touched (exit 1). | +| `vendor_state_retained` / `hosted_state_retained` | remove | `--skip-rollback` targeting only vendored / only hosted state: refused (exit 1). | +| `vendor_revert_kept` | remove | Every matching vendored entry was drift-kept; nothing removed (`status: "partialFailure"`, exit 1). | +| `vendor_state_unreadable` | remove, vendor | `.socket/vendor/state.json` cannot be read or parsed; refused fail-closed (exit 1). | +| `vendor_state_write_failed` | remove | The vendor ledger rewrite after a revert failed (exit 1). | +| `hosted_wiring_contested` | remove, vendor (eject), rollback | The hosted set cannot be read off the lockfiles exactly; refused (see Stable `errorCode` tags). | +| `vendor_commit_failed` / `redirect_revert_failed` / `eject_refused` / `eject_incomplete` / `eject_rollback_failed` / `offline_eject_unavailable` | vendor | The group commit failed, or a hosted→vendored eject was refused, incomplete or could not put the project back (see Stable `errorCode` tags and the Vendor command contract); exit 1. `vendor_ledger_missing` (JVM artifacts with no ledger) is also a `vendor --check` / eject refusal. | +| `no_applicable_patches` | vex | Every candidate was omitted (exit 1; omissions ride `skipped` events). | +| `product_undetected` / `write_failed` | vex | No `--product` and none could be detected / the document could not be written to `--output` (exit 2). | +| `offline` / `managed_install` / `check_failed` / `asset_not_found` / `download_failed` / `checksum_mismatch` / `verify_failed` / `swap_failed` / `permission_denied` / `update_in_progress` | `--update` | See the Self-update contract. | ### Per-subcommand action matrix @@ -1555,9 +1580,11 @@ Every `--json` invocation emits a single JSON object that follows the **unified | `apply` | `Applied` · `Skipped` (already_patched / package_not_installed / vendored) · `Failed` · `Verified` (dry-run) | | `vendor` | `Applied` (= vendored; `command` routes) · `Rebuilt` (a reused artifact restored) · `Skipped` (refusals, warnings, unsupported ecosystems) · `Failed` · `Removed` (reconcile + `--revert`) · `Verified` (dry-run) | | `list` | `Discovered` (with `details.vulnerabilities`, `details.tier`, `details.license`, `details.description`, `details.exportedAt`; hosted pins (v5.0: one per `(purl, uuid)` the lockfiles wire) additionally carry `details.mode: "hosted"` and `details.lockfiles: []` (no `details.ledger` — hosted mode keeps no ledger; the human listing labels them `Mode: hosted (wired in )`), both additive and absent on manifest entries; v5.0: vendor-ledger records carry `details.mode: "vendored"` + `details.ledger: ".socket/vendor/state.json"` the same way, and the human listing labels them `Mode: vendored (recorded in .socket/vendor/state.json)`; a `state.json` that cannot be read or parsed degrades to nothing-to-consult with the stderr line `Warning: unreadable vendor ledger (); its vendored patches are not listed` — muted by `--silent`, exit unchanged) | -| `repair` | `Downloaded` (or `Verified` on dry-run; `details: {count, mode: "file"}` — `mode` is always `"file"` since v5.0 removed the diff download path) · `Rebuilt` (vendored artifacts; `Verified` previews on dry-run) · `Skipped` (vendor_uuid_mismatch, cleanup_failed) · artifact-level `Removed` (or `Verified`) GC carrier (`details.count`, `details.checked`, `bytes`) · `Failed` events · top-level `gc` (absent under `--download-only`) | +| `repair` | `Downloaded` (or `Verified` on dry-run; `details: {count, downloadMode: "file"}` — `downloadMode` is always `"file"` since v5.0 removed the diff download path) · `Rebuilt` (vendored artifacts; `Verified` previews on dry-run; every vendored-phase event carries `details.mode: "vendored"`) · `Skipped` (vendor_uuid_mismatch, cleanup_failed) · artifact-level `Removed` (or `Verified`) GC carrier (`details.count`, `details.checked`, `bytes`) · `Failed` events · top-level `gc` (absent under `--download-only`) | | `vex` | `Verified` (one per attested subcomponent) · `Skipped` (omissions) — only under `--json --output` | -| `remove` | `Removed` (per purl; `Verified` on dry-run) · artifact-level `Removed`/`Verified` event (with `details.blobsRemoved`, `details.archivesRemoved`, `details.rolledBack`; `bytes` when the sweep removed something) · top-level `gc` (absent under `--preserve-state`) | +| `remove` | `Removed` (per purl; `Verified` on dry-run) · `RolledBack` (one per installed copy the in-place rollback restored; `summary.rolledBack`) · vendored/hosted-leg `Removed`/`Skipped` events with `details.mode` · artifact-level `Removed`/`Verified` GC carrier (`details: {count, checked}`, `bytes`; only when the sweep removed something — v5.0, MAJOR: replaces `details.blobsRemoved` / `archivesRemoved` / `rolledBack`) · top-level `gc` (absent under `--preserve-state`) · `cleanup_failed` warnings | +| `rollback` | `RolledBack` (agent copies; vendored / hosted unwinds with `details.mode`) · `Verified` (dry-run previews, manifest removals included) · `Skipped` (already_original / no_files / package_not_installed) · `Failed` (per copy or leg; artifact-level `hosted_write_failed` / `hosted_wiring_contested`) · `Removed` (manifest entries, `details.manifest: true`) · top-level `gc` (absent under `--preserve-state`) | +| `get`, `scan` | Agent mode: `Downloaded` / `Updated` (+`oldUuid`) / `Skipped` (`already_in_manifest`, `vendored`, `package_not_installed`, …) / `Failed` per patch, then `Applied` / `Failed` for the nested apply · Hosted: `Applied` (pin written) / `Skipped` (`details.mode: "hosted"`) · Vendored: `Downloaded` / `Failed`, then the vendor engine's events (`details.mode: "vendored"`) · dry runs: `Verified` / `Skipped` · `scan --prune`: `Removed` (`details.manifest: true`, or a vendored revert) / `Skipped` + top-level `gc` · `get` search outcomes: `Skipped` (`paid_required`, `package_not_installed`, …) — see [`scan` and `get` JSON](#scan-and-get-json-v50-major) | | `--update` | `Downloaded` → `Updated` (success) · `Skipped` (already_latest) · `Verified` (dry-run check, reason update_check) — see the Self-update contract section for details fields and top-level error codes | ### Migration status (v3.0) @@ -1569,131 +1596,69 @@ The unified envelope is the v3.0 contract. As of this release, these commands em - ✅ `repair` - ✅ `remove` - ✅ `vendor` +- ✅ `rollback` (v5.0, MAJOR: its legacy counters, `results[]` and per-leg arrays became events; see the [Rollback command contract](#rollback-command-contract-v50)) +- ✅ `scan` (v5.0, MAJOR — see [`scan` and `get` JSON](#scan-and-get-json-v50-major)) +- ✅ `get` (v5.0, MAJOR — same section) -The remaining commands still emit their pre-v3.0 ad-hoc JSON shapes and will migrate in a follow-up PR. Until then, downstream consumers should branch on the `command` field (envelope) vs the legacy shape (no `command` field, `status` in snake_case). v5.0: their failures already share the envelope's error shape — a top-level `error: {code, message}`, never a string or a top-level `errorCode`: - -- ⏳ `scan` — still emits the discovery + `apply.patches[*]` + `gc.*` shape documented in earlier drafts of this file. -- ⏳ `get` — still emits per-patch action arrays. -- ⏳ `rollback` — still emits per-package result records. Additive (v3.5): a manifest entry with no matching installed package appears in `results[]` as a marker record `{ "purl", "path": null, "skipped": "package_not_installed" }` — no `success`/`error` keys, never counted in `rolledBack`/`failed`, never flips the status or exit code (rollback's job is "make the tree unpatched"; a not-installed package already satisfies that end state, deliberately asymmetric with apply's exit-1 on an all-miss run whose unmatched purls are not lockfile-resolved). v5.0 keeps that legacy shape and adds the ALWAYS-PRESENT keys `warnings[]` (`{code, detail}` objects, now populated), `vendored` (meaning narrowed — MAJOR), `vendoredReverted`, `vendoredPreserved`, `vendoredKept` (`{purl, reason}`), `hosted` (`{reverted, failed: [{purl, error}], unsupported, editedFiles}`), `manifest` (`{removedEntries, preserved}`), `gc` (`{skipped: true}` \| `{removedBlobs, removedDiffArchives, removedPackageArchives, bytesFreed}`), and `paths` — full key semantics and exit rules in the [Rollback command contract](#rollback-command-contract-v50). +v5.0: every command is on the envelope; there are no legacy shapes left. One command is **intentionally not** plain-envelope and will stay that way (not migration debt): - `vex` — **hybrid**: the OpenVEX document is itself JSON and is the primary output; the envelope appears only under `--json --output `. See the [vex output channels](#vex-output-channels) table. -### `patches[]` entry shape for `get` and `scan --mode agent` +### `scan` and `get` JSON (v5.0, MAJOR) -Per-patch records emitted in `patches[]` (and in `scan --mode agent`'s -`apply.patches[*]`) carry the same metadata regardless of which command -produced them — both flow through `download_and_apply_patches_with` in -`src/commands/agent_download.rs`. The shape is stable as of v3.0; consumers can -rely on these keys. +`scan --json` and `get --json` print one envelope per run (`command: "scan"` / `"get"`), like every other envelope command. v5.0 retired their legacy shapes: no top-level `found` / `downloaded` / `applied` / `failed` / `redirected` / `totalPatches` / `freePatches` / `paidPatches` / `packagesWithPatches` counters (`summary` counts the events; the discovery tier counts are derivable from `packages[].patches[].tier`), no `patches[]`, `apply`, `download`, `vendor` or `redirect.patches` arrays (every per-patch outcome is an event), no nested `dryRun`, no string or nested warnings (every warning is a top-level `{code, detail}` entry), and no snake_case statuses or actions (`would_add`, `would_pin`, `would_vendor`, `pinned`, `partial_failure`, `selection_required`, … are gone). ```jsonc { - "purl": "pkg:npm/minimist@1.2.2", - "uuid": "11111111-1111-4111-8111-111111111111", - "action": "added" | "updated" | "skipped" | "failed", - "oldUuid": "", // only on action=updated + "command": "scan", // or "get" + "status": "success", // partialFailure (any failed event) | error | get: notFound | noPackages | noMatch | notInstalled | paidRequired | selectionRequired + "dryRun": false, + "events": [ , ... ], // see the matrix row for get / scan + "summary": { ... }, // = the event counts (vendored-leg advisories are uncounted `skipped` events, as in `vendor`) + "warnings": [ { "code": "...", "detail": "..." } ], + "gc": { ... }, // scan --prune / --sync only + "vex": { "path": "...", "statements": 3, "format": "openvex-0.2.0", "warnings": [ ... ] }, // scan --vex only + + // scan payload (not events) + "scannedPackages": 12, "lockfileOnlyPackages": 0, "canAccessPaidPatches": false, + "packages": [ ... ], // discovery: one entry per package with patches (batch records) + "paths": [], "updates": [ { "purl": "...", "oldUuid": "...", "newUuid": "..." } ], + "rollout": { ... }, "policy": { ... }, "redirectState": { ... }, // as documented above + "redirect": { "mode": "hosted", "rewrittenFiles": [ "package-lock.json" ] }, // hosted only + "removedVendorOrphanDirs": 0, // scan --prune only + + // get payload, selectionRequired only + "purl": "pkg:npm/foo@1.0.0", "options": [ { "uuid", "tier", "publishedAt", "description", "vulnerabilities" } ] +} +``` + +**Events per mode.** Agent mode (`get`, `scan --mode agent` / `--sync`): a fetched patch is `downloaded` (new manifest record) or `updated` (+`oldUuid`: it replaced another uuid), one already recorded at the same uuid is `skipped` / `already_in_manifest` (it is still re-applied), a patch that could not be fetched or recorded is `failed` (`download_failed`, `patch_no_applicable_files`, `blob_write_failed`); then the nested apply adds `applied` for each recorded patch it patched (or found patched) and `failed` (apply's `apply_failed` / `package_not_installed` with its `error`, #424) for each it failed — a failing manifest patch the run did not select (the nested apply covers the whole `--ecosystems`-scoped manifest) gets its own `failed` event, and a failure no single patch explains (an unreadable manifest, the yarn PnP refusal, unavailable patch sources) is a purl-less `failed` event, so a failed apply is always `partialFailure` (exit 1). The apply's mismatch overwrites (#1004) are `content_mismatch_overwritten` warnings; a record whose purl the vendor ledger wires at another uuid adds a `vendored_uuid_drift` warning. Vendor-owned and lockfile-only selections are `skipped` / `vendored` and `skipped` / `package_not_installed` before any download. Hosted and vendored events are described under "Scan modes" above; every one carries `details.mode`. `get`'s narrowing adds `skipped` events (`package_not_installed`, `yarn_pnp_unsupported`, `pnpm_pnp_unsupported`) and `paidRequired` runs one `skipped` / `paid_required` event per patch found. - // ----- patch metadata (only on action=added | updated) ----- +**Patch metadata.** `downloaded` / `updated` events (agent and vendored) carry the patch metadata in `details`, the same keys for every command: + +```jsonc +"details": { "description": "Fixes prototype pollution in minimist", "license": "MIT", "tier": "free" | "paid", - "exportedAt": "2024-01-01T00:00:00Z", // publishedAt from API — when the PATCH was published - "severity": "critical" | "high" | "medium" | "low", // max across all vulnerabilities; omitted when no vulns + "exportedAt": "Fri, 27 Mar 2026 19:12:42 GMT", // publishedAt from the API — when the PATCH was published + "severity": "critical" | "high" | "medium" | "low", // max across vulnerabilities; omitted when none is known "vulnerabilities": [ - { - "id": "GHSA-xvch-5gv4-984h", // GHSA/CVE/etc — the canonical advisory ID - "cves": ["CVE-2024-12345"], - "severity": "high", - "summary": "Prototype Pollution", - "description": "merge() does not check Object.prototype" - } - // … one entry per advisory the patch addresses, sorted by `id` - ], - - // ----- failure path (only on action=failed) ----- - "errorCode": "vendor_bun_workspace_unsupported", // additive; the vendored-mode Bun preflight refusals (+ vendor_state_unreadable) and agent-mode apply failures (apply_failed, package_not_installed) - "error": "could not fetch details" + { "id": "GHSA-xvch-5gv4-984h", "cves": ["CVE-2024-12345"], "severity": "high", + "summary": "Prototype Pollution", "description": "merge() does not check Object.prototype" } + ] // sorted by `id` } ``` -The metadata block (`description`, `license`, `tier`, `exportedAt`, -`severity`, `vulnerabilities[]`) is intentionally **omitted on -`skipped`** — those records mean "already in manifest, no work taken", -and the consumer already saw the metadata when the patch was first -added. It's also omitted on `failed`. - -Additive (v3.6): a `skipped` record may carry an `errorCode` naming WHY it -was skipped before download — `package_not_installed` (the coarse -installed-version narrowing; see "get --mode and installed narrowing"), -`yarn_pnp_unsupported`, or `pnpm_pnp_unsupported` (PnP layout refusals) — -the same calm-skip vocabulary as scan's pre-download partitions. Absent on -the classic "already in manifest" skip. - -Vendored mode (v5.0) uses the detached download vocabulary instead: -`get --mode vendored`'s `patches[]` and `scan --mode vendored`'s -`download.patches[]` carry `action: "downloaded" | "skipped" | "failed"` -(no `added`/`updated` — the vendor ledger, not the manifest, tracks patch -generations; a `downloaded` record whose purl the ledger already holds at -another uuid carries the additive `oldUuid`, and its human `[fetch]` line -reads ` (replacing )`) beside the same metadata keys, and -the enclosing object carries `downloaded: N` and `detached: true`. - -Additive: a `failed` record may ALSO carry `errorCode` beside `error` — -today exactly the vendored-mode Bun preflight refusals -(`vendor_bun_lockb_invalid`, `vendor_lockfile_missing`, -`vendor_lockfile_version_unsupported`, `vendor_bun_workspace_unsupported`, -and `vendor_state_unreadable` when the preflight cannot read -`.socket/vendor/state.json`) -that `get --mode vendored` and `scan --mode vendored` (`download.patches[]`) -emit before any download; see "get --mode and installed narrowing" → -Vendored → Bun vendored preflight. Every other download-phase `failed` -record carries only `error`. The dry-run preview's `would_refuse` records -carry the same pair. - -Agent-mode apply failures (#424): when the nested apply that follows the -download (`get` / `scan --mode agent`, not `--save-only`) fails a patch, -that patch's record becomes `action: "failed"` with the same `errorCode` / -`error` pair the standalone `apply --json` reports — `apply_failed` (the -apply error text, e.g. `Permission denied (os error 13)`) or -`package_not_installed` (no installed copy, and the project's lockfiles -do not resolve it either). The record keeps `purl` and `uuid`, drops the -metadata like every `failed` record, and stays saved in the manifest (only -the apply failed). A failing manifest patch the run did not select (the -nested apply covers the whole `--ecosystems`-scoped manifest) is appended -as its own `failed` record. `failed` counts these records beside the -download failures, and `applied` counts only the patches that did apply. -A failure no single patch explains (an unreadable manifest, the yarn PnP -refusal, unavailable patch sources) sets top-level `error: {code, message}` -on the same object (`apply` in `scan`'s envelope); `status` stays -`partial_failure`. v5.0: this replaced the top-level `errorCode` + string -`error` pair (MAJOR). - -Agent-mode mismatch overwrites (#1004): when the nested apply's default -mismatch policy overwrites a file that matched neither the patch's -beforeHash nor its afterHash (a local edit, a `patch-package` / `npm -patch` change), the same object's string `warnings[]` (`apply` in -`scan`'s envelope) carries one -`(content_mismatch_overwritten) : did not match the patch's -expected original content; the full verified patched content was applied` -entry per file — the warning `apply --json` reports as a -`content_mismatch_overwritten` `skipped` event and the human run prints on -stderr. The patch record keeps its `added` / `updated` / `skipped` action -and counts as applied; the status and exit code are unchanged (`--strict` -turns the case into an apply failure instead). - -`vulnerabilities[]` is always sorted by `id` so consumer diffs and -test snapshots are stable. `severity` at the top level is the max -across the array using the ordering `critical > high > medium = moderate > low > (unknown)`. - -`exportedAt` is the API's `publishedAt` **verbatim**: the date **the -patch** was published, *not* the date the upstream package version was -released. The two are unrelated — a package from 2020 routinely carries -a patch published last week, and two patches for one package version -carry two different dates. Note the wire format is RFC 2822 / HTTP-date -(`Fri, 27 Mar 2026 19:12:42 GMT`), not ISO 8601 — do not compare these -as raw strings, they sort by weekday name. +`exportedAt` is the API's `publishedAt` **verbatim**: the date **the patch** was published, *not* the date the upstream package version was released. The wire format is RFC 2822 / HTTP-date, not ISO 8601 — do not compare these as raw strings. `severity` orders `critical > high > medium = moderate > low > (unknown)`. + +**Errors.** Every failure prints the full envelope (`status: "error"`, `error: {code, message}`) — usage errors (exit 2), `--offline` (`offline_unsupported`), an invalid `socket.yml`, a held lock, an unloadable manifest (`manifest_invalid` / `manifest_unreadable`), every batch or detail query failing (`api_batch_failed` / `patch_details_failed`, keeping the discovery payload computed so far), hosted refusals, a failed embedded VEX. Exactly one JSON document per run, an agent-mode engine error included (v5.0 fixes a run that printed two). `selectionRequired` (exit 1) carries `error.code: "selection_required"` beside the status. + +**VEX and warnings.** `scan --vex` reports the document in the envelope's `vex`; a hosted run's `vex.warnings` carries `vex_hosted_unverified` (its pins are attested from their records, not hash-verified; v5.0: replaces `vex.verified: false`), and `--vex` on a `--dry-run` is a `vex_skipped_dry_run` warning (v5.0: replaces `vex: {skipped: true, reason: "dry_run"}`). Run-level warnings carry no `level` (v5.0: the Gradle notes' `level: "info"` is gone; human mode still prints them as `Note:`). + +**Exit codes.** Unchanged, except that the human and `--json` arms now agree (#1062): a scan whose detail queries all succeed but return no patch records selects nothing and exits 0 in both (v5.0: the human arm used to exit 1); report-only `scan --prune` fetches the detail records whenever a patch is downloadable in both arms, so every query failing exits 1 in both; hosted `--json` gates `--prune` on the socket.yml policy like the human arm; the human vendored `--dry-run --prune` previews the GC like the JSON arm. Agent-mode `scan` with a manifest that exists but cannot be loaded fails (exit 1) before any query, in both arms; the other modes warn (`manifest_invalid` / `manifest_unreadable`) and go on without it. ### Which patch gets selected @@ -1724,12 +1689,11 @@ false, so the winner is the best patch the account can download. applied patch only on a meaningful rung — higher severity, more advisories at equal severity, or a real, strictly later publish date at equal severity and advisory count. The tier and uuid tiebreaks and a missing date never -count. Every mode that fetches the by-package records (hosted, vendored, agent, and -every human run with a downloadable patch) judges this on those records, -so `updates[]` lists exactly the UPGRADE rows the run acts on; a package -the by-package lookup returns no offer for, and a JSON report-only run -(which fetches no by-package records), fall back to the batch records -(`ranking::batch_supersedes`). When the selection does not supersede the +count. Every run with a downloadable patch fetches the by-package records +(every mode, human and `--json` alike — v5.0: a JSON report-only run too) +and judges this on those records, so `updates[]` lists exactly the +UPGRADE rows the run acts on; a package the by-package lookup returns no +offer for falls back to the batch records (`ranking::batch_supersedes`). When the selection does not supersede the recorded patch, scan keeps the recorded one (v5.0): a re-scan never swaps an applied patch for an equal sibling. @@ -1756,19 +1720,19 @@ Production published its first merged patch on 2026-09-04. This ordering is also the presentation order everywhere patches are listed — `scan --json`'s `packages[].patches[]`, `get`'s "Found -patches:" listing, and the `selection_required` `options[]` array — so +patches:" listing, and the `selectionRequired` `options[]` array — so `patches[0]` for a package is the patch that would be applied, and `updates[].newUuid` names that same patch. `scan` never shows a picker: it always takes the top-ranked downloadable patch. Neither does hosted or vendored `get` (v5.0): no picker, no -confirmation, no `selection_required` — the top-ranked accessible patch per +confirmation, no `selectionRequired` — the top-ranked accessible patch per package, in `--json` too. On agent-mode `get`, free/unauthorized callers with more than one candidate -for a PURL still get the interactive picker (or `selection_required` in +for a PURL still get the interactive picker (or `selectionRequired` in `--json`); the ranking decides the presented order and hence the highlighted default, not the outcome. `--yes` answers the picker with that default without showing it (the same pick a non-terminal run makes); -`--json` keeps `selection_required` even with `--yes`. +`--json` keeps `selectionRequired` even with `--yes`. One additive key may appear on `scan --json`'s `packages[].patches[]` entries, omitted when absent: `publishedAt`, present whenever the server @@ -1776,7 +1740,7 @@ supplies it (the public-proxy fallback path fills it in from the per-package results). > **Known gap — batch responses without `publishedAt`.** `scan`'s -> discovery (`packages[]`, and `updates[]` on a JSON report-only run) is +> discovery (`packages[]`) is > built from the **batch** endpoint, whose response shape currently omits `publishedAt`; > the selection that `--mode agent` performs is built from the **by-package** > endpoint, which carries it. The two diverge wherever the date decides — @@ -1825,12 +1789,58 @@ socket-patch repair --json | jq '{ }' ``` +What a `scan --mode agent` or `get` run recorded (v5.0, envelope shape): + +```bash +socket-patch scan --mode agent --json | jq ' + .events[] + | select(.action == "downloaded" or .action == "updated") + | { purl, uuid, oldUuid, severity: .details.severity, fixes: [.details.vulnerabilities[].id] } +' +``` + +Hosted pins a run wrote (dry run: `verified`), and why the rest were skipped: + +```bash +socket-patch scan --mode hosted --json | jq ' + { pinned: [.events[] | select(.details.mode == "hosted" and .action == "applied") | .purl], + skipped: [.events[] | select(.action == "skipped") | {purl, code: .errorCode}], + files: .redirect.rewrittenFiles } +' +``` + +Available patches by tier (the discovery payload; v5.0 removed `freePatches` / `paidPatches`): + +```bash +socket-patch scan --prune --dry-run --json | jq ' + [.packages[].patches[].tier] | group_by(.) | map({(.[0]): length}) | add +' +``` + +The candidates of a `get` that needs a choice (`status == "selectionRequired"`, exit 1): + +```bash +socket-patch get lodash --mode agent --json | jq -r ' + select(.status == "selectionRequired") | .options[] | "\(.uuid) \(.tier) \(.publishedAt)" +' +``` + Why a run failed, on any command (v5.0: `error` is always `{code, message}`): ```bash socket-patch scan --json | jq -r 'select(.status == "error") | "\(.error.code): \(.error.message)"' ``` +What a rollback unwound and what it could not (v5.0 envelope): + +```bash +socket-patch rollback --json --yes | jq '{ + rolledBack: [.events[] | select(.action == "rolledBack") | {purl, mode: (.details.mode // "agent")}], + failed: [.events[] | select(.action == "failed") | {purl, errorCode, error}], + removedFromManifest: [.events[] | select(.action == "removed" and .details.manifest) | .purl] +}' +``` + Combined apply summary for a PR description: ```bash @@ -1863,7 +1873,7 @@ Exit `1` when `status` is `partialFailure` (any `events[*].action == "failed"`) |---|---| | `0` | A non-empty OpenVEX document was produced | | `1` | Nothing attested: `no_applicable_patches` (every candidate was omitted — by verification, a wiring gate, or a missing record; the omissions ride `skipped` events) or `no_patches` (an empty manifest file and nothing wired anywhere) | -| `2` | Hard error: `manifest_not_found` (no manifest AND no vendor-ledger record / lockfile reference anywhere), `manifest_unreadable`, `vendor_ledger_corrupt` (v5.0: `redirect_ledger_corrupt` is a warning), `json_requires_output`, `product_undetected`, `serialize_failed`, `write_failed` | +| `2` | Hard error: `manifest_not_found` (no manifest AND no vendor-ledger record / lockfile reference anywhere), `manifest_invalid` / `manifest_unreadable`, `vendor_ledger_corrupt` (v5.0: `redirect_ledger_corrupt` is a warning), `json_requires_output`, `product_undetected`, `serialize_failed`, `write_failed` | A missing manifest alone is not an error: a hosted or vendored checkout attests from its lockfiles (see "Manifest-less VEX"). Embedded `--vex` maps every failure to the host command's exit `1`. diff --git a/crates/socket-patch-cli/src/commands/agent_download.rs b/crates/socket-patch-cli/src/commands/agent_download.rs index adc5e5aed..e23ef9658 100644 --- a/crates/socket-patch-cli/src/commands/agent_download.rs +++ b/crates/socket-patch-cli/src/commands/agent_download.rs @@ -31,7 +31,10 @@ use crate::commands::vlt_preflight::{ vlt_refusal_for, vlt_vendor_preflight_selected, VltVendorRefusal, }; use crate::ecosystem_dispatch::{find_all_packages_for_rollback, partition_purls}; -use crate::ui::print_json; +use crate::json_envelope::{ + Command as EnvelopeCommand, Envelope, EnvelopeError, PatchAction as EventAction, PatchEvent, + RunWarning, +}; /// The closing error printed when the nested apply failed. Apply's own /// per-package `Error: Failed to patch …` lines print above it, even @@ -52,18 +55,12 @@ pub(crate) enum PatchAction { Skipped, } -/// Compute the `(status, exit_code)` pair for a download+apply run. -/// -/// A non-zero exit code must ALWAYS pair with a non-`success` status: -/// both are derived from the same predicate here so a JSON consumer -/// reading `status` and a shell reading `$?` can never disagree (a failed -/// *apply* step must not report `success`). -pub(crate) fn run_outcome(patches_failed: bool, apply_failed: bool) -> (&'static str, i32) { - if patches_failed || apply_failed { - ("partial_failure", 1) - } else { - ("success", 0) - } +/// The exit code of a download+apply run: 1 when a patch failed to +/// download or the nested apply failed. Every such run also records a +/// `failed` event ([`record_apply_outcome`] guarantees one for a failed +/// apply), so the envelope's `partialFailure` status and `$?` agree. +pub(crate) fn run_outcome(patches_failed: bool, apply_failed: bool) -> i32 { + i32::from(patches_failed || apply_failed) } /// Classify what `download_and_apply_patches_with` will do to a given PURL based on @@ -163,55 +160,41 @@ pub(crate) fn patch_event_metadata(patch: &PatchResponse) -> serde_json::Value { serde_json::Value::Object(meta) } -/// Merge a metadata object (from [`patch_event_metadata`]) into a -/// per-patch action record. Convenience wrapper that handles the -/// unwrap of `Value::Object`. -pub(crate) fn merge_metadata(record: &mut serde_json::Value, meta: serde_json::Value) { - if let (Some(record_obj), serde_json::Value::Object(meta_obj)) = (record.as_object_mut(), meta) - { - for (k, v) in meta_obj { - record_obj.insert(k, v); - } - } -} - -/// Report an error to the caller: a `{status: "error", error: {code, -/// message}}` object on stdout when `json` is true, otherwise a plain -/// `Error: ...` on stderr. Every top-level `get` failure goes through here -/// (or [`report_lock_failure`]) so the error shape cannot drift. -pub(crate) fn report_error(json: bool, code: &str, message: impl std::fmt::Display) { - let message = message.to_string(); - if json { - crate::json_envelope::print_legacy_error(code, &message); - } else { - eprintln!("Error: {message}"); +/// Merge `mode` (`"vendored"` / `"hosted"`) into an event's `details`, the +/// leg tag every vendored- and hosted-leg event carries (rule shared with +/// `list`). Agent-mode events carry no mode. +pub(crate) fn tag_mode(mut event: PatchEvent, mode: Option<&str>) -> PatchEvent { + if let Some(mode) = mode { + let mut details = event + .details + .take() + .filter(serde_json::Value::is_object) + .unwrap_or_else(|| serde_json::json!({})); + details["mode"] = serde_json::json!(mode); + event.details = Some(details); } + event } -/// Report a failed apply-lock acquire in get's legacy error shape — the -/// `{status: "error", error: {code, message}}` object every other hard -/// error here uses, with the stable code (`lock_held` / `lock_io`) the -/// other lock sites emit — and return it for the caller's early-return -/// guard. The message/code mapping is -/// [`crate::commands::lock_cli::lock_failure`]'s, so the waited clause and -/// the I/O rendering cannot drift from `apply`'s. +/// Report a failed apply-lock acquire: the human lock error on stderr +/// (unless `json`), and the `{code, message}` (`lock_held` / `lock_io`) the +/// caller's envelope carries as its top-level `error`. The message/code +/// mapping is [`crate::commands::lock_cli::lock_failure`]'s, so the waited +/// clause and the I/O rendering cannot drift from `apply`'s. pub(crate) fn report_lock_failure( json: bool, socket_dir: &Path, err: &LockError, timeout: Duration, -) -> serde_json::Value { +) -> EnvelopeError { let (code, message) = lock_failure(err, timeout); - let envelope = crate::json_envelope::legacy_error(code, &message); - if json { - print_json(&envelope); - } else { + if !json { eprint!( "{}", crate::commands::lock_cli::format_lock_error(socket_dir, err, timeout) ); } - envelope + EnvelopeError::new(code, message) } /// Decode a base64 string and store it as the blob `blobs_dir/hash` @@ -700,8 +683,6 @@ pub(crate) struct FetchedPatch { /// What the shared fetch loop produced over one selection. pub(crate) struct FetchBatch { - /// Selection size after installed-release narrowing. - found: usize, skipped: usize, /// Manifest store only: the `skipped` patches whose same uuid is /// already recorded — still owed a nested apply, since the installed @@ -713,10 +694,15 @@ pub(crate) struct FetchBatch { /// Ledger store only: `(purl, record)` reused from a detached entry /// already at the selected uuid (no fetch). reused: Vec<(String, PatchRecord)>, - /// Per-patch JSON records in selection order (the contract vocabulary). - patches_json: Vec, + /// Per-patch events in selection order (the envelope vocabulary): + /// `downloaded` / `updated` (+ `oldUuid`) with the patch metadata in + /// `details`, `skipped` (`already_in_manifest`), `failed` (+ code). + /// Ledger-store events carry `details.mode: "vendored"`. + events: Vec, /// Release-narrowing fallbacks (uninstalled base, no matching variant). warnings: Vec, + /// `Some("vendored")` for the ledger store (see [`tag_mode`]). + mode: Option<&'static str>, } impl FetchBatch { @@ -727,28 +713,35 @@ impl FetchBatch { &mut self, json: bool, line: Option, - purl: &str, - uuid: &str, + (purl, uuid): (&str, &str), + error_code: &str, error: &str, - error_code: Option<&str>, ) { if let (false, Some(line)) = (json, line) { eprintln!(" {line}"); } - let mut record = serde_json::json!({ - "purl": purl, - "uuid": uuid, - "action": "failed", - }); - if let Some(code) = error_code { - record["errorCode"] = serde_json::json!(code); - } - record["error"] = serde_json::json!(error); - self.patches_json.push(record); + self.events.push(tag_mode( + PatchEvent::new(EventAction::Failed, purl) + .with_uuid(uuid) + .with_error(error_code, error), + self.mode, + )); self.failed += 1; } } +/// `errorCode` of a patch whose view could not be fetched (network error, +/// 404). +pub(crate) const DOWNLOAD_FAILED: &str = "download_failed"; +/// `errorCode` of a fetched patch with no file it could record. +pub(crate) const PATCH_NO_APPLICABLE_FILES: &str = "patch_no_applicable_files"; +/// `errorCode` of a patch whose blob content could not be decoded or +/// written. +pub(crate) const BLOB_WRITE_FAILED: &str = "blob_write_failed"; +/// `errorCode` of the `skipped` event for a selected patch the manifest +/// already records at the same uuid (agent mode still re-applies it). +pub(crate) const ALREADY_IN_MANIFEST: &str = "already_in_manifest"; + /// The vendored-mode preflight verdicts the download phase refuses by /// (Bun's project-level one, vlt's per purl); agent downloads pass none. #[derive(Clone, Copy, Default)] @@ -944,14 +937,17 @@ pub(crate) async fn fetch_selected_patches( } let mut batch = FetchBatch { - found: selected.len(), skipped: 0, already_recorded: 0, failed: 0, fetched: Vec::new(), reused: Vec::new(), - patches_json: Vec::new(), + events: Vec::new(), warnings, + mode: match store { + RecordStore::Ledger(_) => Some("vendored"), + RecordStore::Manifest(_) => None, + }, }; // The view GETs the loop below makes — every patch past the refusal @@ -992,10 +988,9 @@ pub(crate) async fn fetch_selected_patches( batch.fail( params.json, Some(format!("[error] {purl} ({code}): {detail}")), - purl, - uuid, + (purl, uuid), + code, detail, - Some(code), ); continue; } @@ -1006,11 +1001,9 @@ pub(crate) async fn fetch_selected_patches( if !quiet { eprintln!("{}", format_record_skip(purl, "already vendored")); } - batch.patches_json.push(serde_json::json!({ - "purl": purl, - "uuid": uuid, - "action": "skipped", - })); + // No event: the reused record goes to the vendor engine, whose + // own event (`already_vendored`, `rebuilt`, …) is the package's + // account. batch.reused.push((purl.to_string(), record)); batch.skipped += 1; continue; @@ -1024,10 +1017,9 @@ pub(crate) async fn fetch_selected_patches( batch.fail( params.json, Some(format!("[error] {purl} ({code}): {detail}")), - purl, - uuid, + (purl, uuid), + code, detail, - Some(code), ); continue; } @@ -1057,10 +1049,9 @@ pub(crate) async fn fetch_selected_patches( batch.fail( params.json, Some(format!("[fail] {purl} (could not fetch details)")), - purl, - uuid, + (purl, uuid), + DOWNLOAD_FAILED, "could not fetch details", - None, ); continue; } @@ -1068,10 +1059,9 @@ pub(crate) async fn fetch_selected_patches( batch.fail( params.json, Some(format!("[fail] {purl} ({e})")), - purl, - uuid, + (purl, uuid), + DOWNLOAD_FAILED, &e.to_string(), - None, ); continue; } @@ -1095,11 +1085,11 @@ pub(crate) async fn fetch_selected_patches( if !quiet { eprintln!("{}", format_record_skip(&patch.purl, "already in manifest")); } - batch.patches_json.push(serde_json::json!({ - "purl": patch.purl, - "uuid": patch.uuid, - "action": "skipped", - })); + batch.events.push( + PatchEvent::new(EventAction::Skipped, patch.purl.as_str()) + .with_uuid(patch.uuid.as_str()) + .with_reason(ALREADY_IN_MANIFEST, "already in manifest"), + ); batch.skipped += 1; batch.already_recorded += 1; continue; @@ -1119,10 +1109,9 @@ pub(crate) async fn fetch_selected_patches( "[fail] {} (patch has no applicable files)", patch.purl )), - &patch.purl, - &patch.uuid, + (&patch.purl, &patch.uuid), + PATCH_NO_APPLICABLE_FILES, "patch has no applicable files", - None, ); continue; } @@ -1137,26 +1126,38 @@ pub(crate) async fn fetch_selected_patches( batch.fail( params.json, None, - &patch.purl, - &patch.uuid, + (&patch.purl, &patch.uuid), + BLOB_WRITE_FAILED, "Blob decode or write failed", - None, ); continue; } } } - let (label, tag) = match (store, &action) { - (RecordStore::Ledger(_), _) => ("downloaded", "fetch"), - (RecordStore::Manifest(_), PatchAction::Updated { .. }) => ("updated", "update"), - (RecordStore::Manifest(_), _) => ("added", "add"), + let tag = match (store, &action) { + (RecordStore::Ledger(_), _) => "fetch", + (RecordStore::Manifest(_), PatchAction::Updated { .. }) => "update", + (RecordStore::Manifest(_), _) => "add", + }; + // Description / severity / vulnerability IDs ride `details` so + // PR-comment bots, dashboards, and CLI consumers can render the + // patch without a second round-trip to the API. + let mut details = patch_event_metadata(&patch); + let mut event = match (store, &action) { + // A manifest replacement is the envelope's `updated` (+ oldUuid). + (RecordStore::Manifest(_), PatchAction::Updated { old_uuid }) => { + PatchEvent::new(EventAction::Updated, patch.purl.as_str()).with_old_uuid(old_uuid) + } + // The vendor ledger tracks patch generations, not the download: + // a fetched replacement is still `downloaded`, naming the uuid + // it will replace in `details.oldUuid`. + (RecordStore::Ledger(_), PatchAction::Updated { old_uuid }) => { + details["oldUuid"] = serde_json::json!(old_uuid); + PatchEvent::new(EventAction::Downloaded, patch.purl.as_str()) + } + _ => PatchEvent::new(EventAction::Downloaded, patch.purl.as_str()), }; - let mut record = serde_json::json!({ - "purl": patch.purl, - "uuid": patch.uuid, - "action": label, - }); if let PatchAction::Updated { old_uuid } = &action { if !quiet { // Defensive: a malformed/short UUID in the store must not @@ -1167,15 +1168,11 @@ pub(crate) async fn fetch_selected_patches( crate::ui::short_uuid(old_uuid) ); } - record["oldUuid"] = serde_json::json!(old_uuid); } else if !quiet { eprintln!(" [{tag}] {}", normalize_purl(&patch.purl)); } - // Splice description / severity / vulnerability IDs into the record - // so PR-comment bots, dashboards, and CLI consumers can render the - // patch without a second round-trip to the API. - merge_metadata(&mut record, patch_event_metadata(&patch)); - batch.patches_json.push(record); + event = event.with_uuid(patch.uuid.as_str()).with_details(details); + batch.events.push(tag_mode(event, batch.mode)); batch.fetched.push(FetchedPatch { patch, files, @@ -1186,8 +1183,46 @@ pub(crate) async fn fetch_selected_patches( batch } -/// Download status and patch records used to verify server artifacts. -pub(crate) type DetachedDownload = (i32, serde_json::Value, HashMap); +/// What the detached (vendored) download phase produced: its exit code +/// (1 when a patch failed or was refused), the per-patch events (each +/// `details.mode: "vendored"`) and run-level warnings for the caller's +/// envelope, how many patches failed, and the records the vendor step +/// consumes. +pub(crate) struct DetachedDownload { + pub(crate) code: i32, + pub(crate) events: Vec, + pub(crate) warnings: Vec, + pub(crate) failed: usize, + pub(crate) records: HashMap, +} + +impl DetachedDownload { + /// Fold the phase's events and warnings into `env` (records via + /// [`Envelope::record`], so `summary` and `status` follow) and hand back + /// the exit code and the records. + pub(crate) fn into_envelope( + self, + env: &mut Envelope, + ) -> (i32, usize, HashMap) { + for event in self.events { + env.record(event); + } + env.warnings.extend(self.warnings); + (self.code, self.failed, self.records) + } +} + +/// The release-narrowing fallbacks as run-level warnings. +fn release_warnings(warnings: Vec) -> Vec { + warnings + .into_iter() + .map(|w| RunWarning::new(RELEASE_NARROWING, w)) + .collect() +} + +/// Warning code: release narrowing kept every variant of a base (the base +/// is not installed, or no variant matches the installed distribution). +pub(crate) const RELEASE_NARROWING: &str = "release_narrowing"; /// [`download_patch_records_with`], handing `prior` (scan's npm crawl of /// the untouched tree) to the lock-text refusals' installed-copy lookup. @@ -1272,29 +1307,28 @@ pub(crate) async fn download_patch_records_preflighted( ) .await; - let downloaded = batch.fetched.len(); let mut records: HashMap = batch.reused.into_iter().collect(); for FetchedPatch { patch, files, .. } in batch.fetched { records.insert(patch.purl.clone(), build_patch_record(&patch, files)); } - let mut result_json = serde_json::json!({ - "found": batch.found, - "downloaded": downloaded, - "skipped": batch.skipped, - "failed": batch.failed, - "detached": true, - "patches": batch.patches_json, - }); - if !batch.warnings.is_empty() { - result_json["warnings"] = serde_json::json!(batch.warnings); + DetachedDownload { + code: i32::from(batch.failed > 0), + events: batch.events, + warnings: release_warnings(batch.warnings), + failed: batch.failed, + records, } - (i32::from(batch.failed > 0), result_json, records) } -/// Emit a warning (stderr `[note]` + `warnings[]`) for every added/updated -/// patch record whose purl the vendor ledger still wires at a DIFFERENT -/// uuid — VEX verification fails closed (`vendor_uuid_mismatch`) until a -/// `vendor` run refreshes the committed artifact. +/// Warning code: the manifest now records a patch uuid the vendor ledger +/// does not wire for that purl (see [`warn_on_vendored_uuid_drift`]). +pub(crate) const VENDORED_UUID_DRIFT: &str = "vendored_uuid_drift"; + +/// Emit a warning (stderr `[note]` + `warnings[]`) for every recorded +/// (`downloaded` / `updated`) `(purl, uuid)` whose purl the vendor ledger +/// still wires at a DIFFERENT uuid — VEX verification fails closed +/// (`vendor_uuid_mismatch`) until a `vendor` run refreshes the committed +/// artifact. /// /// Kept out of [`download_and_apply_patches_with`]'s body on purpose: that /// function sits on the in-process scan→download→apply chain, whose summed @@ -1302,8 +1336,8 @@ pub(crate) async fn download_patch_records_preflighted( pub(crate) async fn warn_on_vendored_uuid_drift( project_root: &Path, quiet: bool, - downloaded_patches: &[serde_json::Value], - warnings: &mut Vec, + recorded: &[(String, String)], + warnings: &mut Vec, ) { let Ok(vendor_state) = load_state(project_root).await else { return; @@ -1311,15 +1345,9 @@ pub(crate) async fn warn_on_vendored_uuid_drift( if vendor_state.entries.is_empty() { return; } - for rec in downloaded_patches { - let (Some(purl), Some(uuid)) = (rec["purl"].as_str(), rec["uuid"].as_str()) else { - continue; - }; - if !matches!(rec["action"].as_str(), Some("added" | "updated")) { - continue; - } + for (purl, uuid) in recorded { let entry = lookup_entry(&vendor_state.entries, purl); - if let Some(entry) = entry.filter(|e| e.uuid != uuid) { + if let Some(entry) = entry.filter(|e| &e.uuid != uuid) { let w = format!( "{purl} is vendored at patch {} but the manifest now records {uuid}; \ run `socket-patch vendor` to refresh the committed artifact", @@ -1328,7 +1356,7 @@ pub(crate) async fn warn_on_vendored_uuid_drift( if !quiet { eprintln!(" [note] {w}"); } - warnings.push(w); + warnings.push(RunWarning::new(VENDORED_UUID_DRIFT, w)); } } } @@ -1419,22 +1447,6 @@ pub(crate) async fn run_nested_apply( report } -/// The nested apply's non-fatal warnings (today the default policy's -/// `content_mismatch_overwritten` overwrites, #1004) as `get`'s string -/// `warnings[]` entries, code-prefixed like `get`'s `fold_narrowing_into_result`. -/// A JSON caller's nested apply is silent, so the envelope is the only -/// place these surface; a human caller's apply already printed them. -pub(crate) fn apply_warning_lines(report: Option<&ApplyRunReport>) -> Vec { - report - .map(|r| { - r.warnings - .iter() - .map(|w| format!("({}) {}", w.code, w.detail)) - .collect() - }) - .unwrap_or_default() -} - /// Whether apply's package key `key` covers the patch record purl /// `record`: the same purl, or `key` is the unqualified base of a /// qualified record (apply keys a release-variant base by its base purl). @@ -1444,87 +1456,104 @@ pub(crate) fn apply_key_covers(key: &str, record: &str) -> bool { || (!key.trim().contains(['?', '#']) && PurlKey::same(key, record)) } -/// Fold a failed nested apply into a `get` / `scan --mode agent` JSON -/// envelope, so `--json` says what the human run prints (#424). Each -/// `patches[]` record the apply failed becomes the `failed` record shape -/// (`purl`, `uuid`, `action: "failed"`, `errorCode`, `error`; no metadata, -/// as on every `failed` record); any other failed manifest patch (one this -/// run did not select) gets its own `failed` record (`uuid_of` looks up -/// its uuid); a run-level reason rides the envelope's top-level -/// `error: {code, message}` (status stays `partial_failure`: the downloads -/// it reports still landed). `failed` grows by every record marked or -/// appended here. Returns `applied`: how many of the run's recorded -/// patches apply really patched (or found already patched). -pub(crate) fn fold_apply_failures( - envelope: &mut serde_json::Value, - report: &ApplyRunReport, +/// Record what the nested apply did to the run's recorded patches +/// (`recorded`: `(purl, uuid)` of every selected patch now in the manifest) +/// into `env`, so `--json` says what the human run prints (#424). A clean +/// apply is one `applied` event per recorded patch. A failed one records a +/// `failed` event (apply's `errorCode` / `error`) for each recorded patch a +/// failure covers, `applied` for each one apply reports patched (or already +/// patched), a `failed` event for each other failing manifest patch (one +/// this run did not select — the nested apply covers the whole +/// `--ecosystems`-scoped manifest; `uuid_of` looks up its uuid), and a +/// purl-less `failed` event for a run-level reason (unreadable manifest, +/// the yarn PnP refusal, unavailable sources) — so a failed apply always +/// leaves a `failed` event and the envelope's `partialFailure` agrees with +/// exit 1. The apply's non-fatal warnings (`content_mismatch_overwritten`, +/// #1004) join `env.warnings`: a JSON caller's nested apply is silent, so +/// the envelope is their only channel. `None` (no apply ran) records +/// nothing. +pub(crate) fn record_apply_outcome( + env: &mut Envelope, + recorded: &[(String, String)], + report: Option<&ApplyRunReport>, uuid_of: impl Fn(&str) -> Option, -) -> usize { - let Some(patches) = envelope["patches"].as_array_mut() else { - return 0; +) { + let Some(report) = report else { + return; }; - let selected = patches.len(); - let mut marked = 0usize; + env.warnings.extend(report.warnings.iter().cloned()); + if report.code == 0 { + for (purl, uuid) in recorded { + env.record( + PatchEvent::new(EventAction::Applied, purl.as_str()).with_uuid(uuid.as_str()), + ); + } + return; + } + let mut failed_any = false; + for (purl, uuid) in recorded { + if let Some(failure) = report + .failures + .iter() + .find(|f| apply_key_covers(&f.purl, purl)) + { + env.record( + PatchEvent::new(EventAction::Failed, purl.as_str()) + .with_uuid(uuid.as_str()) + .with_error(failure.code.as_str(), failure.error.as_str()), + ); + failed_any = true; + } else if report.applied.iter().any(|k| apply_key_covers(k, purl)) { + env.record( + PatchEvent::new(EventAction::Applied, purl.as_str()).with_uuid(uuid.as_str()), + ); + } + } + let mut appended: Vec<&str> = Vec::new(); for failure in &report.failures { - let mut hit = false; - for rec in patches.iter_mut().take(selected) { - let purl = rec["purl"].as_str().unwrap_or_default(); - if !apply_key_covers(&failure.purl, purl) { - continue; - } - hit = true; - if rec["action"].as_str() != Some("failed") { - *rec = serde_json::json!({ - "purl": rec["purl"], - "uuid": rec["uuid"], - "action": "failed", - "errorCode": failure.code, - "error": failure.error, - }); - marked += 1; - } + let explained = recorded + .iter() + .any(|(purl, _)| apply_key_covers(&failure.purl, purl)); + let repeated = appended + .iter() + .any(|p| PurlKey::qualified(p) == PurlKey::qualified(&failure.purl)); + if explained || repeated { + continue; } - let appended = patches[selected..].iter().any(|r| { - PurlKey::qualified(r["purl"].as_str().unwrap_or_default()) - == PurlKey::qualified(&failure.purl) - }); - if !hit && !appended { - let mut rec = serde_json::json!({ - "purl": failure.purl, - "action": "failed", - "errorCode": failure.code, - "error": failure.error, - }); - if let Some(uuid) = uuid_of(&failure.purl) { - rec["uuid"] = serde_json::json!(uuid); - } - patches.push(rec); + appended.push(&failure.purl); + let mut event = PatchEvent::new(EventAction::Failed, failure.purl.as_str()) + .with_error(failure.code.as_str(), failure.error.as_str()); + if let Some(uuid) = uuid_of(&failure.purl) { + event = event.with_uuid(uuid); } + env.record(event); + failed_any = true; } - // Recorded patches (added / updated, or the plain already-recorded - // skip) that apply reports as patched. - let applied = patches[..selected] - .iter() - .filter(|r| match r["action"].as_str() { - Some("added" | "updated") => true, - Some("skipped") => r.get("errorCode").is_none(), - _ => false, - }) - .filter(|r| { - let purl = r["purl"].as_str().unwrap_or_default(); - report.applied.iter().any(|k| apply_key_covers(k, purl)) - }) - .count(); - let added = marked + (patches.len() - selected); - let failed = envelope["failed"].as_u64().unwrap_or(0) as usize + added; - envelope["failed"] = serde_json::json!(failed); if let Some((code, error)) = &report.run_error { - crate::json_envelope::set_error_keep_status( - envelope, - crate::json_envelope::EnvelopeError::new(code, error), + env.record( + PatchEvent::artifact(EventAction::Failed).with_error(code.as_str(), error.as_str()), + ); + failed_any = true; + } + if !failed_any { + env.record( + PatchEvent::artifact(EventAction::Failed) + .with_error("apply_failed", "the nested apply failed"), ); } - applied +} + +/// [`download_and_apply_patches_into`] over a fresh `get` envelope: the +/// public entry the in-process tests and embedders drive. Returns `(exit +/// code, envelope)`; nothing is printed on stdout. +pub async fn download_and_apply_patches_with( + selected: &[PatchSearchResult], + params: &DownloadParams, + run: &DownloadRun<'_>, +) -> (i32, Envelope) { + let mut env = Envelope::new(EnvelopeCommand::Get); + let code = download_and_apply_patches_into(selected, params, run, &mut env).await; + (code, env) } /// Download the selected patches into `.socket/` (manifest records + @@ -1532,12 +1561,17 @@ pub(crate) fn fold_apply_failures( /// engine behind `get` and `scan --mode agent`, over the caller's /// run-level context (`run`: the client the run already built, plus the /// `--lock-timeout` / `--verbose` the manifest lock and the nested apply -/// honor). Returns `(exit_code, json)`. -pub async fn download_and_apply_patches_with( +/// honor). Every outcome is recorded into `env` (per-patch events, run +/// warnings, or — for a hard failure: the lock refused, an unloadable +/// manifest, a failed manifest write — its top-level `error`); the CALLER +/// prints it, so a run never puts two JSON documents on stdout. Returns +/// the exit code. +pub async fn download_and_apply_patches_into( selected: &[PatchSearchResult], params: &DownloadParams, run: &DownloadRun<'_>, -) -> (i32, serde_json::Value) { + env: &mut Envelope, +) -> i32 { let quiet = params.quiet(); let manifest_path = params.manifest_path.clone(); let socket_dir = params.socket_dir(); @@ -1553,10 +1587,13 @@ pub async fn download_and_apply_patches_with( let guard = match crate::commands::lock_cli::acquire_with_status(&socket_dir, lock_timeout) { Ok(guard) => guard, Err(e) => { - return ( - 1, - report_lock_failure(params.json, &socket_dir, &e, lock_timeout), - ) + env.mark_error(report_lock_failure( + params.json, + &socket_dir, + &e, + lock_timeout, + )); + return 1; } }; @@ -1567,12 +1604,14 @@ pub async fn download_and_apply_patches_with( // treating it as empty would let the write below replace the file // and destroy every tracked patch record. Err(e) => { - let err = format!("Failed to read manifest: {e}"); - report_error(params.json, "manifest_unreadable", &err); - return ( - 1, - crate::json_envelope::legacy_error("manifest_unreadable", &err), - ); + if !params.json { + eprintln!("Error: Failed to read manifest: {e}"); + } + env.mark_error(crate::json_envelope::manifest_load_error( + &manifest_path, + &e, + )); + return 1; } }; @@ -1593,13 +1632,25 @@ pub async fn download_and_apply_patches_with( .await; // `added` and `updated` are DISJOINT — one patch lands in exactly one, - // matching the per-patch `action` vocabulary (CLI_CONTRACT.md) and the - // single-uuid flow's summary in `save_and_apply_patch`; `downloaded` is - // their sum (a replacement was fetched and applied just like a new - // record) and gates the apply step. + // matching the per-patch event (`downloaded` / `updated`) and the + // single-uuid flow's summary in `save_and_apply_patch`; their sum gates + // the apply step. let downloaded = batch.fetched.len(); let mut updated = 0usize; let mut new_blobs: Vec = Vec::new(); + // `(purl, uuid)` of every selected patch now recorded: the fetched ones + // and the already-recorded (`skipped`) ones. + let mut recorded: Vec<(String, String)> = Vec::new(); + let mut changed: Vec<(String, String)> = Vec::new(); + for event in &batch.events { + if event.action == EventAction::Skipped + && event.error_code.as_deref() == Some(ALREADY_IN_MANIFEST) + { + if let (Some(purl), Some(uuid)) = (&event.purl, &event.uuid) { + recorded.push((purl.clone(), uuid.clone())); + } + } + } for FetchedPatch { patch, files, @@ -1611,6 +1662,7 @@ pub async fn download_and_apply_patches_with( updated += 1; } new_blobs.extend(created); + changed.push((patch.purl.clone(), patch.uuid.clone())); manifest .patches .insert(patch.purl.clone(), build_patch_record(&patch, files)); @@ -1624,19 +1676,23 @@ pub async fn download_and_apply_patches_with( // unwind exactly those (a pre-existing record's blobs stay). unwind_new_blobs(&blobs_dir, &new_blobs).await; let msg = format!("Failed to write manifest: {e}"); - report_error(params.json, "manifest_write_failed", &msg); - return ( - 1, - crate::json_envelope::legacy_error("manifest_write_failed", &msg), - ); + if !params.json { + eprintln!("Error: {msg}"); + } + env.mark_error(EnvelopeError::new("manifest_write_failed", msg)); + return 1; } } + for event in batch.events { + env.record(event); + } + recorded.extend(changed.iter().cloned()); // Every selected patch that is now recorded is owed the nested apply: // the fetched ones AND the already-recorded (`skipped`) ones, whose // installed copy may be pristine again after a reinstall or a failed // earlier apply (#454). Apply is idempotent on already-patched files, // so an in-sync re-run stays a no-op on disk. - let to_apply = downloaded + batch.already_recorded; + let to_apply = recorded.len(); // The lock outlives the manifest write only when a nested apply follows // (it is handed the guard and releases it after its last mutation); // otherwise nothing more is written and it is released here. @@ -1654,14 +1710,9 @@ pub async fn download_and_apply_patches_with( // uuid — tell the operator now instead of letting VEX surprise them // later. (`scan` never hits this: it filters vendored purls before // download.) The nested apply below skips the vendored purl either way. - let mut warnings = batch.warnings; - warn_on_vendored_uuid_drift( - ¶ms.project_root(), - quiet, - &batch.patches_json, - &mut warnings, - ) - .await; + let mut warnings = release_warnings(batch.warnings); + warn_on_vendored_uuid_drift(¶ms.project_root(), quiet, &changed, &mut warnings).await; + env.warnings.extend(warnings); if !quiet { eprintln!(); @@ -1688,43 +1739,13 @@ pub async fn download_and_apply_patches_with( .await, ); } - let apply_succeeded = apply_report.as_ref().is_some_and(|r| r.code == 0); - - // An apply step that ran (recorded patches selected, not --save-only) - // but failed is a partial failure too — not just download failures. The - // `status` field must agree with `exit_code`; reporting `success` - // alongside a non-zero exit code misleads JSON consumers (the scan - // wrapper recomputes status from the exit code for exactly this - // reason, but `get` surfaces this envelope directly). - let apply_failed = !apply_succeeded && to_apply > 0 && !params.save_only; - let (status, exit_code) = run_outcome(batch.failed > 0, apply_failed); - let mut result_json = serde_json::json!({ - "status": status, - "found": batch.found, - "downloaded": downloaded, - "skipped": batch.skipped, - "failed": batch.failed, - "applied": if apply_succeeded { to_apply } else { 0 }, - "updated": updated, - "patches": batch.patches_json, + let apply_failed = apply_report.as_ref().is_some_and(|r| r.code != 0); + // A failed apply records at least one `failed` event, so the status + // (`partialFailure`) agrees with the exit code. + record_apply_outcome(env, &recorded, apply_report.as_ref(), |purl| { + manifest.patches.get(purl).map(|r| r.uuid.clone()) }); - // A failed apply: name what failed, and count only what applied. - if let Some(report) = apply_report.as_ref().filter(|r| r.code != 0) { - let applied = fold_apply_failures(&mut result_json, report, |purl| { - manifest.patches.get(purl).map(|r| r.uuid.clone()) - }); - result_json["applied"] = serde_json::json!(applied); - } - // Surface release-narrowing fallbacks (uninstalled package / no - // matching variant) so JSON consumers can see why all variants were - // kept, and the apply's mismatch overwrites. Omitted entirely when - // both were clean. - warnings.extend(apply_warning_lines(apply_report.as_ref())); - if !warnings.is_empty() { - result_json["warnings"] = serde_json::json!(warnings); - } - - (exit_code, result_json) + run_outcome(batch.failed > 0, apply_failed) } /// Decode a patch view's `blobContent` (canonical base64 as the API diff --git a/crates/socket-patch-cli/src/commands/apply.rs b/crates/socket-patch-cli/src/commands/apply.rs index 0050d906e..b2ad7e938 100644 --- a/crates/socket-patch-cli/src/commands/apply.rs +++ b/crates/socket-patch-cli/src/commands/apply.rs @@ -291,7 +291,16 @@ async fn run_check(args: &ApplyArgs, manifest_path: &Path) -> i32 { // it vanished since (TOCTOU) → nothing to verify. An `Err` means it exists // but is unreadable/corrupt: fail-closed (report drift) rather than // silently passing — the guard treats exit 0 as "in sync". - Ok(None) => return 0, + Ok(None) => { + // Same answer as the no-manifest exit in `run`. + if args.common.json { + let mut env = Envelope::new(Command::Apply); + env.status = Status::NoManifest; + env.dry_run = args.common.dry_run; + println!("{}", env.to_pretty_json()); + } + return 0; + } Err(e) => { let msg = format!( "Patch check could not read the manifest ({e}); \ @@ -299,7 +308,10 @@ async fn run_check(args: &ApplyArgs, manifest_path: &Path) -> i32 { ); if args.common.json { let mut env = Envelope::new(Command::Apply); - env.mark_error(EnvelopeError::new("manifest_unreadable", msg)); + env.dry_run = args.common.dry_run; + let mut err = crate::json_envelope::manifest_load_error(manifest_path, &e); + err.message = msg; + env.mark_error(err); println!("{}", env.to_pretty_json()); } else { // Errors print even under --silent ("errors only", never @@ -1100,14 +1112,20 @@ pub(crate) async fn run_locked( Ok(Some(m)) => m, Ok(None) => { lock.release(); - let code = report_apply_failure(&args, "Invalid manifest", &telemetry).await; - return ApplyRunReport::run_failure(code, "apply_failed", "Invalid manifest"); + let err = EnvelopeError::new( + "manifest_not_found", + format!("Manifest not found at {}", manifest_path.display()), + ); + let code = report_apply_failure(&args, &err, &err.message, &telemetry).await; + return ApplyRunReport::run_failure(code, &err.code, err.message); } Err(e) => { lock.release(); - let error = e.to_string(); - let code = report_apply_failure(&args, &error, &telemetry).await; - return ApplyRunReport::run_failure(code, "apply_failed", error); + // One manifest-load mapping for every command (#931): + // `manifest_invalid` / `manifest_unreadable`, never `apply_failed`. + let err = crate::json_envelope::manifest_load_error(&manifest_path, &e); + let code = report_apply_failure(&args, &err, &e.to_string(), &telemetry).await; + return ApplyRunReport::run_failure(code, &err.code, err.message); } }; @@ -1519,7 +1537,8 @@ pub(crate) async fn run_locked( } Err(e) => { lock.release(); - let code = report_apply_failure(&args, &e, &telemetry).await; + let err = EnvelopeError::new("apply_failed", e.clone()); + let code = report_apply_failure(&args, &err, &e, &telemetry).await; ApplyRunReport::run_failure(code, "apply_failed", e) } } @@ -1529,13 +1548,20 @@ pub(crate) async fn run_locked( /// envelope (`--json`) or an `Error:` line that prints even under /// `--silent` ("errors only", never "nothing" — exit 1 with no message /// would be undiagnosable), exit 1. Shared by the manifest read in `run` -/// and `apply_patches_inner`'s `Err` arm. -async fn report_apply_failure(args: &ApplyArgs, error: &str, telemetry: &TelemetryAuth) -> i32 { +/// and `apply_patches_inner`'s `Err` arm. `err` is the `--json` error +/// (`apply_failed`, or the manifest-load code); `error` is the plain text +/// for telemetry and the human line. +async fn report_apply_failure( + args: &ApplyArgs, + err: &EnvelopeError, + error: &str, + telemetry: &TelemetryAuth, +) -> i32 { track_patch_apply_failed(error, args.common.dry_run, telemetry).await; if args.common.json { let mut env = Envelope::new(Command::Apply); env.dry_run = args.common.dry_run; - env.mark_error(EnvelopeError::new("apply_failed", error.to_string())); + env.mark_error(err.clone()); println!("{}", env.to_pretty_json()); } else { eprintln!("Error: {}", crate::ui::sentence_case(error)); diff --git a/crates/socket-patch-cli/src/commands/get.rs b/crates/socket-patch-cli/src/commands/get.rs index fed344a86..09a1e0600 100644 --- a/crates/socket-patch-cli/src/commands/get.rs +++ b/crates/socket-patch-cli/src/commands/get.rs @@ -28,15 +28,14 @@ use crate::args::{apply_env_toggles, GlobalArgs}; // `commands::get` paths (the in-process tests and embedders call them); // the engine itself lives in the shared `agent_download` helper. use crate::commands::agent_download::{ - apply_warning_lines, decide_patch_action, download_patch_records_preflighted, - download_patch_records_reusing, filter_to_installed_releases, fold_apply_failures, - max_vuln_severity, merge_metadata, nested_apply_args, patch_event_metadata, report_error, - report_lock_failure, run_nested_apply, run_outcome, unwind_new_blobs, - warn_on_vendored_uuid_drift, write_all_patch_blobs, DetachedDownload, PatchAction, - VendorRefusals, + decide_patch_action, download_patch_records_preflighted, download_patch_records_reusing, + filter_to_installed_releases, max_vuln_severity, nested_apply_args, patch_event_metadata, + record_apply_outcome, report_lock_failure, run_nested_apply, run_outcome, tag_mode, + unwind_new_blobs, warn_on_vendored_uuid_drift, write_all_patch_blobs, DetachedDownload, + PatchAction, VendorRefusals, ALREADY_IN_MANIFEST, BLOB_WRITE_FAILED, RELEASE_NARROWING, }; pub use crate::commands::agent_download::{ - download_and_apply_patches_with, DownloadParams, DownloadRun, + download_and_apply_patches_into, download_and_apply_patches_with, DownloadParams, DownloadRun, }; use crate::commands::apply::ApplyRunReport; use crate::commands::bun_preflight::{bun_vendor_preflight, BunVendorRefusal}; @@ -44,7 +43,10 @@ use crate::commands::vlt_preflight::{ vlt_refusal_for, vlt_vendor_preflight_selected, VltVendorRefusal, }; use crate::ecosystem_dispatch::{crawl_ecosystems, find_packages_for_rollback, partition_purls}; -use crate::json_envelope::{usage_error, Command as JsonCommand}; +use crate::json_envelope::{ + usage_error, Command as JsonCommand, Envelope, EnvelopeError, PatchAction as EventAction, + PatchEvent, RunWarning, Status, +}; use crate::ui::{print_json, select_one, SelectError}; /// Best-effort ecosystem extractor for a `pkg:/...` PURL. Used as @@ -58,17 +60,59 @@ fn ecosystem_from_purl(purl: &str) -> String { .to_string() } -/// Build a no-results JSON envelope with the given status code. Used in -/// the `no_packages`, `no_match`, and `not_found` branches of `get`, -/// which all share the same `{status, counts, patches: []}` shape. -fn empty_result_json(status: &str) -> serde_json::Value { - serde_json::json!({ - "status": status, - "found": 0, - "downloaded": 0, - "applied": 0, - "patches": [], - }) +/// A fresh `get` envelope for this run (`dryRun` from the flags). +fn new_envelope(common: &GlobalArgs) -> Envelope { + let mut env = Envelope::new(JsonCommand::Get); + env.dry_run = common.dry_run; + env +} + +/// The one `--json` emitter of `get`: every document it prints is a +/// serialized [`Envelope`], printed here, exactly once per run. +fn emit(env: &Envelope) { + print_json(&env.to_value()); +} + +/// A result with no per-patch outcome (`noPackages`, `noMatch`, +/// `notFound`): an empty envelope with that status, plus the run's +/// warnings. Exit 0. +fn emit_empty(common: &GlobalArgs, status: Status, warnings: &[(String, String)]) { + let mut env = new_envelope(common); + env.status = status; + fold_narrowing_into_result(&mut env, &[], warnings); + emit(&env); +} + +/// Report a top-level failure and return its exit code, 1: under `--json` +/// a full envelope (`status: "error"`, empty `events`, `error: {code, +/// message}`), otherwise `Error: ` on stderr. +fn report_error(common: &GlobalArgs, code: &str, message: impl std::fmt::Display) -> i32 { + let message = message.to_string(); + if common.json { + let mut env = new_envelope(common); + env.mark_error(EnvelopeError::new(code, message)); + emit(&env); + } else { + eprintln!("Error: {message}"); + } + 1 +} + +/// Report a manifest that exists but cannot be loaded: the shared +/// `manifest_invalid` / `manifest_unreadable` error under `--json`, the +/// `Failed to read manifest` line otherwise. Exit 1. +fn report_manifest_error(common: &GlobalArgs, manifest_path: &Path, err: &std::io::Error) -> i32 { + if common.json { + let mut env = new_envelope(common); + env.mark_error(crate::json_envelope::manifest_load_error( + manifest_path, + err, + )); + emit(&env); + } else { + eprintln!("Error: Failed to read manifest: {err}"); + } + 1 } /// Fire a `patch_fetch_failed` telemetry event and surface the error to @@ -79,12 +123,25 @@ async fn report_fetch_failure( error: impl std::fmt::Display, fallback_to_proxy: bool, telemetry: &TelemetryAuth, - json: bool, + common: &GlobalArgs, ) -> i32 { let msg = error.to_string(); track_patch_fetch_failed(identifier, &msg, fallback_to_proxy, telemetry).await; - report_error(json, "patch_fetch_failed", msg); - 1 + report_error(common, "patch_fetch_failed", msg) +} + +/// The `skipped` event for a patch the caller's plan cannot download +/// (`paid_required`), carrying the patch's `tier` in `details`. `purl` is +/// `None` when the public proxy refused with 403 before naming it. +fn paid_required_event(purl: Option<&str>, uuid: &str, tier: &str) -> PatchEvent { + let event = match purl { + Some(purl) => PatchEvent::new(EventAction::Skipped, purl), + None => PatchEvent::artifact(EventAction::Skipped), + }; + event + .with_uuid(uuid) + .with_reason("paid_required", "this patch requires a paid Socket plan") + .with_details(serde_json::json!({ "tier": tier })) } #[derive(Args)] @@ -403,11 +460,11 @@ fn format_did_you_mean( /// The `--verbose` per-version detail behind [`format_skip_summary`]: one /// `[skip]` line per purl (a free and a paid patch for the same version /// would otherwise repeat it), in natural version order. -fn format_verbose_skips(skips: &[serde_json::Value]) -> Vec { +fn format_verbose_skips(skips: &[PatchEvent]) -> Vec { let mut by_purl: std::collections::BTreeMap<&str, &str> = std::collections::BTreeMap::new(); for rec in skips { - let purl = rec["purl"].as_str().unwrap_or_default(); - let reason = match rec["errorCode"].as_str() { + let purl = rec.purl.as_deref().unwrap_or_default(); + let reason = match rec.error_code.as_deref() { Some("package_not_installed") | None => "version not installed", Some(code) => code, }; @@ -471,10 +528,10 @@ fn format_selected_patches(selected: &[PatchSearchResult], color: bool) -> Strin } /// Number of distinct purls among skip records. -fn distinct_skip_purls(skips: &[serde_json::Value]) -> usize { +fn distinct_skip_purls(skips: &[PatchEvent]) -> usize { let purls: std::collections::BTreeSet<&str> = skips .iter() - .map(|r| r["purl"].as_str().unwrap_or_default()) + .map(|r| r.purl.as_deref().unwrap_or_default()) .collect(); purls.len() } @@ -482,11 +539,11 @@ fn distinct_skip_purls(skips: &[serde_json::Value]) -> usize { /// Summary lines for the patches the installed-version narrowing dropped, /// one line per reason (instead of one `[skip]` line per version), in a /// fixed order: not installed first, then each layout code alphabetically. -fn format_skip_summary(skips: &[serde_json::Value]) -> Vec { - let mut by_code: std::collections::BTreeMap<&str, Vec> = +fn format_skip_summary(skips: &[PatchEvent]) -> Vec { + let mut by_code: std::collections::BTreeMap<&str, Vec> = std::collections::BTreeMap::new(); for rec in skips { - let code = rec["errorCode"].as_str().unwrap_or("package_not_installed"); + let code = rec.error_code.as_deref().unwrap_or("package_not_installed"); by_code.entry(code).or_default().push(rec.clone()); } let mut lines = Vec::new(); @@ -516,14 +573,14 @@ fn format_skip_summary(skips: &[serde_json::Value]) -> Vec { /// The result line when the installed-version narrowing dropped EVERY /// accessible patch. -fn format_all_narrowed(skips: &[serde_json::Value]) -> String { +fn format_all_narrowed(skips: &[PatchEvent]) -> String { // When every skip is a PnP layout refusal, "not installed" and the // --all-releases advice would both be wrong: the packages were never // judged (structurally invisible), and the escape hatch cannot make a // PnP layout patchable — point at the layout warning instead. let pnp_only = skips.iter().all(|rec| { matches!( - rec["errorCode"].as_str(), + rec.error_code.as_deref(), Some("yarn_pnp_unsupported" | "pnpm_pnp_unsupported") ) }); @@ -724,21 +781,26 @@ pub(crate) fn select_patches( serde_json::json!({ "uuid": p.uuid, "tier": p.tier, - "published_at": p.published_at, + "publishedAt": p.published_at, "description": p.description, "vulnerabilities": vulns, }) }) .collect(); - print_json(&serde_json::json!({ - "status": "selection_required", - "error": { - "code": "selection_required", - "message": format!("Multiple patches available for {purl}. Re-run with the chosen UUID as the identifier (`socket-patch get `) to select one."), - }, - "purl": purl, - "options": options_json, - })); + // `status` stays `selectionRequired` (the routing + // signal; exit 1) beside the coded `error`. + let mut env = new_envelope(common); + env.status = Status::SelectionRequired; + env.error = Some(EnvelopeError::new( + "selection_required", + format!( + "Multiple patches available for {purl}. Re-run with the chosen \ + UUID as the identifier (`socket-patch get `) to select one." + ), + )); + env.set_extra("purl", serde_json::json!(purl)); + env.set_extra("options", serde_json::Value::Array(options_json)); + emit(&env); return Err(1); } Err(SelectError::Cancelled) => { @@ -759,9 +821,9 @@ pub(crate) fn select_patches( struct InstalledNarrowing { /// Results whose package version is present (kept for selection). kept: Vec, - /// Contract-shaped skip records for the filtered-out results - /// (`action: "skipped"` + `errorCode`), purl-sorted. - skip_records: Vec, + /// `skipped` events (+ `errorCode`) for the filtered-out results, + /// purl-sorted. + skip_records: Vec, /// Run-level `(code, detail)` warnings (PnP layout refusals), for both /// stderr and the JSON `warnings[]`. warnings: Vec<(String, String)>, @@ -923,58 +985,47 @@ async fn filter_to_installed_purls( } else { "package_not_installed" }; - out.skip_records.push(serde_json::json!({ - "purl": result.purl, "uuid": result.uuid, - "action": "skipped", "errorCode": error_code, - })); + out.skip_records.push( + PatchEvent::new(EventAction::Skipped, result.purl.as_str()) + .with_uuid(result.uuid.as_str()) + .with_reason(error_code, narrowing_reason(error_code)), + ); } - out.skip_records - .sort_by(|a, b| a["purl"].as_str().cmp(&b["purl"].as_str())); + out.skip_records.sort_by(|a, b| a.purl.cmp(&b.purl)); out } -/// Fold the coarse-narrowing skip records + PnP warnings into a get JSON -/// envelope: they were "found" by the search and skipped before download, -/// mirroring scan's vendored/not-installed fold. Warnings land as strings -/// (get's `warnings[]` is a string array — unlike scan's `{code, detail}` -/// objects) with the stable code prefixed for greppability. -fn fold_narrowing_into_result( - result: &mut serde_json::Value, - skip_records: &[serde_json::Value], - warnings: &[(String, String)], -) { - let Some(obj) = result.as_object_mut() else { - return; - }; - // Only success-shaped envelopes carry a patches[] array to fold into — - // error envelopes ({status, error}) keep their minimal shape. - if !skip_records.is_empty() && obj.get("patches").and_then(|p| p.as_array()).is_some() { - let n = skip_records.len() as u64; - for key in ["found", "skipped"] { - let bumped = obj.get(key).and_then(|v| v.as_u64()).unwrap_or(0) + n; - obj.insert(key.to_string(), serde_json::json!(bumped)); +/// The human `reason` of a narrowing skip, by its `errorCode`. +fn narrowing_reason(code: &str) -> &'static str { + match code { + "yarn_pnp_unsupported" => { + "this project's yarn Plug'n'Play layout makes its npm packages unpatchable here" } - if let Some(patches) = obj.get_mut("patches").and_then(|p| p.as_array_mut()) { - patches.extend(skip_records.iter().cloned()); + "pnpm_pnp_unsupported" => { + "this project's pnpm Plug'n'Play layout makes its npm packages unpatchable here" } + _ => "this package version is not installed here (--all-releases includes it)", } - if !warnings.is_empty() { - let mut merged: Vec = obj - .get("warnings") - .and_then(|w| w.as_array()) - .map(|w| { - w.iter() - .filter_map(|v| v.as_str().map(str::to_string)) - .collect() - }) - .unwrap_or_default(); - merged.extend( - warnings - .iter() - .map(|(code, detail)| format!("({code}) {detail}")), - ); - obj.insert("warnings".to_string(), serde_json::json!(merged)); +} + +/// Fold the coarse-narrowing skip events and the run's `(code, detail)` +/// warnings (PnP layout refusals, `policy_bypassed`, the auth fallback, +/// release narrowing) into a get envelope: the skips were found by the +/// search and skipped before download, mirroring scan's +/// vendored/not-installed skips. +fn fold_narrowing_into_result( + env: &mut Envelope, + skip_records: &[PatchEvent], + warnings: &[(String, String)], +) { + for event in skip_records { + env.record(event.clone()); } + env.warnings.extend( + warnings + .iter() + .map(|(code, detail)| RunWarning::new(code.as_str(), detail.as_str())), + ); } /// Download patches WITHOUT touching the manifest and return the fetched @@ -1093,13 +1144,12 @@ pub async fn run(args: GetArgs) -> i32 { // client is built (org auto-resolve is itself a network call). No // telemetry fires here: offline gates `is_telemetry_disabled` too. if args.common.offline { - report_error( - args.common.json, + return report_error( + &args.common, "offline_unsupported", "Fetching patches needs network access, so `get` cannot run with \ --offline/SOCKET_OFFLINE (strict airgap)", ); - return 1; } // Classify the identifier with the shared target grammar (the same @@ -1201,7 +1251,7 @@ pub async fn run(args: GetArgs) -> i32 { // event, since it is not this run's patch. if !args.common.purl_ecosystem_selected(&patch.purl) { if args.common.json { - print_json(&empty_result_json("not_found")); + emit_empty(&args.common, Status::NotFound, &[]); } else if !args.common.silent { println!( "No patch found with UUID: {} in the selected ecosystems \ @@ -1313,9 +1363,7 @@ pub async fn run(args: GetArgs) -> i32 { ) .await; if args.common.json { - let mut result = empty_result_json("not_found"); - fold_narrowing_into_result(&mut result, &[], &org_warnings); - print_json(&result); + emit_empty(&args.common, Status::NotFound, &org_warnings); } else if !args.common.silent { println!("No patch found with UUID: {}", args.identifier); } @@ -1327,7 +1375,7 @@ pub async fn run(args: GetArgs) -> i32 { e, fallback_to_proxy, &telemetry, - args.common.json, + &args.common, ) .await; } @@ -1358,7 +1406,7 @@ pub async fn run(args: GetArgs) -> i32 { e, fallback_to_proxy, &telemetry, - args.common.json, + &args.common, ) .await; } @@ -1374,7 +1422,7 @@ pub async fn run(args: GetArgs) -> i32 { if all_packages.is_empty() { status.finish(); if args.common.json { - print_json(&empty_result_json("no_packages")); + emit_empty(&args.common, Status::NoPackages, &[]); } else if !args.common.silent { println!("{}", no_packages_message(args.common.global)); } @@ -1392,7 +1440,7 @@ pub async fn run(args: GetArgs) -> i32 { let matched = installed_target_matches(&target, &all_packages); if matched.is_empty() { if args.common.json { - print_json(&empty_result_json("no_match")); + emit_empty(&args.common, Status::NoMatch, &[]); } else if !args.common.silent { println!("No packages matching \"{}\" found.", args.identifier); // Near names are only ever suggested, never acted on. @@ -1409,10 +1457,7 @@ pub async fn run(args: GetArgs) -> i32 { // on (`lodash` beside `@types/lodash` is `lodash` alone). let target = match target.settle(matched.iter().map(String::as_str)) { Ok(settled) => settled, - Err(msg) => { - report_error(args.common.json, "ambiguous_target", &msg); - return 1; - } + Err(msg) => return report_error(&args.common, "ambiguous_target", &msg), }; let matched: Vec = matched .into_iter() @@ -1457,7 +1502,7 @@ pub async fn run(args: GetArgs) -> i32 { e, fallback_to_proxy, &telemetry, - args.common.json, + &args.common, ) .await; } @@ -1480,9 +1525,7 @@ pub async fn run(args: GetArgs) -> i32 { if search_response.patches.is_empty() { if args.common.json { - let mut result = empty_result_json("not_found"); - fold_narrowing_into_result(&mut result, &[], &org_warnings); - print_json(&result); + emit_empty(&args.common, Status::NotFound, &org_warnings); } else if !args.common.silent { println!("No patches found for {}: {}", id_type, args.identifier); } @@ -1501,18 +1544,17 @@ pub async fn run(args: GetArgs) -> i32 { if accessible.is_empty() { if args.common.json { - let records = search_response + // `paidRequired` (exit 0): one `skipped` / `paid_required` + // event per patch found. + let mut env = new_envelope(&args.common); + env.status = Status::PaidRequired; + let skips: Vec = search_response .patches .iter() - .map(|p| { - serde_json::json!({ - "purl": p.purl, - "uuid": p.uuid, - "tier": p.tier, - }) - }) + .map(|p| paid_required_event(Some(&p.purl), &p.uuid, &p.tier)) .collect(); - print_json(&paid_required_json(records, &org_warnings)); + fold_narrowing_into_result(&mut env, &skips, &org_warnings); + emit(&env); } else if !args.common.silent { let all: Vec<&PatchSearchResult> = search_response.patches.iter().collect(); if id_type == TargetKind::Name && !quiet { @@ -1561,10 +1603,10 @@ pub async fn run(args: GetArgs) -> i32 { .filter(|p| accessible_uuids.contains(p.uuid.as_str())) .cloned() .collect(); - let skips: Vec = narrowing + let skips: Vec = narrowing .skip_records .into_iter() - .filter(|r| accessible_uuids.contains(r["uuid"].as_str().unwrap_or_default())) + .filter(|r| accessible_uuids.contains(r.uuid.as_deref().unwrap_or_default())) .collect(); (kept_accessible, narrowing.kept, skips, narrowing.warnings) }; @@ -1588,15 +1630,10 @@ pub async fn run(args: GetArgs) -> i32 { // `no_match`, which is pinned to the package-name path): // exit 0, the skips carry the detail via their errorCode. if args.common.json { - let mut result = serde_json::json!({ - "status": "not_installed", - "found": narrow_skips.len(), - "downloaded": 0, - "applied": 0, - "patches": narrow_skips, - }); - fold_narrowing_into_result(&mut result, &[], &narrow_warnings); - print_json(&result); + let mut env = new_envelope(&args.common); + env.status = Status::NotInstalled; + fold_narrowing_into_result(&mut env, &narrow_skips, &narrow_warnings); + emit(&env); } else if !args.common.silent { println!("{}", format_all_narrowed(&narrow_skips)); if !quiet && args.common.verbose { @@ -1685,7 +1722,7 @@ pub async fn run(args: GetArgs) -> i32 { narrow_warnings.extend( variant_warnings .into_iter() - .map(|w| ("release_narrowing".to_string(), w)), + .map(|w| (RELEASE_NARROWING.to_string(), w)), ); selected } else { @@ -1742,44 +1779,19 @@ pub async fn run(args: GetArgs) -> i32 { lock_timeout: args.common.lock_timeout, verbose: args.common.verbose, }; - let (code, mut result_json) = download_and_apply_patches_with(&selected, ¶ms, &run).await; - // A download-phase HARD error (lock refused, unreadable manifest, - // failed manifest write) is an `error`-status envelope the engine has - // ALREADY printed — printing below would put a second JSON document on - // stdout (get's `--json` contract is exactly one per run). Per-patch - // failures are NOT this case: they ride a success-shaped - // (`partial_failure`) envelope the engine leaves for us to print. - if result_json["status"] == "error" { - return code; - } - fold_narrowing_into_result(&mut result_json, &narrow_skips, &narrow_warnings); - + let mut env = new_envelope(&args.common); + // The narrowing skips came first (before any download). + fold_narrowing_into_result(&mut env, &narrow_skips, &narrow_warnings); + // The engine records into `env` and never prints it: exactly one JSON + // document per run, a hard error (lock refused, unloadable manifest, + // failed manifest write) included. + let code = download_and_apply_patches_into(&selected, ¶ms, &run, &mut env).await; if args.common.json { - print_json(&result_json); + emit(&env); } - code } -/// `get --json`'s one paid-plan shape (CLI_CONTRACT.md, `paid_required`): -/// the legacy top-level `status: "paid_required"` with the refused -/// `patches` records and nothing downloaded or applied. No `events`, no -/// `error`: it is a clean outcome (exit 0). -fn paid_required_json( - records: Vec, - org_warnings: &[(String, String)], -) -> serde_json::Value { - let mut result = serde_json::json!({ - "status": "paid_required", - "found": records.len(), - "downloaded": 0, - "applied": 0, - "patches": records, - }); - fold_narrowing_into_result(&mut result, &[], org_warnings); - result -} - /// `paid_required` for the uuid path: the patch exists but the caller /// (on the public proxy) cannot download it. A clean outcome, exit 0. /// `purl` is `None` when the proxy refused with 403 before naming it. @@ -1793,11 +1805,11 @@ async fn report_paid_required_uuid( ) -> i32 { track_patch_fetch_failed(patch_id, "paid_required", fallback_to_proxy, telemetry).await; if args.common.json { - let mut record = serde_json::json!({ "uuid": patch_id, "tier": "paid" }); - if let Some(purl) = purl { - record["purl"] = serde_json::json!(purl); - } - print_json(&paid_required_json(vec![record], org_warnings)); + let mut env = new_envelope(&args.common); + env.status = Status::PaidRequired; + let skip = paid_required_event(purl, patch_id, "paid"); + fold_narrowing_into_result(&mut env, &[skip], org_warnings); + emit(&env); } else if !args.common.silent { let name = purl.map(|p| normalize_purl(p).into_owned()); println!( @@ -1811,40 +1823,34 @@ async fn report_paid_required_uuid( /// Agent-mode `--dry-run`: classify each selected patch against the /// manifest (read-only) and report what a wet run would do — no download, /// no manifest or blob write, no apply, no prompt. JSON carries -/// `dryRun: true` and per-patch `would_add` / `would_update` (+`oldUuid`) -/// / `skipped` records, plus the narrowing skips. +/// `dryRun: true`, one `verified` event per patch a wet run would record +/// (`oldUuid` on a would-be update), `skipped` / `already_in_manifest` for +/// the rest, plus the narrowing skips. async fn agent_dry_run( args: &GetArgs, selected: &[PatchSearchResult], - narrow_skips: &[serde_json::Value], + narrow_skips: &[PatchEvent], narrow_warnings: &[(String, String)], ) -> i32 { // Fail closed like the wet run: a preview over an unreadable manifest // would promise an outcome the wet run refuses. - let manifest = match read_manifest(&args.common.resolved_manifest_path()).await { + let manifest_path = args.common.resolved_manifest_path(); + let manifest = match read_manifest(&manifest_path).await { Ok(m) => m.unwrap_or_else(PatchManifest::new), - Err(e) => { - report_error( - args.common.json, - "manifest_unreadable", - format!("Failed to read manifest: {e}"), - ); - return 1; - } + Err(e) => return report_manifest_error(&args.common, &manifest_path, &e), }; - let mut records = Vec::new(); + let mut events = Vec::new(); let mut lines = Vec::new(); let mut changing = 0usize; - let mut skipped = 0usize; for p in selected { let shown = normalize_purl(&p.purl); + let event = + PatchEvent::new(EventAction::Verified, p.purl.as_str()).with_uuid(p.uuid.as_str()); match decide_patch_action(&manifest, &p.purl, &p.uuid) { PatchAction::Added => { changing += 1; lines.push(format!(" [would-add] {shown}")); - records.push(serde_json::json!({ - "purl": p.purl, "uuid": p.uuid, "action": "would_add", - })); + events.push(event); } PatchAction::Updated { old_uuid } => { changing += 1; @@ -1852,32 +1858,26 @@ async fn agent_dry_run( " [would-update] {shown} (replacing {})", crate::ui::short_uuid(&old_uuid) )); - records.push(serde_json::json!({ - "purl": p.purl, "uuid": p.uuid, "action": "would_update", - "oldUuid": old_uuid, - })); + events.push(event.with_old_uuid(old_uuid)); } PatchAction::Skipped => { - skipped += 1; lines.push(format!(" [skip] {shown} (already in manifest)")); - records.push(serde_json::json!({ - "purl": p.purl, "uuid": p.uuid, "action": "skipped", - })); + events.push( + PatchEvent::new(EventAction::Skipped, p.purl.as_str()) + .with_uuid(p.uuid.as_str()) + .with_reason(ALREADY_IN_MANIFEST, "already in manifest"), + ); } } } if args.common.json { - let mut result = serde_json::json!({ - "status": "success", - "dryRun": true, - "found": selected.len(), - "downloaded": 0, - "skipped": skipped, - "applied": 0, - "patches": records, - }); - fold_narrowing_into_result(&mut result, narrow_skips, narrow_warnings); - print_json(&result); + let mut env = new_envelope(&args.common); + env.dry_run = true; + fold_narrowing_into_result(&mut env, narrow_skips, narrow_warnings); + for event in events { + env.record(event); + } + emit(&env); } else if !args.common.silent { for line in &lines { println!("{line}"); @@ -1917,14 +1917,7 @@ async fn save_patch_record( // Fail closed like the download flow: an unreadable manifest // treated as empty would be rewritten below with only this one // patch, destroying every tracked record. - Err(e) => { - report_error( - args.common.json, - "manifest_unreadable", - format!("Failed to read manifest: {e}"), - ); - return Err(1); - } + Err(e) => return Err(report_manifest_error(&args.common, manifest_path, &e)), }; // Build the manifest `files` map, retaining patch-added new files @@ -1937,15 +1930,14 @@ async fn save_patch_record( // as applied would claim protection while writing nothing. Fail // loudly instead of counting a defective patch as `applied:1`. if files.is_empty() { - report_error( - args.common.json, + return Err(report_error( + &args.common, "patch_no_applicable_files", format!( "Patch {} has no applicable files; nothing to apply", patch.purl ), - ); - return Err(1); + )); } // Classify against the manifest state BEFORE the insert, with the same @@ -1960,22 +1952,17 @@ async fn save_patch_record( let blobs_dir = socket_dir.join("blobs"); let Ok(new_blobs) = write_all_patch_blobs(&blobs_dir, patch, args.common.json).await else { if args.common.json { - print_json(&serde_json::json!({ - "status": "error", - "found": 1, - "downloaded": 0, - "applied": 0, - "error": { - "code": "blob_write_failed", - "message": "Blob decode or write failed", - }, - "patches": [{ - "purl": patch.purl, - "uuid": patch.uuid, - "action": "failed", - "error": "Blob decode or write failed", - }], - })); + let mut env = new_envelope(&args.common); + env.record( + PatchEvent::new(EventAction::Failed, patch.purl.as_str()) + .with_uuid(patch.uuid.as_str()) + .with_error(BLOB_WRITE_FAILED, "Blob decode or write failed"), + ); + env.mark_error(EnvelopeError::new( + BLOB_WRITE_FAILED, + "Blob decode or write failed", + )); + emit(&env); } else { eprintln!( "Error: Blob decode or write failed for patch {}", @@ -1991,12 +1978,11 @@ async fn save_patch_record( if let Err(e) = write_manifest(manifest_path, &manifest).await { // No record points at the blobs just written: unwind exactly those. unwind_new_blobs(&blobs_dir, &new_blobs).await; - report_error( - args.common.json, + return Err(report_error( + &args.common, "manifest_write_failed", format!("Failed to write manifest: {e}"), - ); - return Err(1); + )); } Ok(action) } @@ -2037,7 +2023,12 @@ async fn save_and_apply_patch( let guard = match crate::commands::lock_cli::acquire_with_status(&socket_dir, lock_timeout) { Ok(guard) => guard, Err(e) => { - report_lock_failure(args.common.json, &socket_dir, &e, lock_timeout); + let err = report_lock_failure(args.common.json, &socket_dir, &e, lock_timeout); + if args.common.json { + let mut env = new_envelope(&args.common); + env.mark_error(err); + emit(&env); + } return 1; } }; @@ -2058,29 +2049,15 @@ async fn save_and_apply_patch( drop(guard); None }; - let action_label = match &action { - PatchAction::Added => "added", - PatchAction::Updated { .. } => "updated", - PatchAction::Skipped => "skipped", - }; - // Vendored-uuid drift (mirrors `download_and_apply_patches_with`): the user // explicitly fetched this uuid; if the vendor ledger still wires a // different one, VEX verification fails closed (`vendor_uuid_mismatch`) // until a `vendor` run refreshes the committed artifact. - let mut warnings: Vec = Vec::new(); + let mut warnings: Vec = Vec::new(); + let recorded = [(patch.purl.clone(), patch.uuid.clone())]; if changed { - warn_on_vendored_uuid_drift( - &args.common.project_root(), - quiet, - &[serde_json::json!({ - "purl": patch.purl, - "uuid": patch.uuid, - "action": action_label, - })], - &mut warnings, - ) - .await; + warn_on_vendored_uuid_drift(&args.common.project_root(), quiet, &recorded, &mut warnings) + .await; } // Progress narration goes to stderr, like the search path's. @@ -2116,55 +2093,44 @@ async fn save_and_apply_patch( } let apply_succeeded = apply_report.as_ref().is_some_and(|r| r.code == 0); - // The apply step ran (not --save-only) but failed → - // partial failure. The `status` field must agree with the exit code - // returned below; a hardcoded `success` alongside a non-zero exit - // misleads JSON consumers. + // The apply step ran (not --save-only) but failed → partial failure: + // `record_apply_outcome` records a `failed` event for it, so the + // envelope's status agrees with the exit code returned below. No + // "download failed" concept here — a blob failure early-returns with + // status `error` above — so only the apply step can degrade us. let apply_failed = !apply_succeeded && !args.save_only; - // No "download failed" concept here — a blob failure early-returns - // with status `error` above — so only the apply step can degrade us. - let (status, exit_code) = run_outcome(false, apply_failed); + let exit_code = run_outcome(false, apply_failed); if args.common.json { - let mut patch_record = serde_json::json!({ - "purl": patch.purl, - "uuid": patch.uuid, - "action": action_label, - }); - if let PatchAction::Updated { old_uuid } = &action { - patch_record["oldUuid"] = serde_json::json!(old_uuid); - } - if changed { - // Only enrich added/updated records — a `skipped` record means - // the consumer already saw the metadata last time. - merge_metadata(&mut patch_record, patch_event_metadata(patch)); - } - let mut result_json = serde_json::json!({ - "status": status, - "found": 1, - "downloaded": if changed { 1 } else { 0 }, - "applied": if apply_succeeded { 1 } else { 0 }, - "patches": [patch_record], + let mut env = new_envelope(&args.common); + fold_narrowing_into_result(&mut env, &[], run_warnings); + // The metadata rides only a changed record — an `already_in_manifest` + // skip means the consumer already saw it last time. + let event = match &action { + PatchAction::Added => PatchEvent::new(EventAction::Downloaded, patch.purl.as_str()) + .with_details(patch_event_metadata(patch)), + PatchAction::Updated { old_uuid } => { + PatchEvent::new(EventAction::Updated, patch.purl.as_str()) + .with_old_uuid(old_uuid.as_str()) + .with_details(patch_event_metadata(patch)) + } + PatchAction::Skipped => PatchEvent::new(EventAction::Skipped, patch.purl.as_str()) + .with_reason(ALREADY_IN_MANIFEST, "already in manifest"), + }; + env.record(event.with_uuid(patch.uuid.as_str())); + env.warnings.extend(warnings); + // A failed apply names what failed (#424); the manifest names the + // uuid of any other failing record. + let manifest = if apply_report.as_ref().is_some_and(|r| r.code != 0) { + Box::pin(read_manifest(&manifest_path)).await.ok().flatten() + } else { + None + }; + record_apply_outcome(&mut env, &recorded, apply_report.as_ref(), |purl| { + let record = manifest.as_ref()?.patches.get(purl)?; + Some(record.uuid.clone()) }); - // A failed apply names what failed (#424); `failed` appears only - // then, so a clean run's envelope is unchanged. - if let Some(report) = apply_report.as_ref().filter(|r| r.code != 0) { - // The manifest names the uuid of any other failing record. - let recorded = Box::pin(read_manifest(&manifest_path)).await.ok().flatten(); - result_json["failed"] = serde_json::json!(0); - let applied = fold_apply_failures(&mut result_json, report, |purl| { - let record = recorded.as_ref()?.patches.get(purl)?; - Some(record.uuid.clone()) - }); - result_json["applied"] = serde_json::json!(applied); - } - // Same contract as `download_and_apply_patches_with`: omitted when clean. - warnings.extend(apply_warning_lines(apply_report.as_ref())); - if !warnings.is_empty() { - result_json["warnings"] = serde_json::json!(warnings); - } - fold_narrowing_into_result(&mut result_json, &[], run_warnings); - print_json(&result_json); + emit(&env); } exit_code @@ -2209,13 +2175,14 @@ fn get_download_params(args: &GetArgs, save_only: bool, persist_blobs: bool) -> /// hosted engine ([`super::scan::boxed_run_redirect_selected`]) — lockfile /// rewrite only, no manifest, no blobs, no ledger — so the on-disk result /// matches `scan --mode hosted` selecting the same patches. The engine owns -/// all output (and honors `--dry-run` internally); in JSON mode it nests its -/// `redirect` block into the get base envelope passed as `scan_result`. +/// all output (and honors `--dry-run` internally); in JSON mode it records +/// its events (`details.mode: "hosted"`) into the get envelope passed as +/// `scan_result` and prints it. async fn run_get_hosted( args: &GetArgs, api_client: &ApiClient, selected: &[PatchSearchResult], - narrow_skips: &[serde_json::Value], + narrow_skips: &[PatchEvent], narrow_warnings: &[(String, String)], ) -> i32 { let pairs: Vec<(String, String)> = selected @@ -2223,16 +2190,11 @@ async fn run_get_hosted( .map(|s| (s.purl.clone(), s.uuid.clone())) .collect(); // `scan_result` iff --json: the engine's human/JSON split keys on - // common.json, and a --json caller passing None would get a minimal - // envelope that drops get's keys (see run_redirect_selected's doc). + // common.json (see run_redirect_selected's doc). let scan_result = args.common.json.then(|| { - let mut result = serde_json::json!({ - "status": "success", - "found": pairs.len() + narrow_skips.len(), - "patches": narrow_skips, - }); - fold_narrowing_into_result(&mut result, &[], narrow_warnings); - result + let mut env = new_envelope(&args.common); + fold_narrowing_into_result(&mut env, narrow_skips, narrow_warnings); + env }); // Embedded VEX stays a scan/vendor feature (get has no --vex): a // default-off VexEmbedArgs — deliberately NOT env-bound here, so an @@ -2275,10 +2237,12 @@ async fn run_get_vendored( use_public_proxy: bool, selected: &[PatchSearchResult], prefetched: Option<&PatchResponse>, - narrow_skips: &[serde_json::Value], + narrow_skips: &[PatchEvent], narrow_warnings: &[(String, String)], telemetry: &TelemetryAuth, ) -> i32 { + let mut env = new_envelope(&args.common); + fold_narrowing_into_result(&mut env, narrow_skips, narrow_warnings); // Dry run: ledger-classification preview only (scan's posture) — no // download, no vendor step, no writes. if args.common.dry_run { @@ -2287,7 +2251,7 @@ async fn run_get_vendored( selected.iter().map(|p| p.purl.as_str()), ) .await; - let preview = super::scan::preview_vendor_json( + let preview = super::scan::preview_vendor( &args.common.cwd, selected, &super::hosted_unwind::patch_server_origins(&args.common), @@ -2295,17 +2259,11 @@ async fn run_get_vendored( ) .await; if args.common.json { - let mut result = serde_json::json!({ - "status": "success", - "found": selected.len() + narrow_skips.len(), - "patches": narrow_skips, - }); - fold_narrowing_into_result(&mut result, &[], narrow_warnings); - result["vendor"] = preview; - print_json(&result); + preview.record_into(&mut env); + emit(&env); } else if !args.common.silent { println!("{}", format_dry_run("download and vendor", selected.len())); - super::scan::print_dry_run_refusals(&preview); + preview.print_refusals(); } return 0; } @@ -2321,18 +2279,9 @@ async fn run_get_vendored( // `.socket/` is created on a fresh project). The already-fetched // patch is the only network traffic of a refused run. // - // JSON shape (contract: `get --mode vendored` pre-record - // refusal; the record carries BOTH `errorCode` and `error` like the - // search path's failed records, and the envelope carries `skipped` - // like this path's success shape): - // - // { - // "status": "error", - // "found": 1, "downloaded": 0, "skipped": 0, "failed": 1, - // "error": { "code": "", "message": "" }, - // "patches": [{ "purl": "…", "uuid": "…", "action": "failed", - // "errorCode": "", "error": "" }] - // } + // JSON: `status: "error"` with `error: {code: , + // message: }` and the patch's `failed` event (same + // `errorCode` / `error`, `details.mode: "vendored"`). // // Human: `Error (): ` on stderr — an error, so it is // exempt from `--silent` like every other `Error (…)` line here. @@ -2359,21 +2308,14 @@ async fn run_get_vendored( ) .await; if args.common.json { - print_json(&serde_json::json!({ - "status": "error", - "found": 1, - "downloaded": 0, - "skipped": 0, - "failed": 1, - "error": { "code": code, "message": detail }, - "patches": [{ - "purl": patch.purl, - "uuid": patch.uuid, - "action": "failed", - "errorCode": code, - "error": detail, - }], - })); + env.record(tag_mode( + PatchEvent::new(EventAction::Failed, patch.purl.as_str()) + .with_uuid(patch.uuid.as_str()) + .with_error(*code, detail.as_str()), + Some("vendored"), + )); + env.mark_error(EnvelopeError::new(*code, detail.as_str())); + emit(&env); } else { eprintln!( "{}", @@ -2394,7 +2336,7 @@ async fn run_get_vendored( let prefetched_views: HashMap = prefetched .map(|p| HashMap::from([(p.uuid.clone(), p.clone())])) .unwrap_or_default(); - let (dl_code, mut result, records) = if prefetched.is_some() { + let download: DetachedDownload = if prefetched.is_some() { // The preflight above already read the lock: hand its outcome down. let vendor_state = load_state(&args.common.project_root()).await; Box::pin(download_patch_records_preflighted( @@ -2419,7 +2361,7 @@ async fn run_get_vendored( )) .await }; - fold_narrowing_into_result(&mut result, narrow_skips, narrow_warnings); + let (dl_code, _, records) = download.into_envelope(&mut env); // The vendor step (scan's, verbatim): apply lock, in-memory staging // seeded with the blobs fetched above, the engine over exactly the @@ -2440,14 +2382,11 @@ async fn run_get_vendored( { Ok((has_errors, venv)) => { if args.common.json { - result["status"] = serde_json::json!(if has_errors { - "partial_failure" - } else { - "success" - }); - result["vendor"] = - serde_json::to_value(&venv).unwrap_or_else(|_| serde_json::json!({})); - print_json(&result); + super::scan::vendor_flow::merge_vendor_envelope(&mut env, venv); + if has_errors { + env.mark_partial_failure(); + } + emit(&env); } i32::from(has_errors) } @@ -2457,14 +2396,10 @@ async fn run_get_vendored( // included) must reach the JSON consumer even though the // run aborts here. if let Some(venv) = venv { - result["vendor"] = - serde_json::to_value(&*venv).unwrap_or_else(|_| serde_json::json!({})); + super::scan::vendor_flow::merge_vendor_envelope(&mut env, *venv); } - crate::json_envelope::set_error( - &mut result, - crate::json_envelope::EnvelopeError::new(code, message), - ); - print_json(&result); + env.mark_error(EnvelopeError::new(code, message)); + emit(&env); } else { eprintln!( "{}", @@ -3214,49 +3149,20 @@ mod tests { } // --- run_outcome ----------------------------------------------------- - // The `status` field and the process exit code are derived from the - // same predicate: a failed *apply* step (no download failures) must - // still report `partial_failure` AND exit 1. + // Exit 1 for a download or an apply failure; the envelope's status + // follows from the `failed` events those runs record. #[test] - fn run_outcome_clean_is_success_exit_zero() { - assert_eq!(run_outcome(false, false), ("success", 0)); - } - - #[test] - fn run_outcome_download_failure_is_partial_exit_one() { - assert_eq!(run_outcome(true, false), ("partial_failure", 1)); - } - - #[test] - fn run_outcome_apply_failure_alone_is_partial_exit_one() { + fn run_outcome_exits_one_on_any_failure() { + assert_eq!(run_outcome(false, false), 0); + assert_eq!(run_outcome(true, false), 1); // The load-bearing case: nothing failed to download, but the apply - // step failed. status MUST agree with the non-zero exit code. - assert_eq!(run_outcome(false, true), ("partial_failure", 1)); + // step failed. + assert_eq!(run_outcome(false, true), 1); + assert_eq!(run_outcome(true, true), 1); } - #[test] - fn run_outcome_both_failures_is_partial_exit_one() { - assert_eq!(run_outcome(true, true), ("partial_failure", 1)); - } - - #[test] - fn run_outcome_status_and_exit_never_disagree() { - // Exhaustive: a `success` status iff exit 0, `partial_failure` iff - // exit 1, for every input combination. - for pf in [false, true] { - for af in [false, true] { - let (status, code) = run_outcome(pf, af); - assert_eq!( - status == "success", - code == 0, - "status/exit disagree for patches_failed={pf}, apply_failed={af}" - ); - } - } - } - - // --- fold_apply_failures (#424) --------------------------------------- + // --- record_apply_outcome (#424) -------------------------------------- fn failure(purl: &str, code: &str, error: &str) -> crate::commands::apply::ApplyFailure { crate::commands::apply::ApplyFailure { @@ -3279,45 +3185,81 @@ mod tests { } } + fn recorded(pairs: &[(&str, &str)]) -> Vec<(String, String)> { + pairs + .iter() + .map(|(p, u)| (p.to_string(), u.to_string())) + .collect() + } + + /// `(action, purl, uuid, errorCode)` of every event, for compact asserts. + fn outcomes(env: &Envelope) -> Vec<(String, String, String, String)> { + env.events + .iter() + .map(|e| { + ( + serde_json::to_value(e.action) + .unwrap() + .as_str() + .unwrap() + .to_string(), + e.purl.clone().unwrap_or_default(), + e.uuid.clone().unwrap_or_default(), + e.error_code.clone().unwrap_or_default(), + ) + }) + .collect() + } + + fn o(action: &str, purl: &str, uuid: &str, code: &str) -> (String, String, String, String) { + (action.into(), purl.into(), uuid.into(), code.into()) + } + #[test] - fn fold_apply_failures_marks_the_failed_record_and_drops_metadata() { - let mut env = serde_json::json!({ - "failed": 0, - "patches": [ - {"purl": "pkg:npm/a@1.0.0", "uuid": "ua", "action": "added", "license": "MIT"}, - {"purl": "pkg:npm/b@1.0.0", "uuid": "ub", "action": "updated", "oldUuid": "o"}, - ], - }); + fn record_apply_outcome_clean_apply_records_every_patch_applied() { + let mut env = Envelope::new(JsonCommand::Get); + let report = ApplyRunReport::default(); + let recs = recorded(&[("pkg:npm/a@1.0.0", "ua"), ("pkg:npm/b@1.0.0", "ub")]); + record_apply_outcome(&mut env, &recs, Some(&report), |_| None); + assert_eq!( + outcomes(&env), + vec![ + o("applied", "pkg:npm/a@1.0.0", "ua", ""), + o("applied", "pkg:npm/b@1.0.0", "ub", ""), + ] + ); + assert_eq!(env.status, Status::Success); + assert_eq!(env.summary.applied, 2); + // No apply ran: nothing recorded. + let mut env = Envelope::new(JsonCommand::Get); + record_apply_outcome(&mut env, &recs, None, |_| None); + assert!(env.events.is_empty()); + } + + #[test] + fn record_apply_outcome_marks_the_failed_patch_and_counts_the_applied_one() { + let mut env = Envelope::new(JsonCommand::Get); let report = report_with( vec![failure("pkg:npm/a@1.0.0", "apply_failed", "denied")], &["pkg:npm/b@1.0.0"], ); + let recs = recorded(&[("pkg:npm/a@1.0.0", "ua"), ("pkg:npm/b@1.0.0", "ub")]); + record_apply_outcome(&mut env, &recs, Some(&report), |_| None); assert_eq!( - fold_apply_failures(&mut env, &report, |_| None), - 1, - "b applied" - ); - assert_eq!( - env["patches"][0], - serde_json::json!({ - "purl": "pkg:npm/a@1.0.0", "uuid": "ua", "action": "failed", - "errorCode": "apply_failed", "error": "denied", - }) + outcomes(&env), + vec![ + o("failed", "pkg:npm/a@1.0.0", "ua", "apply_failed"), + o("applied", "pkg:npm/b@1.0.0", "ub", ""), + ] ); - assert_eq!(env["patches"][1]["action"], "updated", "{env}"); - assert_eq!(env["failed"], 1, "{env}"); - assert!(env.get("errorCode").is_none(), "{env}"); + assert_eq!(env.events[0].error.as_deref(), Some("denied")); + assert_eq!(env.status, Status::PartialFailure); + assert!(env.error.is_none()); } #[test] - fn fold_apply_failures_matches_percent_encoded_and_base_purl_keys() { - let mut env = serde_json::json!({ - "failed": 0, - "patches": [ - {"purl": "pkg:npm/%40scope/a@1.0.0", "uuid": "u1", "action": "added"}, - {"purl": "pkg:pypi/six@1.16.0?artifact_id=w", "uuid": "u2", "action": "added"}, - ], - }); + fn record_apply_outcome_matches_percent_encoded_and_base_purl_keys() { + let mut env = Envelope::new(JsonCommand::Get); // An unqualified (base) key covers its qualified release variants. let report = report_with( vec![ @@ -3326,25 +3268,32 @@ mod tests { ], &[], ); - assert_eq!(fold_apply_failures(&mut env, &report, |_| None), 0); - assert_eq!(env["patches"][0]["action"], "failed", "{env}"); - assert_eq!(env["patches"][1]["action"], "failed", "{env}"); - assert_eq!(env["patches"][1]["errorCode"], "package_not_installed"); - assert_eq!(env["patches"].as_array().unwrap().len(), 2, "{env}"); - assert_eq!(env["failed"], 2, "{env}"); + let recs = recorded(&[ + ("pkg:npm/%40scope/a@1.0.0", "u1"), + ("pkg:pypi/six@1.16.0?artifact_id=w", "u2"), + ]); + record_apply_outcome(&mut env, &recs, Some(&report), |_| None); + assert_eq!( + outcomes(&env), + vec![ + o("failed", "pkg:npm/%40scope/a@1.0.0", "u1", "apply_failed"), + o( + "failed", + "pkg:pypi/six@1.16.0?artifact_id=w", + "u2", + "package_not_installed" + ), + ] + ); + assert_eq!(env.summary.failed, 2); } #[test] - fn fold_apply_failures_never_blames_a_sibling_variant() { - // A qualified failure that matches no selected record must not be + fn record_apply_outcome_never_blames_a_sibling_variant() { + // A qualified failure that matches no selected patch must not be // pinned on a selected sibling variant that applied: it gets its - // own record, and the sibling stays applied. - let mut env = serde_json::json!({ - "failed": 0, - "patches": [ - {"purl": "pkg:pypi/six@1.16.0?artifact_id=w", "uuid": "u1", "action": "added"}, - ], - }); + // own event, and the sibling stays applied. + let mut env = Envelope::new(JsonCommand::Get); let report = report_with( vec![failure( "pkg:pypi/six@1.16.0?artifact_id=s", @@ -3354,94 +3303,100 @@ mod tests { &["pkg:pypi/six@1.16.0?artifact_id=w"], ); let uuid_of = |p: &str| p.ends_with("=s").then(|| "u0".to_string()); - assert_eq!(fold_apply_failures(&mut env, &report, uuid_of), 1); - assert_eq!(env["patches"][0]["action"], "added", "{env}"); + let recs = recorded(&[("pkg:pypi/six@1.16.0?artifact_id=w", "u1")]); + record_apply_outcome(&mut env, &recs, Some(&report), uuid_of); assert_eq!( - env["patches"][1]["purl"], - "pkg:pypi/six@1.16.0?artifact_id=s" + outcomes(&env), + vec![ + o("applied", "pkg:pypi/six@1.16.0?artifact_id=w", "u1", ""), + o( + "failed", + "pkg:pypi/six@1.16.0?artifact_id=s", + "u0", + "apply_failed" + ), + ] ); - assert_eq!(env["patches"][1]["uuid"], "u0", "{env}"); - assert_eq!(env["patches"][1]["action"], "failed", "{env}"); - assert_eq!(env["failed"], 1, "{env}"); } #[test] - fn fold_apply_failures_counts_only_patches_apply_reported_applied() { - // `c` is selected and recorded but apply never patched it (not - // installed: only a warning beside `a`'s real failure), so it must - // not count as applied. - let mut env = serde_json::json!({ - "failed": 0, - "patches": [ - {"purl": "pkg:npm/a@1.0.0", "uuid": "ua", "action": "added"}, - {"purl": "pkg:npm/b@1.0.0", "uuid": "ub", "action": "skipped"}, - {"purl": "pkg:npm/c@1.0.0", "uuid": "uc", "action": "added"}, - {"purl": "pkg:npm/d@1.0.0", "uuid": "ud", "action": "skipped", - "errorCode": "package_not_installed"}, - ], - }); + fn record_apply_outcome_counts_only_patches_apply_reported_applied() { + // `c` is recorded but apply never patched it (not installed: only a + // warning beside `a`'s real failure), so it gets no `applied` event. + let mut env = Envelope::new(JsonCommand::Get); let report = report_with( vec![failure("pkg:npm/a@1.0.0", "apply_failed", "x")], - &["pkg:npm/b@1.0.0", "pkg:npm/d@1.0.0"], + &["pkg:npm/b@1.0.0"], ); + let recs = recorded(&[ + ("pkg:npm/a@1.0.0", "ua"), + ("pkg:npm/b@1.0.0", "ub"), + ("pkg:npm/c@1.0.0", "uc"), + ]); + record_apply_outcome(&mut env, &recs, Some(&report), |_| None); assert_eq!( - fold_apply_failures(&mut env, &report, |_| None), - 1, - "only the already-recorded b applied: {env}" + outcomes(&env), + vec![ + o("failed", "pkg:npm/a@1.0.0", "ua", "apply_failed"), + o("applied", "pkg:npm/b@1.0.0", "ub", ""), + ] ); - assert_eq!(env["patches"][2]["action"], "added", "{env}"); - assert_eq!(env["failed"], 1, "{env}"); } #[test] - fn fold_apply_failures_appends_an_unselected_manifest_failure() { + fn record_apply_outcome_appends_an_unselected_manifest_failure_once() { // The nested apply covers the whole (ecosystem-scoped) manifest: a - // failing record this run did not select still gets named, without - // costing this run's own patch its `applied` count. - let mut env = serde_json::json!({ - "failed": 1, - "patches": [ - {"purl": "pkg:npm/a@1.0.0", "uuid": "ua", "action": "added"}, - ], - }); + // failing record this run did not select still gets named, once, + // without costing this run's own patch its `applied` event. + let mut env = Envelope::new(JsonCommand::Get); let report = report_with( - vec![failure("pkg:npm/old@2.0.0", "apply_failed", "z")], + vec![ + failure("pkg:npm/old@2.0.0", "apply_failed", "z"), + failure("pkg:npm/old@2.0.0", "apply_failed", "z again"), + ], &["pkg:npm/a@1.0.0"], ); let uuid_of = |p: &str| (p == "pkg:npm/old@2.0.0").then(|| "uo".to_string()); - assert_eq!(fold_apply_failures(&mut env, &report, uuid_of), 1); - assert_eq!(env["patches"][0]["action"], "added", "{env}"); + let recs = recorded(&[("pkg:npm/a@1.0.0", "ua")]); + record_apply_outcome(&mut env, &recs, Some(&report), uuid_of); assert_eq!( - env["patches"][1], - serde_json::json!({ - "purl": "pkg:npm/old@2.0.0", "uuid": "uo", "action": "failed", - "errorCode": "apply_failed", "error": "z", - }) + outcomes(&env), + vec![ + o("applied", "pkg:npm/a@1.0.0", "ua", ""), + o("failed", "pkg:npm/old@2.0.0", "uo", "apply_failed"), + ] ); - assert_eq!(env["failed"], 2, "download failures stay counted: {env}"); } #[test] - fn fold_apply_failures_carries_a_run_level_error() { - let mut env = serde_json::json!({ - "failed": 0, - "patches": [{"purl": "pkg:npm/a@1.0.0", "uuid": "ua", "action": "added"}], - }); + fn record_apply_outcome_records_a_run_level_failure() { + // A failure no single patch explains is a purl-less `failed` event: + // the status agrees with exit 1, and no top-level `error` is set + // (the downloads still landed). + let mut env = Envelope::new(JsonCommand::Get); let report = ApplyRunReport { code: 1, failures: Vec::new(), run_error: Some(("yarn_pnp_unsupported".to_string(), "pnp".to_string())), applied: Vec::new(), - warnings: Vec::new(), + warnings: vec![RunWarning::new("content_mismatch_overwritten", "w")], }; - assert_eq!(fold_apply_failures(&mut env, &report, |_| None), 0); - assert!(env.get("errorCode").is_none(), "{env}"); + let recs = recorded(&[("pkg:npm/a@1.0.0", "ua")]); + record_apply_outcome(&mut env, &recs, Some(&report), |_| None); assert_eq!( - env["error"], - serde_json::json!({"code": "yarn_pnp_unsupported", "message": "pnp"}), - "{env}" - ); - assert_eq!(env["failed"], 0, "{env}"); + outcomes(&env), + vec![o("failed", "", "", "yarn_pnp_unsupported")] + ); + assert_eq!(env.events[0].error.as_deref(), Some("pnp")); + assert_eq!(env.status, Status::PartialFailure); + assert!(env.error.is_none()); + assert_eq!(env.warnings[0].code, "content_mismatch_overwritten"); + // A failed apply with nothing to blame still records one failure. + let mut env = Envelope::new(JsonCommand::Get); + record_apply_outcome(&mut env, &recs, Some(&report_with(Vec::new(), &[])), |_| { + None + }); + assert_eq!(outcomes(&env), vec![o("failed", "", "", "apply_failed")]); } // --- write_blob_entry ------------------------------------------------ @@ -3846,42 +3801,34 @@ mod tests { } // --- fold_narrowing_into_result ---------------------------------------- - // Hosted runs stack release-variant warnings (already in the envelope as - // strings) with coarse-narrowing PnP warnings folded in later; the merge - // must PRESERVE the existing strings and append the new `(code) detail` - // ones, while skip records bump found/skipped and extend patches[]. + // The narrowing skips are recorded (so `summary` counts them) and the + // `(code, detail)` warnings join the envelope's `{code, detail}` + // warnings after any already there. #[test] - fn fold_narrowing_merges_into_existing_warnings_and_counts() { - let mut result = serde_json::json!({ - "status": "success", - "found": 1, - "skipped": 0, - "patches": [{"purl": "pkg:npm/kept@1.0.0", "action": "added"}], - "warnings": ["existing variant warning"], - }); - let skips = vec![serde_json::json!({ - "purl": "pkg:npm/skipped@1.0.0", "uuid": "u", - "action": "skipped", "errorCode": "package_not_installed", - })]; + fn fold_narrowing_records_skips_and_appends_warnings() { + let mut env = Envelope::new(JsonCommand::Get); + env.warn(RELEASE_NARROWING, "existing variant warning"); + let skips = vec![skip("pkg:npm/skipped@1.0.0", "package_not_installed")]; let warnings = vec![( "yarn_pnp_unsupported".to_string(), "PnP layout detail".to_string(), )]; - fold_narrowing_into_result(&mut result, &skips, &warnings); + fold_narrowing_into_result(&mut env, &skips, &warnings); - assert_eq!(result["found"], 2, "skip records count as found"); - assert_eq!(result["skipped"], 1); - let patches = result["patches"].as_array().unwrap(); - assert_eq!(patches.len(), 2, "skip record folded into patches[]"); - assert_eq!(patches[1]["errorCode"], "package_not_installed"); + assert_eq!(env.summary.skipped, 1); + assert_eq!(env.events.len(), 1); assert_eq!( - result["warnings"], + env.events[0].error_code.as_deref(), + Some("package_not_installed") + ); + let v = env.to_value(); + assert_eq!( + v["warnings"], serde_json::json!([ - "existing variant warning", - "(yarn_pnp_unsupported) PnP layout detail" + {"code": "release_narrowing", "detail": "existing variant warning"}, + {"code": "yarn_pnp_unsupported", "detail": "PnP layout detail"}, ]), - "existing warning strings must survive the merge, new ones appended" ); } @@ -4161,8 +4108,10 @@ mod tests { assert_eq!(format_selected_patches(&[], false), "Selected:\n\n"); } - fn skip(purl: &str, code: &str) -> serde_json::Value { - serde_json::json!({"purl": purl, "uuid": "u", "action": "skipped", "errorCode": code}) + fn skip(purl: &str, code: &str) -> PatchEvent { + PatchEvent::new(EventAction::Skipped, purl) + .with_uuid("u") + .with_reason(code, narrowing_reason(code)) } #[test] @@ -4340,11 +4289,11 @@ mod tests { #[test] fn verbose_skips_dedupe_and_sort_naturally() { let rec = |purl: &str, code: Option<&str>| { - let mut r = serde_json::json!({"purl": purl, "action": "skipped"}); - if let Some(c) = code { - r["errorCode"] = serde_json::json!(c); + let r = PatchEvent::new(EventAction::Skipped, purl); + match code { + Some(c) => r.with_reason(c, narrowing_reason(c)), + None => r, } - r }; let skips = vec![ rec("pkg:npm/a@4.10.0", Some("package_not_installed")), @@ -4613,6 +4562,16 @@ mod tests { .0 } + /// A detached download phase folded into a fresh envelope, serialized: + /// `(exit code, envelope JSON, records)`. + fn detached_json( + dl: DetachedDownload, + ) -> (i32, serde_json::Value, HashMap) { + let mut env = Envelope::new(JsonCommand::Get); + let (code, _, records) = dl.into_envelope(&mut env); + (code, env.to_value(), records) + } + /// The 3-arg shape the vendored-download unit tests below drive: builds /// the run's client against `server_url`, and drops the blob seed (the /// stager's concern, pinned by fetch_stage's tests). @@ -4622,9 +4581,9 @@ mod tests { server_url: &str, ) -> (i32, serde_json::Value, HashMap) { let api_client = test_client(server_url).await; - let (code, json, records) = - download_patch_records_with(selected, params, &api_client, HashMap::new()).await; - (code, json, records) + detached_json( + download_patch_records_with(selected, params, &api_client, HashMap::new()).await, + ) } #[tokio::test] @@ -4659,15 +4618,15 @@ mod tests { download_patch_records(&selected, &detached_params(tmp.path()), &server.uri()).await; assert_eq!(code, 1, "guardrail failure must exit 1; json={json}"); - assert_eq!(json["failed"], 1, "json={json}"); - assert_eq!(json["downloaded"], 0, "json={json}"); + assert_eq!(json["summary"]["failed"], 1, "json={json}"); + assert_eq!(json["summary"]["downloaded"], 0, "json={json}"); assert!( records.is_empty(), "no record may be handed to the vendor step" ); - assert_eq!(json["patches"][0]["action"], "failed", "json={json}"); + assert_eq!(json["events"][0]["action"], "failed", "json={json}"); assert_eq!( - json["patches"][0]["error"], "patch has no applicable files", + json["events"][0]["error"], "patch has no applicable files", "json={json}" ); } @@ -4690,11 +4649,11 @@ mod tests { download_patch_records(&selected, &detached_params(tmp.path()), &server.uri()).await; assert_eq!(code, 1, "a fetch miss must exit 1; json={json}"); - assert_eq!(json["failed"], 1, "json={json}"); + assert_eq!(json["summary"]["failed"], 1, "json={json}"); assert!(records.is_empty()); - assert_eq!(json["patches"][0]["action"], "failed", "json={json}"); + assert_eq!(json["events"][0]["action"], "failed", "json={json}"); assert_eq!( - json["patches"][0]["error"], "could not fetch details", + json["events"][0]["error"], "could not fetch details", "json={json}" ); } @@ -4731,38 +4690,48 @@ mod tests { download_patch_records(&selected, &detached_params(tmp.path()), &server.uri()).await; assert_eq!(code, 1, "json={json}"); - assert_eq!(json["found"], 2, "both variants must be kept; json={json}"); - assert_eq!(json["failed"], 2, "json={json}"); + assert_eq!( + json["events"].as_array().unwrap().len(), + 2, + "both variants must be kept; json={json}" + ); + assert_eq!(json["summary"]["failed"], 2, "json={json}"); assert!(records.is_empty()); let warnings = json["warnings"] .as_array() .unwrap_or_else(|| panic!("keep-all fallback must surface warnings; json={json}")); assert!( - warnings.iter().any(|w| w - .as_str() - .unwrap_or_default() - .contains("not installed locally")), + warnings.iter().any(|w| w["code"] == "release_narrowing" + && w["detail"] + .as_str() + .unwrap_or_default() + .contains("not installed locally")), "warning must explain the keep-all fallback; json={json}" ); } // --- misc edge cases ----------------------------------------------------- - /// `merge_metadata` is a best-effort splice: a non-object record (or a - /// non-object metadata value) must be left untouched, never panic — - /// callers hand it freshly-built json! values, but the contract is - /// defensive on both sides. + /// `tag_mode` merges the leg tag into an event's `details`, keeping + /// any metadata already there and replacing a non-object `details`. #[test] - fn merge_metadata_leaves_non_object_inputs_untouched() { - // Non-object record: nothing to insert into. - let mut record = serde_json::Value::Null; - merge_metadata(&mut record, serde_json::json!({"severity": "high"})); - assert!(record.is_null(), "a non-object record must stay untouched"); - - // Non-object metadata: nothing to splice from. - let mut record = serde_json::json!({"purl": "pkg:npm/x@1.0.0"}); - merge_metadata(&mut record, serde_json::Value::String("nope".into())); - assert_eq!(record, serde_json::json!({"purl": "pkg:npm/x@1.0.0"})); + fn tag_mode_merges_into_details() { + let event = PatchEvent::new(EventAction::Downloaded, "pkg:npm/x@1.0.0") + .with_details(serde_json::json!({"tier": "free"})); + let tagged = tag_mode(event, Some("vendored")); + assert_eq!( + tagged.details, + Some(serde_json::json!({"tier": "free", "mode": "vendored"})) + ); + let event = PatchEvent::new(EventAction::Skipped, "pkg:npm/x@1.0.0") + .with_details(serde_json::json!("nope")); + let tagged = tag_mode(event, Some("hosted")); + assert_eq!(tagged.details, Some(serde_json::json!({"mode": "hosted"}))); + let event = PatchEvent::new(EventAction::Skipped, "pkg:npm/x@1.0.0"); + assert!( + tag_mode(event, None).details.is_none(), + "agent events carry no mode" + ); } /// The `TargetKind` Display labels are user-facing vocabulary (the @@ -4811,19 +4780,6 @@ mod tests { ); } - /// `fold_narrowing_into_result` on a non-object envelope (the error - /// shapes are the callers' concern) must be a calm no-op. - #[test] - fn fold_narrowing_ignores_non_object_result() { - let mut result = serde_json::json!(["not", "an", "object"]); - fold_narrowing_into_result( - &mut result, - &[serde_json::json!({"purl": "p", "action": "skipped"})], - &[("code".to_string(), "detail".to_string())], - ); - assert_eq!(result, serde_json::json!(["not", "an", "object"])); - } - /// A corrupt vendor ledger must degrade the coarse narrowing to "no /// ledger extension" (the download path's fail-closed read still guards /// writes): a purl claimed by nothing else is skipped as not installed, @@ -4856,7 +4812,10 @@ mod tests { "nothing may be kept via a corrupt ledger" ); assert_eq!(out.skip_records.len(), 1); - assert_eq!(out.skip_records[0]["errorCode"], "package_not_installed"); + assert_eq!( + out.skip_records[0].error_code.as_deref(), + Some("package_not_installed") + ); } /// The lockfile/vendor-ledger supplements are gated OFF for @@ -4907,7 +4866,10 @@ mod tests { "a prefix-scoped run must not treat lockfile resolution as presence" ); assert_eq!(out.skip_records.len(), 1); - assert_eq!(out.skip_records[0]["errorCode"], "package_not_installed"); + assert_eq!( + out.skip_records[0].error_code.as_deref(), + Some("package_not_installed") + ); } /// B74: a FIFO planted at `pnpm-lock.yaml` of a pnpm-PnP project must @@ -5039,9 +5001,11 @@ mod tests { let code_for = |purl: &str| { out.skip_records .iter() - .find(|r| r["purl"] == purl) - .unwrap_or_else(|| panic!("missing skip record for {purl}"))["errorCode"] + .find(|r| r.purl.as_deref() == Some(purl)) + .unwrap_or_else(|| panic!("missing skip record for {purl}")) + .error_code .clone() + .unwrap_or_default() }; assert_eq!( code_for("pkg:npm/covgap-noversion"), @@ -5092,9 +5056,9 @@ mod tests { let (code, json, records) = download_patch_records(&selected, ¶ms, &server.uri()).await; assert_eq!(code, 1, "json={json}"); - assert_eq!(json["failed"], 1, "json={json}"); + assert_eq!(json["summary"]["failed"], 1, "json={json}"); assert_eq!( - json["patches"][0]["error"], "Blob decode or write failed", + json["events"][0]["error"], "Blob decode or write failed", "json={json}" ); assert!( @@ -5145,9 +5109,9 @@ mod tests { let (code, json, records) = download_patch_records(&selected, ¶ms, &server.uri()).await; assert_eq!(code, 1, "json={json}"); - assert_eq!(json["failed"], 1, "json={json}"); + assert_eq!(json["summary"]["failed"], 1, "json={json}"); assert_eq!( - json["patches"][0]["error"], "Blob decode or write failed", + json["events"][0]["error"], "Blob decode or write failed", "json={json}" ); assert!( @@ -5226,11 +5190,11 @@ mod tests { let (code, json, records) = download_patch_records(&selected, ¶ms, &server.uri()).await; assert_eq!(code, 1, "json={json}"); - assert_eq!(json["downloaded"], 1, "json={json}"); - assert_eq!(json["failed"], 2, "json={json}"); + assert_eq!(json["summary"]["downloaded"], 1, "json={json}"); + assert_eq!(json["summary"]["failed"], 2, "json={json}"); assert_eq!(records.len(), 1, "only the good patch yields a record"); assert!(records.contains_key(good_purl), "json={json}"); - let errors: Vec<&str> = json["patches"] + let errors: Vec<&str> = json["events"] .as_array() .unwrap() .iter() @@ -5299,8 +5263,9 @@ mod tests { let (code, json, records) = download_patch_records(&selected, ¶ms, &server.uri()).await; assert_eq!(code, 0, "json={json}"); - assert_eq!(json["skipped"], 1, "json={json}"); - assert_eq!(json["patches"][0]["action"], "skipped", "json={json}"); + // Reused from the ledger with no fetch: no download event (the + // vendor engine reports the package). + assert_eq!(json["events"], serde_json::json!([]), "json={json}"); assert_eq!( records.get(purl).map(|r| r.uuid.as_str()), Some(uuid), @@ -5384,16 +5349,16 @@ mod tests { download_patch_records(&selected, &detached_params(tmp.path()), &server.uri()).await; assert_eq!(code, 1, "json={json}"); - assert_eq!(json["found"], 1, "json={json}"); - assert_eq!(json["downloaded"], 0, "json={json}"); - assert_eq!(json["failed"], 1, "json={json}"); - assert_eq!(json["patches"][0]["action"], "failed", "json={json}"); + assert_eq!(json["events"].as_array().unwrap().len(), 1, "json={json}"); + assert_eq!(json["summary"]["downloaded"], 0, "json={json}"); + assert_eq!(json["summary"]["failed"], 1, "json={json}"); + assert_eq!(json["events"][0]["action"], "failed", "json={json}"); assert_eq!( - json["patches"][0]["errorCode"], "vendor_bun_lockb_invalid", + json["events"][0]["errorCode"], "vendor_bun_lockb_invalid", "json={json}" ); assert!( - json["patches"][0]["error"] + json["events"][0]["error"] .as_str() .is_some_and(|d| !d.is_empty()), "the record must carry the engine's detail; json={json}" @@ -5426,9 +5391,9 @@ mod tests { download_patch_records(&selected, &detached_params(tmp.path()), &server.uri()).await; assert_eq!(code, 1, "json={json}"); - assert_eq!(json["failed"], 1, "json={json}"); + assert_eq!(json["summary"]["failed"], 1, "json={json}"); assert_eq!( - json["patches"][0]["errorCode"], "vendor_bun_workspace_unsupported", + json["events"][0]["errorCode"], "vendor_bun_workspace_unsupported", "json={json}" ); assert!(records.is_empty()); @@ -5468,10 +5433,13 @@ mod tests { assert_eq!(code, 1, "json={json}"); assert_eq!( - json["patches"][0]["error"], "could not fetch details", + json["events"][0]["error"], "could not fetch details", "a pypi purl must reach the fetch, not the Bun refusal; json={json}" ); - assert!(json["patches"][0].get("errorCode").is_none(), "json={json}"); + assert_eq!( + json["events"][0]["errorCode"], "download_failed", + "json={json}" + ); assert_eq!( server.received_requests().await.unwrap_or_default().len(), 1, @@ -5538,7 +5506,7 @@ mod tests { assert_eq!(code, 1, "json={json}"); let by_purl = |purl: &str| { - json["patches"] + json["events"] .as_array() .unwrap() .iter() @@ -5580,21 +5548,20 @@ mod tests { warn_on_vendored_uuid_drift( tmp.path(), true, - &[serde_json::json!({ - "purl": "pkg:npm/x@1.0.0", - "uuid": "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", - "action": "added", - })], + &[( + "pkg:npm/x@1.0.0".to_string(), + "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa".to_string(), + )], &mut warnings, ) .await; assert!(warnings.is_empty(), "unreadable state must warn nothing"); } - /// Malformed per-patch records (missing purl/uuid) are skipped without - /// panicking, while a well-formed drifting record still warns. + /// A recorded patch whose purl the ledger wires at another uuid warns + /// (`vendored_uuid_drift`); one the ledger does not hold does not. #[tokio::test] - async fn warn_on_vendored_uuid_drift_skips_malformed_records_and_flags_drift() { + async fn warn_on_vendored_uuid_drift_flags_drift() { let tmp = tempfile::tempdir().unwrap(); let purl = "pkg:npm/covgap-drift@1.0.0"; let vendored_uuid = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; @@ -5624,17 +5591,19 @@ mod tests { tmp.path(), true, &[ - // Malformed: no purl/uuid — must be skipped, not panic. - serde_json::json!({"action": "added"}), + // Not vendored at all: no warning. + ("pkg:npm/other@1.0.0".to_string(), new_uuid.to_string()), // Genuine drift: manifest moved to a different uuid. - serde_json::json!({"purl": purl, "uuid": new_uuid, "action": "added"}), + (purl.to_string(), new_uuid.to_string()), ], &mut warnings, ) .await; assert_eq!(warnings.len(), 1, "warnings={warnings:?}"); + assert_eq!(warnings[0].code, "vendored_uuid_drift"); assert!( - warnings[0].contains(purl) && warnings[0].contains("is vendored at patch"), + warnings[0].detail.contains(purl) + && warnings[0].detail.contains("is vendored at patch"), "warnings={warnings:?}" ); } @@ -5728,16 +5697,20 @@ mod tests { let client = test_client(&server.uri()).await; let prefetched = HashMap::from([(patch.uuid.clone(), patch.clone())]); - let (code, json, records) = - download_patch_records_with(&selected, ¶ms, &client, prefetched).await; + let (code, json, records) = detached_json( + download_patch_records_with(&selected, ¶ms, &client, prefetched).await, + ); assert_eq!(code, 0, "json={json}"); - assert_eq!(json["downloaded"], 1, "json={json}"); + assert_eq!(json["summary"]["downloaded"], 1, "json={json}"); assert!(!tmp.path().join(".socket/blobs").exists()); - assert_eq!(json["detached"], true, "json={json}"); - assert_eq!(json["patches"][0]["action"], "downloaded", "json={json}"); + assert_eq!(json["events"][0]["action"], "downloaded", "json={json}"); + assert_eq!( + json["events"][0]["details"]["mode"], "vendored", + "json={json}" + ); assert!( - json["patches"][0].get("oldUuid").is_none(), + json["events"][0]["details"].get("oldUuid").is_none(), "no ledger entry, no oldUuid; json={json}" ); assert_eq!( @@ -5815,10 +5788,13 @@ mod tests { download_patch_records(&selected, &detached_params(tmp.path()), &server.uri()).await; assert_eq!(code, 0, "json={json}"); - assert_eq!(json["downloaded"], 1, "json={json}"); - assert_eq!(json["skipped"], 0, "json={json}"); - assert_eq!(json["patches"][0]["action"], "downloaded", "json={json}"); - assert_eq!(json["patches"][0]["oldUuid"], old_uuid, "json={json}"); + assert_eq!(json["summary"]["downloaded"], 1, "json={json}"); + assert_eq!(json["summary"]["skipped"], 0, "json={json}"); + assert_eq!(json["events"][0]["action"], "downloaded", "json={json}"); + assert_eq!( + json["events"][0]["details"]["oldUuid"], old_uuid, + "json={json}" + ); assert_eq!( records.get(purl).map(|r| r.uuid.as_str()), Some(new_uuid), @@ -5953,15 +5929,17 @@ mod tests { .map(|(u, n)| mk_patch(u, &purl(n), "free", "2024-01-01")) .collect(); let client = test_client(&server.uri()).await; - let (_code, json, records) = download_patch_records_with( - &selected, - &detached_params(tmp.path()), - &client, - HashMap::from([(d.clone(), held)]), - ) - .await; + let (_code, json, records) = detached_json( + download_patch_records_with( + &selected, + &detached_params(tmp.path()), + &client, + HashMap::from([(d.clone(), held)]), + ) + .await, + ); - let rows: Vec<(String, String, String)> = json["patches"] + let rows: Vec<(String, String, String)> = json["events"] .as_array() .unwrap() .iter() @@ -5982,14 +5960,15 @@ mod tests { row("b", "failed", "could not fetch details"), row("c", "failed", "API request failed with status 500: boom"), row("d", "downloaded", ""), - row("e", "skipped", ""), + // e: the ledger already holds it at this uuid — reused, no + // fetch and no event (the vendor engine reports it). row("f", "downloaded", ""), ], "json={json}" ); - assert_eq!(json["downloaded"], 3, "json={json}"); - assert_eq!(json["failed"], 2, "json={json}"); - assert_eq!(json["skipped"], 1, "json={json}"); + assert_eq!(json["summary"]["downloaded"], 3, "json={json}"); + assert_eq!(json["summary"]["failed"], 2, "json={json}"); + assert_eq!(json["summary"]["skipped"], 0, "json={json}"); let mut got: Vec<&String> = records.keys().collect(); got.sort(); assert_eq!(got, vec![&purl("a"), &purl("d"), &purl("e"), &purl("f")]); @@ -6269,7 +6248,7 @@ mod tests { let (_code, json, _records) = download_patch_records(&selected, &detached_params(tmp.path()), &server.uri()).await; - let got: Vec<&str> = json["patches"] + let got: Vec<&str> = json["events"] .as_array() .expect("patches[]") .iter() @@ -6278,7 +6257,7 @@ mod tests { let want: Vec<&str> = selected.iter().map(|p| p.purl.as_str()).collect(); assert_eq!( got, want, - "download.patches must be emitted in the selection's purl order; json={json}" + "download events must be emitted in the selection's purl order; json={json}" ); } } diff --git a/crates/socket-patch-cli/src/commands/list.rs b/crates/socket-patch-cli/src/commands/list.rs index fda140265..ed0d42416 100644 --- a/crates/socket-patch-cli/src/commands/list.rs +++ b/crates/socket-patch-cli/src/commands/list.rs @@ -346,21 +346,10 @@ pub async fn run(args: ListArgs) -> i32 { let manifest = match &loaded.manifest { Ok(manifest) => manifest.as_ref(), Err(e) => { - // `InvalidData` (bad JSON or schema) is the contract's - // `manifest_invalid`; everything else is `manifest_unreadable` - // (see CLI_CONTRACT.md error-code table). Ledger records never - // mask either: a present-but-broken manifest is an error state. - let code = if e.kind() == std::io::ErrorKind::InvalidData { - "manifest_invalid" - } else { - "manifest_unreadable" - }; - emit_error( - &args, - code, - crate::ui::manifest_error_message(&manifest_path, e), - Vec::new(), - ); + // Ledger records never mask a present-but-broken manifest: it + // is an error state. + let err = crate::json_envelope::manifest_load_error(&manifest_path, e); + emit_error(&args, &err.code, err.message, Vec::new()); return 1; } }; diff --git a/crates/socket-patch-cli/src/commands/remove.rs b/crates/socket-patch-cli/src/commands/remove.rs index d0c82e6b0..9036a0d73 100644 --- a/crates/socket-patch-cli/src/commands/remove.rs +++ b/crates/socket-patch-cli/src/commands/remove.rs @@ -25,7 +25,7 @@ use crate::commands::vendored_backend::{ KeepCause, RevertedEntry, VendorRevertStep, VendoredBackend, }; use crate::json_envelope::{ - Command, Envelope, EnvelopeError, GcReport, PatchAction, PatchEvent, Status, + Command, Envelope, EnvelopeError, GcReport, PatchAction, PatchEvent, PatchEventFile, Status, }; use crate::ui::short_uuid; use crate::ui::{plural, sweep_failure}; @@ -428,26 +428,23 @@ pub async fn run(args: RemoveArgs) -> i32 { match read_manifest(&manifest_path).await { Ok(Some(m)) => m, Ok(None) => { + // Present at the existence check above, gone by the read. emit_error_envelope( args.common.json, args.common.dry_run, - "manifest_invalid", - "Invalid manifest".to_string(), + "manifest_not_found", + format!("Manifest not found at {}", manifest_path.display()), ); return 1; } Err(e) => { - // A manifest that exists but is unparseable (bad JSON or a - // schema violation) surfaces as `ErrorKind::InvalidData` — - // the contract's `manifest_invalid`. Everything else is a - // genuine I/O failure (`manifest_unreadable`). See the - // CLI_CONTRACT.md error-code table; `list` shares the split. - let code = if e.kind() == std::io::ErrorKind::InvalidData { - "manifest_invalid" - } else { - "manifest_unreadable" - }; - emit_error_envelope(args.common.json, args.common.dry_run, code, e.to_string()); + let err = crate::json_envelope::manifest_load_error(&manifest_path, &e); + emit_error_envelope( + args.common.json, + args.common.dry_run, + &err.code, + err.message, + ); return 1; } } @@ -611,7 +608,10 @@ pub async fn run(args: RemoveArgs) -> i32 { .as_ref() .map(socket_patch_core::vendor::VendorState::purl_keys) .unwrap_or_default(); - let mut rollback_count = 0; + // One `rolledBack` event per restored installed copy (recorded, so + // `summary.rolledBack` counts them). A dry run restores nothing, so it + // records none. + let mut rolled_back_events: Vec = Vec::new(); // In-scope manifest entries the nested rollback SKIPPED because the // crawler found no installed package (`RollbackOutcome::not_installed`, // sorted). These were NOT reverted — and "not installed" can also mean @@ -684,14 +684,28 @@ pub async fn run(args: RemoveArgs) -> i32 { // `all_files_already_original` predicate, whose non-empty // guard keeps a zero-file or not-installed result from // counting as "already in original state"). - // `rollback_count` stays per copy: it feeds the JSON - // envelope's `rolledBack`, which is unchanged. + // The JSON events stay per copy: each restored copy is + // one `rolledBack` event naming its install path. let tally = super::rollback::tally_rollback_results(&outcome.results); - rollback_count = outcome + rolled_back_events = outcome .results .iter() .filter(|r| r.success && !r.files_rolled_back.is_empty()) - .count(); + .map(|r| { + PatchEvent::new(PatchAction::RolledBack, r.package_key.clone()) + .with_files( + r.files_rolled_back + .iter() + .map(|path| PatchEventFile { + path: path.clone(), + verified: true, + applied_via: None, + }) + .collect(), + ) + .with_details(serde_json::json!({ "path": r.package_path })) + }) + .collect(); print_hosted_leg_warnings(&args.common, &rollback_warnings); if loud { @@ -878,7 +892,12 @@ pub async fn run(args: RemoveArgs) -> i32 { if args.common.json { let mut env = Envelope::new(Command::Remove); env.dry_run = args.common.dry_run; - for ev in vendor_leg.skipped { + let mut skipped = vendor_leg.skipped; + crate::commands::vendored_backend::tag_event_mode( + &mut skipped, + crate::commands::VENDORED_MODE_LABEL, + ); + for ev in skipped { env.record(ev); } env.status = Status::PartialFailure; @@ -969,6 +988,10 @@ pub async fn run(args: RemoveArgs) -> i32 { ); // `None` under `--preserve-state` (no sweep ran): no `gc` in the JSON. let mut gc: Option = None; + // Artifacts the sweep examined (repair's `details.checked`), and the + // passes that failed (`cleanup_failed` run warnings under `--json`). + let mut gc_checked = 0usize; + let mut cleanup_warnings: Vec<(String, String)> = Vec::new(); if !args.preserve_state { let sweep = references.sweep(&socket_dir, args.common.dry_run).await; // repair's posture: a failed pass (or a pass that could not unlink @@ -978,6 +1001,7 @@ pub async fn run(args: RemoveArgs) -> i32 { if loud { eprintln!("Warning: {detail}"); } + cleanup_warnings.push(("cleanup_failed".to_string(), detail)); } // The GC lines are one block, opened by a blank line. let mut gc_printed = false; @@ -1005,6 +1029,7 @@ pub async fn run(args: RemoveArgs) -> i32 { if loud { eprintln!("Warning: {detail}"); } + cleanup_warnings.push(("cleanup_failed".to_string(), detail)); } // The archives the sweep took are named like repair names them, // so the human run accounts for everything `gc` reports. @@ -1021,6 +1046,11 @@ pub async fn run(args: RemoveArgs) -> i32 { } } } + gc_checked = [&sweep.blobs, &sweep.diffs, &sweep.packages] + .into_iter() + .filter_map(|r| r.as_ref().ok()) + .map(|r| r.blobs_checked) + .sum(); gc = Some(GcReport::from_passes( sweep.blobs.as_ref().ok(), sweep.diffs.as_ref().ok(), @@ -1082,63 +1112,57 @@ pub async fn run(args: RemoveArgs) -> i32 { // stays equal to the number of manifest entries deleted (same rule // as the blob-sweep carrier below); retained/warning Skipped // events bump `summary.skipped` normally. - for ev in vendor_leg.reverted { - env.events.push(ev); - } + // Vendored- and hosted-leg events carry `details.mode`, as in + // every envelope; manifest (agent-mode) events carry none. + use crate::commands::vendored_backend::tag_event_mode; + use crate::commands::{HOSTED_MODE_LABEL, VENDORED_MODE_LABEL}; + let mut vendor_reverted = vendor_leg.reverted; + tag_event_mode(&mut vendor_reverted, VENDORED_MODE_LABEL); + env.events.extend(vendor_reverted); // Hosted unwinds likewise bypass `record` — summary.removed stays // "manifest entries deleted". - for ev in hosted_reverted_events { - env.events.push(ev); + tag_event_mode(&mut hosted_reverted_events, HOSTED_MODE_LABEL); + env.events.extend(hosted_reverted_events); + let mut vendor_skipped = vendor_leg.skipped; + tag_event_mode(&mut vendor_skipped, VENDORED_MODE_LABEL); + for ev in vendor_skipped { + env.record(ev); } - for ev in vendor_leg.skipped { + env.warnings.extend( + rollback_warnings + .iter() + .chain(&hosted_leg_warnings) + .chain(&cleanup_warnings) + .map(|(code, detail)| crate::json_envelope::RunWarning { + code: code.clone(), + detail: detail.clone(), + }), + ); + // The in-place restores ran before the manifest mutation. + for ev in rolled_back_events { env.record(ev); } - env.warnings - .extend( - rollback_warnings - .iter() - .chain(&hosted_leg_warnings) - .map(|(code, detail)| crate::json_envelope::RunWarning { - code: code.clone(), - detail: detail.clone(), - }), - ); // One Removed event per purl whose manifest entry was deleted // (Verified on --dry-run). for purl in &removed { env.record(PatchEvent::new(removal_action, purl.clone())); } - // One artifact-level Removed event carrying the blob-sweep and - // rollback counts. Emitted whenever either is non-zero so the - // `rolledBack` count is still reported even when no blobs happened - // to be swept (e.g. the removed patch's afterHash blobs are still - // referenced elsewhere). - // - // Pushed directly rather than via `env.record`: this is a - // purl-less metadata carrier, not a removed manifest entry. The - // per-purl events above are the authoritative patch-removal - // count, so `summary.removed` must equal the number of entries - // deleted (`removed.len()`) — letting this carrier bump `removed` - // too would double-count, reporting e.g. `removed: 2` for a - // single-patch removal that happened to sweep an orphan blob. - // Consumers read the blob/rollback totals from `details`, never - // from `summary.removed`. - // The sweep's per-kind totals and byte count are also the - // envelope's `gc` (`summary.bytesFreed`), the shape every GC-running - // command prints. + // One artifact-level event for the blob sweep, the same carrier + // `repair` prints: `details.count` = artifacts swept, `bytes` = + // bytes freed. Pushed directly rather than via `env.record`: it is + // a purl-less carrier, not a removed manifest entry, so + // `summary.removed` stays the number of entries deleted. The + // per-kind totals are the envelope's `gc` (`summary.bytesFreed`). let report = gc.unwrap_or_default(); - if report.total_removed() > 0 || rollback_count > 0 { - let mut carrier = - PatchEvent::artifact(removal_action).with_details(serde_json::json!({ - "blobsRemoved": report.removed_blobs, - "rolledBack": rollback_count, - "archivesRemoved": report.removed_diff_archives - + report.removed_package_archives, - })); - if report.total_removed() > 0 { - carrier = carrier.with_bytes(report.bytes_freed); - } - env.events.push(carrier); + if report.total_removed() > 0 { + env.events.push( + PatchEvent::artifact(removal_action) + .with_bytes(report.bytes_freed) + .with_details(serde_json::json!({ + "count": report.total_removed(), + "checked": gc_checked, + })), + ); } if let Some(gc) = gc { env.set_gc(gc); @@ -1536,10 +1560,14 @@ async fn remove_hosted_only( } // Human per-purl lines already printed inside `run_hosted_leg`. for purl in &leg.reverted { - env.record(PatchEvent::new(action, purl.clone()).with_reason( - "hosted_reverted", - "hosted lockfile pin restored to the upstream registry on remove", - )); + env.record( + PatchEvent::new(action, purl.clone()) + .with_reason( + "hosted_reverted", + "hosted lockfile pin restored to the upstream registry on remove", + ) + .with_details(serde_json::json!({ "mode": crate::commands::HOSTED_MODE_LABEL })), + ); } if args.common.json { println!("{}", env.to_pretty_json()); @@ -1639,10 +1667,12 @@ async fn remove_ledger_only( // The reverts ARE the removal: every event is recorded, so // `summary.removed` counts the reverted entries (`summary.verified` // the would-be removals on --dry-run). - for ev in leg.reverted { - env.record(ev); - } - for ev in leg.skipped { + let mut events: Vec = leg.reverted.into_iter().chain(leg.skipped).collect(); + crate::commands::vendored_backend::tag_event_mode( + &mut events, + crate::commands::VENDORED_MODE_LABEL, + ); + for ev in events { env.record(ev); } if !leg.kept.is_empty() { diff --git a/crates/socket-patch-cli/src/commands/repair.rs b/crates/socket-patch-cli/src/commands/repair.rs index 2052f0a35..57dbf0ddd 100644 --- a/crates/socket-patch-cli/src/commands/repair.rs +++ b/crates/socket-patch-cli/src/commands/repair.rs @@ -16,7 +16,9 @@ use std::time::Duration; use crate::args::{apply_env_toggles, parse_bool_flag, GlobalArgs}; use crate::commands::lock_cli::{acquire_or_emit, error_envelope}; -use crate::json_envelope::{Command, Envelope, GcReport, PatchAction, PatchEvent, Status}; +use crate::json_envelope::{ + Command, Envelope, EnvelopeError, GcReport, PatchAction, PatchEvent, Status, +}; use crate::ui::sweep_failure; #[derive(Args)] @@ -229,12 +231,12 @@ pub async fn run(args: RepairArgs) -> i32 { } } Err(e) => { - track_patch_repair_failed(&e, &telemetry).await; + track_patch_repair_failed(&e.message, &telemetry).await; if args.common.json { - let env = error_envelope(Command::Repair, args.common.dry_run, "repair_failed", &e); + let env = error_envelope(Command::Repair, args.common.dry_run, &e.code, &e.message); println!("{}", env.to_pretty_json()); } else { - eprintln!("Error: {e}"); + eprintln!("Error: {}", e.message); } 1 } @@ -461,12 +463,12 @@ async fn repair_inner( client: &mut Option, // `(eco, uuid, rel)` lockfile vendor references, scanned once by `run`. vendor_references: Vec<(String, String, String)>, -) -> Result<(Envelope, RepairCounts), String> { +) -> Result<(Envelope, RepairCounts), EnvelopeError> { // `Ok(None)` = no manifest (vendor-only repair); present-but-invalid - // stays a hard error. + // stays a hard error, under the shared manifest-load code (#931). let manifest = read_manifest(manifest_path) .await - .map_err(|e| crate::ui::manifest_error_message(manifest_path, &e))?; + .map_err(|e| crate::json_envelope::manifest_load_error(manifest_path, &e))?; let socket_dir = crate::args::socket_dir_of(manifest_path, &args.common.cwd); let blobs_path = socket_dir.join("blobs"); @@ -562,6 +564,7 @@ async fn repair_inner( // ledger entry are reported. Runs under `--download-only` too: // restoring artifacts IS repair's download half. The reference scan // and ledger load above are handed over, not repeated. + let vendored_since = env.events.len(); let vendor_redownloaded = crate::commands::vendored_backend::VendoredBackend::new(&args.common, None) .repair( @@ -574,6 +577,11 @@ async fn repair_inner( &mut env, ) .await; + // Vendored-leg events say so (`details.mode`), as in every envelope. + crate::commands::vendored_backend::tag_event_mode( + &mut env.events[vendored_since..], + crate::commands::VENDORED_MODE_LABEL, + ); if !quiet && vendor_redownloaded > 0 { stdout_started = true; println!( @@ -681,7 +689,7 @@ async fn repair_inner( "count": count, // Constant since v5 (blobs are the only download); kept so // the event's shape is unchanged. - "mode": "file", + "downloadMode": "file", })), ); } @@ -823,11 +831,14 @@ mod tests { } /// True when `env` carries the download / would-download artifact event - /// (identified by its `details.mode` field, unique to that event). + /// (identified by its `details.downloadMode` field, unique to that event). fn has_download_event(env: &Envelope) -> bool { - env.events - .iter() - .any(|e| e.details.as_ref().and_then(|d| d.get("mode")).is_some()) + env.events.iter().any(|e| { + e.details + .as_ref() + .and_then(|d| d.get("downloadMode")) + .is_some() + }) } /// Regression for the offline + dry-run leak: with `--offline` set, the diff --git a/crates/socket-patch-cli/src/commands/rollback.rs b/crates/socket-patch-cli/src/commands/rollback.rs index 5bee5708a..c10de1181 100644 --- a/crates/socket-patch-cli/src/commands/rollback.rs +++ b/crates/socket-patch-cli/src/commands/rollback.rs @@ -25,7 +25,7 @@ use std::path::{Path, PathBuf}; use std::time::Duration; use crate::args::{apply_env_toggles, is_local_go, parse_bool_flag, GlobalArgs}; -use crate::commands::hosted_unwind::run_hosted_leg; +use crate::commands::hosted_unwind::{run_hosted_leg, HostedLegOutcome}; use crate::commands::lock_cli::acquire_or_emit; use crate::commands::vendored_backend::{ KeepCause, RevertedEntry, VendorRevertStep, VendoredBackend, @@ -33,7 +33,10 @@ use crate::commands::vendored_backend::{ use crate::ecosystem_dispatch::{ distinct_npm_copies, find_all_packages_for_rollback, partition_purls, JvmScope, }; -use crate::json_envelope::Command as EnvelopeCommand; +use crate::json_envelope::{ + Command as EnvelopeCommand, Envelope, EnvelopeError, GcReport, PatchAction, PatchEvent, + PatchEventFile, Status, +}; use crate::ui::{plural, StatusLine}; #[derive(Args)] @@ -621,55 +624,119 @@ pub(crate) fn all_files_already_original(result: &RollbackResult) -> bool { .all(|f| f.status == VerifyRollbackStatus::AlreadyOriginal) } -/// One `results[]` record of the rollback JSON envelope. -fn result_to_json(result: &RollbackResult) -> serde_json::Value { - serde_json::json!({ - "purl": result.package_key, - "path": result.package_path, - "success": result.success, - "error": result.error, - "filesRolledBack": result.files_rolled_back, - // Rollback-side sidecar resync record (e.g. cargo's - // `.cargo-checksum.json` rewritten back to original hashes), or - // an error-severity advisory when the resync failed. Null when - // no sidecar applied — same serialization as `error` above. - "sidecar": result.sidecar, - "filesVerified": result.files_verified.iter().map(|f| { - serde_json::json!({ - "file": f.file, - "status": verify_rollback_status_str(&f.status), - "message": f.message, - "currentHash": f.current_hash, - "expectedHash": f.expected_hash, - "targetHash": f.target_hash, +/// The camelCase per-file status a failed event's `details.filesVerified` +/// carries (the human `--verbose` block keeps the snake_case labels of +/// [`verify_rollback_status_str`]). +fn verify_rollback_status_camel(status: &VerifyRollbackStatus) -> &'static str { + match status { + VerifyRollbackStatus::Ready => "ready", + VerifyRollbackStatus::AlreadyOriginal => "alreadyOriginal", + VerifyRollbackStatus::HashMismatch => "hashMismatch", + VerifyRollbackStatus::NotFound => "notFound", + VerifyRollbackStatus::MissingBlob => "missingBlob", + } +} + +/// The `errorCode` of a failed agent-leg result: the first blocking file's +/// verify status, else the generic `rollback_failed` (a write error, a +/// local-go redirect that could not be dropped). +fn rollback_failure_code(result: &RollbackResult) -> &'static str { + result + .files_verified + .iter() + .find_map(|f| match f.status { + VerifyRollbackStatus::HashMismatch => Some("hash_mismatch"), + VerifyRollbackStatus::NotFound => Some("file_not_found"), + VerifyRollbackStatus::MissingBlob => Some("missing_blob"), + VerifyRollbackStatus::Ready | VerifyRollbackStatus::AlreadyOriginal => None, + }) + .unwrap_or("rollback_failed") +} + +/// The event one agent-leg (in-place) result becomes: `rolledBack` (wet +/// restore; `files` = the restored files), `verified` (dry-run preview; +/// `files` = the files that would be restored), `skipped` +/// `already_original`, or `failed` with the blocking file's code. The +/// installed copy rides in `details.path`; a failure adds +/// `details.filesVerified` (per-file camelCase status + hashes). +fn agent_result_event(result: &RollbackResult, uuid: Option<&str>, dry_run: bool) -> PatchEvent { + let file = |path: &str| PatchEventFile { + path: path.to_string(), + verified: true, + applied_via: None, + }; + let mut details = serde_json::json!({ "path": result.package_path }); + let mut event = if !result.success { + details["filesVerified"] = result + .files_verified + .iter() + .map(|f| { + serde_json::json!({ + "file": f.file, + "status": verify_rollback_status_camel(&f.status), + "message": f.message, + "currentHash": f.current_hash, + "expectedHash": f.expected_hash, + "targetHash": f.target_hash, + }) }) - }).collect::>(), - }) + .collect(); + PatchEvent::new(PatchAction::Failed, result.package_key.clone()).with_error( + rollback_failure_code(result), + result.error.as_deref().unwrap_or("unknown error"), + ) + } else if all_files_already_original(result) { + PatchEvent::new(PatchAction::Skipped, result.package_key.clone()).with_reason( + "already_original", + "every file already matches its original (beforeHash) content", + ) + } else if dry_run { + PatchEvent::new(PatchAction::Verified, result.package_key.clone()).with_files( + result + .files_verified + .iter() + .filter(|f| f.status == VerifyRollbackStatus::Ready) + .map(|f| file(&f.file)) + .collect(), + ) + } else if !result.files_rolled_back.is_empty() { + PatchEvent::new(PatchAction::RolledBack, result.package_key.clone()) + .with_files(result.files_rolled_back.iter().map(|f| file(f)).collect()) + } else { + // A successful wet result that restored nothing and is not + // "already original": a patch record that lists no files. + PatchEvent::new(PatchAction::Skipped, result.package_key.clone()) + .with_reason("no_files", "the patch record lists no files to restore") + }; + if let Some(uuid) = uuid { + event = event.with_uuid(uuid); + } + event.with_details(details) } -/// Skipped marker appended to `results[]` for an in-scope manifest entry -/// with no installed package — apply's `package_not_installed` Skipped -/// event, rollback-side. Deliberately NOT a result record: no `success`, -/// no `error`, `path` null (there is no installed tree to name), and it -/// never counts toward `rolledBack`/`failed` or flips the status — -/// rollback exits 0 even when ALL in-scope targets land here (see -/// `RollbackOutcome` for the apply/rollback asymmetry). -fn skipped_not_installed_json(purl: &str) -> serde_json::Value { - serde_json::json!({ - "purl": purl, - "path": null, - "skipped": "package_not_installed", - }) +/// An in-scope manifest entry with no installed package: `skipped` +/// `package_not_installed` (apply's event, rollback-side). It never fails +/// the run — rollback exits 0 even when ALL in-scope targets land here +/// (see `RollbackOutcome` for the apply/rollback asymmetry). +fn not_installed_event(purl: &str, uuid: Option<&str>) -> PatchEvent { + let event = PatchEvent::new(PatchAction::Skipped, purl).with_reason( + "package_not_installed", + "no installed package matches this manifest entry", + ); + match uuid { + Some(uuid) => event.with_uuid(uuid), + None => event, + } } /// Per-package failure results for the pre-flight before-blob abort. /// /// The abort fires before the rollback loop produces any per-package -/// results, so without these the `--json` envelope claimed `failed: 0` -/// with empty `results[]` on an exit-1 run — contentless and +/// results, so without these the `--json` envelope claimed +/// `summary.failed: 0` with no events on an exit-1 run — contentless and /// self-contradictory, and `--json` mutes the stderr explanation the -/// human path gets. One failed result per affected package keeps the -/// `failed` counter meaning "packages that failed" (the same per-package +/// human path gets. One failed result (a `failed` `missing_blob` event) +/// per affected package keeps `summary.failed` meaning "packages that failed" (the same per-package /// semantics as a mid-run failure) and names each missing blob hash plus /// the re-run remedy in machine-readable form, using the /// engine's own `missing_blob` verify vocabulary. `reason_for` renders @@ -740,13 +807,25 @@ fn missing_blob_abort_results( results } -/// Legacy top-level error emission (the pre-envelope rollback shape): -/// `{status: "error", error: {code, message}}` on `--json`, an `Error:` -/// stderr line otherwise. Errors print even under --silent ("errors only", -/// never "nothing"). -fn emit_rollback_error(json: bool, code: &str, msg: &str) { +/// Rollback's `--json` failure document: a full envelope with +/// `status: "error"`, empty `events`, a zero `summary` and `error: {code, +/// message}`. +fn error_envelope(dry_run: bool, err: EnvelopeError) -> Envelope { + let mut env = Envelope::new(EnvelopeCommand::Rollback); + env.dry_run = dry_run; + env.mark_error(err); + env +} + +/// Report a top-level rollback error: the error envelope on `--json` +/// (message verbatim), an `Error:` stderr line otherwise. Errors print even +/// under --silent ("errors only", never "nothing"). +fn emit_rollback_error(json: bool, dry_run: bool, code: &str, msg: &str) { if json { - crate::json_envelope::print_legacy_error(code, msg); + println!( + "{}", + error_envelope(dry_run, EnvelopeError::new(code, msg)).to_pretty_json() + ); } else { eprintln!("Error: {}", crate::ui::sentence_case(msg)); } @@ -766,7 +845,10 @@ struct VendoredLegOutcome { /// artifact, and manifest record all stay (exit 1 — the system is /// still patched). kept: Vec<(String, String)>, - failed: Vec<(String, String)>, + /// `(key, errorCode, error)`: `vendor_revert_failed`, or + /// `vendor_state_write_failed` when the revert landed but the ledger + /// could not be saved. + failed: Vec<(String, &'static str, String)>, warnings: Vec<(String, String)>, } @@ -808,7 +890,7 @@ async fn run_vendored_leg( if !common.json { eprintln!("Error: Failed to revert vendoring for {key}: {why}"); } - out.failed.push((key, why)); + out.failed.push((key, "vendor_revert_failed", why)); } VendorRevertStep::Kept(KeepCause::Drift) => out.kept.push(( key, @@ -852,13 +934,239 @@ async fn run_vendored_leg( if !common.json { eprintln!("Error: Failed to revert vendoring for {key}: {why}"); } - out.failed.push((key, why)); + out.failed.push((key, "vendor_state_write_failed", why)); } } } out } +/// Hosted-leg failure keys that are not purls: `run_hosted_leg`'s lockfile +/// write failure (an artifact-level event, `hosted_write_failed`). +const HOSTED_WRITE_FAILURE_KEY: &str = "files"; + +/// Everything the main rollback run reports, for [`build_rollback_envelope`]. +struct RollbackReport<'a> { + dry_run: bool, + /// Whether the run leaves the system unpatched: false drives exit 1 and + /// at least `partialFailure`. + success: bool, + manifest: &'a PatchManifest, + results: &'a [RollbackResult], + not_installed: &'a [String], + vendored: &'a VendoredLegOutcome, + vendor_entries: &'a [(String, socket_patch_core::vendor::VendorEntry)], + hosted: &'a HostedLegOutcome, + hosted_pins: &'a [HostedPin], + /// An unscoped run's contested hosted wiring (`hosted_wiring_contested`). + contested: Option<&'a str>, + /// Manifest entries the run dropped (would drop, on a dry run). + removed: &'a [String], + gc: Option, + warnings: &'a [(String, String)], + paths: &'a [String], +} + +/// Rollback's `--json` envelope (v5.0): one event per outcome across the +/// three legs plus the manifest cleanup, `summary` counted from them. +/// +/// * agent leg: [`agent_result_event`] per installed copy, then one +/// `skipped` `package_not_installed` per in-scope entry with no copy; +/// * vendored leg (`details.mode: "vendored"`): reverted / preserved +/// entries are `rolledBack` (`verified` on a dry run; preserved adds +/// `details.preserved: true`), drift-keeps `failed` `vendor_revert_kept`, +/// failures `failed` with their code; +/// * hosted leg (`details.mode: "hosted"`): restored pins `rolledBack` +/// (`verified`), refusals `failed` `hosted_restore_refused`, a lockfile +/// write failure an artifact-level `failed` `hosted_write_failed`, +/// contested wiring an artifact-level `failed` `hosted_wiring_contested`; +/// * manifest cleanup: each dropped entry is `removed` (`verified` on a dry +/// run) with `details.manifest: true`. +/// +/// Status: `success` iff `report.success`; a failure with nothing restored, +/// restorable, already original or not installed is `error` +/// `rollback_failed` (#1066); anything else is `partialFailure`. +fn build_rollback_envelope(report: &RollbackReport<'_>) -> Envelope { + let dry_run = report.dry_run; + let mut env = Envelope::new(EnvelopeCommand::Rollback); + env.dry_run = dry_run; + let restored = if dry_run { + PatchAction::Verified + } else { + PatchAction::RolledBack + }; + let with_uuid = |event: PatchEvent, uuid: Option<&str>| match uuid { + Some(uuid) => event.with_uuid(uuid), + None => event, + }; + + // ── agent leg ── + let manifest_uuid = |purl: &str| report.manifest.patches.get(purl).map(|p| p.uuid.as_str()); + for result in report.results { + env.record(agent_result_event( + result, + manifest_uuid(&result.package_key), + dry_run, + )); + if let Some(sidecar) = &result.sidecar { + env.sidecars.push(sidecar.clone()); + } + } + for purl in report.not_installed { + env.record(not_installed_event(purl, manifest_uuid(purl))); + } + + // ── vendored leg ── + let vendored = |details: serde_json::Value| { + let mut d = serde_json::json!({ "mode": "vendored" }); + if let (Some(d), Some(extra)) = (d.as_object_mut(), details.as_object()) { + d.extend(extra.clone()); + } + d + }; + let vendor_uuid = |key: &str| { + report + .vendor_entries + .iter() + .find(|(k, _)| k == key) + .map(|(_, e)| e.uuid.as_str()) + }; + let leg = report.vendored; + for key in &leg.reverted { + env.record(with_uuid( + PatchEvent::new(restored, key.clone()).with_details(vendored(serde_json::json!({}))), + vendor_uuid(key), + )); + } + for key in &leg.preserved { + env.record(with_uuid( + PatchEvent::new(restored, key.clone()) + .with_details(vendored(serde_json::json!({ "preserved": true }))), + vendor_uuid(key), + )); + } + for (key, reason) in &leg.kept { + env.record(with_uuid( + PatchEvent::new(PatchAction::Failed, key.clone()) + .with_error("vendor_revert_kept", reason.clone()) + .with_details(vendored(serde_json::json!({}))), + vendor_uuid(key), + )); + } + for (key, code, error) in &leg.failed { + env.record(with_uuid( + PatchEvent::new(PatchAction::Failed, key.clone()) + .with_error(*code, error.clone()) + .with_details(vendored(serde_json::json!({}))), + vendor_uuid(key), + )); + } + + // ── hosted leg ── + let hosted = || serde_json::json!({ "mode": crate::commands::HOSTED_MODE_LABEL }); + let pin_uuid = |purl: &str| { + report + .hosted_pins + .iter() + .find(|pin| pin.purl == purl) + .map(|pin| pin.uuid.as_str()) + }; + let leg = report.hosted; + for purl in &leg.reverted { + env.record(with_uuid( + PatchEvent::new(restored, purl.clone()).with_details(hosted()), + pin_uuid(purl), + )); + } + for (purl, error) in &leg.failed { + let event = if purl == HOSTED_WRITE_FAILURE_KEY { + PatchEvent::artifact(PatchAction::Failed) + .with_error("hosted_write_failed", error.clone()) + } else { + with_uuid( + PatchEvent::new(PatchAction::Failed, purl.clone()) + .with_error("hosted_restore_refused", error.clone()), + pin_uuid(purl), + ) + }; + env.record(event.with_details(hosted())); + } + for purl in &leg.unsupported { + env.record( + PatchEvent::new(PatchAction::Failed, purl.clone()) + .with_error( + "hosted_unsupported", + "this ecosystem has no per-package hosted restore", + ) + .with_details(hosted()), + ); + } + if let Some(refusal) = report.contested { + env.record( + PatchEvent::artifact(PatchAction::Failed) + .with_error("hosted_wiring_contested", refusal) + .with_details(hosted()), + ); + } + + // ── manifest cleanup ── + let removal = if dry_run { + PatchAction::Verified + } else { + PatchAction::Removed + }; + for purl in report.removed { + env.record(with_uuid( + PatchEvent::new(removal, purl.clone()) + .with_details(serde_json::json!({ "manifest": true })), + manifest_uuid(purl), + )); + } + + if let Some(gc) = report.gc { + env.set_gc(gc); + } + for (code, detail) in report.warnings { + env.warn(code.clone(), detail.clone()); + } + env.set_extra( + "hosted", + serde_json::json!({ "editedFiles": report.hosted.edited_files.len() }), + ); + env.set_extra("paths", serde_json::json!(report.paths)); + + // ── status ── + if !report.success { + // Run-level failures (a corrupt vendor ledger, a failed manifest + // write) carry no event but still leave the system patched. + env.mark_partial_failure(); + let s = &env.summary; + let reached_unpatched = s.rolled_back > 0 + || s.verified > 0 + || env.events.iter().any(|e| { + e.action == PatchAction::Skipped + && matches!( + e.error_code.as_deref(), + Some("already_original" | "package_not_installed") + ) + }); + if s.failed > 0 && !reached_unpatched { + let message = format!( + "nothing was rolled back: {}", + plural(s.failed as usize, "patch failed", "patches failed") + ); + env.mark_error(EnvelopeError::new("rollback_failed", message)); + } + } else { + debug_assert_eq!( + env.status, + Status::Success, + "a failed event on a success run" + ); + } + env +} + /// Delete a pre-v5 hosted ledger once no hosted pin is left for it to /// describe (v5 never writes it; it is read only for migration). A wet run /// only; a failure is a warning (the file is inert). @@ -982,7 +1290,12 @@ pub async fn run(args: RollbackArgs) -> i32 { // Hosted wiring the lockfiles name but cannot attribute is still // hosted state: refuse, naming it, instead of "Manifest not found". if let Some(refusal) = hosted_inventory.contested_refusal() { - emit_rollback_error(args.common.json, "hosted_wiring_contested", &refusal); + emit_rollback_error( + args.common.json, + args.common.dry_run, + "hosted_wiring_contested", + &refusal, + ); return 1; } // Only a pre-v5 hosted ledger left: no lockfile pins it any more, @@ -992,24 +1305,16 @@ pub async fn run(args: RollbackArgs) -> i32 { if project_state && tokio::fs::symlink_metadata(&legacy).await.is_ok() { let warning = retire_legacy_redirect_ledger(&args.common).await; if args.common.json { - println!( - "{}", - serde_json::to_string_pretty(&serde_json::json!({ - "status": "success", - "rolledBack": 0, - "alreadyOriginal": 0, - "failed": 0, - "dryRun": args.common.dry_run, - "warnings": warning - .iter() - .map(|(code, detail)| serde_json::json!({ - "code": code, "detail": detail, - })) - .collect::>(), - "legacyRedirectLedgerRemoved": warning.is_none() && !args.common.dry_run, - })) - .expect("serializing an in-memory JSON value cannot fail") + let mut env = Envelope::new(EnvelopeCommand::Rollback); + env.dry_run = args.common.dry_run; + if let Some((code, detail)) = &warning { + env.warn(code.clone(), detail.clone()); + } + env.set_extra( + "legacyRedirectLedgerRemoved", + serde_json::json!(warning.is_none() && !args.common.dry_run), ); + println!("{}", env.to_pretty_json()); } else if let Some((_, detail)) = &warning { eprintln!("Warning: {}", crate::ui::sentence_case(detail)); } else if !args.common.silent { @@ -1037,6 +1342,7 @@ pub async fn run(args: RollbackArgs) -> i32 { if !wired.is_empty() { emit_rollback_error( args.common.json, + args.common.dry_run, "vendor_ledger_missing", "lockfiles still reference .socket/vendor/ artifacts but the vendor ledger \ is missing — restore .socket/vendor/state.json from version control, then \ @@ -1045,18 +1351,15 @@ pub async fn run(args: RollbackArgs) -> i32 { return 1; } if args.common.json { - println!( - "{}", - serde_json::to_string_pretty(&serde_json::json!({ - "status": "error", - "error": { - "code": "manifest_not_found", - "message": "Manifest not found", - }, - "path": manifest_path.display().to_string(), - })) - .expect("serializing an in-memory JSON value cannot fail") + let mut env = error_envelope( + args.common.dry_run, + EnvelopeError::new("manifest_not_found", "Manifest not found"), + ); + env.set_extra( + "path", + serde_json::json!(manifest_path.display().to_string()), ); + println!("{}", env.to_pretty_json()); } else { // Errors print even under --silent ("errors only", never // "nothing"): exit 1 with no message would be undiagnosable. @@ -1114,14 +1417,29 @@ pub async fn run(args: RollbackArgs) -> i32 { match read_manifest(&manifest_path).await { Ok(Some(m)) => m, Ok(None) => { - track_patch_rollback_failed("Invalid manifest", &telemetry).await; - emit_rollback_error(args.common.json, "manifest_invalid", "Invalid manifest"); + // Deleted between the existence probe and the read. + let msg = format!("Manifest not found at {}", manifest_path.display()); + track_patch_rollback_failed(&msg, &telemetry).await; + emit_rollback_error( + args.common.json, + args.common.dry_run, + "manifest_not_found", + &msg, + ); return 1; } Err(e) => { let msg = e.to_string(); track_patch_rollback_failed(&msg, &telemetry).await; - emit_rollback_error(args.common.json, "manifest_unreadable", &msg); + if args.common.json { + let err = crate::json_envelope::manifest_load_error(&manifest_path, &e); + println!( + "{}", + error_envelope(args.common.dry_run, err).to_pretty_json() + ); + } else { + eprintln!("Error: {}", crate::ui::sentence_case(&msg)); + } return 1; } } @@ -1217,7 +1535,12 @@ pub async fn run(args: RollbackArgs) -> i32 { Ok(settled) => settled, Err(msg) => { track_patch_rollback_failed(&msg, &telemetry).await; - emit_rollback_error(args.common.json, "ambiguous_target", &msg); + emit_rollback_error( + args.common.json, + args.common.dry_run, + "ambiguous_target", + &msg, + ); return 1; } }; @@ -1237,17 +1560,11 @@ pub async fn run(args: RollbackArgs) -> i32 { if args.common.json { println!( "{}", - serde_json::to_string_pretty(&serde_json::json!({ - "status": "error", - "error": { "code": "patch_not_found", "message": msg }, - "rolledBack": 0, - "alreadyOriginal": 0, - "failed": 0, - "dryRun": args.common.dry_run, - "vendored": [], - "results": [], - })) - .expect("serializing an in-memory JSON value cannot fail") + error_envelope( + args.common.dry_run, + EnvelopeError::new("patch_not_found", msg) + ) + .to_pretty_json() ); } else { eprintln!("Error: {msg}"); @@ -1313,7 +1630,12 @@ pub async fn run(args: RollbackArgs) -> i32 { unmatched.1 ); track_patch_rollback_failed(&msg, &telemetry).await; - emit_rollback_error(args.common.json, "path_glob_no_match", &msg); + emit_rollback_error( + args.common.json, + args.common.dry_run, + "path_glob_no_match", + &msg, + ); return 1; } for purl in &path_selected { @@ -1466,21 +1788,21 @@ pub async fn run(args: RollbackArgs) -> i32 { .filter(|pin| hosted_scope.contains(&pin.purl)) .cloned() .collect(); - let mut hosted_leg = run_hosted_leg(&args.common, &in_scope).await; + let hosted_leg = run_hosted_leg(&args.common, &in_scope).await; // An unscoped rollback promises to unwind EVERY hosted patch: // contested wiring it cannot restore fails the leg (a scoped run // names its own targets and leaves unrelated wiring alone). - if !scoped { - if let Some(refusal) = hosted_inventory.contested_refusal() { - if !args.common.json { - eprintln!("Error: {}", crate::ui::sentence_case(&refusal)); - } - hosted_leg - .failed - .push(("hosted_wiring_contested".to_string(), refusal)); + let contested = if scoped { + None + } else { + hosted_inventory.contested_refusal() + }; + if let Some(refusal) = &contested { + if !args.common.json { + eprintln!("Error: {}", crate::ui::sentence_case(refusal)); } } - if hosted_leg.failed.is_empty() { + if hosted_leg.failed.is_empty() && contested.is_none() { if let Some(warning) = retire_legacy_redirect_ledger(&args.common).await { run_warnings.push(warning); } @@ -1575,8 +1897,7 @@ pub async fn run(args: RollbackArgs) -> i32 { // ── GC ─────────────────────────────────────────────────────── // Removal retains originals for active patches and crawler misses. - let mut gc_json: serde_json::Value = serde_json::json!({ "skipped": true }); - let mut gc_bytes_freed: u64 = 0; + let mut gc: Option = None; if cleanup_allowed { let references = ArtifactReferences::after_removal( &manifest, @@ -1596,14 +1917,13 @@ pub async fn run(args: RollbackArgs) -> i32 { run_warnings.push(("cleanup_failed".into(), detail)); } } - let report = crate::json_envelope::GcReport::from_passes( + gc = Some(GcReport::from_passes( sweep.blobs.as_ref().ok(), sweep.diffs.as_ref().ok(), sweep.packages.as_ref().ok(), - ); - gc_bytes_freed = report.bytes_freed; - gc_json = report.to_value(); + )); } + let gc_bytes_freed = gc.map_or(0, |gc| gc.bytes_freed); // ── run-level warnings ─────────────────────────────────────── let unwired_any = !vendored_leg.reverted.is_empty() @@ -1686,7 +2006,7 @@ pub async fn run(args: RollbackArgs) -> i32 { .for_each(|(code, detail)| run_warnings.push((code.clone(), detail.clone()))); // A restored package whose ownership could not be put back // (the engine reports it on `error` with `success: true`) is - // restored but worth a note; `results[].error` carries it too. + // restored but worth a note, here and in the envelope. for r in results.iter().filter(|r| r.success) { if let Some(note) = &r.error { let warning = ( @@ -1709,40 +2029,18 @@ pub async fn run(args: RollbackArgs) -> i32 { && vendored_leg.failed.is_empty() && hosted_leg.failed.is_empty() && hosted_leg.unsupported.is_empty() + && contested.is_none() && !vendor_corrupt && manifest_write_failed.is_none(); - let rolled_back_count = results + // Telemetry's count spans every leg (#1066), like the envelope's + // `summary.rolledBack`. + let rolled_back_total = results .iter() .filter(|r| r.success && !r.files_rolled_back.is_empty()) - .count(); - let already_original_count = results - .iter() - .filter(|r| r.success && all_files_already_original(r)) - .count(); - let failed_count = results.iter().filter(|r| !r.success).count(); - // The top-level counters span every leg (#1066): a vendored or - // hosted unwind is a rollback, and a drift-keep, failure or - // unsupported hosted target is a failure, exactly as each one - // drives the status. A package wired through two legs counts - // once per leg; the per-leg arrays below say which. - let rolled_back_total = rolled_back_count + .count() + vendored_leg.reverted.len() + vendored_leg.preserved.len() + hosted_leg.reverted.len(); - let failed_total = failed_count - + vendored_leg.kept.len() - + vendored_leg.failed.len() - + hosted_leg.failed.len() - + hosted_leg.unsupported.len(); - // Something failed and nothing reached the unpatched end state - // (rolled back, already original, or not installed): the run - // failed as a whole, so the status is an error (with `error`), - // not a partial failure. - let total_failure = !success - && failed_total > 0 - && rolled_back_total == 0 - && already_original_count == 0 - && not_installed.is_empty(); if let Some(e) = &manifest_write_failed { if !args.common.json { @@ -1755,88 +2053,35 @@ pub async fn run(args: RollbackArgs) -> i32 { } if args.common.json { - // Legacy shape plus the additive duality keys — every key - // always present so consumers never null-check. - println!( - "{}", - serde_json::to_string_pretty(&{ - let mut out = serde_json::json!({ - "status": if success { "success" } else { "partial_failure" }, - "rolledBack": rolled_back_total, - "alreadyOriginal": already_original_count, - "failed": failed_total, - "dryRun": args.common.dry_run, - "warnings": run_warnings - .iter() - .map(|(code, detail)| serde_json::json!({ - "code": code, "detail": detail, - })) - .collect::>(), - // The legacy "benign, untouched" array is - // reserved-empty in v5.0: acted-on entries land in - // the vendored* arrays below, and the corrupt-ledger - // skip cannot name vendor-owned purls (the detection - // itself needs the ledger) — it surfaces via the - // `vendor_state_unreadable` warning and exit 1. - "vendored": [], - "vendoredReverted": vendored_leg.reverted, - "vendoredPreserved": vendored_leg.preserved, - "vendoredKept": vendored_leg.kept - .iter() - .map(|(key, reason)| serde_json::json!({ - "purl": key, "reason": reason, - })) - .collect::>(), - "vendoredFailed": vendored_leg.failed - .iter() - .map(|(key, error)| serde_json::json!({ - "purl": key, "error": error, - })) - .collect::>(), - "hosted": { - "reverted": hosted_leg.reverted, - "failed": hosted_leg.failed - .iter() - .map(|(purl, error)| serde_json::json!({ - "purl": purl, "error": error, - })) - .collect::>(), - "unsupported": hosted_leg.unsupported, - "editedFiles": hosted_leg.edited_files.len(), - }, - "manifest": { - "removedEntries": removed, - "preserved": args.preserve_state, - }, - "gc": gc_json, - "paths": path_scope.raw(), - // Real result records first, then one skipped marker - // per in-scope entry with no installed package — - // apply's `package_not_installed` Skipped event, - // rollback-side. Markers never count toward - // `rolledBack`/`failed` and never flip the status. - "results": results - .iter() - .map(result_to_json) - .chain(not_installed.iter().map(|p| skipped_not_installed_json(p))) - .collect::>(), - }); - if total_failure { - crate::json_envelope::set_error( - &mut out, - crate::json_envelope::EnvelopeError::new( - "rollback_failed", - format!( - "nothing was rolled back: {}", - plural(failed_total, "patch failed", "patches failed") - ), - ), - ); - } - out - }) - .expect("serializing an in-memory JSON value cannot fail") - ); + // The GC was requested (not `--preserve-state`) but could not + // run: `gc` stays absent and this warning says why. + if !args.preserve_state && gc.is_none() { + let why = if aborted { + "the rollback aborted before restoring anything, so the revert data a \ + retry needs was kept" + } else { + "the vendor ledger is unreadable, so artifact ownership cannot be \ + established" + }; + run_warnings.push(("gc_skipped".into(), format!("artifact GC skipped: {why}"))); + } + let env = build_rollback_envelope(&RollbackReport { + dry_run: args.common.dry_run, + success, + manifest: &manifest, + results: &results, + not_installed: ¬_installed, + vendored: &vendored_leg, + vendor_entries: &vendor_entries, + hosted: &hosted_leg, + hosted_pins: &hosted_pins, + contested: contested.as_deref(), + removed: &removed, + gc, + warnings: &run_warnings, + paths: path_scope.raw(), + }); + println!("{}", env.to_pretty_json()); } else if !args.common.silent && !results.is_empty() { let cwd_abs = std::fs::canonicalize(&cwd).unwrap_or_else(|_| cwd.clone()); let lines = if args.common.dry_run { @@ -2004,17 +2249,11 @@ pub async fn run(args: RollbackArgs) -> i32 { if args.common.json { println!( "{}", - serde_json::to_string_pretty(&serde_json::json!({ - "status": "error", - "error": { "code": "rollback_failed", "message": e }, - "rolledBack": 0, - "alreadyOriginal": 0, - "failed": 0, - "dryRun": args.common.dry_run, - "vendored": [], - "results": [], - })) - .expect("serializing an in-memory JSON value cannot fail") + error_envelope( + args.common.dry_run, + EnvelopeError::new("rollback_failed", e) + ) + .to_pretty_json() ); } else { // Errors print even under --silent ("errors only", never @@ -5567,6 +5806,344 @@ mod tests { )); } + // ── v5.0 envelope (build_rollback_envelope) ───────────────────────── + + fn failed_rb(purl: &str) -> RollbackResult { + RollbackResult { + package_key: purl.to_string(), + package_path: "/p/bad".to_string(), + success: false, + files_verified: vec![VerifyRollbackResult { + file: "index.js".to_string(), + status: VerifyRollbackStatus::HashMismatch, + message: Some("drifted".to_string()), + current_hash: Some("c".to_string()), + expected_hash: Some("e".to_string()), + target_hash: None, + }], + files_rolled_back: Vec::new(), + error: Some("cannot roll back index.js: drifted".to_string()), + sidecar: None, + } + } + + fn report<'a>( + dry_run: bool, + success: bool, + manifest: &'a PatchManifest, + results: &'a [RollbackResult], + vendored: &'a VendoredLegOutcome, + hosted: &'a HostedLegOutcome, + ) -> RollbackReport<'a> { + RollbackReport { + dry_run, + success, + manifest, + results, + not_installed: &[], + vendored, + vendor_entries: &[], + hosted, + hosted_pins: &[], + contested: None, + removed: &[], + gc: None, + warnings: &[], + paths: &[], + } + } + + /// Every top-level key, status and event action/errorCode is from the + /// shared vocabulary, and `summary` equals the event counts. + fn assert_envelope_invariants(v: &serde_json::Value) { + assert_eq!(v["command"], "rollback", "{v}"); + let allowed = [ + "command", + "status", + "dryRun", + "events", + "summary", + "error", + "sidecars", + "warnings", + "vex", + "gc", + "hosted", + "paths", + "path", + "legacyRedirectLedgerRemoved", + ]; + for key in v.as_object().unwrap().keys() { + assert!(allowed.contains(&key.as_str()), "unexpected key {key}: {v}"); + assert!(!key.contains('_'), "snake_case key {key}"); + } + assert!(!v["status"].as_str().unwrap().contains('_'), "{v}"); + let events = v["events"].as_array().unwrap(); + for action in [ + "discovered", + "downloaded", + "applied", + "updated", + "skipped", + "failed", + "removed", + "verified", + "rebuilt", + "rolledBack", + ] { + let n = events.iter().filter(|e| e["action"] == action).count(); + assert_eq!(v["summary"][action], n, "summary.{action}: {v}"); + } + } + + #[test] + fn envelope_maps_every_leg_to_events() { + let mut manifest = PatchManifest::new(); + manifest + .patches + .insert("pkg:npm/a@1.0.0".to_string(), make_record("uuid-a")); + let results = vec![ + rb("pkg:npm/a@1.0.0", "/p/a", VerifyRollbackStatus::Ready, true), + rb( + "pkg:npm/o@1.0.0", + "/p/o", + VerifyRollbackStatus::AlreadyOriginal, + false, + ), + failed_rb("pkg:npm/bad@1.0.0"), + ]; + let vendored = VendoredLegOutcome { + reverted: vec!["pkg:npm/v@1.0.0".to_string()], + preserved: vec!["pkg:npm/vp@1.0.0".to_string()], + kept: vec![("pkg:npm/vk@1.0.0".to_string(), "drifted".to_string())], + failed: vec![( + "pkg:npm/vf@1.0.0".to_string(), + "vendor_revert_failed", + "boom".to_string(), + )], + warnings: Vec::new(), + }; + let hosted = HostedLegOutcome { + reverted: vec!["pkg:npm/h@1.0.0".to_string()], + failed: vec![ + ("pkg:npm/hf@1.0.0".to_string(), "refused".to_string()), + ( + HOSTED_WRITE_FAILURE_KEY.to_string(), + "disk full".to_string(), + ), + ], + edited_files: ["package-lock.json".to_string()].into(), + ..Default::default() + }; + let not_installed = vec!["pkg:npm/gone@1.0.0".to_string()]; + let removed = vec!["pkg:npm/a@1.0.0".to_string()]; + let warnings = vec![("reinstall_required".to_string(), "x".to_string())]; + let gc = GcReport { + removed_blobs: 1, + bytes_freed: 42, + ..Default::default() + }; + let env = build_rollback_envelope(&RollbackReport { + not_installed: ¬_installed, + removed: &removed, + warnings: &warnings, + gc: Some(gc), + ..report(false, false, &manifest, &results, &vendored, &hosted) + }); + let v = env.to_value(); + assert_envelope_invariants(&v); + assert_eq!(v["status"], "partialFailure"); + assert!(v.get("error").is_none()); + assert_eq!(v["summary"]["rolledBack"], 4, "a, v, vp, h: {v}"); + assert_eq!(v["summary"]["failed"], 5, "bad, vk, vf, hf, files: {v}"); + assert_eq!( + v["summary"]["skipped"], 2, + "already original + not installed" + ); + assert_eq!(v["summary"]["removed"], 1); + assert_eq!(v["summary"]["bytesFreed"], 42); + assert_eq!(v["gc"]["removedBlobs"], 1); + assert_eq!(v["hosted"]["editedFiles"], 1); + assert_eq!(v["paths"], serde_json::json!([])); + assert_eq!(v["warnings"][0]["code"], "reinstall_required"); + + let events = v["events"].as_array().unwrap(); + let find = |action: &str, purl: &str| { + events + .iter() + .find(|e| e["action"] == action && e["purl"] == purl) + .unwrap_or_else(|| panic!("no {action} {purl}: {v}")) + }; + let a = find("rolledBack", "pkg:npm/a@1.0.0"); + assert_eq!(a["uuid"], "uuid-a"); + assert_eq!(a["files"][0]["path"], "index.js"); + assert_eq!(a["details"]["path"], "/p/a"); + assert!( + a["details"].get("mode").is_none(), + "agent events carry no mode" + ); + assert_eq!( + find("skipped", "pkg:npm/o@1.0.0")["errorCode"], + "already_original" + ); + let bad = find("failed", "pkg:npm/bad@1.0.0"); + assert_eq!(bad["errorCode"], "hash_mismatch"); + assert_eq!(bad["details"]["filesVerified"][0]["status"], "hashMismatch"); + assert_eq!( + find("skipped", "pkg:npm/gone@1.0.0")["errorCode"], + "package_not_installed" + ); + assert_eq!( + find("rolledBack", "pkg:npm/v@1.0.0")["details"]["mode"], + "vendored" + ); + let vp = find("rolledBack", "pkg:npm/vp@1.0.0"); + assert_eq!(vp["details"]["mode"], "vendored"); + assert_eq!(vp["details"]["preserved"], true); + assert_eq!( + find("failed", "pkg:npm/vk@1.0.0")["errorCode"], + "vendor_revert_kept" + ); + assert_eq!( + find("failed", "pkg:npm/vf@1.0.0")["errorCode"], + "vendor_revert_failed" + ); + assert_eq!( + find("rolledBack", "pkg:npm/h@1.0.0")["details"]["mode"], + "hosted" + ); + assert_eq!( + find("failed", "pkg:npm/hf@1.0.0")["errorCode"], + "hosted_restore_refused" + ); + let write = events + .iter() + .find(|e| e["errorCode"] == "hosted_write_failed") + .expect("artifact-level write failure"); + assert!(write.get("purl").is_none()); + let removed = find("removed", "pkg:npm/a@1.0.0"); + assert_eq!(removed["details"]["manifest"], true); + } + + #[test] + fn envelope_dry_run_previews_are_verified() { + let manifest = PatchManifest::new(); + let results = vec![rb( + "pkg:npm/a@1.0.0", + "/p/a", + VerifyRollbackStatus::Ready, + false, + )]; + let vendored = VendoredLegOutcome { + reverted: vec!["pkg:npm/v@1.0.0".to_string()], + ..Default::default() + }; + let hosted = HostedLegOutcome { + reverted: vec!["pkg:npm/h@1.0.0".to_string()], + ..Default::default() + }; + let removed = vec!["pkg:npm/a@1.0.0".to_string()]; + let env = build_rollback_envelope(&RollbackReport { + removed: &removed, + ..report(true, true, &manifest, &results, &vendored, &hosted) + }); + let v = env.to_value(); + assert_envelope_invariants(&v); + assert_eq!(v["status"], "success"); + assert_eq!(v["dryRun"], true); + assert_eq!(v["summary"]["verified"], 4, "{v}"); + assert_eq!(v["summary"]["rolledBack"], 0); + assert_eq!(v["summary"]["removed"], 0); + assert_eq!(v["events"][0]["files"][0]["path"], "index.js"); + assert!(v.get("gc").is_none()); + } + + #[test] + fn envelope_total_failure_is_rollback_failed_error() { + let manifest = PatchManifest::new(); + let results = vec![failed_rb("pkg:npm/bad@1.0.0")]; + let vendored = VendoredLegOutcome::default(); + let hosted = HostedLegOutcome::default(); + let v = build_rollback_envelope(&report( + false, false, &manifest, &results, &vendored, &hosted, + )) + .to_value(); + assert_envelope_invariants(&v); + assert_eq!(v["status"], "error"); + assert_eq!(v["error"]["code"], "rollback_failed"); + assert_eq!(v["summary"]["failed"], 1); + // The failed event survives beside the top-level error. + assert_eq!(v["events"][0]["action"], "failed"); + } + + #[test] + fn envelope_run_level_failure_is_partial_without_events() { + // A corrupt vendor ledger / failed manifest write: no event, exit 1. + let manifest = PatchManifest::new(); + let vendored = VendoredLegOutcome::default(); + let hosted = HostedLegOutcome::default(); + let warnings = vec![("vendor_state_unreadable".to_string(), "bad".to_string())]; + let v = build_rollback_envelope(&RollbackReport { + warnings: &warnings, + ..report(false, false, &manifest, &[], &vendored, &hosted) + }) + .to_value(); + assert_envelope_invariants(&v); + assert_eq!(v["status"], "partialFailure"); + assert!(v.get("error").is_none()); + } + + #[test] + fn envelope_contested_wiring_is_an_artifact_failure() { + let manifest = PatchManifest::new(); + let results = vec![rb( + "pkg:npm/a@1.0.0", + "/p/a", + VerifyRollbackStatus::Ready, + true, + )]; + let vendored = VendoredLegOutcome::default(); + let hosted = HostedLegOutcome::default(); + let v = build_rollback_envelope(&RollbackReport { + contested: Some("cannot attribute"), + ..report(false, false, &manifest, &results, &vendored, &hosted) + }) + .to_value(); + assert_envelope_invariants(&v); + assert_eq!(v["status"], "partialFailure"); + let e = &v["events"][1]; + assert_eq!(e["action"], "failed"); + assert_eq!(e["errorCode"], "hosted_wiring_contested"); + assert_eq!(e["details"]["mode"], "hosted"); + } + + #[test] + fn error_envelope_is_a_full_envelope() { + let v = error_envelope(true, EnvelopeError::new("patch_not_found", "nope")).to_value(); + assert_envelope_invariants(&v); + assert_eq!(v["status"], "error"); + assert_eq!(v["dryRun"], true); + assert_eq!(v["events"], serde_json::json!([])); + assert_eq!(v["summary"]["failed"], 0); + assert_eq!(v["error"]["code"], "patch_not_found"); + assert_eq!(v["error"]["message"], "nope"); + } + + #[test] + fn failure_codes_follow_the_blocking_file() { + assert_eq!( + rollback_failure_code(&failed_rb("pkg:npm/x@1")), + "hash_mismatch" + ); + let mut r = failed_rb("pkg:npm/x@1"); + r.files_verified[0].status = VerifyRollbackStatus::MissingBlob; + assert_eq!(rollback_failure_code(&r), "missing_blob"); + r.files_verified[0].status = VerifyRollbackStatus::NotFound; + assert_eq!(rollback_failure_code(&r), "file_not_found"); + r.files_verified.clear(); + assert_eq!(rollback_failure_code(&r), "rollback_failed"); + } + /// #477: next to a PyPI reinstall advisory the note must not imply /// that the next sync refreshes the copy. #[test] diff --git a/crates/socket-patch-cli/src/commands/scan/gc.rs b/crates/socket-patch-cli/src/commands/scan/gc.rs index 7c4bce187..ab5eabcd7 100644 --- a/crates/socket-patch-cli/src/commands/scan/gc.rs +++ b/crates/socket-patch-cli/src/commands/scan/gc.rs @@ -14,12 +14,12 @@ use std::time::Duration; use crate::args::GlobalArgs; use crate::commands::lock_cli::lock_failure; use crate::commands::vendor::{run_vendor_gc, VendorGcSummary}; -use crate::json_envelope::GcReport; +use crate::json_envelope::{Envelope, GcReport, PatchAction, PatchEvent}; use crate::ui::sweep_failure; -/// Aggregated outcome of a GC pass (or preview). Serialized into the -/// `scan --json` output's `gc` sub-object. See CLI_CONTRACT.md for the -/// stable schema. +/// Aggregated outcome of a GC pass (or preview), recorded into the `scan +/// --json` envelope by [`GcSummary::record_into`]. See CLI_CONTRACT.md for +/// the stable schema. #[derive(Debug, Default)] pub(super) struct GcSummary { /// PURLs removed from the manifest (apply mode) or eligible to be @@ -94,38 +94,89 @@ impl GcSummary { self.vendor_orphan_dirs = v.orphan_dirs; } - /// The `gc` sub-object. One shape for both passes: the artifact half is - /// the `gc` object every GC-running command prints (`GcReport`), the - /// manifest and vendored halves are scan's. On a `--dry-run` preview the - /// counts are what the pass would remove, and the keys only a real pass - /// can fill (`keptVendoredEntries`, `failedVendoredEntries`, `skipped`, - /// `warnings`) are left out rather than reported as an empty check. - pub(super) fn to_json(&self, preview: bool) -> serde_json::Value { - let mut json = - GcReport::from_passes(Some(&self.blobs), Some(&self.diffs), Some(&self.packages)) - .to_value(); - json["prunedManifestEntries"] = serde_json::json!(self.pruned); - json["revertedVendoredEntries"] = serde_json::json!(self.vendored_reverted); - json["removedVendorOrphanDirs"] = serde_json::json!(self.vendor_orphan_dirs); - if preview { - return json; - } - json["keptVendoredEntries"] = serde_json::json!(self.vendored_kept); - json["failedVendoredEntries"] = serde_json::json!(self.vendored_failed); + /// Record the pass into a `scan` envelope. The artifact sweep is the + /// envelope's `gc` (the shared [`GcReport`], counts of what a preview + /// would remove), each pruned manifest entry a `removed` event + /// (`verified` on a preview) with `details.manifest: true`, each + /// reverted vendored entry a `removed` (`verified`) event with + /// `errorCode: "vendor_reverted"` and `details.mode: "vendored"`, each + /// drift-kept entry a `skipped` / `vendor_revert_kept` event, and each + /// entry whose revert failed a `skipped` / `vendor_revert_failed` event + /// (a GC revert failure never fails the run, so it is not a `failed` + /// event). The orphan `.socket/vendor//` dirs swept are + /// counted in the top-level `removedVendorOrphanDirs`, and the pass's + /// warnings join the top-level `warnings`. A pass that could not run + /// (lock held, lock I/O) records only a `gc_skipped` warning and no + /// `gc`. + pub(super) fn record_into(&self, env: &mut Envelope, preview: bool) { if let Some((code, message)) = &self.skipped { - json["skipped"] = serde_json::json!({ "code": code, "message": message }); + env.warn(GC_SKIPPED, format!("{message} ({code})")); + return; + } + let removal = if preview { + PatchAction::Verified + } else { + PatchAction::Removed + }; + for purl in &self.pruned { + env.record( + PatchEvent::new(removal, purl.as_str()) + .with_details(serde_json::json!({ "manifest": true })), + ); + } + let vendored = || serde_json::json!({ "mode": "vendored" }); + for purl in &self.vendored_reverted { + env.record( + PatchEvent::new(removal, purl.as_str()) + .with_reason( + "vendor_reverted", + "vendored entry reverted by the prune GC (its patch left the \ + manifest or its dependency left the lockfile)", + ) + .with_details(vendored()), + ); + } + for purl in &self.vendored_kept { + env.record( + PatchEvent::new(PatchAction::Skipped, purl.as_str()) + .with_reason( + "vendor_revert_kept", + "lock entries were re-resolved since vendoring, so the artifact \ + and the manifest/ledger entries were retained", + ) + .with_details(vendored()), + ); } - if !self.warnings.is_empty() { - json["warnings"] = self - .warnings - .iter() - .map(|(code, detail)| serde_json::json!({ "code": code, "detail": detail })) - .collect(); + for purl in &self.vendored_failed { + env.record( + PatchEvent::new(PatchAction::Skipped, purl.as_str()) + .with_reason( + "vendor_revert_failed", + "the prune GC could not revert this vendored entry; its ledger \ + entry and artifacts were kept", + ) + .with_details(vendored()), + ); + } + env.set_gc(GcReport::from_passes( + Some(&self.blobs), + Some(&self.diffs), + Some(&self.packages), + )); + env.set_extra( + "removedVendorOrphanDirs", + serde_json::json!(self.vendor_orphan_dirs), + ); + for (code, detail) in &self.warnings { + env.warn(*code, detail.clone()); } - json } } +/// Warning code: a requested GC pass could not run (another run holds the +/// apply lock, or the lock file could not be opened); nothing was swept. +pub(super) const GC_SKIPPED: &str = "gc_skipped"; + /// The orphan blob/diff/package sweep against the (post-prune) manifest. /// `dry_run = true` for the preview path; `dry_run = false` for the apply /// path — `ArtifactReferences::sweep` natively supports dry-run, so @@ -369,25 +420,24 @@ async fn preview_apply_gc( gc } -/// The `gc` sub-object for the JSON paths: a read-only preview under -/// `--dry-run`, the mutating pass otherwise, both in the one `gc` shape. -pub(super) async fn gc_json( +/// The GC for the JSON paths, recorded into `env` (see +/// [`GcSummary::record_into`]): a read-only preview under `--dry-run`, the +/// mutating pass otherwise. +pub(super) async fn gc_into( common: &GlobalArgs, manifest_path: &Path, socket_dir: &Path, scanned_purls: &HashSet, vendored: &HashSet, dry_run: bool, -) -> serde_json::Value { - if dry_run { - preview_apply_gc(common, manifest_path, socket_dir, scanned_purls, vendored) - .await - .to_json(true) + env: &mut Envelope, +) { + let gc = if dry_run { + preview_apply_gc(common, manifest_path, socket_dir, scanned_purls, vendored).await } else { - run_apply_gc(common, manifest_path, socket_dir, scanned_purls, vendored) - .await - .to_json(false) - } + run_apply_gc(common, manifest_path, socket_dir, scanned_purls, vendored).await + }; + gc.record_into(env, dry_run); } /// `1 manifest entry` / `2 manifest entries` (and friends). @@ -986,13 +1036,17 @@ mod tests { )), "a lock-contended pass must say why it pruned nothing" ); - let json = gc.to_json(false); - assert_eq!(json["skipped"]["code"], "lock_held", "{json}"); - assert_eq!( - json["prunedManifestEntries"], - serde_json::json!([]), + let json = recorded(&gc, false); + assert_eq!(json["warnings"][0]["code"], GC_SKIPPED, "{json}"); + assert!( + json["warnings"][0]["detail"] + .as_str() + .unwrap() + .ends_with("(lock_held)"), "{json}" ); + assert!(json.get("gc").is_none(), "a skipped pass has no gc: {json}"); + assert_eq!(json["events"], serde_json::json!([]), "{json}"); } /// `--lock-timeout` reaches the GC acquire: the pass waits (and says @@ -1067,7 +1121,15 @@ mod tests { assert!(blob_path.exists(), "nothing may be swept on a lock fault"); let m = read_manifest(&manifest_path).await.unwrap().unwrap(); assert!(m.patches.contains_key("pkg:npm/gone@1.0.0")); - assert_eq!(gc.to_json(false)["skipped"]["code"], "lock_io"); + let json = recorded(&gc, false); + assert_eq!(json["warnings"][0]["code"], GC_SKIPPED, "{json}"); + assert!( + json["warnings"][0]["detail"] + .as_str() + .unwrap() + .ends_with("(lock_io)"), + "{json}" + ); } #[tokio::test] @@ -1275,10 +1337,10 @@ mod tests { "preview must not create a manifest file" ); // The serialized degenerate preview is the normal all-zero shape. - let json = gc.to_json(true); - assert_eq!(json["prunedManifestEntries"], serde_json::json!([])); - assert_eq!(json["removedBlobs"], serde_json::json!(0)); - assert_eq!(json["bytesFreed"], serde_json::json!(0)); + let json = recorded(&gc, true); + assert_eq!(json["events"], serde_json::json!([])); + assert_eq!(json["gc"]["removedBlobs"], serde_json::json!(0)); + assert_eq!(json["gc"]["bytesFreed"], serde_json::json!(0)); // Corrupt manifest, fresh tempdir. let tmp = tempfile::tempdir().unwrap(); @@ -1503,18 +1565,21 @@ mod tests { "the keep must be counted — the only signal that the entry the \ preview listed as revertable was deliberately not reclaimed" ); + let json = recorded(&gc, false); assert_eq!( - gc.to_json(false)["keptVendoredEntries"], - serde_json::json!([PURL]), + json["events"], + serde_json::json!([{ + "action": "skipped", "purl": PURL, "errorCode": "vendor_revert_kept", + "reason": json["events"][0]["reason"], "details": {"mode": "vendored"}, + }]), "scan --prune --json must carry the keep" ); // The revert's own drift warnings (`vendor_lock_entry_drifted`, // `vendor_artifact_kept`) are already said by the keep above; they - // must not also land in `gc.warnings[]`. + // must not also land in `warnings[]`. assert!( - gc.to_json(false).get("warnings").is_none(), - "a drift keep adds no gc warning: {}", - gc.to_json(false) + json.get("warnings").is_none(), + "a drift keep adds no warning: {json}" ); // Nothing reclaimed: manifest record, blob, ledger entry, and // artifacts all survive (the drift-keep contract). @@ -1536,24 +1601,28 @@ mod tests { assert!(uuid_dir.exists(), "kept artifacts must survive the sweep"); } - /// The `keptVendoredEntries` / `failedVendoredEntries` / `skipped` / - /// `warnings` plumbing in isolation: absorbed sorted, serialized on the - /// apply shape, absent from the preview shape (a read-only preview - /// cannot detect drift, reverts nothing and takes no lock, so emitting - /// a constant `[]`/marker would claim a check that never ran). The - /// vendored half's typed fields land where they belong: `failed` holds - /// only purls, its failed rewrites become `warnings` — never mislabelled - /// as `lock_held`; `skipped` is this pass's own lock outcome. + /// A pass recorded into a fresh `scan` envelope, serialized. + fn recorded(gc: &GcSummary, preview: bool) -> serde_json::Value { + let mut env = Envelope::new(crate::json_envelope::Command::Scan); + gc.record_into(&mut env, preview); + env.to_value() + } + + /// The vendored half's typed fields absorb sorted and land where they + /// belong in the envelope: kept and failed reverts are `skipped` events + /// (`vendor_revert_kept` / `vendor_revert_failed`, `details.mode: + /// "vendored"`), failed rewrites top-level warnings — never mislabelled + /// as a skipped pass; a pass that could not take the lock is only the + /// `gc_skipped` warning, with no `gc`. #[test] - fn gc_json_shapes_carry_drift_keeps_only_on_apply() { + fn record_into_carries_kept_failed_and_skipped_passes() { const LOCK_MARKER: &str = "another socket-patch process is operating in this directory"; - let mut gc = GcSummary { - skipped: Some(("lock_held", LOCK_MARKER.into())), - ..Default::default() - }; + let mut gc = GcSummary::default(); gc.absorb_vendor_gc(VendorGcSummary { kept: vec!["pkg:npm/b@1.0.0".into(), "pkg:npm/a@1.0.0".into()], failed: vec!["pkg:npm/d@1.0.0".into(), "pkg:npm/c@1.0.0".into()], + dropped_reverted: vec!["pkg:npm/e@1.0.0".into()], + orphan_dirs: 2, write_failures: vec![( "vendor_state_write_failed", "reverted vendored entries but could not update .socket/vendor/state.json: EROFS" @@ -1561,75 +1630,87 @@ mod tests { )], ..Default::default() }); + gc.pruned = vec!["pkg:npm/gone@1.0.0".into()]; assert_eq!( gc.vendored_kept, vec!["pkg:npm/a@1.0.0".to_string(), "pkg:npm/b@1.0.0".to_string()], "absorb must sort, like every other purl list" ); + let json = recorded(&gc, false); + let rows: Vec<(String, String, String)> = json["events"] + .as_array() + .unwrap() + .iter() + .map(|e| { + ( + e["action"].as_str().unwrap().to_string(), + e["purl"].as_str().unwrap().to_string(), + e["errorCode"].as_str().unwrap_or_default().to_string(), + ) + }) + .collect(); + let row = |a: &str, p: &str, c: &str| (a.to_string(), p.to_string(), c.to_string()); assert_eq!( - gc.vendored_failed, - vec!["pkg:npm/c@1.0.0".to_string(), "pkg:npm/d@1.0.0".to_string()], - "failed reverts are absorbed sorted" - ); - assert_eq!( - gc.skipped, - Some(("lock_held", LOCK_MARKER.to_string())), - "absorbing the vendored half leaves this pass's own skip reason alone" + rows, + vec![ + row("removed", "pkg:npm/gone@1.0.0", ""), + row("removed", "pkg:npm/e@1.0.0", "vendor_reverted"), + row("skipped", "pkg:npm/a@1.0.0", "vendor_revert_kept"), + row("skipped", "pkg:npm/b@1.0.0", "vendor_revert_kept"), + row("skipped", "pkg:npm/c@1.0.0", "vendor_revert_failed"), + row("skipped", "pkg:npm/d@1.0.0", "vendor_revert_failed"), + ], + "{json}" ); - let apply = gc.to_json(false); assert_eq!( - apply["keptVendoredEntries"], - serde_json::json!(["pkg:npm/a@1.0.0", "pkg:npm/b@1.0.0"]) + json["events"][0]["details"], + serde_json::json!({"manifest": true}) ); + assert_eq!(json["events"][1]["details"]["mode"], "vendored"); + assert_eq!(json["summary"]["removed"], 2); + assert_eq!(json["summary"]["skipped"], 4); assert_eq!( - apply["failedVendoredEntries"], - serde_json::json!(["pkg:npm/c@1.0.0", "pkg:npm/d@1.0.0"]) + json["summary"]["failed"], 0, + "a GC revert failure never fails the run" ); - assert_eq!(apply["revertedVendoredEntries"], serde_json::json!([])); - assert_eq!(apply["skipped"]["code"], "lock_held", "{apply}"); + assert_eq!(json["status"], "success"); + assert_eq!(json["removedVendorOrphanDirs"], 2); + assert_eq!(json["gc"]["removedBlobs"], 0); assert_eq!( - apply["warnings"], + json["warnings"], serde_json::json!([{ "code": "vendor_state_write_failed", "detail": "reverted vendored entries but could not update \ .socket/vendor/state.json: EROFS", }]), - "{apply}" - ); - let preview = gc.to_json(true); - for key in [ - "keptVendoredEntries", - "failedVendoredEntries", - "skipped", - "warnings", - ] { - assert!( - preview.get(key).is_none(), - "preview must not claim a check it cannot run ({key}): {preview}" - ); - } + "{json}" + ); + // A preview flips the removals to `verified`. + let preview = recorded(&gc, true); + assert_eq!(preview["events"][0]["action"], "verified"); + assert_eq!(preview["events"][1]["action"], "verified"); + assert_eq!(preview["summary"]["removed"], 0); - // A pass that took its own lock fine reports NO skip and NO - // warnings, and the apply shape omits both keys entirely (additive: - // absent, not null). - let clean = GcSummary::vendor_only(VendorGcSummary::default()); - assert!(clean.skipped.is_none()); - let clean_json = clean.to_json(false); - assert!(clean_json.get("skipped").is_none(), "{clean_json}"); - assert!(clean_json.get("warnings").is_none(), "{clean_json}"); - // A failed rewrite in the vendored half is a warning, never a skip - // and never a per-purl failure. - let mut io = GcSummary::default(); - io.absorb_vendor_gc(VendorGcSummary { - write_failures: vec![("manifest_write_failed", "could not update manifest".into())], + // A pass that could not take its lock: only the warning. + let skipped = GcSummary { + skipped: Some(("lock_held", LOCK_MARKER.into())), ..Default::default() - }); - assert!(io.skipped.is_none(), "the vendored half never sets skipped"); + }; + let json = recorded(&skipped, false); assert_eq!( - io.to_json(false)["warnings"][0]["code"], - "manifest_write_failed" + json["warnings"], + serde_json::json!([{ + "code": GC_SKIPPED, + "detail": format!("{LOCK_MARKER} (lock_held)"), + }]) ); - assert!(io.vendored_failed.is_empty()); + assert!(json.get("gc").is_none()); + assert!(json.get("removedVendorOrphanDirs").is_none()); + + // A clean pass: a zero gc, no warnings. + let clean = recorded(&GcSummary::vendor_only(VendorGcSummary::default()), false); + assert!(clean.get("warnings").is_none(), "{clean}"); + assert_eq!(clean["gc"]["bytesFreed"], 0); } /// The human GC vocabulary the contract pins (`GC: skipped (): diff --git a/crates/socket-patch-cli/src/commands/scan/hosted.rs b/crates/socket-patch-cli/src/commands/scan/hosted.rs index c09419652..52c1bb2f0 100644 --- a/crates/socket-patch-cli/src/commands/scan/hosted.rs +++ b/crates/socket-patch-cli/src/commands/scan/hosted.rs @@ -17,9 +17,13 @@ use socket_patch_core::utils::concurrent::{ }; use socket_patch_core::utils::purl::purl_parts; +use crate::commands::agent_download::tag_mode; use crate::commands::vex::generate_vex_from_manifest_path; +use crate::json_envelope::{ + Envelope, EnvelopeError, PatchAction, PatchEvent, RunWarning, VexSummary, +}; -use super::{discover_selected, ScanArgs}; +use super::{discover_selected, emit_scan, scan_envelope, ScanArgs}; mod nuget; mod python; @@ -38,7 +42,6 @@ pub(crate) use socket_patch_core::hosted::guidance::{ pnpm_trust_workspace_unreadable_detail, read_npmrc_for_allow_remote, read_workspace_for_trust, TrustPlan, }; -pub(crate) use socket_patch_core::hosted::render::redirect_json_block; /// Most hosted wheel-metadata downloads in flight at once, below the patch /// API's own in-flight cap: each one buffers a whole wheel (up to @@ -57,71 +60,110 @@ fn wheel_metadata_concurrency(use_public_proxy: bool) -> usize { } /// The hosted-mode JSON error envelope, for bail-outs that return before the -/// success envelope at the bottom of [`run_redirect`] is built. When the -/// classic scan object (`scan_result`, threaded in from `run`) is present it -/// is reused so the error envelope carries the SAME top-level scan keys as -/// the success path — folding in `status`/`error` and a minimal `redirect` -/// block — instead of a bare shape that flips the schema. When absent (never -/// in JSON mode today) the bare envelope is emitted. A `--json` consumer must -/// always get parseable stdout — never empty output plus an exit code. The -/// top-level `error` is `{code, message}` like every command's (v5.0). -fn emit_json_error(scan_result: Option, code: &str, message: &str) { - let mut result = scan_result.unwrap_or_else(|| serde_json::json!({})); - crate::json_envelope::set_error( - &mut result, - crate::json_envelope::EnvelopeError::new(code, message), - ); +/// success envelope at the bottom of [`run_redirect_selected`] is built: +/// the caller's envelope (`scan_result`: scan's discovery payload, or +/// `get`'s narrowing events) marked `status: "error"` with the coded +/// `error`, printed once. A `--json` consumer must always get parseable +/// stdout — never empty output plus an exit code. +fn emit_json_error( + common: &crate::args::GlobalArgs, + scan_result: Option, + code: &str, + message: &str, +) { + let mut env = scan_result.unwrap_or_else(|| scan_envelope(common)); + env.mark_error(EnvelopeError::new(code, message)); // The rollout block describes a successful run only. - if let Some(obj) = result.as_object_mut() { - obj.remove("rollout"); - } - if !result.get("redirect").is_some_and(|r| r.is_object()) { - result["redirect"] = serde_json::json!({ "mode": "hosted" }); - } - println!( - "{}", - serde_json::to_string_pretty(&result) - .expect("serializing an in-memory JSON value cannot fail") - ); + env.extra.remove("rollout"); + emit_scan(&env); } -/// Build the hosted `--json` success envelope: the classic scan object -/// (`scan_result`, built by `run` — scannedPackages / totalPatches / -/// canAccessPaidPatches plus the `packages` enumeration) with the redirect -/// summary NESTED under `redirect`, mirroring vendored mode's nested `vendor` -/// block. Extracted so the schema (classic scan keys + nested `redirect`) is -/// unit-testable without a live API. When `scan_result` is absent (never in -/// JSON mode today) a minimal `{status:"success"}` base is used so stdout is -/// still parseable. -fn build_redirect_json_envelope( - scan_result: Option, - redirect: serde_json::Value, -) -> serde_json::Value { - let mut result = scan_result.unwrap_or_else(|| serde_json::json!({ "status": "success" })); - result["status"] = serde_json::json!("success"); - result["redirect"] = redirect; - result +/// The hosted run's `redirect` payload: `{mode: "hosted", rewrittenFiles}` +/// (the files the rewrite changed — or would change, on a dry run). The +/// per-patch outcomes are the envelope's events (`details.mode: "hosted"`) +/// and its warnings the top-level `warnings`. +pub(super) fn redirect_block(rewritten: Vec) -> serde_json::Value { + serde_json::json!({ "mode": "hosted", "rewrittenFiles": rewritten }) } -/// The `redirect_prune_ignored` warning object (`--prune` is a no-op in -/// hosted mode; see the constants' doc in `run`'s module). -pub(super) fn prune_ignored_warning() -> serde_json::Value { - serde_json::json!({ - "code": super::REDIRECT_PRUNE_IGNORED, - "detail": super::REDIRECT_PRUNE_IGNORED_DETAIL, - }) +/// The `redirect_prune_ignored` warning (`--prune` is a no-op in hosted +/// mode; see the constants' doc in `run`'s module). +pub(super) fn prune_ignored_warning() -> RunWarning { + RunWarning::new( + super::REDIRECT_PRUNE_IGNORED, + super::REDIRECT_PRUNE_IGNORED_DETAIL, + ) +} + +/// Warning code inside `vex.warnings` of a hosted run: the patches this +/// run pinned are attested from their patch records, not hash-verified +/// (their bytes are fetched at install time). +pub(super) const VEX_HOSTED_UNVERIFIED: &str = "vex_hosted_unverified"; + +/// Record a hosted run's per-patch outcomes into `env`, each event +/// carrying `details.mode: "hosted"` and sorted by purl then uuid: `applied` +/// (`verified` on a dry run) for each pin the rewrite confirmed, `skipped` +/// with the skip's reason as `errorCode` (its detail, when it has one, as +/// `reason`), and `skipped` / `redirect_unconfirmed` for each granted patch no lockfile entry pins +/// (status and exit unchanged, pending #704). +pub(super) fn record_hosted_events( + env: &mut Envelope, + confirmed: &[(String, String)], + unconfirmed: &[(String, String)], + skipped: &[socket_patch_core::hosted::engine::SkippedPatch], + dry_run: bool, +) { + let pinned = if dry_run { + PatchAction::Verified + } else { + PatchAction::Applied + }; + let mut events: Vec = confirmed + .iter() + .map(|(purl, uuid)| PatchEvent::new(pinned, purl.as_str()).with_uuid(uuid.as_str())) + .chain(skipped.iter().map(|s| { + // The skip's code is its `errorCode`; its detail (when the + // engine gave one) the `reason`. + let mut event = + PatchEvent::new(PatchAction::Skipped, s.purl.as_str()).with_uuid(s.uuid.as_str()); + event.error_code = Some(s.reason.clone()); + event.reason = s.detail.clone(); + event + })) + .chain(unconfirmed.iter().map(|(purl, uuid)| { + PatchEvent::new(PatchAction::Skipped, purl.as_str()) + .with_uuid(uuid.as_str()) + .with_reason( + socket_patch_core::hosted::render::REDIRECT_UNCONFIRMED, + "no lockfile entry pinning it could be rewritten", + ) + })) + .collect(); + events.sort_by(|a, b| (&a.purl, &a.uuid).cmp(&(&b.purl, &b.uuid))); + for event in events { + env.record(tag_mode(event, Some("hosted"))); + } +} + +/// A `{code, detail}` warning object (the shape the stale-install probes +/// build) as a [`RunWarning`]. +fn run_warning_of(w: &serde_json::Value) -> RunWarning { + RunWarning::new( + w["code"].as_str().unwrap_or_default(), + w["detail"].as_str().unwrap_or_default(), + ) } /// An engine refusal (nothing was written): `Error (): ` on /// stderr plus the `--json` error envelope carrying the code, exit 1. fn refuse( common: &crate::args::GlobalArgs, - scan_result: Option, + scan_result: Option, refusal: &socket_patch_core::hosted::engine::Refusal, ) -> i32 { eprintln!("Error ({}): {}", refusal.code, refusal.message); if common.json { - emit_json_error(scan_result, &refusal.code, &refusal.message); + emit_json_error(common, scan_result, &refusal.code, &refusal.message); } 1 } @@ -135,12 +177,11 @@ fn refuse( /// nothing leaves no residue. Contention / IO failures render through the /// shared [`crate::commands::lock_cli::lock_failure`] mapping — the /// `lock_held` / `lock_io` codes and the "(waited …)" clause match every -/// other mutating command — into the hosted error envelope (NOT -/// `acquire_or_emit`, whose `Envelope` would replace the classic scan / get -/// object) plus the stderr line and, for a live holder, the wait hint. +/// other mutating command — into the caller's error envelope plus the +/// stderr line and, for a live holder, the wait hint. fn acquire_hosted_lock( common: &crate::args::GlobalArgs, - scan_result: &mut Option, + scan_result: &mut Option, ) -> Result { let socket_dir = common.socket_dir(); let timeout = Duration::from_secs(common.lock_timeout.unwrap_or(0)); @@ -155,7 +196,7 @@ fn acquire_hosted_lock( crate::commands::lock_cli::format_lock_error(&socket_dir, &err, timeout) ); if common.json { - emit_json_error(scan_result.take(), code, &message); + emit_json_error(common, scan_result.take(), code, &message); } Err(1) } @@ -535,11 +576,10 @@ pub(super) async fn run_redirect( all_packages_with_patches: &[BatchPackagePatches], can_access_paid_patches: bool, policy: &super::policy::ScanPolicy, - // The classic scan object `run` builds for the `--json` path (`Some` in - // JSON mode, `None` for human output). The redirect result is NESTED into - // it so the hosted `--json` envelope stays schema-consistent with every - // other scan; `.take()` at each terminal (error or success) folds it in. - mut scan_result: Option, + // The scan envelope `run` builds for the `--json` path (`Some` in JSON + // mode, `None` for human output). The redirect outcome is recorded into + // it; `.take()` at each terminal (error or success) prints it. + mut scan_result: Option, // Scan's pending telemetry, flushed by `discover_selected` before // anything below writes to stdout. telemetry: &mut socket_patch_core::telemetry::PendingTelemetry, @@ -556,7 +596,8 @@ pub(super) async fn run_redirect( // Scan's pre-redirect lockfile discovery (see `rollout::Gate::prior`). prior: Option>, // `--prune` / `--sync`, gated by the policy exactly as the human arm - // gates it (`patches.enabled: false` writes nothing, the GC included). + // gates it (`patches.enabled: false` writes nothing, the GC included): + // only feeds the `redirect_prune_ignored` warning. prune: bool, ) -> i32 { // Same discovery/selection as agent and vendored mode. @@ -581,7 +622,12 @@ pub(super) async fn run_redirect( // stdout is never empty on failure. Err((code, message)) => { if args.common.json { - emit_json_error(scan_result.take(), super::PATCH_DETAILS_FAILED, &message); + emit_json_error( + &args.common, + scan_result.take(), + super::PATCH_DETAILS_FAILED, + &message, + ); } else if code == 0 && !args.common.silent { // Unreachable from scan (it never prompts, so selection // cannot be cancelled); kept for a code-0 selection error. @@ -702,8 +748,9 @@ fn discovery_after_writes<'d>( /// /// `scan_result` must be `Some` exactly when `common.json` is set (the /// human/JSON split keys on `common.json`; a `--json` caller passing `None` -/// would get a minimal envelope that drops its own keys). `prune_requested` -/// only feeds the `redirect_prune_ignored` warning — `get` passes `false`. +/// would get a bare scan envelope without its own payload). +/// `prune_requested` only feeds the `redirect_prune_ignored` warning — +/// `get` passes `false`. #[allow(clippy::too_many_arguments)] pub(crate) async fn run_redirect_selected( common: &crate::args::GlobalArgs, @@ -711,7 +758,7 @@ pub(crate) async fn run_redirect_selected( prune_requested: bool, api_client: &socket_patch_core::api::client::ApiClient, selected: &[(String, String)], - mut scan_result: Option, + mut scan_result: Option, npm_prior: Option<&crate::ecosystem_dispatch::NpmCrawlSnapshot>, // `scan`'s rollout gate: NEW rows past the budget are deferred after // every write-free eligibility check below (§5.2). `get` passes `None`. @@ -766,7 +813,12 @@ pub(crate) async fn run_redirect_selected( format_error_line(&message) ); if common.json { - emit_json_error(scan_result.take(), "reference_resolve_failed", &message); + emit_json_error( + common, + scan_result.take(), + "reference_resolve_failed", + &message, + ); } return 1; } @@ -1364,7 +1416,12 @@ pub(crate) async fn run_redirect_selected( { eprintln!("{}", format_error_line(&message)); if common.json { - emit_json_error(scan_result.take(), "lockfile_write_failed", &message); + emit_json_error( + common, + scan_result.take(), + "lockfile_write_failed", + &message, + ); } return 1; } @@ -1469,7 +1526,7 @@ pub(crate) async fn run_redirect_selected( // and stderr) about any overlap left, WITHOUT deleting the other ledger. // Classified over the lockfiles as this run left them and the vendored // ledger as the takeover left it. - let mut takeover_warnings: Vec = Vec::new(); + let mut takeover_warnings: Vec = Vec::new(); // Nothing vendored, nothing to overlap: skip the lockfile walk (#993). let vendor_now = vendor_state.as_ref().ok().filter(|v| !v.entries.is_empty()); let superseded = if vendor_now.is_none() { @@ -1519,17 +1576,17 @@ pub(crate) async fn run_redirect_selected( .redirect }; if !superseded.is_empty() { - takeover_warnings.push(serde_json::json!({ - "code": super::REDIRECT_SUPERSEDES_VENDORED, - "detail": super::mode_takeover_detail(&superseded), - })); + takeover_warnings.push(RunWarning::new( + super::REDIRECT_SUPERSEDES_VENDORED, + super::mode_takeover_detail(&superseded), + )); } // `--prune` is a no-op in hosted mode (both hosted terminals return // before the GC blocks): make that explicit in the JSON `warnings[]` // rather than silently dropping the flag. The human path warns once up // front in `run`. - let mut prune_warnings: Vec = Vec::new(); + let mut prune_warnings: Vec = Vec::new(); if prune_requested { prune_warnings.push(prune_ignored_warning()); } @@ -1645,13 +1702,15 @@ pub(crate) async fn run_redirect_selected( .push(socket_patch_core::patch::redirect::RewriteWarning { code, detail }); } } - let mut warnings: Vec = - socket_patch_core::hosted::render::rewrite_warnings_json(&engine_warnings); - warnings.extend(gem_stale.warnings.iter().cloned()); - warnings.extend(nuget_stale.warnings.iter().cloned()); - warnings.extend(python_stale.warnings.iter().cloned()); - warnings.extend(vlt_stale.warnings.iter().cloned()); - warnings.extend(takeover_pre_warnings.iter().cloned()); + let mut warnings: Vec = engine_warnings + .iter() + .map(|w| RunWarning::new(w.code.as_str(), w.detail.as_str())) + .collect(); + warnings.extend(gem_stale.warnings.iter().map(run_warning_of)); + warnings.extend(nuget_stale.warnings.iter().map(run_warning_of)); + warnings.extend(python_stale.warnings.iter().map(run_warning_of)); + warnings.extend(vlt_stale.warnings.iter().map(run_warning_of)); + warnings.extend(takeover_pre_warnings.iter().map(run_warning_of)); warnings.extend(takeover_warnings.iter().cloned()); warnings.extend(prune_warnings.iter().cloned()); @@ -1666,46 +1725,45 @@ pub(crate) async fn run_redirect_selected( &skipped, ); if common.json { - // Nest the redirect result under `redirect` inside the classic scan - // object (built by `run`, threaded in via `scan_result`), mirroring - // vendored mode's nested `vendor` block, so the hosted `--json` - // envelope keeps the same top-level scan keys as every other scan. - let redirect = redirect_json_block( - &confirmed, - &unconfirmed, - done.rewritten.clone(), - &skipped, - warnings, - common.dry_run, - ); - let mut result = build_redirect_json_envelope(scan_result.take(), redirect); + // Record the redirect outcome into the caller's envelope (scan's + // discovery payload, or get's narrowing events): per-patch events + // tagged `details.mode: "hosted"`, the `redirect` payload, and the + // engine's warnings hoisted to the top-level `warnings`. + let mut env = scan_result.take().unwrap_or_else(|| scan_envelope(common)); + record_hosted_events(&mut env, &confirmed, &unconfirmed, &skipped, common.dry_run); + env.set_extra("redirect", redirect_block(done.rewritten.clone())); + env.warnings.extend(warnings); if let Some(gate) = &rollout { - super::finish_rollout_json(gate.stage, &mut result); + super::finish_rollout_json(gate.stage, &mut env); } if let Some(statements) = vex_statements { - result["vex"] = serde_json::json!({ - "path": vex.vex.as_ref().expect("vex_statements is Some only when --vex was given").display().to_string(), - "statements": statements, - "format": "openvex-0.2.0", - "verified": false, + // Hosted pins are attested from their records, not verified + // against installed bytes: said once, in `vex.warnings`. + let mut warnings = vec![RunWarning::new( + VEX_HOSTED_UNVERIFIED, + "hosted patches are attested from their patch records, not hash-verified \ + (their bytes are fetched at install time); run `socket-patch vex` after \ + installing to verify against the installed tree", + )]; + warnings.extend(vex_warnings); + env.vex = Some(VexSummary { + path: vex + .vex + .as_ref() + .expect("vex_statements is Some only when --vex was given") + .display() + .to_string(), + statements, + format: "openvex-0.2.0".to_string(), + warnings, }); - // Same skip-if-empty `warnings` key as the agent arm's VEX block. - if !vex_warnings.is_empty() { - result["vex"]["warnings"] = serde_json::to_value(&vex_warnings) - .expect("RunWarning is a plain string struct: serialization cannot fail"); - } } else if let Some(e) = &vex_error { - crate::json_envelope::set_error( - &mut result, - crate::json_envelope::EnvelopeError::new(e.code.to_string(), e.message.clone()), - ); - super::append_vex_error_warnings(&mut result, &vex_warnings); + env.mark_error(EnvelopeError::new(e.code.to_string(), e.message.clone())); + super::append_vex_error_warnings(&mut env, &vex_warnings); + } else if vex.vex.is_some() && common.dry_run { + env.warnings.push(super::vex_dry_run_warning()); } - println!( - "{}", - serde_json::to_string_pretty(&result) - .expect("serializing an in-memory JSON value cannot fail") - ); + emit_scan(&env); } else { if !common.silent { // Wrap long warnings only on a terminal: logs and pipes keep one @@ -1731,12 +1789,7 @@ pub(crate) async fn run_redirect_selected( ); let human_warnings: Vec<(&str, &str)> = warnings .iter() - .map(|w| { - ( - w["code"].as_str().unwrap_or_default(), - w["detail"].as_str().unwrap_or_default(), - ) - }) + .map(|w| (w.code.as_str(), w.detail.as_str())) // The prune notice already printed up front (in `run`); // successful takeovers printed above as progress lines. .filter(|(code, _)| { @@ -2290,7 +2343,7 @@ pub(crate) fn boxed_run_redirect_selected<'a>( prune_requested: bool, api_client: &'a socket_patch_core::api::client::ApiClient, selected: &'a [(String, String)], - scan_result: Option, + scan_result: Option, npm_prior: Option<&'a crate::ecosystem_dispatch::NpmCrawlSnapshot>, rollout: Option>, ) -> std::pin::Pin + 'a>> { @@ -2385,24 +2438,23 @@ use socket_patch_core::hosted::engine::SBT_OWNED_FILE_UNREADABLE; #[cfg(test)] mod tests { - use super::{ - build_redirect_json_envelope, gem_stale_cache_warning, gem_stale_install_warning, - gem_stale_install_warnings, installed_stale_positive_evidence, - npm_allow_remote_already_detail, npm_allow_remote_configured_detail, - npm_allow_remote_env_set_detail, npm_allow_remote_manual_detail, - npm_allow_remote_outer_set_detail, npm_allow_remote_unreadable_detail, - npm_allow_remote_user_set_detail, plan_workspace_trust, pnpm_heal_root, - pnpm_lock_carries_hosted_redirect, pnpm_lock_version_major, pnpm_trust_configured_detail, - pnpm_trust_legacy_detail, pnpm_trust_manual_guidance, - pnpm_trust_workspace_unreadable_detail, prune_ignored_warning, read_npmrc_for_allow_remote, - read_workspace_for_trust, redirect_json_block, TrustPlan, - }; use super::{ describe_skip_reason, format_error_line, format_next_steps, format_redirect_summary, format_takeover_line, format_unredirected, format_warning, join_names, lock_entry_warning_count, pnpm_lock_may_need_store_flag, pnpm_trust_rerun_reminder, split_sentences, wrap_tokens, wrap_words, TAKEOVER_INFO_CODES, }; + use super::{ + gem_stale_cache_warning, gem_stale_install_warning, gem_stale_install_warnings, + installed_stale_positive_evidence, npm_allow_remote_already_detail, + npm_allow_remote_configured_detail, npm_allow_remote_env_set_detail, + npm_allow_remote_manual_detail, npm_allow_remote_outer_set_detail, + npm_allow_remote_unreadable_detail, npm_allow_remote_user_set_detail, plan_workspace_trust, + pnpm_heal_root, pnpm_lock_carries_hosted_redirect, pnpm_lock_version_major, + pnpm_trust_configured_detail, pnpm_trust_legacy_detail, pnpm_trust_manual_guidance, + pnpm_trust_workspace_unreadable_detail, prune_ignored_warning, read_npmrc_for_allow_remote, + read_workspace_for_trust, record_hosted_events, redirect_block, TrustPlan, + }; use super::{wheel_metadata_concurrency, WHEEL_METADATA_CONCURRENCY}; use socket_patch_core::hosted::engine::REDIRECT_CANDIDATE_FILES; @@ -3107,68 +3159,75 @@ mod tests { assert!(pnpm_heal_root(false, None, &overrides).is_none()); } - /// The classic scan object `run` builds for the `--json` path with ≥1 - /// discovered package (scannedPackages/totalPatches/… + the `packages` - /// enumeration). Mirrors the `serde_json::json!` in `scan::run`. - fn classic_scan_result() -> serde_json::Value { - serde_json::json!({ - "status": "success", - "scannedPackages": 3, - "lockfileOnlyPackages": 0, - "packagesWithPatches": 1, - "totalPatches": 2, - "freePatches": 2, - "paidPatches": 0, - "canAccessPaidPatches": false, - "packages": [ - { "purl": "pkg:npm/minimist@1.2.2", "patches": [ { "uuid": "abc-123" } ] } - ], - "updates": [], - }) - } - + /// The hosted outcome recorded into a scan envelope: one event per + /// selected patch, tagged `details.mode: "hosted"` and purl-sorted — + /// `applied` for a confirmed pin (`verified` on a dry run), `skipped` + /// with the skip's code, `skipped` / `redirect_unconfirmed` for a grant + /// nothing pins — counted in `summary`, beside the discovery payload, + /// the `redirect` block and the hoisted warnings. #[test] - fn hosted_json_envelope_nests_redirect_into_classic_scan_object() { - // With ≥1 package, the hosted `--json` envelope must carry the SAME - // top-level scan keys as a zero-discovery / non-hosted scan AND nest - // the redirect summary under `redirect`. Built through the ONE - // spelling of the block (`run`'s zero-discovery arm uses the same - // helper). - let redirect = redirect_json_block( + fn hosted_events_join_the_scan_envelope() { + use crate::json_envelope::{Command, Envelope}; + use socket_patch_core::hosted::engine::SkippedPatch; + let mut env = Envelope::new(Command::Scan); + env.set_extra("scannedPackages", serde_json::json!(3)); + let skipped: Vec = serde_json::from_value(serde_json::json!([ + {"purl": "pkg:npm/b@1.0.0", "uuid": "ub", "reason": "not_found"} + ])) + .unwrap(); + record_hosted_events( + &mut env, + &[("pkg:npm/minimist@1.2.2".to_string(), "abc-123".to_string())], + &[("pkg:npm/a@1.0.0".to_string(), "ua".to_string())], + &skipped, + false, + ); + env.set_extra("redirect", redirect_block(vec!["package-lock.json".into()])); + env.warnings.push(prune_ignored_warning()); + let v = env.to_value(); + assert_eq!(v["command"], "scan"); + assert_eq!(v["status"], "success"); + assert_eq!(v["scannedPackages"], 3); + let actions: Vec<(&str, &str, Option<&str>)> = v["events"] + .as_array() + .unwrap() + .iter() + .map(|e| { + assert_eq!(e["details"]["mode"], "hosted", "{e}"); + ( + e["action"].as_str().unwrap(), + e["purl"].as_str().unwrap(), + e["errorCode"].as_str(), + ) + }) + .collect(); + assert_eq!( + actions, + vec![ + ("skipped", "pkg:npm/a@1.0.0", Some("redirect_unconfirmed")), + ("skipped", "pkg:npm/b@1.0.0", Some("not_found")), + ("applied", "pkg:npm/minimist@1.2.2", None), + ] + ); + assert_eq!(v["summary"]["applied"], 1); + assert_eq!(v["summary"]["skipped"], 2); + assert_eq!( + v["redirect"], + serde_json::json!({"mode": "hosted", "rewrittenFiles": ["package-lock.json"]}) + ); + assert_eq!(v["warnings"][0]["code"], "redirect_prune_ignored"); + + // A dry run previews the pin as `verified`. + let mut env = Envelope::new(Command::Scan); + record_hosted_events( + &mut env, &[("pkg:npm/minimist@1.2.2".to_string(), "abc-123".to_string())], &[], - vec!["package-lock.json".to_string()], &[], - vec![prune_ignored_warning()], - false, + true, ); - let envelope = build_redirect_json_envelope(Some(classic_scan_result()), redirect); - - // Classic scan keys survive. - assert_eq!(envelope["status"], "success"); - assert_eq!(envelope["scannedPackages"], 3); - assert_eq!(envelope["packagesWithPatches"], 1); - assert_eq!(envelope["totalPatches"], 2); - assert_eq!(envelope["freePatches"], 2); - assert_eq!(envelope["paidPatches"], 0); - assert_eq!(envelope["canAccessPaidPatches"], false); - assert!(envelope["updates"].is_array()); - - // Per-package / patch-uuid enumeration is present. - assert!(envelope["packages"].is_array()); - assert_eq!(envelope["packages"][0]["purl"], "pkg:npm/minimist@1.2.2"); - assert_eq!(envelope["packages"][0]["patches"][0]["uuid"], "abc-123"); - - // Redirect result is NESTED, preserving every sub-field, not replacing - // the whole envelope. - let r = &envelope["redirect"]; - assert!(r.is_object()); - assert_eq!(r["mode"], "hosted"); - assert_eq!(r["redirected"], 1); - assert_eq!(r["rewrittenFiles"][0], "package-lock.json"); - assert!(r["skipped"].is_array()); - assert!(r["warnings"].is_array()); - assert_eq!(r["dryRun"], false); + assert_eq!(env.summary.verified, 1); + assert_eq!(env.summary.applied, 0); } // ── gem stale-install probe (redirect_gem_stale_install) ────────── diff --git a/crates/socket-patch-cli/src/commands/scan/mod.rs b/crates/socket-patch-cli/src/commands/scan/mod.rs index 55bf835b4..6d204040e 100644 --- a/crates/socket-patch-cli/src/commands/scan/mod.rs +++ b/crates/socket-patch-cli/src/commands/scan/mod.rs @@ -36,11 +36,15 @@ use crate::ecosystem_dispatch::{ crawl_ecosystems, crawl_ecosystems_with_npm, find_all_packages_for_rollback_reusing, partition_purls, }; -use crate::json_envelope::{usage_error, Command as JsonCommand}; +use crate::json_envelope::{ + usage_error, Command as JsonCommand, Envelope, EnvelopeError, PatchAction, PatchEvent, + RunWarning, VexSummary, +}; use crate::ui::{self, plural, print_json, StatusLine}; use crate::commands::agent_download::{ - download_and_apply_patches_with, DownloadParams, DownloadRun, + download_and_apply_patches_into, download_and_apply_patches_with, DownloadParams, DownloadRun, + ALREADY_IN_MANIFEST, }; use self::policy::{load_invocation_policy, InvocationPolicy, PolicyLoadError, ScanPolicy}; @@ -69,16 +73,13 @@ pub(crate) use self::discovery::{ lockfile_supplement as project_lockfile_supplement, unsupported_layout_warnings, vendored_ledger_supplement as project_vendored_supplement, }; -use self::gc::gc_json; +use self::gc::gc_into; pub(crate) use self::hosted::boxed_run_redirect_selected; use self::hosted::run_redirect; use self::vendor_flow::{ - boxed_vendor_interactive_path, boxed_vendor_json_path, fold_vendored_skips_into_apply, - partition_skipped_selected, -}; -pub(crate) use self::vendor_flow::{ - boxed_vendor_step, preview_vendor_json, print_dry_run_refusals, VendorStep, + boxed_vendor_interactive_path, boxed_vendor_json_path, partition_skipped_selected, }; +pub(crate) use self::vendor_flow::{boxed_vendor_step, preview_vendor, VendorStep}; /// Packages per batch request on the authenticated API when `--batch-size` /// is not given: the server's own per-request maximum @@ -377,20 +378,45 @@ pub struct ScanArgs { pub(crate) use socket_patch_core::policy::package_spec_matches; +/// Warning code: `--vex` was requested on a `--dry-run`, which generates +/// no document (a preview neither verifies nor writes an attestation). +pub(super) const VEX_SKIPPED_DRY_RUN: &str = "vex_skipped_dry_run"; + +/// The [`VEX_SKIPPED_DRY_RUN`] warning. +pub(super) fn vex_dry_run_warning() -> RunWarning { + RunWarning::new( + VEX_SKIPPED_DRY_RUN, + "--vex was not generated: a dry run changes nothing to attest", + ) +} + +/// The one `--json` emitter of `scan`: every document it prints is this +/// [`Envelope`], printed here once per run. +pub(super) fn emit_scan(env: &Envelope) { + print_json(&env.to_value()); +} + +/// A fresh `scan` envelope (`dryRun` from the flags). +pub(super) fn scan_envelope(common: &GlobalArgs) -> Envelope { + let mut env = Envelope::new(JsonCommand::Scan); + env.dry_run = common.dry_run; + env +} + /// Embedded-VEX side-effect for `scan`'s JSON terminal returns. When /// `--vex` was requested and `base_code` is 0, generate the OpenVEX /// document from the post-scan manifest and fold the outcome into -/// `result` — a `vex` object on success, or `status: "error"` + `error` +/// `env` — its `vex` summary on success, or `status: "error"` + `error` /// on failure (per the fail-the-command contract). Returns the final exit /// code: `base_code` when not requested / skipped / on VEX success, `1` -/// when VEX generation failed. Caller prints `result` after this returns. +/// when VEX generation failed. Caller prints `env` after this returns. async fn embed_vex_into_json( common: &GlobalArgs, vex_args: &VexEmbedArgs, api_client: &ApiClient, manifest_path: &Path, base_code: i32, - result: &mut serde_json::Value, + env: &mut Envelope, hosted: bool, ) -> i32 { if vex_args.vex.is_none() || base_code != 0 { @@ -398,11 +424,10 @@ async fn embed_vex_into_json( } // A dry run is a non-mutating preview: generating here would verify the // deliberately untouched tree (failing outright on a not-yet-vendored - // project) and write an attestation file to disk. The marker keeps the - // request visible to JSON consumers instead of silently dropping it - // (same shape as the vendor JSON arm's early return). + // project) and write an attestation file to disk. The warning keeps the + // request visible to JSON consumers instead of silently dropping it. if common.dry_run { - result["vex"] = serde_json::json!({ "skipped": true, "reason": "dry_run" }); + env.warnings.push(vex_dry_run_warning()); return base_code; } let mut params = vex_args.to_build_params(Some(api_client)); @@ -411,26 +436,24 @@ async fn embed_vex_into_json( params.hosted_gem_mirror_check = hosted; match generate_vex_from_manifest_path(common, ¶ms, manifest_path).await { Ok(summary) => { - result["vex"] = serde_json::json!({ - "path": vex_args.vex.as_ref().expect("--vex is Some: guarded by the early return above").display().to_string(), - "statements": summary.statements, - "format": "openvex-0.2.0", + // `vex.warnings`: note_warning suppressed these on stderr under + // --json, so this is their only surviving channel. + env.vex = Some(VexSummary { + path: vex_args + .vex + .as_ref() + .expect("--vex is Some: guarded by the early return above") + .display() + .to_string(), + statements: summary.statements, + format: "openvex-0.2.0".to_string(), + warnings: summary.warnings, }); - // Same additive `warnings` key the envelope's `VexSummary` - // carries (skip-if-empty): note_warning suppressed these on - // stderr under --json, so this is their only surviving channel. - if !summary.warnings.is_empty() { - result["vex"]["warnings"] = serde_json::to_value(&summary.warnings) - .expect("RunWarning is a plain string struct: serialization cannot fail"); - } 0 } Err(e) => { - crate::json_envelope::set_error( - result, - crate::json_envelope::EnvelopeError::new(e.code.to_string(), e.message.clone()), - ); - append_vex_error_warnings(result, &e.embedded_warnings()); + env.mark_error(EnvelopeError::new(e.code.to_string(), e.message.clone())); + append_vex_error_warnings(env, &e.embedded_warnings()); 1 } } @@ -438,23 +461,11 @@ async fn embed_vex_into_json( /// Fold a failed embedded VEX's run-level advisories (the lockfile /// discovery diagnostics — often the only explanation of a -/// `vendor_unwired` / `redirect_unwired` omission) into the scan JSON's +/// `vendor_unwired` / `redirect_unwired` omission) into the envelope's /// top-level `warnings[]`, the channel `--json` has once stderr is -/// silenced. Appends to an existing array (layout refusals) or creates it. -pub(super) fn append_vex_error_warnings( - result: &mut serde_json::Value, - warnings: &[crate::json_envelope::RunWarning], -) { - if warnings.is_empty() { - return; - } - let extra = warnings - .iter() - .map(|w| serde_json::json!({ "code": w.code, "detail": w.detail })); - match result.get_mut("warnings").and_then(|w| w.as_array_mut()) { - Some(existing) => existing.extend(extra), - None => result["warnings"] = serde_json::Value::Array(extra.collect()), - } +/// silenced. +pub(super) fn append_vex_error_warnings(env: &mut Envelope, warnings: &[RunWarning]) { + env.warnings.extend(warnings.iter().cloned()); } /// Embedded-VEX side-effect for `scan`'s human-readable terminal returns. @@ -533,7 +544,7 @@ async fn discover_selected( warn: bool, detail_error_line: bool, telemetry: &mut PendingTelemetry, - json_warnings: Option<&mut serde_json::Value>, + json_warnings: Option<&mut Envelope>, ) -> Result { let (all_search_results, failures) = fetch_patch_details(api_client, packages, show_progress, warn).await; @@ -559,15 +570,14 @@ async fn discover_selected( // warning (`warn` is human-only), so each failed package becomes a // run-level `warnings[]` entry — never a silent drop from the envelope. let offers = select_accessible(all_search_results, can_access_paid_patches, policy); - if let Some(result) = json_warnings { + if let Some(env) = json_warnings { for (purl, e) in &failures { - push_scan_json_warning( - result, + env.warn( PATCH_DETAILS_FAILED, - &format!("could not fetch details for {purl}: {e}"), + format!("could not fetch details for {purl}: {e}"), ); } - policy.fold_into_json(result); + policy.fold_into_envelope(env); } Ok(Discovered { offers, @@ -606,7 +616,7 @@ fn classified_rows( recorded: &rollout::RecordedState<'_>, batch_failed: bool, packages: &[BatchPackagePatches], - result: Option<&mut serde_json::Value>, + result: Option<&mut Envelope>, ) -> Vec { let failed: Vec = discovered .failed @@ -615,9 +625,9 @@ fn classified_rows( .collect(); stage.incomplete = rollout::lookup_incomplete(&recorded.index, &failed, batch_failed); let rows = rollout::classify(&discovered.offers, &recorded.index, &stage.project); - if let Some(result) = result { + if let Some(env) = result { let updates = offer_updates(&rows, discovered, recorded, packages); - result["updates"] = serde_json::Value::Array(updates_json(&updates)); + env.set_extra("updates", serde_json::Value::Array(updates_json(&updates))); } rows } @@ -677,11 +687,11 @@ fn plan_kept_rows( .collect() } -/// Fold the stage's `rollout` block and warnings into a JSON result. -pub(super) fn finish_rollout_json(stage: &rollout::Stage, result: &mut serde_json::Value) { - result["rollout"] = stage.json(); +/// Fold the stage's `rollout` block and warnings into the envelope. +pub(super) fn finish_rollout_json(stage: &rollout::Stage, env: &mut Envelope) { + env.set_extra("rollout", stage.json()); for (code, detail) in stage.warnings() { - push_scan_json_warning(result, code, &detail); + env.warn(code, detail); } } @@ -790,22 +800,16 @@ async fn fetch_patch_details( (results, failures) } -/// Fold a [`discover_selected`] failure into a JSON caller's `result` and -/// print it. The discovery counts already in `result` stay — they were -/// computed from the (successful) batch phase — while `status`/`error` -/// mirror the all-batches-failed envelope so JSON consumers see one -/// consistent scan-error schema instead of empty stdout. The code is -/// [`PATCH_DETAILS_FAILED`]: every patch-detail query failing is the only -/// way discovery fails. -fn emit_discovery_error_json(result: &mut serde_json::Value, message: &str) { - crate::json_envelope::set_error( - result, - crate::json_envelope::EnvelopeError::new(PATCH_DETAILS_FAILED, message), - ); - if let Some(obj) = result.as_object_mut() { - obj.remove("rollout"); - } - print_json(result); +/// Fold a [`discover_selected`] failure into a JSON caller's envelope and +/// print it. The discovery payload already in it stays — it was computed +/// from the (successful) batch phase — while `status`/`error` carry the +/// failure. The code is [`PATCH_DETAILS_FAILED`]: every patch-detail query +/// failing is the only way discovery fails. +fn emit_discovery_error_json(env: &mut Envelope, message: &str) { + env.mark_error(EnvelopeError::new(PATCH_DETAILS_FAILED, message)); + // The rollout block describes a successful run only. + env.extra.remove("rollout"); + emit_scan(env); } /// The agent-flow selection split both arms (JSON + human) share. Vendor- @@ -819,10 +823,8 @@ fn emit_discovery_error_json(result: &mut serde_json::Value, message: &str) { struct AgentSelection { /// What is left to download + apply. kept: Vec, - /// Every skip record (`vendored` + `package_not_installed`), purl-sorted, - /// in the `{purl, uuid, action: "skipped", errorCode}` shape the apply - /// report folds in. - skip_records: Vec, + /// Every skip event (`vendored` + `package_not_installed`), purl-sorted. + skip_records: Vec, /// The vendored partition's purls alone — feeds the run-level /// `vendored_ownership_retained` warning and the human `[skip]` lines. vendored_purls: Vec, @@ -842,17 +844,14 @@ fn partition_agent_selection( |p| lockfile_only_contains(&lockfile_only.purls, p), "package_not_installed", ); - let purls_of = |records: &[serde_json::Value]| -> Vec { - records - .iter() - .filter_map(|r| r["purl"].as_str().map(str::to_string)) - .collect() + let purls_of = |records: &[PatchEvent]| -> Vec { + records.iter().filter_map(|r| r.purl.clone()).collect() }; let vendored_purls = purls_of(&vendored_records); let not_installed_purls = purls_of(¬_installed_records); let mut skip_records = vendored_records; skip_records.extend(not_installed_records); - skip_records.sort_by(|a, b| a["purl"].as_str().cmp(&b["purl"].as_str())); + skip_records.sort_by(|a, b| a.purl.cmp(&b.purl)); AgentSelection { kept, skip_records, @@ -1127,22 +1126,13 @@ pub(super) fn push_run_warning( }); } -/// Top-level `warnings[]` JSON for scan's envelope from `(code, detail)` -/// pairs (see [`unsupported_layout_warnings`]). Same `{code, detail}` object -/// shape as the run-level `warnings[]` on the unified envelope. -fn layout_refusal_json(refusals: &[(String, String)]) -> serde_json::Value { - serde_json::Value::Array( - refusals - .iter() - .map(|(code, detail)| { - let mut entry = serde_json::json!({ "code": code, "detail": detail }); - if let Some(level) = warning_level(code) { - entry["level"] = serde_json::json!(level); - } - entry - }) - .collect(), - ) +/// Top-level `warnings[]` for scan's envelope from `(code, detail)` pairs +/// (see [`unsupported_layout_warnings`]). +fn layout_warnings(refusals: &[(String, String)]) -> Vec { + refusals + .iter() + .map(|(code, detail)| RunWarning::new(code.as_str(), detail.as_str())) + .collect() } // --------------------------------------------------------------------------- @@ -1218,22 +1208,20 @@ struct GradleScan { locked: Option>, } -/// The level of a run-level warning code: `info` for the Gradle advisories -/// that need no action, `warn` for the Gradle warning, `None` (no `level` -/// field, printed as a warning) for every other code. -fn warning_level(code: &str) -> Option<&'static str> { - match code { - GRADLE_MAVEN_LOCAL_UNDETERMINED | GRADLE_USER_HOME_DIFFERS => Some("info"), - GRADLE_BUILD_IGNORES_M2 => Some("warn"), - _ => None, - } +/// Whether a run-level warning code is an advisory that needs no action +/// (the Gradle discovery notes), printed as `Note:` for humans. +fn is_info_note(code: &str) -> bool { + matches!( + code, + GRADLE_MAVEN_LOCAL_UNDETERMINED | GRADLE_USER_HOME_DIFFERS + ) } /// Print the run-level warnings to stderr: advisories as `Note:` (not /// under `--silent`), everything else as `Warning:`. fn print_layout_refusals(refusals: &[(String, String)], silent: bool) { for (code, detail) in refusals { - if warning_level(code) == Some("info") { + if is_info_note(code) { if !silent { eprintln!("Note: {detail}"); } @@ -1474,7 +1462,7 @@ pub(super) fn vendored_ownership_retained_detail(purls: &[String]) -> String { ) } -/// Additive top-level `redirectState` block for the scan `--json` envelope: +/// Top-level `redirectState` block for the scan `--json` envelope: /// the hosted pins the lockfiles wire — project STATE, so a descriptive /// block rather than a warning — plus the scanned purls among them. /// @@ -1513,40 +1501,13 @@ pub(super) fn redirect_state_json( })) } -/// Append one `{code, detail}` entry to the scan `--json` result's -/// top-level `warnings` array (created on first use — the key is additive -/// and absent when no run-level warning fired), mirroring the -/// [`crate::json_envelope::RunWarning`] wire shape. -fn push_scan_json_warning(result: &mut serde_json::Value, code: &str, detail: &str) { - let warnings = result - .as_object_mut() - .expect("scan JSON result is an object") - .entry("warnings") - .or_insert_with(|| serde_json::json!([])); - if let Some(arr) = warnings.as_array_mut() { - arr.push(serde_json::json!({ "code": code, "detail": detail })); - } -} - -/// Print the scan error envelope for a refusal before any scanning -/// (`--offline`): the all-batches-failed shape with every count at -/// zero, so JSON consumers see one consistent scan-error schema. -fn print_zero_error_envelope(code: &str, err: &str, paths: &[String]) { - let result = serde_json::json!({ - "status": "error", - "error": { "code": code, "message": err }, - "scannedPackages": 0, - "lockfileOnlyPackages": 0, - "packagesWithPatches": 0, - "totalPatches": 0, - "freePatches": 0, - "paidPatches": 0, - "canAccessPaidPatches": false, - "packages": [], - "updates": [], - "paths": paths, - }); - print_json(&result); +/// Print the scan error envelope for a failure before (or instead of) any +/// discovery: `status: "error"`, empty `events`, zero `summary`, the coded +/// `error`. +fn emit_scan_error(common: &GlobalArgs, error: EnvelopeError) { + let mut env = scan_envelope(common); + env.mark_error(error); + emit_scan(&env); } pub async fn run(args: ScanArgs) -> i32 { @@ -1715,7 +1676,10 @@ fn scan_usage_error(args: &ScanArgs, code: &str, message: &str) -> i32 { /// Print a policy file that cannot be honored (fail closed, exit 1). fn report_policy_error(err: &socket_patch_core::policy::PolicyError, args: &ScanArgs) -> i32 { if args.common.json { - print_json(&policy::policy_error_json(err, &args.paths)); + emit_scan_error( + &args.common, + EnvelopeError::new(err.code(), err.to_string()), + ); } else { eprintln!("Error ({}): {err}", err.code()); } @@ -1853,9 +1817,7 @@ async fn run_scan( let err = "scan requires network access to query the patch API and cannot run with \ --offline/SOCKET_OFFLINE (strict airgap)"; if args.common.json { - // Mirror the all-batches-failed error envelope shape so JSON - // consumers see one consistent scan-error schema. - print_zero_error_envelope("offline_unsupported", err, path_scope.raw()); + emit_scan_error(&args.common, EnvelopeError::new("offline_unsupported", err)); } else { eprintln!("Error: {err}"); } @@ -2039,6 +2001,25 @@ async fn run_scan( // Read existing manifest once for update detection. let existing_manifest = ctx.ledgers().await.manifest; + // A manifest that exists but cannot be loaded (rule shared by every + // command: `manifest_invalid` / `manifest_unreadable`). Agent mode + // reads and rewrites it (its dry run previews against it), so it fails + // closed before any query, in both outputs; every other mode only + // reads it for update detection and the GC, so it warns and goes on + // without it (never silently as "no manifest"). + if let Err(e) = &ctx.loaded().await.manifest { + let err = crate::json_envelope::manifest_load_error(&manifest_path, e); + if apply { + status.finish(); + if args.common.json { + emit_scan_error(&args.common, err); + } else { + eprintln!("Error: {}", err.message); + } + return 1; + } + layout_refusals.push((err.code, err.message)); + } // Hosted mode records its patches ONLY in the lockfiles (v5 keeps no // hosted ledger) and vendored mode ONLY in its ledger, so the hosted // pins and the vendor ledger's purl→uuid records are folded into update @@ -2266,45 +2247,28 @@ async fn run_scan( // GC is intentionally skipped when the crawl finds nothing: // pruning every manifest entry is too destructive (`repair` // does full cleanup explicitly). - let mut result = serde_json::json!({ - "status": "success", - "scannedPackages": 0, - "lockfileOnlyPackages": 0, - "packagesWithPatches": 0, - "totalPatches": 0, - "freePatches": 0, - "paidPatches": 0, - "canAccessPaidPatches": false, - "packages": [], - "updates": [], - "paths": path_scope.raw(), - "rollout": stage.json(), - }); - // Layout refusals: additive top-level `warnings` (omitted when - // empty) so a consumer can tell an unscannable project from an - // empty one. - if !layout_refusals.is_empty() { - result["warnings"] = layout_refusal_json(&layout_refusals); - } + let mut env = scan_envelope(&args.common); + env.set_extra("scannedPackages", serde_json::json!(0)); + env.set_extra("lockfileOnlyPackages", serde_json::json!(0)); + env.set_extra("canAccessPaidPatches", serde_json::json!(false)); + env.set_extra("packages", serde_json::json!([])); + env.set_extra("updates", serde_json::json!([])); + env.set_extra("paths", serde_json::json!(path_scope.raw())); + env.set_extra("rollout", stage.json()); + // Layout refusals ride `warnings` so a consumer can tell an + // unscannable project from an empty one. + env.warnings.extend(layout_warnings(&layout_refusals)); if let Some(gc) = &unwired_gc { - result["gc"] = gc.to_json(args.common.dry_run); + gc.record_into(&mut env, 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. + policy.fold_into_envelope(&mut env); + // Hosted mode: the same `redirect` block as the ≥1-package + // path (nothing rewritten). if hosted { - let mut warnings: Vec = Vec::new(); if prune { - warnings.push(hosted::prune_ignored_warning()); + env.warnings.push(hosted::prune_ignored_warning()); } - result["redirect"] = hosted::redirect_json_block( - &[], - &[], - Vec::new(), - &[], - warnings, - args.common.dry_run, - ); + env.set_extra("redirect", hosted::redirect_block(Vec::new())); } else if !vendor { // `redirectState` rides the empty-discovery envelope too // (same rule as the ≥1-package path). `wiringLive` is empty @@ -2316,7 +2280,7 @@ async fn run_scan( ), )); if let Some(state) = redirect_state_json(redirect_state.as_ref(), &[]) { - result["redirectState"] = state; + env.set_extra("redirectState", state); } } let code = embed_vex_into_json( @@ -2325,11 +2289,11 @@ async fn run_scan( &api_client, &manifest_path, 0, - &mut result, + &mut env, hosted, ) .await; - print_json(&result); + emit_scan(&env); return code; } else if !args.common.silent { // A project the policy skipped as a whole is not an empty one. @@ -2526,21 +2490,15 @@ async fn run_scan( // The failure prints right away: nothing to overlap the send with. telemetry.flush().await; if args.common.json { - let result = serde_json::json!({ - "status": "error", - "error": { "code": API_BATCH_FAILED, "message": err }, - "scannedPackages": package_count, - "lockfileOnlyPackages": lockfile_only_count, - "packagesWithPatches": 0, - "totalPatches": 0, - "freePatches": 0, - "paidPatches": 0, - "canAccessPaidPatches": false, - "packages": [], - "updates": [], - "paths": path_scope.raw(), - }); - print_json(&result); + let mut env = scan_envelope(&args.common); + env.set_extra("scannedPackages", serde_json::json!(package_count)); + env.set_extra( + "lockfileOnlyPackages", + serde_json::json!(lockfile_only_count), + ); + env.set_extra("paths", serde_json::json!(path_scope.raw())); + env.mark_error(EnvelopeError::new(API_BATCH_FAILED, err)); + emit_scan(&env); } else { eprintln!("{}", render::all_batches_failed(total_batches, &err)); } @@ -2606,36 +2564,42 @@ async fn run_scan( // packages this run covered), unlike the PRE-filter `scanned_purls` // the GC prune uses. + // Count downloadable patches: a free-tier org whose every offer is + // paid-tier has nothing any mode could select. Shared by both outputs. + let downloadable_count = if can_access_paid_patches { + all_packages_with_patches.len() + } else { + all_packages_with_patches + .iter() + .filter(|pkg| pkg.patches.iter().any(|p| p.tier == "free")) + .count() + }; + // Whether this run fetches the by-package detail records — ONE decision + // both outputs read (#1062): every mode that selects needs them, and so + // does report-only whenever a patch is downloadable (its listing and + // `updates[]` come from the same records the other modes act on). Only + // `discover_selected`'s own `Err` (every query failed) fails the run; + // queries that succeed with no records leave nothing to select. + let fetch_details = downloadable_count > 0; + if args.common.json { - let mut result = serde_json::json!({ - "status": "success", - "scannedPackages": package_count, - "lockfileOnlyPackages": lockfile_only_count, - "packagesWithPatches": all_packages_with_patches.len(), - "totalPatches": total_patches, - "freePatches": free_patches, - "paidPatches": paid_patches, - "canAccessPaidPatches": can_access_paid_patches, - "packages": all_packages_with_patches, - "paths": path_scope.raw(), - "updates": updates_json(&updates), - "rollout": stage.json(), - }); - // Layout refusals ride the non-empty envelope too (additive, - // omitted when empty). - if !layout_refusals.is_empty() { - result["warnings"] = layout_refusal_json(&layout_refusals); - } - // One warning per failed batch (status and exit unchanged). - for (batch, err) in &failed_batches { - let line = render::batch_failed_warning(*batch, total_batches, err); - let detail = line.strip_prefix("Warning: ").unwrap_or(&line); - push_scan_json_warning(&mut result, API_BATCH_FAILED, detail); - } - policy.fold_into_json(&mut result); - // Flag lockfile-only packages (additive; absent means installed), - // with the same predicate as the `[NOT INSTALLED]` marker. - if let Some(packages) = result["packages"].as_array_mut() { + let mut env = scan_envelope(&args.common); + env.set_extra("scannedPackages", serde_json::json!(package_count)); + env.set_extra( + "lockfileOnlyPackages", + serde_json::json!(lockfile_only_count), + ); + env.set_extra( + "canAccessPaidPatches", + serde_json::json!(can_access_paid_patches), + ); + // Flag lockfile-only packages (absent means installed), with the + // same predicate as the `[NOT INSTALLED]` marker, and Gradle-cached + // packages with whether the build's lock files name them (an + // annotation, never a filter). + let mut packages = + serde_json::to_value(&all_packages_with_patches).expect("batch packages serialize"); + if let Some(packages) = packages.as_array_mut() { for pkg in packages { let is_lockfile_only = pkg["purl"] .as_str() @@ -2643,8 +2607,6 @@ async fn run_scan( if is_lockfile_only { pkg["notInstalled"] = serde_json::json!(true); } - // Gradle-cached packages: whether the build's lock files - // name them (additive; an annotation, never a filter). if let Some(base) = pkg["purl"] .as_str() .map(canonical_base_purl) @@ -2656,9 +2618,21 @@ async fn run_scan( } } } + env.set_extra("packages", packages); + env.set_extra("paths", serde_json::json!(path_scope.raw())); + env.set_extra("updates", serde_json::Value::Array(updates_json(&updates))); + env.set_extra("rollout", stage.json()); + env.warnings.extend(layout_warnings(&layout_refusals)); + // One warning per failed batch (status and exit unchanged). + for (batch, err) in &failed_batches { + let line = render::batch_failed_warning(*batch, total_batches, err); + let detail = line.strip_prefix("Warning: ").unwrap_or(&line); + env.warn(API_BATCH_FAILED, detail); + } + policy.fold_into_envelope(&mut env); - // Hosted mode: NEST the redirect result under `redirect` in the scan - // object above (like vendored mode's `vendor` block). + // Hosted mode: the redirect engine records its events into this + // envelope and prints it. if hosted { return run_redirect( &args, @@ -2666,7 +2640,7 @@ async fn run_scan( &all_packages_with_patches, can_access_paid_patches, &policy, - Some(result), + Some(env), telemetry, npm_crawl.as_ref(), &recorded, @@ -2678,11 +2652,11 @@ async fn run_scan( .await; } - // The additive `redirectState` block rides every report-only and - // agent `--json` envelope. Hosted and vendored runs are excluded: - // both may rewrite the ledger mid-run, so a pre-run snapshot would - // go stale. The live-wiring probe runs ONCE here and is shared with - // the agent-flow warning below. + // `redirectState` rides every report-only and agent `--json` + // envelope. Hosted and vendored runs are excluded: both may rewrite + // the lockfiles mid-run, so a pre-run snapshot would go stale. The + // live-wiring probe runs ONCE here and is shared with the agent-flow + // warning below. let hosted_retained = if vendor { Vec::new() } else { @@ -2690,19 +2664,20 @@ async fn run_scan( }; if !vendor { if let Some(state) = redirect_state_json(redirect_state, &hosted_retained) { - result["redirectState"] = state; + env.set_extra("redirectState", state); } } let dry = args.common.dry_run; let mut apply_code = 0i32; - // A report-only run selects nothing, but a severity floor or - // `enabled: false` still hides candidates; report them like the - // human arm does (the detail fetch runs only then). - if !apply && !vendor && policy.reports_selection() && !all_packages_with_patches.is_empty() - { - if let Err((code, message)) = discover_selected( + // Report-only: select nothing, but fetch the details exactly when + // the human arm does (`fetch_details`), so a total detail failure + // fails both outputs alike and `updates[]` comes from the same + // records; a severity floor or `enabled: false` still reports the + // candidates it hides. + if !apply && !vendor && fetch_details { + match discover_selected( &api_client, &all_packages_with_patches, can_access_paid_patches, @@ -2711,12 +2686,24 @@ async fn run_scan( false, false, telemetry, - Some(&mut result), + Some(&mut env), ) .await { - emit_discovery_error_json(&mut result, &message); - return code; + Ok(discovered) => { + classified_rows( + &mut stage, + &discovered, + &recorded, + batch_error_count > 0, + &all_packages_with_patches, + Some(&mut env), + ); + } + Err((code, message)) => { + emit_discovery_error_json(&mut env, &message); + return code; + } } } @@ -2731,13 +2718,13 @@ async fn run_scan( false, false, telemetry, - Some(&mut result), + Some(&mut env), ) .await { Ok(d) => d, Err((code, message)) => { - emit_discovery_error_json(&mut result, &message); + emit_discovery_error_json(&mut env, &message); return code; } }; @@ -2747,12 +2734,12 @@ async fn run_scan( &recorded, batch_error_count > 0, &all_packages_with_patches, - Some(&mut result), + Some(&mut env), ); // Vendor-owned and lockfile-only purls leave the selection as - // skip records BEFORE download (see `partition_agent_selection`); - // they cannot land, so they hold no rollout slot either. + // skips BEFORE download (see `partition_agent_selection`); they + // cannot land, so they hold no rollout slot either. let AgentSelection { kept, skip_records: vendored_records, @@ -2760,97 +2747,71 @@ async fn run_scan( .. } = partition_agent_selection(writers_of(&rows), &vendor_owned_purls, &lockfile_only); let selected = plan_kept_rows(&mut stage, rows, kept); + for event in vendored_records { + env.record(event); + } if dry { - // Synthesize the per-patch outcome without touching disk. + // Preview each patch without touching disk: `verified` for + // what a wet run would record (`oldUuid` on a replacement), + // `skipped` / `already_in_manifest` for the rest. let empty_manifest = PatchManifest::new(); let manifest_for_preview = existing_manifest.unwrap_or(&empty_manifest); - let mut patches: Vec = selected - .iter() - .map(|p| { - match crate::commands::agent_download::decide_patch_action( - manifest_for_preview, - &p.purl, - &p.uuid, - ) { - crate::commands::agent_download::PatchAction::Added => { - serde_json::json!({ - "purl": p.purl, "uuid": p.uuid, "action": "added", - }) - } - crate::commands::agent_download::PatchAction::Updated { old_uuid } => { - serde_json::json!({ - "purl": p.purl, "uuid": p.uuid, - "action": "updated", "oldUuid": old_uuid, - }) - } - crate::commands::agent_download::PatchAction::Skipped => { - serde_json::json!({ - "purl": p.purl, "uuid": p.uuid, "action": "skipped", - }) - } + for p in &selected { + let event = match crate::commands::agent_download::decide_patch_action( + manifest_for_preview, + &p.purl, + &p.uuid, + ) { + crate::commands::agent_download::PatchAction::Added => { + PatchEvent::new(PatchAction::Verified, p.purl.as_str()) } - }) - .collect(); - patches.extend(vendored_records.iter().cloned()); - let added = patches.iter().filter(|p| p["action"] == "added").count(); - let updated = patches.iter().filter(|p| p["action"] == "updated").count(); - let skipped = patches.iter().filter(|p| p["action"] == "skipped").count(); - result["apply"] = serde_json::json!({ - "found": selected.len() + vendored_records.len(), - "downloaded": 0, - "skipped": skipped, - "failed": 0, - "applied": 0, - "updated": updated, - "added": added, - "patches": patches, - "dryRun": true, - }); - } else if selected.is_empty() { - // Nothing left to download: a stable-shape `apply` carrying - // any skips, then fall through to GC if requested. - result["apply"] = serde_json::json!({ - "found": vendored_records.len(), - "downloaded": 0, - "skipped": vendored_records.len(), - "failed": 0, "applied": 0, "updated": 0, - "patches": vendored_records, - }); - } else { + crate::commands::agent_download::PatchAction::Updated { old_uuid } => { + PatchEvent::new(PatchAction::Verified, p.purl.as_str()) + .with_old_uuid(old_uuid) + } + crate::commands::agent_download::PatchAction::Skipped => { + PatchEvent::new(PatchAction::Skipped, p.purl.as_str()) + .with_reason(ALREADY_IN_MANIFEST, "already in manifest") + } + }; + env.record(event.with_uuid(p.uuid.as_str())); + } + } else if !selected.is_empty() { let params = download_params( &args, /*save_only=*/ false, /*json=*/ true, /*silent=*/ true, ); - let (code, apply_json) = download_and_apply_patches_with( + // The engine records into `env` and never prints: one JSON + // document per run, a hard engine error included. + apply_code = download_and_apply_patches_into( &selected, ¶ms, &download_run(&args, &api_client), + &mut env, ) .await; - apply_code = code; - let mut apply_obj = apply_json; - fold_vendored_skips_into_apply(&mut apply_obj, &vendored_records); - result["apply"] = apply_obj; - if apply_code != 0 { - result["status"] = serde_json::json!("partial_failure"); + if env.error.is_some() { + env.extra.remove("rollout"); + emit_scan(&env); + return apply_code; } } - // Cross-mode visibility: additive run-level warnings, never a - // status or exit-code change. + // Cross-mode visibility: run-level warnings, never a status or + // exit-code change. if !vendored_skip_purls.is_empty() { let detail = vendored_ownership_retained_detail(&vendored_skip_purls); if !args.common.silent { eprintln!("Warning: {detail}"); } - push_scan_json_warning(&mut result, VENDORED_OWNERSHIP_RETAINED, &detail); + env.warn(VENDORED_OWNERSHIP_RETAINED, detail); } if !hosted_retained.is_empty() { let detail = hosted_wiring_retained_detail(&hosted_retained); if !args.common.silent { eprintln!("Warning: {detail}"); } - push_scan_json_warning(&mut result, HOSTED_WIRING_RETAINED, &detail); + env.warn(HOSTED_WIRING_RETAINED, detail); } // --- Vendor path (if requested; --sync selects agent instead) --- } else if vendor { @@ -2867,7 +2828,7 @@ async fn run_scan( batch_error_count > 0, &mut stage, &policy, - &mut result, + &mut env, &manifest_path, &socket_dir, &scanned_purls, @@ -2881,35 +2842,35 @@ async fn run_scan( } // The GC and the VEX build below can write to stderr; the report- - // only arm has not flushed the scan event yet (the agent arm - // did, in `discover_selected`). + // only arm may not have flushed the scan event yet. telemetry.flush().await; // --- GC (post-apply, or standalone --prune GC-sweep) ------------- if prune { - result["gc"] = gc_json( + gc_into( &args.common, &manifest_path, &socket_dir, &scanned_purls, &vendored_purls, dry, + &mut env, ) .await; } - finish_rollout_json(&stage, &mut result); + finish_rollout_json(&stage, &mut env); let final_code = embed_vex_into_json( &args.common, &args.vex, &api_client, &manifest_path, apply_code, - &mut result, + &mut env, hosted, ) .await; - print_json(&result); + emit_scan(&env); return final_code; } @@ -2925,7 +2886,7 @@ async fn run_scan( // "nothing to apply" exit still runs the GC, vendored mode included: // its wet vendor step runs its own GC and never reaches this closure, // but the early exits (nothing patched, paid-only, nothing selected, - // `--dry-run`) must reconcile the ledger like the JSON arm (#1127). + // `--dry-run`) reconcile and preview like the JSON arm (#1127, #1062). let (args_ref, manifest_ref, socket_ref) = (&args, &manifest_path, &socket_dir); let client_ref: &ApiClient = &api_client; let (scanned_ref, vendored_ref) = (&scanned_purls, &vendored_purls); @@ -2962,17 +2923,9 @@ async fn run_scan( return finish_human(0).await; } - // Count downloadable patches: a free-tier org whose every offer is + // `downloadable_count` (above): a free-tier org whose every offer is // paid-tier has nothing any mode could select, so every human arm stops // below the table with the same paid-subscription line. - let downloadable_count = if can_access_paid_patches { - all_packages_with_patches.len() - } else { - all_packages_with_patches - .iter() - .filter(|pkg| pkg.patches.iter().any(|p| p.tier == "free")) - .count() - }; // The by-package records every arm selects from, fetched before the // table so its `[UPDATE]` markers are the same UPGRADE rows the @@ -2982,7 +2935,7 @@ async fn run_scan( // alike (#1062). A failed discovery still prints the table first; its // exit code is returned below it. let mut discovery_failure: Option = None; - let rows: Vec = if downloadable_count == 0 { + let rows: Vec = if !fetch_details { Vec::new() } else { match discover_selected( @@ -3332,7 +3285,7 @@ async fn run_scan( ) .await; Some( - preview_vendor_json( + preview_vendor( &args.common.cwd, &selected, &crate::commands::hosted_unwind::patch_server_origins(&args.common), @@ -3343,15 +3296,10 @@ async fn run_scan( } else { None }; - let refused = preview - .as_ref() - .and_then(|p| p["patches"].as_array()) - .map_or(0, |a| { - a.iter().filter(|p| p["action"] == "would_refuse").count() - }); + let refused = preview.as_ref().map_or(0, |p| p.refused_count()); println!("{}", render::dry_run_line(plan, refused)); if let Some(preview) = &preview { - print_dry_run_refusals(preview); + preview.print_refusals(); } } print_rollout_human(&stage, true, silent); @@ -5121,29 +5069,18 @@ mod tests { ); } - /// A failed embedded VEX's discovery diagnostics reach the scan JSON: - /// appended after existing `warnings[]` (layout refusals), or creating - /// the array; an empty list leaves the object untouched. + /// A failed embedded VEX's discovery diagnostics reach the scan + /// envelope's top-level `warnings[]`, after any already there; an empty + /// list adds nothing. #[test] fn vex_error_warnings_append_to_scan_json() { - let w = crate::json_envelope::RunWarning { - code: "lockfile_unparseable".to_string(), - detail: "pnpm-lock.yaml: bad".to_string(), - }; - let mut fresh = serde_json::json!({ "status": "error" }); - append_vex_error_warnings(&mut fresh, &[]); - assert!(fresh.get("warnings").is_none()); - append_vex_error_warnings(&mut fresh, std::slice::from_ref(&w)); - assert_eq!(fresh["warnings"][0]["code"], "lockfile_unparseable"); - - let mut existing = serde_json::json!({ "warnings": [{ "code": "pnp", "detail": "d" }] }); - append_vex_error_warnings(&mut existing, &[w]); - let codes: Vec<&str> = existing["warnings"] - .as_array() - .unwrap() - .iter() - .map(|v| v["code"].as_str().unwrap()) - .collect(); + let w = RunWarning::new("lockfile_unparseable", "pnpm-lock.yaml: bad"); + let mut env = Envelope::new(JsonCommand::Scan); + append_vex_error_warnings(&mut env, &[]); + assert!(env.to_value().get("warnings").is_none()); + env.warn("pnp", "d"); + append_vex_error_warnings(&mut env, std::slice::from_ref(&w)); + let codes: Vec<&str> = env.warnings.iter().map(|w| w.code.as_str()).collect(); assert_eq!(codes, ["pnp", "lockfile_unparseable"]); } } diff --git a/crates/socket-patch-cli/src/commands/scan/policy.rs b/crates/socket-patch-cli/src/commands/scan/policy.rs index 17f795c0d..417bd9e79 100644 --- a/crates/socket-patch-cli/src/commands/scan/policy.rs +++ b/crates/socket-patch-cli/src/commands/scan/policy.rs @@ -458,12 +458,6 @@ impl ScanPolicy { .collect() } - /// Whether selection can filter anything (a floor, or patching - /// disabled): report-only runs select only for the report then. - pub(crate) fn reports_selection(&self) -> bool { - !self.policy.enabled() || self.policy.min_severity().0.is_some() - } - /// Whether the policy filtered this whole project root. pub(crate) fn root_excluded(&self) -> bool { self.root_verdict.is_err() @@ -670,25 +664,16 @@ impl ScanPolicy { } /// Put the `policy` block and the policy warnings on a scan `--json` - /// result (idempotent: the block is rebuilt, warnings added once). - pub(crate) fn fold_into_json(&self, result: &mut serde_json::Value) { - result["policy"] = self.json(); - let warnings = result - .as_object_mut() - .expect("scan JSON result is an object") - .entry("warnings") - .or_insert_with(|| serde_json::json!([])); - if let Some(arr) = warnings.as_array_mut() { - for w in self.warnings.iter().chain(&self.shared_copy_warnings()) { - let present = arr - .iter() - .any(|e| e["code"] == w.code && e["detail"] == w.detail.as_str()); - if !present { - arr.push(serde_json::json!({ "code": w.code, "detail": w.detail })); - } - } - if arr.is_empty() { - result.as_object_mut().map(|o| o.remove("warnings")); + /// envelope (idempotent: the block is rebuilt, warnings added once). + pub(crate) fn fold_into_envelope(&self, env: &mut crate::json_envelope::Envelope) { + env.set_extra("policy", self.json()); + for w in self.warnings.iter().chain(&self.shared_copy_warnings()) { + let present = env + .warnings + .iter() + .any(|e| e.code == w.code && e.detail == w.detail); + if !present { + env.warn(w.code, w.detail.clone()); } } } @@ -811,25 +796,6 @@ fn shared_copy_detail(purl: &str, projects: &BTreeSet) -> String { ) } -/// The JSON error object for a policy file that cannot be honored: scan's -/// error shape, `error: {code, message}`. -pub(crate) fn policy_error_json(err: &PolicyError, paths: &[String]) -> serde_json::Value { - serde_json::json!({ - "status": "error", - "error": { "code": err.code(), "message": err.to_string() }, - "scannedPackages": 0, - "lockfileOnlyPackages": 0, - "packagesWithPatches": 0, - "totalPatches": 0, - "freePatches": 0, - "paidPatches": 0, - "canAccessPaidPatches": false, - "packages": [], - "updates": [], - "paths": paths, - }) -} - /// `get`'s `policy_bypassed` warnings: `get` is explicit intent, so it /// ignores the policy, but says when the repo's socket.yml would have /// filtered what it is about to patch. Never fails: an unreadable or 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 17e989b5e..20a35bcc7 100644 --- a/crates/socket-patch-cli/src/commands/scan/vendor_flow.rs +++ b/crates/socket-patch-cli/src/commands/scan/vendor_flow.rs @@ -28,6 +28,7 @@ use std::path::Path; use std::time::Duration; use crate::args::GlobalArgs; +use crate::commands::agent_download::tag_mode; use crate::commands::agent_download::{ download_patch_records_reusing, DetachedDownload, DownloadParams, }; @@ -39,14 +40,18 @@ use crate::commands::vendor::{ use crate::commands::vendored_backend::{records_manifest, ApplyRequest, VendoredBackend}; use crate::commands::vlt_preflight::{vlt_refusal_for, vlt_vendor_preflight_selected}; use crate::ecosystem_dispatch::NpmCrawlSnapshot; -use crate::json_envelope::{Command as EnvelopeCommand, Envelope}; -use crate::ui::{plural, print_json}; +use crate::json_envelope::{ + Command as EnvelopeCommand, Envelope, EnvelopeError, PatchAction, PatchEvent, RunWarning, + Status, +}; +use crate::ui::plural; -use super::gc::{gc_json, print_gc_vendored_line, run_apply_gc}; +use super::gc::{gc_into, print_gc_vendored_line, run_apply_gc}; use super::rollout::Stage; use super::{ classified_rows, discover_selected, download_params, embed_vex_into_json, - emit_discovery_error_json, finish_rollout_json, push_run_warning, writers_of, ScanArgs, + emit_discovery_error_json, emit_scan, finish_rollout_json, push_run_warning, writers_of, + ScanArgs, }; /// Run-level warning: a `.socket/manifest.json` record for a purl the @@ -66,33 +71,127 @@ type VendorStepError = (&'static str, String, Option>); /// [`VendorStepError`]. type VendorStepResult = Result<(bool, Envelope), VendorStepError>; +/// One selected patch's verdict in the vendored dry-run preview. +#[derive(Debug, Clone)] +pub(crate) enum PreviewVerdict { + /// The wet run's preflight would refuse it before any download. + WouldRefuse { code: String, detail: String }, + /// The vendor ledger already holds it at this uuid. + AlreadyVendored, + /// The ledger holds the purl at another uuid: a re-vendor. + WouldRevendor { old_uuid: String }, + /// Not vendored yet. + WouldVendor, +} + +/// One row of [`VendorPreview`]. +#[derive(Debug, Clone)] +pub(crate) struct PreviewRow { + pub(crate) purl: String, + pub(crate) uuid: String, + pub(crate) verdict: PreviewVerdict, + /// Symlinked wiring files the wet run's commit refuses to rename over + /// (see [`symlinked_wiring_warnings`]); only on vendor/revendor rows. + pub(crate) warnings: Vec, +} + +/// The vendored dry-run preview, purl-sorted (see [`preview_vendor`]). +#[derive(Debug, Clone, Default)] +pub(crate) struct VendorPreview { + pub(crate) rows: Vec, +} + +impl VendorPreview { + /// How many rows the wet run would refuse. + pub(crate) fn refused_count(&self) -> usize { + self.rows + .iter() + .filter(|r| matches!(r.verdict, PreviewVerdict::WouldRefuse { .. })) + .count() + } + + /// Record the preview into a dry-run envelope, every event carrying + /// `details.mode: "vendored"`: `verified` for a patch the wet run would + /// vendor (`oldUuid` on a re-vendor), `skipped` / `already_vendored` + /// for one in sync, and `skipped` with the preflight's code for one the + /// wet run would refuse (never a status or exit change). Each symlink + /// advisory joins the top-level `warnings`, prefixed with its purl. + pub(crate) fn record_into(&self, env: &mut Envelope) { + for row in &self.rows { + let event = match &row.verdict { + PreviewVerdict::WouldRefuse { code, detail } => { + PatchEvent::new(PatchAction::Skipped, row.purl.as_str()) + .with_reason(code.as_str(), detail.as_str()) + } + PreviewVerdict::AlreadyVendored => { + PatchEvent::new(PatchAction::Skipped, row.purl.as_str()).with_reason( + "already_vendored", + "artifact and wiring already in sync for this patch uuid", + ) + } + PreviewVerdict::WouldRevendor { old_uuid } => { + PatchEvent::new(PatchAction::Verified, row.purl.as_str()) + .with_old_uuid(old_uuid.as_str()) + } + PreviewVerdict::WouldVendor => { + PatchEvent::new(PatchAction::Verified, row.purl.as_str()) + } + }; + env.record(tag_mode( + event.with_uuid(row.uuid.as_str()), + Some("vendored"), + )); + env.warnings.extend( + row.warnings.iter().map(|w| { + RunWarning::new(w.code.as_str(), format!("{}: {}", row.purl, w.detail)) + }), + ); + } + } + + /// 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) { + for row in &self.rows { + if let PreviewVerdict::WouldRefuse { code, detail } = &row.verdict { + println!(" [would-refuse] {} ({code}): {detail}", row.purl); + } + } + for row in &self.rows { + for w in &row.warnings { + println!(" [warning] {} ({}): {}", row.purl, w.code, w.detail); + } + } + } +} + /// Dry-run preview for `scan --mode vendored` (and `get … --mode vendored /// --dry-run`): classify each selected patch against the vendor ledger -/// without writing anything or touching the network beyond discovery. -/// Action values are part of the CLI contract: `would_vendor` (no ledger -/// entry), `already_vendored` (entry at this uuid), `would_revendor` + -/// `oldUuid` (entry at an older uuid), and — additive — `would_refuse` + -/// `errorCode` + `error` for npm purls the wet run's Bun, vlt or npm -/// package-lock preflight +/// without writing anything or touching the network beyond discovery +/// (see [`PreviewVerdict`]): would-refuse for npm purls the wet run's Bun, +/// vlt or npm package-lock preflight /// ([`crate::commands::bun_preflight::BunVendorRefusal`], /// [`crate::commands::vlt_preflight`], [`npm_lock_refusal`]) would refuse /// before any download, or whose hosted pnpm pin the takeover would fail /// to replace ([`hosted_pnpm_refusals`]: the pnpm backend's lock-text /// refusal, #853). `origins` are the run's `--patch-server-url` origins. /// The preview stays a ledger classification otherwise (engine refusals -/// outside the preflights are not predicted), and `would_refuse` never +/// outside the preflights are not predicted), and a would-refuse never /// flips the run's status or exit code. The preflights (the only disk /// access besides the ledger) run only when the selection holds an npm purl. /// `takeover_refusals` adds the hosted→vendored takeover refusals the /// caller resolved (the gem gates of /// [`crate::commands::vendor::gem_takeover_preview_refusals`]), keyed by -/// the selected purl, as `would_refuse` rows too. -pub(crate) async fn preview_vendor_json( +/// the selected purl, as would-refuse rows too. +pub(crate) async fn preview_vendor( cwd: &Path, selected: &[PatchSearchResult], origins: &[String], takeover_refusals: &HashMap, -) -> serde_json::Value { +) -> VendorPreview { // The ledger load outcome reaches the preflight AS a result, so an // unreadable ledger previews as `vendor_state_unreadable` rather than // as an empty ledger. @@ -104,82 +203,101 @@ pub(crate) async fn preview_vendor_json( let npm_lock_refusal = npm_lock_refusal(cwd, selected).await; let state = state.unwrap_or_default(); let pnpm_refusals = hosted_pnpm_refusals(cwd, selected, origins, &state).await; - let mut patches: Vec = selected + let refuse = |code: &str, detail: &str| PreviewVerdict::WouldRefuse { + code: code.to_string(), + detail: detail.to_string(), + }; + let mut rows: Vec = selected .iter() - .map(|p| match lookup_entry(&state.entries, &p.purl) { - // Refusal takes priority: a preserved ledger can name this - // UUID even after rollback has removed its live wiring. - _ if refusal.as_ref().is_some_and(|r| r.applies_to(&p.purl)) => { - let r = refusal.as_ref().expect("checked by the guard"); - serde_json::json!({ - "purl": p.purl, "uuid": p.uuid, "action": "would_refuse", - "errorCode": r.code, "error": r.detail, - }) - } - _ if vlt_refusal_for(&vlt_refusals, &p.purl).is_some() => { - let r = vlt_refusal_for(&vlt_refusals, &p.purl).expect("checked by the guard"); - serde_json::json!({ - "purl": p.purl, "uuid": p.uuid, "action": "would_refuse", - "errorCode": r.code, "error": r.detail, - }) - } - _ if pnpm_refusals.contains_key(&p.purl) => { - let (code, detail) = &pnpm_refusals[&p.purl]; - serde_json::json!({ - "purl": p.purl, "uuid": p.uuid, "action": "would_refuse", - "errorCode": code, "error": detail, - }) - } - _ if p.purl.starts_with("pkg:npm/") && npm_lock_refusal.is_some() => { - let (code, detail) = npm_lock_refusal.as_ref().expect("checked by the guard"); - serde_json::json!({ - "purl": p.purl, "uuid": p.uuid, "action": "would_refuse", - "errorCode": code, "error": detail, - }) - } - _ if takeover_refusals.contains_key(&p.purl) => { - let (code, detail) = &takeover_refusals[&p.purl]; - serde_json::json!({ - "purl": p.purl, "uuid": p.uuid, "action": "would_refuse", - "errorCode": code, "error": detail, - }) + .map(|p| { + let verdict = match lookup_entry(&state.entries, &p.purl) { + // Refusal takes priority: a preserved ledger can name this + // UUID even after rollback has removed its live wiring. + _ if refusal.as_ref().is_some_and(|r| r.applies_to(&p.purl)) => { + let r = refusal.as_ref().expect("checked by the guard"); + refuse(r.code, &r.detail) + } + _ if vlt_refusal_for(&vlt_refusals, &p.purl).is_some() => { + let r = vlt_refusal_for(&vlt_refusals, &p.purl).expect("checked by the guard"); + refuse(r.code, &r.detail) + } + _ if pnpm_refusals.contains_key(&p.purl) => { + let (code, detail) = &pnpm_refusals[&p.purl]; + refuse(code, detail) + } + _ if p.purl.starts_with("pkg:npm/") && npm_lock_refusal.is_some() => { + let (code, detail) = npm_lock_refusal.as_ref().expect("checked by the guard"); + refuse(code, detail) + } + _ if takeover_refusals.contains_key(&p.purl) => { + let (code, detail) = &takeover_refusals[&p.purl]; + refuse(code, detail) + } + Some(e) if e.uuid == p.uuid => PreviewVerdict::AlreadyVendored, + Some(e) => PreviewVerdict::WouldRevendor { + old_uuid: e.uuid.clone(), + }, + None => PreviewVerdict::WouldVendor, + }; + let warnings = match verdict { + PreviewVerdict::WouldRevendor { .. } | PreviewVerdict::WouldVendor => { + symlinked_wiring_warnings(cwd, &p.purl) + .into_iter() + .map(|w| RunWarning::new(w.code, w.detail)) + .collect() + } + _ => Vec::new(), + }; + PreviewRow { + purl: p.purl.clone(), + uuid: p.uuid.clone(), + verdict, + warnings, } - Some(e) if e.uuid == p.uuid => serde_json::json!({ - "purl": p.purl, "uuid": p.uuid, "action": "already_vendored", - }), - Some(e) => with_symlink_warnings( - cwd, - &p.purl, - serde_json::json!({ - "purl": p.purl, "uuid": p.uuid, - "action": "would_revendor", "oldUuid": e.uuid, - }), - ), - None => with_symlink_warnings( - cwd, - &p.purl, - serde_json::json!({ - "purl": p.purl, "uuid": p.uuid, "action": "would_vendor", - }), - ), }) .collect(); - patches.sort_by(|a, b| a["purl"].as_str().cmp(&b["purl"].as_str())); - serde_json::json!({ "dryRun": true, "patches": patches }) + rows.sort_by(|a, b| a.purl.cmp(&b.purl)); + VendorPreview { rows } } -/// A `would_vendor` / `would_revendor` preview row, plus a `warnings` list -/// naming each symlinked wiring file the wet run's commit refuses to rename -/// over (see [`symlinked_wiring_warnings`]); no key when there are none. -fn with_symlink_warnings(cwd: &Path, purl: &str, mut row: serde_json::Value) -> serde_json::Value { - let warnings: Vec = symlinked_wiring_warnings(cwd, purl) - .into_iter() - .map(|w| serde_json::json!({ "code": w.code, "detail": w.detail })) - .collect(); - if !warnings.is_empty() { - row["warnings"] = serde_json::Value::Array(warnings); +/// Fold the vendor engine's envelope into a `scan` / `get` envelope (no +/// nested envelope): its events — each tagged `details.mode: "vendored"` +/// — are appended with their summary counts as the engine counted them +/// (its per-package advisories are uncounted `skipped` events, as in +/// `vendor`), its warnings and sidecars join the outer ones, and a failed +/// or partially failed engine run marks the outer run `partialFailure`. +/// The engine's own top-level error (if any) becomes the outer run's +/// `error` (a caller that aborts with its own error overrides it). +pub(crate) fn merge_vendor_envelope(env: &mut Envelope, venv: Envelope) { + let (to, from) = (&mut env.summary, &venv.summary); + to.discovered += from.discovered; + to.downloaded += from.downloaded; + to.applied += from.applied; + to.updated += from.updated; + to.skipped += from.skipped; + to.failed += from.failed; + to.removed += from.removed; + to.verified += from.verified; + to.rebuilt += from.rebuilt; + to.rolled_back += from.rolled_back; + env.events.extend( + venv.events + .into_iter() + .map(|e| tag_mode(e, Some("vendored"))), + ); + env.warnings.extend(venv.warnings); + env.sidecars.extend(venv.sidecars); + // A hard engine error (an unreadable vendor ledger, a refused group + // commit, `vendor_commit_failed`) keeps its `{code, message}`: the + // engine returns `Ok` with the error marked, so dropping it here would + // leave a bare `partialFailure` with no code. + if let Some(error) = venv.error { + env.mark_error(error); + } else if venv.summary.failed > 0 + || matches!(venv.status, Status::PartialFailure | Status::Error) + { + env.mark_partial_failure(); } - row } /// The purls of `selected` the wet run's Bun, vlt or npm package-lock @@ -271,36 +389,6 @@ async fn hosted_pnpm_refusals( socket_patch_core::vendor::pnpm_takeover_lock_text_refusals(cwd, &candidates).await } -/// Human rendering of the vendored dry-run preview's `would_refuse` records -/// (see [`preview_vendor_json`]): 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_dry_run_refusals(preview: &serde_json::Value) { - let Some(patches) = preview["patches"].as_array() else { - return; - }; - for p in patches.iter().filter(|p| p["action"] == "would_refuse") { - println!( - " [would-refuse] {} ({}): {}", - p["purl"].as_str().unwrap_or_default(), - p["errorCode"].as_str().unwrap_or_default(), - p["error"].as_str().unwrap_or_default() - ); - } - for p in patches { - for w in p["warnings"].as_array().into_iter().flatten() { - println!( - " [warning] {} ({}): {}", - p["purl"].as_str().unwrap_or_default(), - w["code"].as_str().unwrap_or_default(), - w["detail"].as_str().unwrap_or_default() - ); - } - } -} - /// Everything the vendor step takes: the in-memory `records` to vendor /// (from [`download_patch_records_reusing`] or `get`'s download phase), the /// blob `seed` that phase fetched (so the stager fetches no view twice), @@ -575,8 +663,8 @@ async fn migrate_legacy_manifest_records( } /// The `scan --mode vendored` JSON path: discovery → (dry-run preview | download -/// → vendor engine → GC → embedded VEX) → print `result` → exit code. -/// The dry-run arm skips the VEX embed (emitting a `vex.skipped` marker +/// → vendor engine → GC → embedded VEX) → print `env` → exit code. +/// The dry-run arm skips the VEX embed (a `vex_skipped_dry_run` warning /// instead): a dry run vendors nothing, so there is no state to attest. /// /// Extracted from `run` (and called through `Box::pin`) so its temporaries @@ -593,7 +681,7 @@ async fn run_vendor_json_path( batch_failed: bool, stage: &mut Stage, policy: &super::policy::ScanPolicy, - result: &mut serde_json::Value, + env: &mut Envelope, manifest_path: &Path, socket_dir: &Path, scanned_purls: &HashSet, @@ -618,13 +706,13 @@ async fn run_vendor_json_path( false, false, telemetry, - Some(&mut *result), + Some(&mut *env), ) .await { Ok(d) => d, Err((code, message)) => { - emit_discovery_error_json(result, &message); + emit_discovery_error_json(env, &message); return code; } }; @@ -634,7 +722,7 @@ async fn run_vendor_json_path( recorded, batch_failed, all_packages_with_patches, - Some(&mut *result), + Some(&mut *env), ); // The planning pass: a patch the preflight refuses holds no slot (it // still reaches the engine, which reports the refusal). @@ -647,47 +735,51 @@ async fn run_vendor_json_path( .into_iter() .filter(|p| !deferred.contains(&(p.purl.clone(), p.uuid.clone()))) .collect(); - finish_rollout_json(stage, result); + finish_rollout_json(stage, env); if args.common.dry_run { // No downloads, no backends: classify against the ledger - // and preview the GC, exactly like agent mode's dry run. + // and preview the GC, exactly like agent mode's dry run (and the + // human arm, which previews the same GC through `finish_human`). let takeover = crate::commands::vendor::gem_takeover_preview_refusals( &args.common, selected.iter().map(|p| p.purl.as_str()), ) .await; - result["vendor"] = - preview_vendor_json(&args.common.cwd, &selected, &origins, &takeover).await; + preview_vendor(&args.common.cwd, &selected, &origins, &takeover) + .await + .record_into(env); if prune { - result["gc"] = gc_json( + gc_into( &args.common, manifest_path, socket_dir, scanned_purls, vendored_purls, true, + env, ) .await; } // Embedded VEX is skipped on a dry run (nothing was vendored to - // attest); the marker keeps the request visible to JSON consumers. + // attest); the warning keeps the request visible. if args.vex.vex.is_some() { - result["vex"] = serde_json::json!({ "skipped": true, "reason": "dry_run" }); + env.warnings.push(super::vex_dry_run_warning()); } - print_json(result); + emit_scan(env); return 0; } - // 1) Download phase: fetch the selected records in memory. The - // manifest is never written; `download.detached: true` stays on - // the sub-object for consumers that keyed on it. + // 1) Download phase: fetch the selected records in memory (the + // manifest is never written); its events join the envelope with + // `details.mode: "vendored"`. let params = download_params( args, /*save_only=*/ true, /*json=*/ true, /*silent=*/ true, ); - let (dl_code, dl_json, records) = - boxed_download_patch_records(&selected, ¶ms, api_client, HashMap::new(), prior).await; - result["download"] = dl_json; + let (dl_code, _, records) = + boxed_download_patch_records(&selected, ¶ms, api_client, HashMap::new(), prior) + .await + .into_envelope(env); // 2) The vendor engine, under the same lock as apply/vendor (a no-op // that creates nothing when there is nothing to vendor). @@ -704,31 +796,24 @@ async fn run_vendor_json_path( .await { Ok((has_errors, venv)) => { - result["vendor"] = - serde_json::to_value(&venv).unwrap_or_else(|_| serde_json::json!({})); + merge_vendor_envelope(env, venv); i32::from(has_errors) } Err((code, message, venv)) => { // A step that ran (and died at staging) hands back its demoted - // envelope; it must reach the JSON consumer even though the run - // aborts here. A lock failure carries none — no `vendor` key. + // envelope; its events must reach the JSON consumer even though + // the run aborts here. A lock failure carries none. if let Some(venv) = venv { - result["vendor"] = - serde_json::to_value(&*venv).unwrap_or_else(|_| serde_json::json!({})); - } - crate::json_envelope::set_error( - result, - crate::json_envelope::EnvelopeError::new(code, message), - ); - if let Some(obj) = result.as_object_mut() { - obj.remove("rollout"); + merge_vendor_envelope(env, *venv); } - print_json(result); + env.mark_error(EnvelopeError::new(code, message)); + env.extra.remove("rollout"); + emit_scan(env); return 1; } }; if vendor_code != 0 { - result["status"] = serde_json::json!("partial_failure"); + env.mark_partial_failure(); } // 3) GC AFTER the vendor step (when --prune), like the apply arm: the @@ -736,13 +821,14 @@ async fn run_vendor_json_path( // prune, and running it last lets the sweep reclaim what this run // orphaned (a migrated legacy record's blobs, a superseded uuid dir). if prune { - result["gc"] = gc_json( + gc_into( &args.common, manifest_path, socket_dir, scanned_purls, vendored_purls, false, + env, ) .await; } @@ -753,11 +839,11 @@ async fn run_vendor_json_path( api_client, manifest_path, vendor_code, - result, + env, false, ) .await; - print_json(result); + emit_scan(env); final_code } @@ -791,10 +877,11 @@ async fn run_vendor_interactive_path( plural(selected.len(), "patch", "patches") ); } - let (dl_code, dl_json, records) = + let download = boxed_download_patch_records(selected, params, api_client, prefetched, prior).await; + let (dl_code, records) = (download.code, download.records); // Patches the download phase could not get (it reported each one). - let download_failed = dl_json["failed"].as_u64().unwrap_or(0); + let download_failed = download.failed as u64; // The vendor step is a silent no-op on an empty record set (it can't // know why it is empty); this arm can. let nothing_to_vendor = records.is_empty(); @@ -877,8 +964,8 @@ pub(crate) fn format_vendor_step_error(code: &str, message: &str) -> String { out } -/// Partition purls matching `skip` out of the selected set and pre-render -/// their skip records (sorted by purl) with the contract `error_code`. +/// Partition purls matching `skip` out of the selected set and build their +/// `skipped` events (sorted by purl) with the contract `error_code`. /// Two skip classes ride this, both removed BEFORE download: /// /// * `"vendored"` — the patch is consumed from the committed artifact, and @@ -895,47 +982,24 @@ pub(super) fn partition_skipped_selected( selected: Vec, skip: impl Fn(&str) -> bool, error_code: &str, -) -> (Vec, Vec) { +) -> (Vec, Vec) { + let reason = match error_code { + "vendored" => "managed by `socket-patch vendor`; skipped before download", + _ => "not installed (lockfile-only); `scan --mode vendored` fetches it pristine", + }; let (skipped, kept): (Vec<_>, Vec<_>) = selected.into_iter().partition(|p| skip(&p.purl)); - let mut records: Vec = skipped + let mut records: Vec = skipped .iter() .map(|p| { - serde_json::json!({ - "purl": p.purl, "uuid": p.uuid, - "action": "skipped", "errorCode": error_code, - }) + PatchEvent::new(PatchAction::Skipped, p.purl.as_str()) + .with_uuid(p.uuid.as_str()) + .with_reason(error_code, reason) }) .collect(); - records.sort_by(|a, b| a["purl"].as_str().cmp(&b["purl"].as_str())); + records.sort_by(|a, b| a.purl.cmp(&b.purl)); (kept, records) } -/// Fold the pre-download vendored skips into the apply report returned by -/// `download_and_apply_patches_with`: they were "found" by discovery and -/// skipped here, never downloaded. Also strips the inner `status` (scan -/// recomputes its own). Plain fn for the same poll-frame reason as -/// [`partition_skipped_selected`]. -pub(super) fn fold_vendored_skips_into_apply( - apply_obj: &mut serde_json::Value, - vendored_records: &[serde_json::Value], -) { - let Some(obj) = apply_obj.as_object_mut() else { - return; - }; - obj.remove("status"); - if vendored_records.is_empty() { - return; - } - let n = vendored_records.len() as u64; - for key in ["found", "skipped"] { - let bumped = obj.get(key).and_then(|v| v.as_u64()).unwrap_or(0) + n; - obj.insert(key.to_string(), serde_json::json!(bumped)); - } - if let Some(patches) = obj.get_mut("patches").and_then(|p| p.as_array_mut()) { - patches.extend(vendored_records.iter().cloned()); - } -} - /// Construct the (large) vendor-JSON-path future on THIS transient frame /// and hand `run` only the heap pointer. `Box::pin(run_vendor_json_path(..))` /// inline in `run` would materialize the future (which embeds the whole @@ -952,7 +1016,7 @@ pub(super) fn boxed_vendor_json_path<'a>( batch_failed: bool, stage: &'a mut Stage, policy: &'a super::policy::ScanPolicy, - result: &'a mut serde_json::Value, + env: &'a mut Envelope, manifest_path: &'a Path, socket_dir: &'a Path, scanned_purls: &'a HashSet, @@ -972,7 +1036,7 @@ pub(super) fn boxed_vendor_json_path<'a>( batch_failed, stage, policy, - result, + env, manifest_path, socket_dir, scanned_purls, @@ -1289,11 +1353,92 @@ mod migration_tests { #[cfg(test)] mod preview_tests { - use super::preview_vendor_json; + use super::{preview_vendor, PreviewVerdict}; + use crate::json_envelope::{Command, Envelope}; use socket_patch_core::api::types::PatchSearchResult; use std::collections::HashMap; use std::path::Path; + /// The typed preview as a compact classification document: one row per + /// selected patch, its verdict as `action` (`would_refuse` + + /// `errorCode`/`error`, `already_vendored`, `would_revendor` + + /// `oldUuid`, `would_vendor`) and its symlink advisories as `warnings` + /// — test scaffolding that pins the verdicts; the envelope the preview + /// records is pinned by `preview_records_verdicts_as_vendored_events`. + async fn preview_vendor_json( + cwd: &Path, + selected: &[PatchSearchResult], + origins: &[String], + takeover: &HashMap, + ) -> serde_json::Value { + let preview = preview_vendor(cwd, selected, origins, takeover).await; + let patches: Vec = preview + .rows + .iter() + .map(|r| { + let mut row = match &r.verdict { + PreviewVerdict::WouldRefuse { code, detail } => serde_json::json!({ + "purl": r.purl, "uuid": r.uuid, "action": "would_refuse", + "errorCode": code, "error": detail, + }), + PreviewVerdict::AlreadyVendored => serde_json::json!({ + "purl": r.purl, "uuid": r.uuid, "action": "already_vendored", + }), + PreviewVerdict::WouldRevendor { old_uuid } => serde_json::json!({ + "purl": r.purl, "uuid": r.uuid, "action": "would_revendor", + "oldUuid": old_uuid, + }), + PreviewVerdict::WouldVendor => serde_json::json!({ + "purl": r.purl, "uuid": r.uuid, "action": "would_vendor", + }), + }; + if !r.warnings.is_empty() { + row["warnings"] = serde_json::to_value(&r.warnings).unwrap(); + } + row + }) + .collect(); + serde_json::json!({ "dryRun": true, "patches": patches }) + } + + /// The preview recorded into a dry-run envelope: every verdict is an + /// event tagged `details.mode: "vendored"` (`verified` to vendor, + /// `skipped` in sync or refused, with the code), counted in `summary`, + /// and never a failure. + #[tokio::test] + async fn preview_records_verdicts_as_vendored_events() { + let tmp = tempfile::tempdir().unwrap(); + seed_entry(tmp.path(), NPM, UUID); + let preview = preview_vendor( + tmp.path(), + &[sel(UUID, NPM), sel(UUID, PYPI)], + &[], + &HashMap::from([( + PYPI.to_string(), + ("vendor_test_refusal", "refused".to_string()), + )]), + ) + .await; + let mut env = Envelope::new(Command::Scan); + env.dry_run = true; + preview.record_into(&mut env); + let v = env.to_value(); + assert_eq!( + v["events"], + serde_json::json!([ + {"action": "skipped", "purl": NPM, "uuid": UUID, + "reason": "artifact and wiring already in sync for this patch uuid", + "errorCode": "already_vendored", "details": {"mode": "vendored"}}, + {"action": "skipped", "purl": PYPI, "uuid": UUID, "reason": "refused", + "errorCode": "vendor_test_refusal", "details": {"mode": "vendored"}}, + ]), + "{v}" + ); + assert_eq!(v["summary"]["skipped"], 2); + assert_eq!(v["status"], "success"); + assert_eq!(preview.refused_count(), 1); + } + 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"; @@ -1630,107 +1775,6 @@ mod preview_tests { } } -#[cfg(test)] -mod fold_vendored_skips_tests { - use super::fold_vendored_skips_into_apply; - - /// A pre-rendered vendored-skip record, shaped exactly like - /// [`super::partition_skipped_selected`]'s output. - fn record(purl: &str) -> serde_json::Value { - serde_json::json!({ - "purl": purl, - "uuid": "11111111-1111-4111-8111-111111111111", - "action": "skipped", - "errorCode": "vendored", - }) - } - - /// The count-consistency contract: every pre-download vendored skip - /// was "found" by discovery and "skipped" here, so both counters bump - /// by the record count, the records land appended after the download - /// phase's own entries, and every other counter is left alone. - #[test] - fn fold_bumps_found_and_skipped_and_appends_records() { - let mut apply_obj = serde_json::json!({ - "status": "partialFailure", - "found": 2, - "downloaded": 1, - "skipped": 1, - "failed": 1, - "applied": 1, - "patches": [{ "purl": "pkg:npm/a@1.0.0" }], - }); - let records = [record("pkg:npm/b@1.0.0"), record("pkg:npm/c@1.0.0")]; - - fold_vendored_skips_into_apply(&mut apply_obj, &records); - - let obj = apply_obj.as_object().expect("still an object"); - assert!( - !obj.contains_key("status"), - "the inner status is scan's to recompute: {apply_obj}" - ); - assert_eq!(apply_obj["found"], 4, "{apply_obj}"); - assert_eq!(apply_obj["skipped"], 3, "{apply_obj}"); - assert_eq!(apply_obj["downloaded"], 1, "untouched: {apply_obj}"); - assert_eq!(apply_obj["failed"], 1, "untouched: {apply_obj}"); - assert_eq!(apply_obj["applied"], 1, "untouched: {apply_obj}"); - let patches = apply_obj["patches"].as_array().expect("patches array"); - assert_eq!(patches.len(), 3, "{apply_obj}"); - assert_eq!(patches[0]["purl"], "pkg:npm/a@1.0.0", "{apply_obj}"); - assert_eq!(patches[1], records[0], "appended in order: {apply_obj}"); - assert_eq!(patches[2], records[1], "appended in order: {apply_obj}"); - } - - /// Missing counters default to zero before the bump (the - /// `unwrap_or(0)` fallback) — the keys are CREATED, not skipped, so a - /// minimal download report still ends up count-consistent. - #[test] - fn fold_missing_counts_default_to_zero() { - let mut apply_obj = serde_json::json!({ "patches": [] }); - let records = [record("pkg:npm/b@1.0.0")]; - - fold_vendored_skips_into_apply(&mut apply_obj, &records); - - assert_eq!(apply_obj["found"], 1, "{apply_obj}"); - assert_eq!(apply_obj["skipped"], 1, "{apply_obj}"); - let patches = apply_obj["patches"].as_array().expect("patches array"); - assert_eq!(patches.len(), 1, "{apply_obj}"); - assert_eq!(patches[0], records[0], "{apply_obj}"); - } - - /// A non-object report (defensive arm) is left byte-identical — no - /// panic, no partial mutation. - #[test] - fn fold_non_object_report_is_a_noop() { - let mut apply_obj = serde_json::json!("nope"); - fold_vendored_skips_into_apply(&mut apply_obj, &[record("pkg:npm/b@1.0.0")]); - assert_eq!(apply_obj, serde_json::json!("nope")); - } - - /// With zero records the fold only strips the inner `status`: counts - /// and patches stay exactly as the download phase reported them. - #[test] - fn fold_empty_records_only_strips_status() { - let mut apply_obj = serde_json::json!({ - "status": "success", - "found": 2, - "skipped": 1, - "patches": [{ "purl": "pkg:npm/a@1.0.0" }], - }); - fold_vendored_skips_into_apply(&mut apply_obj, &[]); - assert_eq!( - apply_obj, - serde_json::json!({ - "found": 2, - "skipped": 1, - "patches": [{ "purl": "pkg:npm/a@1.0.0" }], - }), - "only the status may change on the zero-record fold" - ); - } -} - -/// Exact-string tests for the scan-driven vendor step's human lines. #[cfg(test)] mod ui_format_tests { use super::{format_nothing_vendored, format_vendor_step_error}; @@ -1777,3 +1821,37 @@ mod ui_format_tests { ); } } + +#[cfg(test)] +mod merge_tests { + use super::merge_vendor_envelope; + use crate::json_envelope::{Command, Envelope, EnvelopeError, Status}; + + /// The engine marks a hard error (e.g. `vendor_state_unreadable`) and + /// still returns `Ok`: the merged scan envelope must carry that + /// `{code, message}`, not a bare `partialFailure`. + #[test] + fn merge_carries_the_engine_error() { + let mut env = Envelope::new(Command::Scan); + let mut venv = Envelope::new(Command::Vendor); + venv.mark_error(EnvelopeError::new( + "vendor_state_unreadable", + "cannot read .socket/vendor/state.json", + )); + merge_vendor_envelope(&mut env, venv); + assert_eq!(env.status, Status::Error); + let error = env.error.expect("the engine error is carried"); + assert_eq!(error.code, "vendor_state_unreadable"); + } + + /// A partially failed engine run without an error stays partialFailure. + #[test] + fn merge_without_error_marks_partial_failure() { + let mut env = Envelope::new(Command::Scan); + let mut venv = Envelope::new(Command::Vendor); + venv.mark_partial_failure(); + merge_vendor_envelope(&mut env, venv); + assert_eq!(env.status, Status::PartialFailure); + assert!(env.error.is_none()); + } +} diff --git a/crates/socket-patch-cli/src/commands/vendor.rs b/crates/socket-patch-cli/src/commands/vendor.rs index 424b506f3..517a00f3a 100644 --- a/crates/socket-patch-cli/src/commands/vendor.rs +++ b/crates/socket-patch-cli/src/commands/vendor.rs @@ -1215,7 +1215,10 @@ async fn run_check(args: &VendorArgs) -> i32 { let manifest_path = args.common.resolved_manifest_path(); let manifest = match read_manifest(&manifest_path).await { Ok(m) => m.unwrap_or_default(), - Err(e) => return emit_eject_refusal(&args.common, "manifest_unreadable", &e.to_string()), + Err(e) => { + let err = crate::json_envelope::manifest_load_error(&manifest_path, &e); + return emit_eject_refusal(&args.common, &err.code, &err.message); + } }; let mut entries: Vec<_> = state.entries.iter().collect(); entries.sort_by_key(|(key, _)| *key); @@ -1362,7 +1365,7 @@ async fn run_check(args: &VendorArgs) -> i32 { /// A refused eject: the JSON error envelope (`status: error`) or an /// `Error:` line (printed even under `--silent`). Exit 1; nothing touched. -fn emit_eject_refusal(common: &GlobalArgs, code: &'static str, message: &str) -> i32 { +fn emit_eject_refusal(common: &GlobalArgs, code: &str, message: &str) -> i32 { if common.json { let mut env = Envelope::new(Command::Vendor); env.dry_run = common.dry_run; @@ -2145,7 +2148,10 @@ async fn run_vendor( Ok(Some(m)) => m, Ok(None) => return 0, // vanished since the existence check (TOCTOU) Err(e) => { - env.mark_error(EnvelopeError::new("invalid_manifest", e.to_string())); + // The shared manifest-load mapping (#931): `manifest_invalid` + // for unparseable JSON / a schema violation, else + // `manifest_unreadable`. + env.mark_error(crate::json_envelope::manifest_load_error(manifest_path, &e)); if !common.json { eprintln!("Error: Could not read manifest: {e}"); } diff --git a/crates/socket-patch-cli/src/commands/vendored_backend/mod.rs b/crates/socket-patch-cli/src/commands/vendored_backend/mod.rs index bc1b9bdd4..0830ec6b1 100644 --- a/crates/socket-patch-cli/src/commands/vendored_backend/mod.rs +++ b/crates/socket-patch-cli/src/commands/vendored_backend/mod.rs @@ -25,6 +25,23 @@ use crate::commands::vendor::{dispatch_revert_one_opts, vendor_records_reusing, use crate::ecosystem_dispatch::NpmCrawlSnapshot; use crate::json_envelope::Envelope; +/// Stamp `details.mode` (`"vendored"` / `"hosted"`) on `events`: the +/// convention every envelope uses to tell a vendored- or hosted-leg event +/// from an agent-mode (manifest) one, which carries no mode. Existing +/// `details` keys are kept; an event with no `details` gets +/// `{"mode": ...}`. +pub(crate) fn tag_event_mode(events: &mut [crate::json_envelope::PatchEvent], mode: &str) { + for event in events { + match event.details.as_mut() { + Some(serde_json::Value::Object(map)) => { + map.insert("mode".to_string(), serde_json::json!(mode)); + } + Some(_) => {} + None => event.details = Some(serde_json::json!({ "mode": mode })), + } + } +} + /// The vendored-mode backend for one run: the run's global args and its /// patch-service config (`--revert` does not need one). pub(crate) struct VendoredBackend<'a> { diff --git a/crates/socket-patch-cli/src/commands/vex.rs b/crates/socket-patch-cli/src/commands/vex.rs index f2cf467bf..abbe1dbc6 100644 --- a/crates/socket-patch-cli/src/commands/vex.rs +++ b/crates/socket-patch-cli/src/commands/vex.rs @@ -1312,8 +1312,10 @@ async fn generate_vex_from_manifest_path_inner( Err(e) => { // Core's text ("Failed to parse manifest JSON: ...") does not // say which file; in a workspace that matters. + // The shared manifest-load mapping (#931); exit 2 stays vex's. let message = format!("{e} (in {})", manifest_path.display()); - return Err(fail(common, params, "manifest_unreadable", message).await); + let code = crate::json_envelope::manifest_load_error_code(&e); + return Err(fail(common, params, code, message).await); } }; let had_manifest_file = manifest_file.is_some(); diff --git a/crates/socket-patch-cli/src/json_envelope.rs b/crates/socket-patch-cli/src/json_envelope.rs index e9d3f5b67..47ddd873b 100644 --- a/crates/socket-patch-cli/src/json_envelope.rs +++ b/crates/socket-patch-cli/src/json_envelope.rs @@ -1,10 +1,10 @@ //! Unified JSON output envelope shared across every subcommand. //! -//! The `--json` output of `apply`, `list`, `remove`, `repair`/`gc`, -//! `vendor`, `self-update` and `vex --json --output` (and every command's -//! lock-contention error) uses this top-level shape; `scan`, `get`, -//! and `rollback` still emit their legacy shapes (see -//! CLI_CONTRACT.md's migration status): +//! Every command's `--json` output uses this top-level shape (v5.0: `scan`, +//! `get` and `rollback` joined `apply`, `list`, `remove`, `repair`, +//! `vendor`, `--update` and `vex --json --output`). A command's own +//! payload (scan's `packages`, rollback's `hosted`, …) rides beside the +//! shared keys through [`Envelope::extra`]: //! //! ```json //! { @@ -98,8 +98,21 @@ pub struct Envelope { /// --preserve-state`, every command without a GC pass). #[serde(skip_serializing_if = "Option::is_none")] pub gc: Option, + /// Command-specific top-level keys, flattened beside the shared ones + /// (scan's `packages` / `redirect`, rollback's `hosted` / `manifest`, + /// …). A key here must never shadow a shared key; [`Envelope::set_extra`] + /// enforces that. + #[serde(flatten)] + pub extra: serde_json::Map, } +/// The keys every envelope owns. [`Envelope::set_extra`] refuses them so a +/// command payload can't shadow the shared vocabulary. +const SHARED_KEYS: &[&str] = &[ + "command", "status", "dryRun", "events", "summary", "error", "sidecars", "warnings", "vex", + "gc", +]; + /// One artifact GC pass — the orphan sweeps of `.socket/blobs`, /// `.socket/diffs` and `.socket/packages` — serialized identically by /// every command that runs one: the envelope's `gc` (`repair`, `remove`), @@ -188,9 +201,32 @@ impl Envelope { warnings: Vec::new(), vex: None, gc: None, + extra: serde_json::Map::new(), } } + /// Set a command-specific top-level key (see [`Envelope::extra`]). + /// + /// # Panics + /// When `key` is one of the shared envelope keys — a programming error. + pub fn set_extra(&mut self, key: &str, value: serde_json::Value) { + assert!( + !SHARED_KEYS.contains(&key), + "`{key}` is a shared envelope key, not a command payload key" + ); + self.extra.insert(key.to_string(), value); + } + + /// Append a run-level warning. + pub fn warn(&mut self, code: impl Into, detail: impl Into) { + self.warnings.push(RunWarning::new(code, detail)); + } + + /// Serialize to a JSON value. + pub fn to_value(&self) -> serde_json::Value { + serde_json::to_value(self).expect("envelope serialize") + } + /// Attach the run's artifact GC outcome (`gc`) and mirror its byte /// count into `summary.bytesFreed`. pub fn set_gc(&mut self, gc: GcReport) { @@ -476,6 +512,9 @@ pub enum PatchAction { /// from verified sources (lockfiles and the vendor ledger untouched /// unless drift was healed). Rebuilt, + /// `rollback`: a patched package was restored to its original state + /// (`files` lists what was restored). + RolledBack, } /// Patch-source strategy used to apply a file. Mirrors the existing @@ -525,9 +564,21 @@ pub enum Status { /// there's nothing to apply. Distinct from `Success` because some /// consumers want to early-exit on this state. NoManifest, - /// `remove` / `rollback`: the patch identifier didn't resolve to - /// anything in the local manifest. + /// `get`: the requested patch requires a paid plan but the caller's + /// API token isn't entitled. Distinct from `Error` so PR bots can post + /// an "upgrade your plan" comment instead of failing. + PaidRequired, + /// The identifier didn't resolve to a patch (`get`: none published; + /// `remove`: nothing in the local manifest). NotFound, + /// `get`: the identifier matched no installed package. + NotInstalled, + /// `get`: the search matched no package at all. + NoMatch, + /// `get`: the project has no packages to search. + NoPackages, + /// `get`: several patches match and the caller must pick one (exit 1). + SelectionRequired, } /// Pre-aggregated counts across all events in this envelope. Field names @@ -543,20 +594,14 @@ pub struct Summary { pub failed: u32, pub removed: u32, pub verified: u32, - /// `repair`-only (vendored artifact rebuilds); omitted while zero so - /// every other command's summary shape is unchanged. - #[serde(skip_serializing_if = "u32_is_zero")] pub rebuilt: u32, + pub rolled_back: u32, /// Bytes the run's artifact GC freed (would free, on a dry run) — the /// envelope's `gc.bytesFreed`, 0 when no GC ran. Not derived from /// `events`: GC is reported once, in `gc`. pub bytes_freed: u64, } -fn u32_is_zero(n: &u32) -> bool { - *n == 0 -} - impl Summary { fn bump(&mut self, action: PatchAction) { match action { @@ -569,6 +614,7 @@ impl Summary { PatchAction::Removed => self.removed += 1, PatchAction::Verified => self.verified += 1, PatchAction::Rebuilt => self.rebuilt += 1, + PatchAction::RolledBack => self.rolled_back += 1, } } } @@ -594,71 +640,42 @@ impl EnvelopeError { } } -/// The `{code, message}` object every `--json` failure carries as its -/// top-level `error` — the serialized form of an [`EnvelopeError`]. -pub(crate) fn error_object(err: &EnvelopeError) -> serde_json::Value { - serde_json::json!({ "code": err.code, "message": err.message }) +/// The top-level error for a manifest that exists but couldn't be loaded. +/// One mapping for every command (#931): malformed JSON or a schema +/// violation (`read_manifest`'s `InvalidData`) is `manifest_invalid`; any +/// other I/O failure is `manifest_unreadable`. +pub(crate) fn manifest_load_error( + manifest_path: &std::path::Path, + err: &std::io::Error, +) -> EnvelopeError { + EnvelopeError::new( + manifest_load_error_code(err), + crate::ui::manifest_error_message(manifest_path, err), + ) } -/// Mark a legacy (`scan` / `get` / `rollback`) JSON result as a top-level -/// failure: `status: "error"` plus `error: {code, message}`. Any older -/// top-level `errorCode` sibling is removed — the code lives in -/// `error.code` (v5.0). Per-record `errorCode`s inside arrays are untouched. -pub(crate) fn set_error(value: &mut serde_json::Value, err: EnvelopeError) { - // `status` first, so a fresh object reads `{status, error}`. - if let Some(obj) = value.as_object_mut() { - obj.insert("status".into(), serde_json::json!("error")); - } - set_error_keep_status(value, err); -} - -/// [`set_error`] without touching `status`, for results whose status is -/// itself the routing signal (get's `selection_required`). -pub(crate) fn set_error_keep_status(value: &mut serde_json::Value, err: EnvelopeError) { - if let Some(obj) = value.as_object_mut() { - obj.remove("errorCode"); - obj.insert("error".into(), error_object(&err)); +/// The code half of [`manifest_load_error`], for callers that carry a +/// `&'static str` code (`vex`'s `VexGenError`). +pub(crate) fn manifest_load_error_code(err: &std::io::Error) -> &'static str { + if err.kind() == std::io::ErrorKind::InvalidData { + "manifest_invalid" + } else { + "manifest_unreadable" } } -/// The minimal legacy failure shape: `{status: "error", error: {code, -/// message}}`. -pub(crate) fn legacy_error(code: &str, message: &str) -> serde_json::Value { - let mut v = serde_json::json!({}); - set_error(&mut v, EnvelopeError::new(code, message)); - v -} - -/// Print [`legacy_error`] on stdout. -pub(crate) fn print_legacy_error(code: &str, message: &str) { - println!( - "{}", - serde_json::to_string_pretty(&legacy_error(code, message)).expect("json serialize") - ); -} - -/// Whether `command` still prints its legacy (non-[`Envelope`]) JSON shape. -fn is_legacy_shape(command: Command) -> bool { - matches!(command, Command::Scan | Command::Get | Command::Rollback) -} - /// The JSON a self-enforced usage error prints under `--json`: a full -/// [`Envelope`] for commands already on it, the legacy error shape for -/// `scan` / `get` / `rollback`. +/// [`Envelope`] with `status: "error"`. pub(crate) fn usage_error_json( command: Command, dry_run: bool, code: &str, message: &str, ) -> serde_json::Value { - if is_legacy_shape(command) { - legacy_error(code, message) - } else { - let mut env = Envelope::new(command); - env.dry_run = dry_run; - env.mark_error(EnvelopeError::new(code, message)); - serde_json::to_value(&env).expect("envelope serialize") - } + let mut env = Envelope::new(command); + env.dry_run = dry_run; + env.mark_error(EnvelopeError::new(code, message)); + env.to_value() } /// Report a usage error a command enforces itself (clap's own parse errors @@ -695,6 +712,15 @@ pub struct RunWarning { pub detail: String, } +impl RunWarning { + pub fn new(code: impl Into, detail: impl Into) -> Self { + Self { + code: code.into(), + detail: detail.into(), + } + } +} + // --------------------------------------------------------------------------- // Tests — pin the JSON serialization shape that downstream consumers see. // --------------------------------------------------------------------------- @@ -704,54 +730,12 @@ mod tests { use super::*; #[test] - fn set_error_writes_object_and_drops_error_code() { - let mut v = serde_json::json!({ - "status": "success", - "errorCode": "lock_held", - "error": "old", - "patches": [{ "errorCode": "apply_failed", "error": "per-record" }], - }); - set_error(&mut v, EnvelopeError::new("lock_held", "held")); - assert_eq!(v["status"], "error"); - assert_eq!( - v["error"], - serde_json::json!({"code": "lock_held", "message": "held"}) - ); - assert!(v.get("errorCode").is_none(), "{v}"); - // Per-record keys are out of scope and untouched. - assert_eq!(v["patches"][0]["errorCode"], "apply_failed"); - assert_eq!(v["patches"][0]["error"], "per-record"); - } - - #[test] - fn set_error_keep_status_leaves_status() { - let mut v = serde_json::json!({ "status": "selection_required" }); - set_error_keep_status(&mut v, EnvelopeError::new("selection_required", "pick")); - assert_eq!(v["status"], "selection_required"); - assert_eq!(v["error"]["code"], "selection_required"); - assert_eq!(v["error"]["message"], "pick"); - } - - #[test] - fn legacy_error_has_minimal_shape() { - let v = legacy_error("manifest_unreadable", "bad json"); - assert_eq!( - v, - serde_json::json!({ - "status": "error", - "error": { "code": "manifest_unreadable", "message": "bad json" }, - }) - ); - } - - #[test] - fn usage_error_json_legacy_vs_envelope() { - for cmd in [Command::Scan, Command::Get, Command::Rollback] { - let v = usage_error_json(cmd, true, "invalid_args", "bad"); - assert_eq!(v, legacy_error("invalid_args", "bad"), "{cmd:?}"); - } + fn usage_error_json_is_a_full_envelope_for_every_command() { for cmd in [ + Command::Scan, + Command::Get, Command::Apply, + Command::Rollback, Command::List, Command::Remove, Command::Repair, diff --git a/crates/socket-patch-cli/tests/apply/bun_global_store.rs b/crates/socket-patch-cli/tests/apply/bun_global_store.rs index af971c940..0ebcfa482 100644 --- a/crates/socket-patch-cli/tests/apply/bun_global_store.rs +++ b/crates/socket-patch-cli/tests/apply/bun_global_store.rs @@ -124,8 +124,7 @@ fn write_manifest(root: &Path, purls: &[&str]) { fn event<'a>(v: &'a Value, purl: &str) -> &'a Value { v["events"] .as_array() - .or_else(|| v["results"].as_array()) - .expect("events or results array") + .expect("events array") .iter() .find(|e| e["purl"] == purl) .unwrap_or_else(|| panic!("no event for {purl}: {v}")) @@ -188,10 +187,10 @@ fn rollback_refuses_bun_global_store_packages() { ); let v = run(&proj, "rollback"); - // Every package refused, nothing rolled back: the run failed as a - // whole (#1066). + // Both refused and nothing rolled back: the run failed as a whole. assert_eq!(v["status"], "error", "{v}"); assert_eq!(v["error"]["code"], "rollback_failed", "{v}"); + assert_eq!(v["summary"]["failed"], 2, "{v}"); assert_refused(&v, "pkg:npm/left-pad@1.3.0"); assert_refused(&v, "pkg:npm/is-number@6.0.0"); assert_eq!(std::fs::read(left_pad.join("index.js")).unwrap(), AFTER); diff --git a/crates/socket-patch-cli/tests/apply/in_process_npm_multicopy.rs b/crates/socket-patch-cli/tests/apply/in_process_npm_multicopy.rs index 275f36bfd..1f3158237 100644 --- a/crates/socket-patch-cli/tests/apply/in_process_npm_multicopy.rs +++ b/crates/socket-patch-cli/tests/apply/in_process_npm_multicopy.rs @@ -371,8 +371,12 @@ fn apply_and_rollback_reach_both_vlt_peer_variant_copies_from_an_importer_link() let (code, v) = run_rollback(&root); assert_eq!(code, 0, "rollback must succeed; envelope={v}"); - assert_eq!(v["rolledBack"], 1, "envelope={v}"); - assert_eq!(v["alreadyOriginal"], 0, "envelope={v}"); + assert_eq!(v["summary"]["rolledBack"], 1, "envelope={v}"); + assert_eq!( + crate::rollback_json::already_original(&v), + 0, + "envelope={v}" + ); assert_vlt_copies([&primary, &twin], false, "after rollback"); } @@ -394,8 +398,12 @@ fn apply_and_rollback_reach_both_transitive_only_vlt_store_copies() { let (code, v) = run_rollback(&root); assert_eq!(code, 0, "rollback must succeed; envelope={v}"); - assert_eq!(v["rolledBack"], 1, "envelope={v}"); - assert_eq!(v["alreadyOriginal"], 1, "envelope={v}"); + assert_eq!(v["summary"]["rolledBack"], 1, "envelope={v}"); + assert_eq!( + crate::rollback_json::already_original(&v), + 1, + "envelope={v}" + ); assert_vlt_copies([&primary, &twin], false, "after rollback"); } @@ -509,8 +517,12 @@ fn apply_and_rollback_report_a_write_to_only_a_store_twin() { let (code, v) = run_rollback(&root); assert_eq!(code, 0, "{layout}: rollback; envelope={v}"); assert_vlt_copies([&primary, &twin], false, "after rollback"); - assert_eq!(v["rolledBack"], 1, "{layout}: envelope={v}"); - assert_eq!(v["alreadyOriginal"], 0, "{layout}: envelope={v}"); + assert_eq!(v["summary"]["rolledBack"], 1, "{layout}: envelope={v}"); + assert_eq!( + crate::rollback_json::already_original(&v), + 0, + "{layout}: envelope={v}" + ); } } @@ -775,8 +787,12 @@ fn apply_and_rollback_visit_a_pnpm_workspace_member_link_once() { let (code, v) = run_rollback_with(root, &["--preserve-state".as_ref()]); assert_eq!(code, 0, "rollback; envelope={v}"); assert_eq!(std::fs::read(&store_copy).unwrap(), original); - assert_eq!(v["rolledBack"], 1, "rollback; envelope={v}"); - assert_eq!(v["alreadyOriginal"], 0, "rollback; envelope={v}"); + assert_eq!(v["summary"]["rolledBack"], 1, "rollback; envelope={v}"); + assert_eq!( + crate::rollback_json::already_original(&v), + 0, + "rollback; envelope={v}" + ); // A path target still selects the copy through the member's link. let (code, v) = run_apply(root); @@ -785,8 +801,15 @@ fn apply_and_rollback_visit_a_pnpm_workspace_member_link_once() { let (code, v) = run_rollback_with(root, &["packages/a".as_ref()]); assert_eq!(code, 0, "scoped rollback; envelope={v}"); assert_eq!(std::fs::read(&store_copy).unwrap(), original); - assert_eq!(v["rolledBack"], 1, "scoped rollback; envelope={v}"); - assert_eq!(v["alreadyOriginal"], 0, "scoped rollback; envelope={v}"); + assert_eq!( + v["summary"]["rolledBack"], 1, + "scoped rollback; envelope={v}" + ); + assert_eq!( + crate::rollback_json::already_original(&v), + 0, + "scoped rollback; envelope={v}" + ); } /// One pnpm 11+ isolated global install, `//node_modules`, with diff --git a/crates/socket-patch-cli/tests/apply/main.rs b/crates/socket-patch-cli/tests/apply/main.rs index edfaa50b0..2ed840f61 100644 --- a/crates/socket-patch-cli/tests/apply/main.rs +++ b/crates/socket-patch-cli/tests/apply/main.rs @@ -4,6 +4,8 @@ #[path = "../common/mod.rs"] mod common; +#[path = "../common/rollback_json.rs"] +mod rollback_json; #[path = "../vlt_hosted_common/mod.rs"] mod vlt_hosted_common; #[path = "../vlt_hosted_common/vendored.rs"] diff --git a/crates/socket-patch-cli/tests/cli/api_client_errors_e2e.rs b/crates/socket-patch-cli/tests/cli/api_client_errors_e2e.rs index 8d6984eab..dbaa5bd54 100644 --- a/crates/socket-patch-cli/tests/cli/api_client_errors_e2e.rs +++ b/crates/socket-patch-cli/tests/cli/api_client_errors_e2e.rs @@ -180,10 +180,14 @@ async fn get_uuid_with_401_falls_back_to_proxy() { assert_eq!(code, 0, "graceful fallback must exit 0; stderr={stderr}"); let v = json_stdout(&out); assert_eq!( - v["status"], "not_found", + v["status"], "notFound", "after proxy 404 the patch is not found, got: {v}" ); - assert_eq!(v["found"], 0, "not_found envelope reports zero found: {v}"); + assert_eq!( + v["events"], + serde_json::json!([]), + "notFound reports no events: {v}" + ); } /// A 500 is NOT a fallback candidate: it must surface as a hard error @@ -459,10 +463,14 @@ async fn get_by_ghsa_with_404_reports_not_found() { assert_eq!(code, 0, "GHSA 404 is a graceful not-found, exit 0"); let v = json_stdout(&out); assert_eq!( - v["status"], "not_found", + v["status"], "notFound", "404 search must map to not_found, got: {v}" ); - assert_eq!(v["found"], 0, "not_found envelope reports zero found: {v}"); + assert_eq!( + v["events"], + serde_json::json!([]), + "notFound reports no events: {v}" + ); } // --------------------------------------------------------------------------- diff --git a/crates/socket-patch-cli/tests/cli/cli_dry_run_paths_e2e.rs b/crates/socket-patch-cli/tests/cli/cli_dry_run_paths_e2e.rs index 68ae871bc..ddbfdad14 100644 --- a/crates/socket-patch-cli/tests/cli/cli_dry_run_paths_e2e.rs +++ b/crates/socket-patch-cli/tests/cli/cli_dry_run_paths_e2e.rs @@ -402,14 +402,14 @@ fn rollback_with_empty_manifest_emits_envelope() { // Empty-but-valid manifest: rollback is a clean success that touches nothing. assert_eq!(out.status.code(), Some(0), "rollback should exit 0: {v}"); assert_eq!(v["status"], "success", "expected success status: {v}"); - assert_eq!(v["rolledBack"], 0, "nothing should roll back: {v}"); - assert_eq!(v["alreadyOriginal"], 0, "no files to inspect: {v}"); - assert_eq!(v["failed"], 0, "no rollback should fail: {v}"); + assert_eq!(v["command"], "rollback", "{v}"); assert_eq!( - v["results"], - serde_json::json!([]), - "unexpected results: {v}" + v["summary"]["rolledBack"], 0, + "nothing should roll back: {v}" ); + assert_eq!(v["summary"]["skipped"], 0, "no files to inspect: {v}"); + assert_eq!(v["summary"]["failed"], 0, "no rollback should fail: {v}"); + assert_eq!(v["events"], serde_json::json!([]), "unexpected events: {v}"); } /// `remove --json` with no manifest at all: the early-exit diff --git a/crates/socket-patch-cli/tests/cli/covgap_api_client.rs b/crates/socket-patch-cli/tests/cli/covgap_api_client.rs index 023b1a39d..f956f4018 100644 --- a/crates/socket-patch-cli/tests/cli/covgap_api_client.rs +++ b/crates/socket-patch-cli/tests/cli/covgap_api_client.rs @@ -158,19 +158,23 @@ async fn get_with_hash_shaped_token_under_json_keeps_warnings_and_envelope() { ); let v = json_stdout(&out); assert_eq!( - v["status"], "not_found", + v["status"], "notFound", "404 after failed org resolution maps to not_found, got: {v}" ); - assert_eq!(v["found"], 0, "not_found envelope reports zero found: {v}"); + assert_eq!( + v["events"], + serde_json::json!([]), + "notFound reports no events: {v}" + ); // The UUID path reports the startup downgrade in `warnings[]` too. let warnings = v["warnings"] .as_array() .unwrap_or_else(|| panic!("no warnings[]: {v}")); - let prefix = "(api_auth_fallback) Could not determine your organization"; assert!( - warnings - .iter() - .any(|w| w.as_str().is_some_and(|w| w.starts_with(prefix))), + warnings.iter().any(|w| w["code"] == "api_auth_fallback" + && w["detail"] + .as_str() + .is_some_and(|d| d.starts_with("Could not determine your organization"))), "api_auth_fallback missing from warnings[]: {v}" ); } @@ -191,7 +195,7 @@ async fn get_with_hash_shaped_token_under_silent_prints_no_warnings() { "--silent must mute the token-shape warning; stderr={stderr}" ); assert_eq!(out.status.code(), Some(0), "stderr={stderr}"); - assert_eq!(json_stdout(&out)["status"], "not_found"); + assert_eq!(json_stdout(&out)["status"], "notFound"); } /// #648: the uuid path's agent `--dry-run` preview must report the @@ -281,11 +285,11 @@ async fn unresolved_org_reaches_get_uuid_dry_run_json_warnings() { let warnings = v["warnings"] .as_array() .unwrap_or_else(|| panic!("no warnings[]: {v}; stderr={stderr}")); - let prefix = "(api_auth_fallback) Could not determine your organization"; assert!( - warnings - .iter() - .any(|w| w.as_str().is_some_and(|w| w.starts_with(prefix))), + warnings.iter().any(|w| w["code"] == "api_auth_fallback" + && w["detail"] + .as_str() + .is_some_and(|d| d.starts_with("Could not determine your organization"))), "api_auth_fallback missing from the dry-run warnings[]: {v}; stderr={stderr}" ); } diff --git a/crates/socket-patch-cli/tests/cli/output_modes_e2e.rs b/crates/socket-patch-cli/tests/cli/output_modes_e2e.rs index 8005ef442..d606a5234 100644 --- a/crates/socket-patch-cli/tests/cli/output_modes_e2e.rs +++ b/crates/socket-patch-cli/tests/cli/output_modes_e2e.rs @@ -626,7 +626,7 @@ fn get_with_explicit_package_flag_works() { ); // `--package` forces a package-name search. With no installed packages // it short-circuits locally (never reaching the dead API), exits 0, and - // emits the structured "no_packages" JSON. The old `0 || 1` would have + // emits the structured `noPackages` envelope. The old `0 || 1` would have // accepted a crash or a misrouted vuln lookup. assert_eq!( code, 0, @@ -634,8 +634,8 @@ fn get_with_explicit_package_flag_works() { ); let v: serde_json::Value = serde_json::from_str(stdout.trim()).expect("must emit parseable JSON"); - assert_eq!(v["status"], "no_packages", "got: {stdout}"); - assert_eq!(v["found"], 0, "got: {stdout}"); + assert_eq!(v["status"], "noPackages", "got: {stdout}"); + assert_eq!(v["events"], serde_json::json!([]), "got: {stdout}"); } // --------------------------------------------------------------------------- diff --git a/crates/socket-patch-cli/tests/cli_apply_silent.rs b/crates/socket-patch-cli/tests/cli_apply_silent.rs index c96180923..c78b2e07c 100644 --- a/crates/socket-patch-cli/tests/cli_apply_silent.rs +++ b/crates/socket-patch-cli/tests/cli_apply_silent.rs @@ -175,9 +175,10 @@ fn apply_check_silent_unreadable_manifest_keeps_error_output() { ); } -/// `apply --check --json` on an unreadable manifest must emit the unified +/// `apply --check --json` on a corrupt manifest must emit the unified /// envelope (CLI_CONTRACT.md: every `--json` invocation emits a single -/// JSON object) — not exit 1 with empty stdout. +/// JSON object) — not exit 1 with empty stdout. The code is the shared +/// manifest-load mapping (#931): unparseable JSON is `manifest_invalid`. #[test] fn apply_check_json_unreadable_manifest_emits_error_envelope() { let tmp = tempfile::tempdir().unwrap(); @@ -189,7 +190,7 @@ fn apply_check_json_unreadable_manifest_emits_error_envelope() { .unwrap_or_else(|e| panic!("--json must emit an envelope ({e}); stdout was: {stdout:?}")); assert_eq!(env["command"], "apply"); assert_eq!(env["status"], "error"); - assert_eq!(env["error"]["code"], "manifest_unreadable"); + assert_eq!(env["error"]["code"], "manifest_invalid"); } /// `apply --check --silent` with real redirect drift must still print the diff --git a/crates/socket-patch-cli/tests/cli_parse_scan.rs b/crates/socket-patch-cli/tests/cli_parse_scan.rs index 94c10cf8a..3a146aa57 100644 --- a/crates/socket-patch-cli/tests/cli_parse_scan.rs +++ b/crates/socket-patch-cli/tests/cli_parse_scan.rs @@ -474,13 +474,17 @@ fn scan_json_empty_cwd_emits_updates_key() { // object that must NOT appear when neither was requested, since both // default to false here). let expected = serde_json::json!({ + "command": "scan", "status": "success", + "dryRun": false, + "events": [], + "summary": { + "discovered": 0, "downloaded": 0, "applied": 0, "updated": 0, + "skipped": 0, "failed": 0, "removed": 0, "verified": 0, + "rebuilt": 0, "rolledBack": 0, "bytesFreed": 0 + }, "scannedPackages": 0, "lockfileOnlyPackages": 0, - "packagesWithPatches": 0, - "totalPatches": 0, - "freePatches": 0, - "paidPatches": 0, "canAccessPaidPatches": false, "packages": [], "updates": [], @@ -492,16 +496,9 @@ fn scan_json_empty_cwd_emits_updates_key() { "counts": { "new": 0, "deferred": 0, "upgrade": 0, "already": 0 }, "deferred": [], }, - // v5: a bare scan runs hosted mode, so its result nests here. - "redirect": { - "mode": "hosted", - "redirected": 0, - "rewrittenFiles": [], - "skipped": [], - "patches": [], - "warnings": [], - "dryRun": false - }, + // v5: a bare scan runs hosted mode: its payload (events would + // carry `details.mode: "hosted"`). + "redirect": { "mode": "hosted", "rewrittenFiles": [] }, // v5: the socket.yml patch policy block rides every successful // scan (no file here: the built-in defaults). "policy": { diff --git a/crates/socket-patch-cli/tests/common/envelope.rs b/crates/socket-patch-cli/tests/common/envelope.rs index a72b473f9..dd5907a73 100644 --- a/crates/socket-patch-cli/tests/common/envelope.rs +++ b/crates/socket-patch-cli/tests/common/envelope.rs @@ -107,3 +107,133 @@ pub fn all_codes(doc: &Value) -> Vec { walk(doc, &mut out); out } + +/// The top-level `warnings[]` codes (every command, v5.0: warnings are only +/// ever top level). +pub fn warning_codes(envelope: &Value) -> Vec { + codes_in(&envelope["warnings"]) +} + +/// The events of one leg: those whose `details.mode` is `mode` +/// (`"hosted"` / `"vendored"`; agent-mode events carry no mode). +pub fn mode_events<'a>(envelope: &'a Value, mode: &str) -> Vec<&'a Value> { + events(envelope) + .iter() + .filter(|e| e["details"]["mode"] == mode) + .collect() +} + +/// `(purl, uuid)` of every hosted pin the run wrote — or, on a dry run, +/// would write: the `applied` / `verified` events with +/// `details.mode: "hosted"` (v5.0's `redirect.patches[]` `pinned` / +/// `would_pin` rows). +pub fn hosted_pins(envelope: &Value) -> Vec<(String, String)> { + mode_events(envelope, "hosted") + .into_iter() + .filter(|e| e["action"] == "applied" || e["action"] == "verified") + .map(|e| { + ( + e["purl"].as_str().unwrap_or_default().to_string(), + e["uuid"].as_str().unwrap_or_default().to_string(), + ) + }) + .collect() +} + +/// The `{purl, uuid, reason, detail}` rows of the hosted `skipped` events +/// (v5.0's `redirect.skipped[]`: `reason` is the event's `errorCode`). +pub fn hosted_skips(envelope: &Value) -> Vec<(String, String)> { + mode_events(envelope, "hosted") + .into_iter() + .filter(|e| e["action"] == "skipped") + .map(|e| { + ( + e["purl"].as_str().unwrap_or_default().to_string(), + e["errorCode"].as_str().unwrap_or_default().to_string(), + ) + }) + .collect() +} + +/// The shared envelope invariants every `--json` document holds: `command` +/// is `command`, `status` is a camelCase `Status`, `dryRun` a bool, +/// `events` an array, every counted `summary` field equals its event count +/// (`uncounted` skipped events — the vendor engine's advisories — are +/// allowed on top of `summary.skipped`), `error` is `{code, message}` iff +/// `status` is `error` or `selectionRequired`, and no top-level key or event +/// action is snake_case. +pub fn assert_envelope_invariants(envelope: &Value, command: &str) { + assert_eq!(envelope["command"], command, "{envelope:#}"); + let status = envelope["status"].as_str().expect("status"); + assert!( + [ + "success", + "partialFailure", + "error", + "noManifest", + "paidRequired", + "notFound", + "notInstalled", + "noMatch", + "noPackages", + "selectionRequired", + ] + .contains(&status), + "status {status:?} is not a Status: {envelope:#}" + ); + assert!(envelope["dryRun"].is_boolean(), "{envelope:#}"); + let evs = events(envelope); + let summary = &envelope["summary"]; + for (field, action) in [ + ("discovered", "discovered"), + ("downloaded", "downloaded"), + ("applied", "applied"), + ("updated", "updated"), + ("failed", "failed"), + ("removed", "removed"), + ("verified", "verified"), + ("rebuilt", "rebuilt"), + ("rolledBack", "rolledBack"), + ] { + let n = evs.iter().filter(|e| e["action"] == action).count() as u64; + assert_eq!(summary[field], n, "summary.{field} vs events: {envelope:#}"); + } + let skipped = evs.iter().filter(|e| e["action"] == "skipped").count() as u64; + assert!( + summary["skipped"].as_u64().unwrap() <= skipped, + "summary.skipped exceeds the skipped events: {envelope:#}" + ); + if summary["failed"].as_u64().unwrap() > 0 { + assert!( + matches!(status, "partialFailure" | "error"), + "a failed event must not leave {status:?}: {envelope:#}" + ); + } + let has_error = envelope.get("error").is_some(); + assert_eq!( + has_error, + matches!(status, "error" | "selectionRequired"), + "`error` iff status error/selectionRequired: {envelope:#}" + ); + if has_error { + assert!(envelope["error"]["code"].is_string(), "{envelope:#}"); + assert!(envelope["error"]["message"].is_string(), "{envelope:#}"); + } + for key in envelope.as_object().unwrap().keys() { + assert!( + !key.contains('_'), + "snake_case top-level key {key:?}: {envelope:#}" + ); + } + for e in evs { + let action = e["action"].as_str().expect("action"); + assert!(!action.contains('_'), "snake_case action {action:?}"); + assert!(e.get("dryRun").is_none(), "nested dryRun in an event: {e}"); + } + for w in envelope["warnings"].as_array().into_iter().flatten() { + assert!( + w["code"].is_string() && w["detail"].is_string() && w.as_object().unwrap().len() == 2, + "a warning is exactly {{code, detail}}: {w}" + ); + } +} diff --git a/crates/socket-patch-cli/tests/common/rollback_json.rs b/crates/socket-patch-cli/tests/common/rollback_json.rs new file mode 100644 index 000000000..313732f6c --- /dev/null +++ b/crates/socket-patch-cli/tests/common/rollback_json.rs @@ -0,0 +1,207 @@ +//! Readers for `rollback --json` (v5.0: the unified envelope). +//! +//! Every rollback outcome is an event; these project the events back into +//! the per-leg views tests assert on (which purls each leg rolled back, +//! which failed, which manifest entries left), so a test reads one call +//! instead of re-filtering `events[]`. Each view is a JSON array, so +//! `assert_eq!(view, serde_json::json!([...]))` and `view[0]["purl"]` work. +//! +//! Include with `#[path = "common/rollback_json.rs"] mod rollback_json;` +//! (`"../common/rollback_json.rs"` from a test directory). + +#![allow(dead_code)] + +use serde_json::{json, Value}; + +fn events(v: &Value) -> &[Value] { + v["events"] + .as_array() + .map(Vec::as_slice) + .unwrap_or_else(|| panic!("no `events` array in:\n{v:#}")) +} + +fn mode(e: &Value) -> Option<&str> { + e["details"]["mode"].as_str() +} + +/// A restore (`rolledBack`, or its `verified` dry-run preview) — never a +/// manifest-removal preview. +fn is_restore(e: &Value) -> bool { + (e["action"] == "rolledBack" || e["action"] == "verified") && e["details"]["manifest"] != true +} + +fn purls<'a>(it: impl Iterator) -> Value { + Value::Array(it.map(|e| e["purl"].clone()).collect()) +} + +/// `summary.` as a number. +pub fn summary(v: &Value, action: &str) -> u64 { + v["summary"][action] + .as_u64() + .unwrap_or_else(|| panic!("no summary.{action} in:\n{v:#}")) +} + +/// Manifest entries the run dropped (`removed`, or `verified` on a dry +/// run, with `details.manifest: true`), in event order. +pub fn manifest_removed(v: &Value) -> Value { + purls( + events(v) + .iter() + .filter(|e| e["details"]["manifest"] == true), + ) +} + +/// Agent-leg (in-place) restores: purls with a `rolledBack` (wet) or +/// `verified` (dry run) event and no `details.mode`. +pub fn agent_rolled_back(v: &Value) -> Value { + purls( + events(v) + .iter() + .filter(|e| is_restore(e) && mode(e).is_none()), + ) +} + +/// The agent-leg event for `purl` with `action`; panics when absent. +pub fn agent_event<'a>(v: &'a Value, action: &str, purl: &str) -> &'a Value { + events(v) + .iter() + .find(|e| { + e["action"] == action + && e["purl"] == purl + && mode(e).is_none() + && e["details"]["manifest"] != true + }) + .unwrap_or_else(|| panic!("no agent `{action}` event for {purl} in:\n{v:#}")) +} + +/// `skipped` events with `errorCode == code`, as purls. +pub fn skipped_with(v: &Value, code: &str) -> Value { + purls( + events(v) + .iter() + .filter(|e| e["action"] == "skipped" && e["errorCode"] == code), + ) +} + +/// How many packages were already original (`skipped` `already_original`). +pub fn already_original(v: &Value) -> usize { + skipped_with(v, "already_original") + .as_array() + .unwrap() + .len() +} + +/// In-scope manifest entries with no installed package. +pub fn not_installed(v: &Value) -> Value { + skipped_with(v, "package_not_installed") +} + +/// Vendored entries reverted (artifact deleted), previewed on a dry run. +pub fn vendored_reverted(v: &Value) -> Value { + purls(events(v).iter().filter(|e| { + is_restore(e) && mode(e) == Some("vendored") && e["details"]["preserved"] != true + })) +} + +/// `--preserve-state` vendored entries: unwired, artifact + ledger kept. +pub fn vendored_preserved(v: &Value) -> Value { + purls(events(v).iter().filter(|e| { + is_restore(e) && mode(e) == Some("vendored") && e["details"]["preserved"] == true + })) +} + +fn failed_view(v: &Value, pick: impl Fn(&Value) -> bool, key: &str) -> Value { + Value::Array( + events(v) + .iter() + .filter(|e| e["action"] == "failed" && pick(e)) + .map(|e| json!({ "purl": e["purl"], key: e["error"], "errorCode": e["errorCode"] })) + .collect(), + ) +} + +/// Vendored drift-keeps as `[{purl, reason, errorCode}]`. +pub fn vendored_kept(v: &Value) -> Value { + failed_view( + v, + |e| mode(e) == Some("vendored") && e["errorCode"] == "vendor_revert_kept", + "reason", + ) +} + +/// Vendored revert failures as `[{purl, error, errorCode}]`. +pub fn vendored_failed(v: &Value) -> Value { + failed_view( + v, + |e| mode(e) == Some("vendored") && e["errorCode"] != "vendor_revert_kept", + "error", + ) +} + +/// Hosted pins restored to upstream (previewed on a dry run). +pub fn hosted_reverted(v: &Value) -> Value { + purls( + events(v) + .iter() + .filter(|e| is_restore(e) && mode(e) == Some("hosted")), + ) +} + +/// Hosted-leg failures as `[{purl, error, errorCode}]` (`purl` null for +/// the artifact-level `hosted_write_failed` / `hosted_wiring_contested`). +pub fn hosted_failed(v: &Value) -> Value { + failed_view(v, |e| mode(e) == Some("hosted"), "error") +} + +/// Every failed event as `[{purl, error, errorCode}]`. +pub fn failed(v: &Value) -> Value { + failed_view(v, |_| true, "error") +} + +/// The run-level warning codes. +pub fn warning_codes(v: &Value) -> Vec { + v["warnings"] + .as_array() + .into_iter() + .flatten() + .filter_map(|w| w["code"].as_str().map(str::to_string)) + .collect() +} + +/// The shared-envelope invariants every rollback document keeps: +/// `command: "rollback"`, a camelCase status, `summary` == event counts, +/// no snake_case top-level key. +pub fn assert_rollback_envelope(v: &Value) { + assert_eq!(v["command"], "rollback", "{v:#}"); + let status = v["status"].as_str().expect("status string"); + assert!( + ["success", "partialFailure", "error"].contains(&status), + "status {status}: {v:#}" + ); + for key in v.as_object().expect("object").keys() { + assert!(!key.contains('_'), "snake_case key {key}: {v:#}"); + } + for action in [ + "discovered", + "downloaded", + "applied", + "updated", + "skipped", + "failed", + "removed", + "verified", + "rebuilt", + "rolledBack", + ] { + let n = events(v).iter().filter(|e| e["action"] == action).count() as u64; + assert_eq!(summary(v, action), n, "summary.{action}: {v:#}"); + } +} + +/// Every event whose `details.mode` is `mode` (`vendored` / `hosted`). +pub fn events_with_mode<'a>(v: &'a Value, mode_name: &str) -> Vec<&'a Value> { + events(v) + .iter() + .filter(|e| mode(e) == Some(mode_name)) + .collect() +} diff --git a/crates/socket-patch-cli/tests/common/yarn_classic_vex.rs b/crates/socket-patch-cli/tests/common/yarn_classic_vex.rs index e09c14805..7a75a9eb8 100644 --- a/crates/socket-patch-cli/tests/common/yarn_classic_vex.rs +++ b/crates/socket-patch-cli/tests/common/yarn_classic_vex.rs @@ -321,8 +321,13 @@ impl<'a> ManifestlessVex<'a> { let out = run_vex(&binary(), project, &run); if missing_ledger { assert_eq!(out.code, Some(1), "{out}"); - assert_eq!( - out.envelope["vendor"]["events"][0]["errorCode"], "vendor_ledger_entry_missing", + // The vendor engine's events join the scan envelope (after + // the download events), tagged `details.mode: "vendored"`. + assert!( + out.envelope["events"] + .as_array() + .is_some_and(|events| events.iter().any(|e| e["action"] == "failed" + && e["errorCode"] == "vendor_ledger_entry_missing")), "{out}" ); assert!(out.doc.is_none(), "failed scan cannot emit VEX: {out}"); diff --git a/crates/socket-patch-cli/tests/contract_paid_required.rs b/crates/socket-patch-cli/tests/contract_paid_required.rs index 51662d28d..b23efc331 100644 --- a/crates/socket-patch-cli/tests/contract_paid_required.rs +++ b/crates/socket-patch-cli/tests/contract_paid_required.rs @@ -1,10 +1,10 @@ //! #982: CLI_CONTRACT.md's paid-plan contract matches what ships. //! -//! `get --json` reports a paid-only result through its legacy top-level -//! `status: "paid_required"` (one emitter), and no command emits the -//! envelope status `paidRequired`, so the contract must neither list that -//! status nor describe `paid_required` as an event tag `scan` emits. The -//! emitted shape itself is pinned by the `--test get paid` tests. +//! v5.0: `get --json` reports a paid-only result on the shared envelope, +//! `status: "paidRequired"` with one `skipped` / `paid_required` event per +//! patch (exit 0). The legacy top-level `status: "paid_required"` shape is +//! retired, and `scan` never reports the outcome. The emitted shape itself +//! is pinned by the `--test get paid` tests. use std::path::Path; @@ -23,34 +23,37 @@ fn contract_paid_required_row_matches_get() { .find(|l| l.starts_with("| `paid_required`")) .expect("CLI_CONTRACT.md documents `paid_required`"); assert!( - row.contains(r#""status": "paid_required""#), - "the row names the status spelling `get` emits: {row}" + row.contains("`paidRequired`"), + "the row names the envelope status `get` emits: {row}" ); assert!( - !row.contains("paidRequired"), - "no command emits the envelope status `paidRequired`: {row}" + !row.contains(r#""status": "paid_required""#), + "the legacy top-level shape is retired: {row}" ); assert!( row.contains("`scan` never reports it"), "scan has no paid_required outcome: {row}" ); assert!( - !contract.contains("\"paidRequired\""), - "the envelope status enum must not list `paidRequired`" + contract.contains("\"paidRequired\""), + "the envelope status enum lists `paidRequired`" ); } #[test] -fn get_writes_the_paid_required_shape_once() { +fn get_emits_the_paid_required_envelope_status() { let get = read("src/commands/get.rs"); - assert_eq!( - get.matches(r#""status": "paid_required""#).count(), - 1, - "both paid paths share one emitter" + assert!( + !get.contains(r#""status": "paid_required""#), + "get no longer prints the legacy paid shape" + ); + assert!( + get.contains("Status::PaidRequired"), + "get reports paid-only results through the envelope status" ); let envelope = read("src/json_envelope.rs"); assert!( - !envelope.contains("PaidRequired"), - "the envelope has no paid status variant" + envelope.contains("PaidRequired"), + "the envelope carries the paid status variant" ); } diff --git a/crates/socket-patch-cli/tests/coverage_fix_scan_hosted_dryrun_vendored.rs b/crates/socket-patch-cli/tests/coverage_fix_scan_hosted_dryrun_vendored.rs index a8702ff68..e5ead69e6 100644 --- a/crates/socket-patch-cli/tests/coverage_fix_scan_hosted_dryrun_vendored.rs +++ b/crates/socket-patch-cli/tests/coverage_fix_scan_hosted_dryrun_vendored.rs @@ -27,6 +27,37 @@ use socket_patch_core::hash::git_sha256::compute_git_sha256_from_bytes; use wiremock::matchers::{method, path, path_regex}; use wiremock::{Mock, MockServer, ResponseTemplate}; +/// The hosted `skipped` events as the pre-v5.0 `redirect.skipped[]` rows +/// (`{purl, uuid, reason: , detail?: }`). +fn hosted_skipped(doc: &Value) -> Vec { + doc["events"] + .as_array() + .into_iter() + .flatten() + .filter(|e| e["details"]["mode"] == "hosted" && e["action"] == "skipped") + .map(|e| { + let mut row = json!({"purl": e["purl"], "uuid": e["uuid"], "reason": e["errorCode"]}); + if e["reason"].is_string() { + row["detail"] = e["reason"].clone(); + } + row + }) + .collect() +} + +/// How many pins a hosted run wrote (`applied`) or would write (`verified`). +fn hosted_pinned(doc: &Value) -> u64 { + doc["events"] + .as_array() + .into_iter() + .flatten() + .filter(|e| { + e["details"]["mode"] == "hosted" + && (e["action"] == "applied" || e["action"] == "verified") + }) + .count() as u64 +} + const ORG: &str = "test-org"; const NAME: &str = "dryrun-vendored-takeover"; const VERSION: &str = "1.0.0"; @@ -317,7 +348,7 @@ fn tree_snapshot(root: &Path) -> std::collections::BTreeMap> { } fn warning_detail<'a>(doc: &'a Value, code: &str) -> Option<&'a str> { - doc["redirect"]["warnings"] + doc["warnings"] .as_array()? .iter() .find(|w| w["code"] == code) @@ -357,9 +388,9 @@ async fn dry_run_over_vendored_project_previews_the_wet_takeover() { vendored_tree, "dry-run must leave every file, the vendored tarball included, byte-identical" ); - assert_eq!(doc["redirect"]["dryRun"], true, "envelope: {doc:#}"); + assert_eq!(doc["dryRun"], true, "envelope: {doc:#}"); - let codes = codes_in(&doc["redirect"]["warnings"]); + let codes = codes_in(&doc["warnings"]); assert!( codes.iter().any(|c| c == "redirect_would_revert_vendored"), "the takeover plan must be announced: {doc:#}" @@ -374,11 +405,12 @@ async fn dry_run_over_vendored_project_previews_the_wet_takeover() { // The preview must count the migration the wet run lands (below) — the // CI-gate signal this envelope exists for. assert_eq!( - doc["redirect"]["redirected"], 1, + hosted_pinned(&doc), + 1, "the dry-run must preview the wet outcome: {doc:#}" ); assert_eq!( - doc["redirect"]["skipped"].as_array().map(Vec::len), + Some(&hosted_skipped(&doc)).map(Vec::len), Some(0), "a revertable vendored purl is not skipped: {doc:#}" ); @@ -429,7 +461,8 @@ async fn dry_run_over_vendored_project_previews_the_wet_takeover() { let (code, wet) = scan_hosted_json(root, &server.uri(), /*dry_run=*/ false); assert_eq!(code, 0, "wet scan --mode hosted must succeed: {wet:#}"); assert_eq!( - wet["redirect"]["redirected"], 1, + hosted_pinned(&wet), + 1, "the wet takeover must land: {wet:#}" ); let lock = std::fs::read_to_string(root.join("pnpm-lock.yaml")).unwrap(); @@ -476,7 +509,7 @@ async fn dry_run_refuses_unrevertable_vendored_state_like_the_wet_run() { let (code, doc) = scan_hosted_json(root, &server.uri(), /*dry_run=*/ true); assert_eq!(code, 0, "dry-run scan --mode hosted must succeed: {doc:#}"); - let codes = codes_in(&doc["redirect"]["warnings"]); + let codes = codes_in(&doc["warnings"]); assert!( codes.iter().any(|c| c == "redirect_vendored_revert_failed"), "the unrevertable state must be refused in the preview too: {doc:#}" @@ -486,13 +519,14 @@ async fn dry_run_refuses_unrevertable_vendored_state_like_the_wet_run() { "a refused purl must not also be promised a takeover: {doc:#}" ); assert!( - doc["redirect"]["skipped"].as_array().is_some_and(|s| s + Some(&hosted_skipped(&doc)).is_some_and(|s| s .iter() .any(|e| e["purl"] == PURL && e["reason"] == "vendored_revert_failed")), "the refusal must be accounted as skipped: {doc:#}" ); assert_eq!( - doc["redirect"]["redirected"], 0, + hosted_pinned(&doc), + 0, "a refused takeover previews as not redirected: {doc:#}" ); assert_eq!( @@ -655,12 +689,12 @@ async fn dry_run_package_lock_takeover_previews_the_npmrc_write() { assert_eq!(code, 0, "{doc:#}"); assert_eq!(tree_snapshot(root), vendored_tree, "dry run writes nothing"); assert!( - codes_in(&doc["redirect"]["warnings"]) + codes_in(&doc["warnings"]) .iter() .any(|c| c == "redirect_would_revert_vendored"), "{doc:#}" ); - assert_eq!(doc["redirect"]["redirected"], 1, "{doc:#}"); + assert_eq!(hosted_pinned(&doc), 1, "{doc:#}"); let detail = warning_detail(&doc, "redirect_npm_allow_remote") .unwrap_or_else(|| panic!("the .npmrc write must be previewed: {doc:#}")); assert!( @@ -677,7 +711,7 @@ async fn dry_run_package_lock_takeover_previews_the_npmrc_write() { let (code, wet) = scan_hosted_json(root, &server.uri(), /*dry_run=*/ false); assert_eq!(code, 0, "{wet:#}"); - assert_eq!(wet["redirect"]["redirected"], 1, "{wet:#}"); + assert_eq!(hosted_pinned(&wet), 1, "{wet:#}"); assert_eq!( std::fs::read_to_string(root.join(".npmrc")).unwrap(), "allow-remote=all\n", diff --git a/crates/socket-patch-cli/tests/covgap_commands_get.rs b/crates/socket-patch-cli/tests/covgap_commands_get.rs index 0a741f2b0..d55a734fd 100644 --- a/crates/socket-patch-cli/tests/covgap_commands_get.rs +++ b/crates/socket-patch-cli/tests/covgap_commands_get.rs @@ -50,7 +50,8 @@ async fn download_and_apply_patches( lock_timeout: None, verbose: false, }; - download_and_apply_patches_with(selected, params, &run).await + let (code, env) = download_and_apply_patches_with(selected, params, &run).await; + (code, env.to_value()) } #[path = "common/mod.rs"] @@ -555,11 +556,16 @@ async fn get_uuid_traversal_after_hash_fails_blob_write_both_modes() { assert_eq!(code, 1, "blob failure must exit 1; stdout={stdout}"); let v = parse_single_json_doc(&stdout); assert_eq!(v["status"], "error", "stdout={stdout}"); + assert_eq!(v["error"]["code"], "blob_write_failed", "stdout={stdout}"); assert_eq!( v["error"]["message"], "Blob decode or write failed", "stdout={stdout}" ); - assert_eq!(v["patches"][0]["action"], "failed", "stdout={stdout}"); + assert_eq!(v["events"][0]["action"], "failed", "stdout={stdout}"); + assert_eq!( + v["events"][0]["errorCode"], "blob_write_failed", + "stdout={stdout}" + ); assert_no_manifest(tmp.path()); assert!( !tmp.path().join("covgap-escape").exists() @@ -781,8 +787,8 @@ async fn package_search_without_a_match_is_no_match_in_both_modes() { ); assert_eq!(code, 0, "no_match is a clean exit; stdout={stdout}"); let v = parse_single_json_doc(&stdout); - assert_eq!(v["status"], "no_match", "stdout={stdout}"); - assert_eq!(v["patches"].as_array().unwrap().len(), 0); + assert_eq!(v["status"], "noMatch", "stdout={stdout}"); + assert_eq!(v["events"].as_array().unwrap().len(), 0); assert!( received_paths(&server).await.is_empty(), "no_match must be decided before any API call" @@ -883,12 +889,16 @@ async fn engine_no_applicable_files_is_failed_and_unrecorded() { download_and_apply_patches(&selected, &engine_params(tmp.path()), &server.uri()).await; assert_eq!(code, 1, "json={json}"); - assert_eq!(json["status"], "partial_failure", "json={json}"); - assert_eq!(json["failed"], 1, "json={json}"); - assert_eq!(json["downloaded"], 0, "json={json}"); - assert_eq!(json["patches"][0]["action"], "failed", "json={json}"); + assert_eq!(json["status"], "partialFailure", "json={json}"); + assert_eq!(json["summary"]["failed"], 1, "json={json}"); + assert_eq!(json["summary"]["downloaded"], 0, "json={json}"); + assert_eq!(json["events"][0]["action"], "failed", "json={json}"); assert_eq!( - json["patches"][0]["error"], "patch has no applicable files", + json["events"][0]["errorCode"], "patch_no_applicable_files", + "json={json}" + ); + assert_eq!( + json["events"][0]["error"], "patch has no applicable files", "json={json}" ); assert_no_manifest(tmp.path()); @@ -925,9 +935,13 @@ async fn engine_invalid_blob_hash_is_failed_and_unrecorded() { download_and_apply_patches(&selected, &engine_params(tmp.path()), &server.uri()).await; assert_eq!(code, 1, "json={json}"); - assert_eq!(json["failed"], 1, "json={json}"); + assert_eq!(json["summary"]["failed"], 1, "json={json}"); assert_eq!( - json["patches"][0]["error"], "Blob decode or write failed", + json["events"][0]["errorCode"], "blob_write_failed", + "json={json}" + ); + assert_eq!( + json["events"][0]["error"], "Blob decode or write failed", "json={json}" ); assert_no_manifest(tmp.path()); @@ -950,9 +964,13 @@ async fn engine_view_404_is_could_not_fetch_details() { download_and_apply_patches(&selected, &engine_params(tmp.path()), &server.uri()).await; assert_eq!(code, 1, "json={json}"); - assert_eq!(json["failed"], 1, "json={json}"); + assert_eq!(json["summary"]["failed"], 1, "json={json}"); + assert_eq!( + json["events"][0]["errorCode"], "download_failed", + "json={json}" + ); assert_eq!( - json["patches"][0]["error"], "could not fetch details", + json["events"][0]["error"], "could not fetch details", "json={json}" ); assert_no_manifest(tmp.path()); @@ -1166,16 +1184,21 @@ async fn engine_uninstalled_variant_base_keeps_all_with_warning() { let (code, json) = download_and_apply_patches(&selected, ¶ms, &server.uri()).await; assert_eq!(code, 1, "json={json}"); - assert_eq!(json["found"], 2, "both variants must be kept; json={json}"); - assert_eq!(json["failed"], 2, "json={json}"); + assert_eq!( + json["events"].as_array().unwrap().len(), + 2, + "both variants must be kept; json={json}" + ); + assert_eq!(json["summary"]["failed"], 2, "json={json}"); let warnings = json["warnings"] .as_array() .unwrap_or_else(|| panic!("keep-all fallback must surface warnings; json={json}")); assert!( - warnings.iter().any(|w| w - .as_str() - .unwrap_or_default() - .contains("not installed locally")), + warnings.iter().any(|w| w["code"] == "release_narrowing" + && w["detail"] + .as_str() + .unwrap_or_default() + .contains("not installed locally")), "json={json}" ); } @@ -1200,13 +1223,13 @@ async fn engine_human_mode_skip_and_failed_summary() { let (code, json) = download_and_apply_patches(&selected, ¶ms, &server.uri()).await; assert_eq!(code, 1, "json={json}"); - assert_eq!(json["status"], "partial_failure", "json={json}"); + assert_eq!(json["status"], "partialFailure", "json={json}"); assert_eq!( - json["skipped"], 1, + json["summary"]["skipped"], 1, "same-uuid entry is skipped; json={json}" ); - assert_eq!(json["failed"], 1, "json={json}"); - assert_eq!(json["downloaded"], 0, "json={json}"); + assert_eq!(json["summary"]["failed"], 1, "json={json}"); + assert_eq!(json["summary"]["downloaded"], 0, "json={json}"); // The skipped purl's record is untouched. assert_eq!(manifest_json(tmp.path())["patches"][PURL]["uuid"], UUID); assert!(manifest_json(tmp.path())["patches"][PURL_V2].is_null()); @@ -1289,16 +1312,16 @@ async fn engine_variant_no_hash_match_keeps_all_variants_with_note() { code, 0, "keep-all downloads must still succeed; json={json}" ); - assert_eq!(json["found"], 2, "json={json}"); - assert_eq!(json["downloaded"], 2, "json={json}"); + assert_eq!(json["summary"]["downloaded"], 2, "json={json}"); let warnings = json["warnings"] .as_array() .unwrap_or_else(|| panic!("no-match fallback must warn; json={json}")); assert!( - warnings.iter().any(|w| w - .as_str() - .unwrap_or_default() - .contains("No release variant")), + warnings.iter().any(|w| w["code"] == "release_narrowing" + && w["detail"] + .as_str() + .unwrap_or_default() + .contains("No release variant")), "json={json}" ); // Keep-all is observable in the manifest: BOTH qualified purls recorded. @@ -1358,17 +1381,18 @@ async fn engine_variant_view_fetch_error_keeps_errored_variant() { "the kept variant's failure must surface; json={json}" ); assert_eq!( - json["found"], 1, + json["events"].as_array().unwrap().len(), + 1, "only the fetch-error variant may be kept (vacuous match); json={json}" ); - assert_eq!(json["failed"], 1, "json={json}"); + assert_eq!(json["summary"]["failed"], 1, "json={json}"); assert_eq!( - json["patches"][0]["purl"], purl_erroring, + json["events"][0]["purl"], purl_erroring, "the KEPT variant must be the one whose view errored; json={json}" ); // The mismatching sibling was narrowed out entirely. assert!( - !json["patches"] + !json["events"] .as_array() .unwrap() .iter() @@ -1501,12 +1525,22 @@ async fn get_uuid_vendored_superseding_action_carries_old_uuid() { assert_eq!(code, 0, "stdout={stdout}\nstderr={stderr}"); let v = parse_single_json_doc(&stdout); assert_eq!(v["status"], "success", "stdout={stdout}"); - assert_eq!(v["downloaded"], 1, "stdout={stdout}"); - assert_eq!(v["skipped"], 0, "stdout={stdout}"); - assert_eq!(v["detached"], true, "stdout={stdout}"); - assert_eq!(v["patches"][0]["action"], "downloaded", "stdout={stdout}"); - assert_eq!(v["patches"][0]["oldUuid"], UUID_B, "stdout={stdout}"); - assert!(v["vendor"].is_object(), "stdout={stdout}"); + assert_eq!(v["summary"]["downloaded"], 1, "stdout={stdout}"); + assert_eq!(v["events"][0]["action"], "downloaded", "stdout={stdout}"); + assert_eq!( + v["events"][0]["details"]["mode"], "vendored", + "stdout={stdout}" + ); + assert_eq!( + v["events"][0]["details"]["oldUuid"], UUID_B, + "stdout={stdout}" + ); + // The vendor engine's events follow (no nested `vendor` envelope). + assert!(v.get("vendor").is_none(), "stdout={stdout}"); + assert!( + v["summary"]["applied"].as_u64().unwrap() >= 1, + "stdout={stdout}" + ); assert_vendored_detached(tmp.path(), PURL, UUID); } @@ -1623,25 +1657,25 @@ fn assert_legacy_state_untouched(root: &Path, manifest_before: &str, state_befor } fn assert_vendor_error_envelope(v: &serde_json::Value) { - assert_eq!(v["status"], "partial_failure", "envelope={v}"); - assert_eq!( - v["vendor"]["events"][0]["errorCode"], "vendor_lockfile_missing", + assert_eq!(v["status"], "partialFailure", "envelope={v}"); + let events = v["events"] + .as_array() + .unwrap_or_else(|| panic!("the envelope must have events[]; envelope={v}")); + // The download half reports the record it fetched first; the vendor + // engine's events follow in the same envelope. + assert_eq!(events[0]["action"], "downloaded", "envelope={v}"); + assert!( + events + .iter() + .any(|e| e["errorCode"] == "vendor_lockfile_missing" + && e["details"]["mode"] == "vendored"), "{v}" ); - assert_eq!( - v["vendor"]["status"], "partialFailure", - "the carried envelope's status must be demoted; envelope={v}" - ); - let events = v["vendor"]["events"] - .as_array() - .unwrap_or_else(|| panic!("the carried envelope must have events[]; envelope={v}")); assert!( !events.iter().any(|e| e["purl"] == UNSELECTED_PURL), "the detached vendor step must not reconcile (revert) unselected ledger entries; envelope={v}" ); - // The download half reports the record it fetched before the abort. - assert_eq!(v["patches"][0]["action"], "downloaded", "envelope={v}"); - assert_eq!(v["detached"], true, "envelope={v}"); + assert!(v.get("vendor").is_none(), "no nested vendor envelope: {v}"); } /// `get --mode vendored --json` whose vendor step dies at staging: @@ -2052,8 +2086,8 @@ async fn engine_human_silent_no_applicable_files_still_fails() { let (code, json) = download_and_apply_patches(&selected, ¶ms, &server.uri()).await; assert_eq!(code, 1, "json={json}"); - assert_eq!(json["status"], "partial_failure", "json={json}"); - assert_eq!(json["failed"], 1, "json={json}"); + assert_eq!(json["status"], "partialFailure", "json={json}"); + assert_eq!(json["summary"]["failed"], 1, "json={json}"); assert_no_manifest(tmp.path()); } @@ -2234,8 +2268,8 @@ async fn vendored_search_ignores_corrupt_manifest_and_vendors() { assert_eq!(code, 0, "stdout={stdout}\nstderr={stderr}"); let v = parse_single_json_doc(&stdout); assert_eq!(v["status"], "success", "stdout={stdout}"); - assert_eq!(v["patches"][0]["action"], "downloaded", "stdout={stdout}"); - assert_eq!(v["vendor"]["summary"]["applied"], 1, "stdout={stdout}"); + assert_eq!(v["events"][0]["action"], "downloaded", "stdout={stdout}"); + assert_eq!(v["summary"]["applied"], 1, "stdout={stdout}"); assert_eq!( std::fs::read(tmp.path().join(".socket/manifest.json")).unwrap(), b"{ corrupt", @@ -2275,12 +2309,17 @@ async fn vendored_search_json_download_failure_with_clean_vendor_is_partial_fail ); assert_eq!(code, 1, "stdout={stdout}\nstderr={stderr}"); let v = parse_single_json_doc(&stdout); - assert_eq!(v["status"], "partial_failure", "stdout={stdout}"); - assert_eq!(v["failed"], 1, "stdout={stdout}"); - assert_eq!( - v["vendor"]["status"], "success", - "the vendor step itself was clean; stdout={stdout}" - ); + assert_eq!(v["status"], "partialFailure", "stdout={stdout}"); + assert_eq!(v["summary"]["failed"], 1, "stdout={stdout}"); + // The only failure is the download's: the vendor step itself was clean. + let failed: Vec<&serde_json::Value> = v["events"] + .as_array() + .unwrap() + .iter() + .filter(|e| e["action"] == "failed") + .collect(); + assert_eq!(failed.len(), 1, "stdout={stdout}"); + assert_eq!(failed[0]["errorCode"], "download_failed", "stdout={stdout}"); // The successfully-downloaded patch was still vendored. let artifact = tmp .path() @@ -2321,7 +2360,7 @@ async fn vendored_lock_held_vendor_step_errors_without_vendor_envelope() { "no pre-failure vendor envelope exists to carry; stdout={stdout}" ); assert_eq!( - v["patches"][0]["action"], "downloaded", + v["events"][0]["action"], "downloaded", "the download phase preceded the refusal; stdout={stdout}" ); assert_no_manifest(tmp.path()); @@ -2469,9 +2508,9 @@ async fn hosted_lock_held_get_errors_with_top_level_error_code() { v.get("errorCode").is_none(), "no top-level `errorCode` (v5.0); stdout={stdout}" ); - assert_eq!( - v["redirect"]["mode"], "hosted", - "the hosted error envelope keeps its redirect block; stdout={stdout}" + assert!( + v.get("redirect").is_none(), + "nothing was rewritten, so no redirect payload (v5.0); stdout={stdout}" ); // Wet human: the shared lock error line and the wait hint. @@ -2500,8 +2539,11 @@ async fn hosted_lock_held_get_errors_with_top_level_error_code() { ); let v = parse_single_json_doc(&stdout); assert_eq!(v["status"], "success", "stdout={stdout}"); - assert_eq!(v["redirect"]["dryRun"], true, "stdout={stdout}"); - assert_eq!(v["redirect"]["redirected"], 1, "stdout={stdout}"); + assert_eq!(v["dryRun"], true, "stdout={stdout}"); + assert_eq!( + v["summary"]["verified"], 1, + "the pin is previewed; stdout={stdout}" + ); assert_eq!( std::fs::read(tmp.path().join("package-lock.json")).unwrap(), @@ -2628,8 +2670,7 @@ async fn vendored_uuid_json_leaves_unselected_ledger_entries_alone() { assert_eq!(code, 0, "stdout={stdout}\nstderr={stderr}"); let v = parse_single_json_doc(&stdout); assert_eq!(v["status"], "success", "stdout={stdout}"); - assert_eq!(v["vendor"]["status"], "success", "stdout={stdout}"); - let events = v["vendor"]["events"] + let events = v["events"] .as_array() .unwrap_or_else(|| panic!("vendor events must be carried; stdout={stdout}")); assert!( @@ -2724,9 +2765,9 @@ async fn agent_uuid_dry_run_json_classifies_against_the_manifest() { let v = parse_single_json_doc(&stdout); assert_eq!(v["status"], "success", "{v}"); assert_eq!(v["dryRun"], true, "{v}"); - assert_eq!(v["applied"], 0, "{v}"); - assert_eq!(v["patches"][0]["action"], "would_update", "{v}"); - assert_eq!(v["patches"][0]["oldUuid"], UUID_B, "{v}"); + assert_eq!(v["summary"]["applied"], 0, "{v}"); + assert_eq!(v["events"][0]["action"], "verified", "{v}"); + assert_eq!(v["events"][0]["oldUuid"], UUID_B, "{v}"); assert_eq!( before, std::fs::read_to_string(tmp.path().join(".socket/manifest.json")).unwrap() @@ -2881,9 +2922,15 @@ async fn proxy_403_on_uuid_is_paid_required() { let (code, stdout, stderr) = run(&["--json"]); assert_eq!(code, 0, "stdout={stdout}\nstderr={stderr}"); let v = parse_single_json_doc(&stdout); - assert_eq!(v["status"], "paid_required", "{v}"); - assert_eq!(v["patches"][0]["uuid"], UUID, "{v}"); - assert_eq!(v["patches"][0]["tier"], "paid", "{v}"); + assert_eq!(v["status"], "paidRequired", "{v}"); + assert_eq!(v["events"][0]["action"], "skipped", "{v}"); + assert_eq!(v["events"][0]["errorCode"], "paid_required", "{v}"); + assert_eq!(v["events"][0]["uuid"], UUID, "{v}"); + assert!( + v["events"][0].get("purl").is_none(), + "the proxy never named it: {v}" + ); + assert_eq!(v["events"][0]["details"]["tier"], "paid", "{v}"); let (code, stdout, stderr) = run(&[]); assert_eq!(code, 0, "stdout={stdout}\nstderr={stderr}"); @@ -3029,11 +3076,11 @@ async fn agent_dry_run_previews_only_the_installed_release_variant() { assert_eq!(code, 0, "stdout={stdout}\nstderr={stderr}"); let v = parse_single_json_doc(&stdout); assert_eq!(v["dryRun"], true, "{v}"); - let adds: Vec<&serde_json::Value> = v["patches"] + let adds: Vec<&serde_json::Value> = v["events"] .as_array() .unwrap() .iter() - .filter(|p| p["action"] == "would_add") + .filter(|p| p["action"] == "verified") .collect(); assert_eq!(adds.len(), 1, "only the installed variant; {v}"); assert_eq!(adds[0]["purl"], purl_wheel.as_str(), "{v}"); @@ -3171,14 +3218,18 @@ async fn engine_nested_apply_failure_reaches_the_json_envelope() { let (code, json) = download_and_apply_patches(&selected, ¶ms, &server.uri()).await; assert_eq!(code, 1, "json={json}"); - assert_eq!(json["status"], "partial_failure", "json={json}"); - assert_eq!(json["downloaded"], 1, "the download itself worked: {json}"); + assert_eq!(json["status"], "partialFailure", "json={json}"); assert_eq!( - json["failed"], 1, + json["summary"]["downloaded"], 1, + "the download itself worked: {json}" + ); + assert_eq!(json["events"][0]["action"], "downloaded", "json={json}"); + assert_eq!( + json["summary"]["failed"], 1, "the apply failure must be counted: {json}" ); - assert_eq!(json["applied"], 0, "json={json}"); - let rec = &json["patches"][0]; + assert_eq!(json["summary"]["applied"], 0, "json={json}"); + let rec = &json["events"][1]; assert_eq!(rec["purl"], PURL, "json={json}"); assert_eq!(rec["uuid"], UUID, "json={json}"); assert_eq!(rec["action"], "failed", "json={json}"); @@ -3212,11 +3263,12 @@ async fn engine_nested_apply_not_installed_reaches_the_json_envelope() { let (code, json) = download_and_apply_patches(&selected, ¶ms, &server.uri()).await; assert_eq!(code, 1, "json={json}"); - assert_eq!(json["status"], "partial_failure", "json={json}"); - assert_eq!(json["failed"], 1, "json={json}"); - assert_eq!(json["applied"], 0, "json={json}"); - let rec = &json["patches"][0]; + assert_eq!(json["status"], "partialFailure", "json={json}"); + assert_eq!(json["summary"]["failed"], 1, "json={json}"); + assert_eq!(json["summary"]["applied"], 0, "json={json}"); + let rec = &json["events"][1]; assert_eq!(rec["action"], "failed", "json={json}"); + assert_eq!(rec["purl"], PURL, "json={json}"); assert_eq!(rec["errorCode"], "package_not_installed", "json={json}"); assert!( rec["error"].as_str().is_some_and(|e| !e.is_empty()), @@ -3241,10 +3293,11 @@ async fn engine_nested_apply_success_keeps_added_and_counts_applied() { assert_eq!(code, 0, "json={json}"); assert_eq!(json["status"], "success", "json={json}"); - assert_eq!(json["failed"], 0, "json={json}"); - assert_eq!(json["applied"], 1, "json={json}"); - assert_eq!(json["patches"][0]["action"], "added", "json={json}"); - assert!(json["patches"][0].get("errorCode").is_none(), "json={json}"); + assert_eq!(json["summary"]["failed"], 0, "json={json}"); + assert_eq!(json["summary"]["applied"], 1, "json={json}"); + assert_eq!(json["events"][0]["action"], "downloaded", "json={json}"); + assert_eq!(json["events"][1]["action"], "applied", "json={json}"); + assert!(json["events"][0].get("errorCode").is_none(), "json={json}"); assert_eq!( std::fs::read(tmp.path().join("node_modules").join(NAME).join("index.js")).unwrap(), AFTER_BYTES, @@ -3265,10 +3318,10 @@ async fn get_uuid_json_nested_apply_failure_names_the_patch() { let (code, stdout, stderr) = run_get_bin(tmp.path(), &server.uri(), &[UUID, "--json"]); assert_eq!(code, 1, "stdout={stdout}\nstderr={stderr}"); let json = parse_single_json_doc(&stdout); - assert_eq!(json["status"], "partial_failure", "json={json}"); - assert_eq!(json["failed"], 1, "json={json}"); - assert_eq!(json["applied"], 0, "json={json}"); - let rec = &json["patches"][0]; + assert_eq!(json["status"], "partialFailure", "json={json}"); + assert_eq!(json["summary"]["failed"], 1, "json={json}"); + assert_eq!(json["summary"]["applied"], 0, "json={json}"); + let rec = &json["events"][1]; assert_eq!(rec["action"], "failed", "json={json}"); assert_eq!(rec["errorCode"], "apply_failed", "json={json}"); assert!( @@ -3288,10 +3341,11 @@ const LOCAL_EDIT_BYTES: &[u8] = b"vulnerable\n// local edit\n"; /// entry naming `purl` and the overwritten file. fn has_mismatch_warning(json: &serde_json::Value, purl: &str) -> bool { json["warnings"].as_array().is_some_and(|ws| { - ws.iter().filter_map(|w| w.as_str()).any(|w| { - w.starts_with("(content_mismatch_overwritten) ") - && w.contains(purl) - && w.contains("package/index.js") + ws.iter().any(|w| { + let detail = w["detail"].as_str().unwrap_or_default(); + w["code"] == "content_mismatch_overwritten" + && detail.contains(purl) + && detail.contains("package/index.js") }) }) } @@ -3318,8 +3372,8 @@ async fn engine_nested_apply_mismatch_overwrite_reaches_the_json_envelope() { assert_eq!(code, 0, "json={json}"); assert_eq!(json["status"], "success", "json={json}"); - assert_eq!(json["applied"], 1, "json={json}"); - assert_eq!(json["patches"][0]["action"], "added", "json={json}"); + assert_eq!(json["summary"]["applied"], 1, "json={json}"); + assert_eq!(json["events"][0]["action"], "downloaded", "json={json}"); assert!( has_mismatch_warning(&json, PURL), "the overwrite must be reported: {json}" @@ -3360,7 +3414,7 @@ async fn get_uuid_json_mismatch_overwrite_is_reported() { assert_eq!(code, 0, "stdout={stdout}\nstderr={stderr}"); let json = parse_single_json_doc(&stdout); assert_eq!(json["status"], "success", "json={json}"); - assert_eq!(json["applied"], 1, "json={json}"); + assert_eq!(json["summary"]["applied"], 1, "json={json}"); assert!( has_mismatch_warning(&json, PURL), "the overwrite must be reported: {json}" diff --git a/crates/socket-patch-cli/tests/covgap_commands_rollback.rs b/crates/socket-patch-cli/tests/covgap_commands_rollback.rs index ce97ce3a9..5cfae05cc 100644 --- a/crates/socket-patch-cli/tests/covgap_commands_rollback.rs +++ b/crates/socket-patch-cli/tests/covgap_commands_rollback.rs @@ -24,6 +24,9 @@ //! rollback restores the upstream registry entry from a mock npm registry //! (`SOCKET_NPM_REGISTRY`, see `NpmRegistry`). +#[path = "common/rollback_json.rs"] +mod rollback_json; + #[path = "prebuilt_common/mod.rs"] mod prebuilt_common; #[path = "common/pty_io.rs"] @@ -513,10 +516,11 @@ fn corrupt_manifest_json_errors_in_both_modes() { ); let v = parse_envelope(&stdout, &stderr); assert_eq!(v["status"], "error", "stdout=\n{stdout}"); + assert_eq!(v["error"]["code"], "manifest_invalid", "stdout=\n{stdout}"); assert!( v["error"]["message"] .as_str() - .is_some_and(|e| e.contains("Failed to parse manifest JSON")), + .is_some_and(|e| e.contains("not valid JSON")), "the error must name the parse failure; stdout=\n{stdout}" ); @@ -577,10 +581,14 @@ fn blobs_path_as_file_yields_legacy_error_envelope() { .is_some_and(|e| !e.is_empty()), "the envelope carries the io error; stdout=\n{stdout}" ); - assert_eq!(v["rolledBack"], 0, "stdout=\n{stdout}"); - assert_eq!(v["failed"], 0, "stdout=\n{stdout}"); - assert_eq!(v["vendored"], json!([]), "stdout=\n{stdout}"); - assert_eq!(v["results"], json!([]), "stdout=\n{stdout}"); + assert_eq!(v["summary"]["rolledBack"], 0, "stdout=\n{stdout}"); + assert_eq!(v["summary"]["failed"], 0, "stdout=\n{stdout}"); + assert_eq!(v["command"], "rollback", "stdout=\n{stdout}"); + assert_eq!( + v["events"], + json!([]), + "a full error envelope; stdout=\n{stdout}" + ); // ── human: bare Error line ── let tmp = build(); @@ -666,7 +674,7 @@ fn path_scope_warns_about_out_of_scope_restored_copies() { "the singular grammar must hold; stdout=\n{stdout}" ); assert_eq!( - v["manifest"]["removedEntries"], + rollback_json::manifest_removed(&v), json!([purl]), "the fully-restored patch leaves the manifest; stdout=\n{stdout}" ); @@ -935,10 +943,9 @@ fn vendored_unknown_ecosystem_fails_leg_in_both_modes() { // (#1066), counted in the top-level `failed`. assert_eq!(v["status"], "error", "stdout=\n{stdout}"); assert_eq!(v["error"]["code"], "rollback_failed", "stdout=\n{stdout}"); - assert_eq!(v["failed"], 1, "stdout=\n{stdout}"); - let failed = v["vendoredFailed"] - .as_array() - .expect("vendoredFailed array"); + assert_eq!(v["summary"]["failed"], 1, "stdout=\n{stdout}"); + let failed_view = rollback_json::vendored_failed(&v); + let failed = failed_view.as_array().expect("vendoredFailed array"); assert_eq!(failed.len(), 1, "stdout=\n{stdout}"); assert_eq!(failed[0]["purl"], V_PURL, "stdout=\n{stdout}"); assert!( @@ -1071,7 +1078,7 @@ fn ecosystems_filter_narrows_vendored_scope() { assert_eq!(code, 0, "stdout=\n{stdout}\nstderr=\n{stderr}"); let v = parse_envelope(&stdout, &stderr); assert_eq!( - v["vendoredReverted"], + rollback_json::vendored_reverted(&v), json!([]), "a pypi-scoped run must not revert the npm entry; stdout=\n{stdout}" ); @@ -1093,7 +1100,7 @@ fn ecosystems_filter_narrows_vendored_scope() { assert_eq!(code, 0, "stdout=\n{stdout}\nstderr=\n{stderr}"); let v = parse_envelope(&stdout, &stderr); assert_eq!( - v["vendoredReverted"], + rollback_json::vendored_reverted(&v), json!([V_PURL]), "the npm-scoped run must revert it; stdout=\n{stdout}" ); @@ -1122,18 +1129,21 @@ fn corrupt_vendor_ledger_warns_and_fails_in_both_modes() { "a corrupt vendor ledger must exit 1; stdout=\n{stdout}\nstderr=\n{stderr}" ); let v = parse_envelope(&stdout, &stderr); - assert_eq!(v["status"], "partial_failure", "stdout=\n{stdout}"); + assert_eq!(v["status"], "partialFailure", "stdout=\n{stdout}"); assert!( warning_codes(&v).contains(&"vendor_state_unreadable".to_string()), "the warning must be surfaced; stdout=\n{stdout}" ); - assert_eq!( - v["gc"], - json!({ "skipped": true }), + assert!( + v.get("gc").is_none(), "GC must be skipped fail-closed; stdout=\n{stdout}" ); + assert!( + warning_codes(&v).contains(&"gc_skipped".to_string()), + "the skipped GC is warned; stdout=\n{stdout}" + ); assert_eq!( - v["manifest"]["removedEntries"], + rollback_json::manifest_removed(&v), json!([]), "manifest cleanup must be skipped fail-closed; stdout=\n{stdout}" ); @@ -1181,7 +1191,7 @@ fn path_glob_selects_vendored_entry() { ); let v = parse_envelope(&stdout, &stderr); assert_eq!( - v["vendoredReverted"], + rollback_json::vendored_reverted(&v), json!([V_PURL]), "the path target must select the vendored entry; stdout=\n{stdout}" ); @@ -1309,10 +1319,9 @@ fn vendored_ledger_save_failure_fails_closed() { // (#1066), counted in the top-level `failed`. assert_eq!(v["status"], "error", "stdout=\n{stdout}"); assert_eq!(v["error"]["code"], "rollback_failed", "stdout=\n{stdout}"); - assert_eq!(v["failed"], 1, "stdout=\n{stdout}"); - let failed = v["vendoredFailed"] - .as_array() - .expect("vendoredFailed array"); + assert_eq!(v["summary"]["failed"], 1, "stdout=\n{stdout}"); + let failed_view = rollback_json::vendored_failed(&v); + let failed = failed_view.as_array().expect("vendoredFailed array"); assert_eq!(failed.len(), 1, "stdout=\n{stdout}"); assert_eq!(failed[0]["purl"], V_PURL, "stdout=\n{stdout}"); assert!( @@ -1352,12 +1361,12 @@ fn qualified_manifest_purl_removed_after_vendored_revert() { assert_eq!(code, 0, "stdout=\n{stdout}\nstderr=\n{stderr}"); let v = parse_envelope(&stdout, &stderr); assert_eq!( - v["vendoredReverted"], + rollback_json::vendored_reverted(&v), json!([V_PURL]), "the ledger revert reports the LEDGER key; stdout=\n{stdout}" ); assert_eq!( - v["manifest"]["removedEntries"], + rollback_json::manifest_removed(&v), json!([QUALIFIED]), "the qualified manifest spelling must still be removed; stdout=\n{stdout}" ); @@ -1653,8 +1662,9 @@ fn per_purl_revert_failure_lands_in_hosted_failed() { "a refused pin must exit 1; stdout=\n{stdout}\nstderr=\n{stderr}" ); let v = parse_envelope(&stdout, &stderr); - assert_eq!(v["status"], "partial_failure", "stdout=\n{stdout}"); - let failed = v["hosted"]["failed"].as_array().expect("failed array"); + assert_eq!(v["status"], "partialFailure", "stdout=\n{stdout}"); + let failed_view = rollback_json::hosted_failed(&v); + let failed = failed_view.as_array().expect("failed array"); assert_eq!(failed.len(), 1, "stdout=\n{stdout}"); assert_eq!(failed[0]["purl"], LP_PURL, "stdout=\n{stdout}"); let error = failed[0]["error"].as_str().unwrap_or_default(); @@ -1667,7 +1677,7 @@ fn per_purl_revert_failure_lands_in_hosted_failed() { stdout=\n{stdout}" ); assert_eq!( - v["hosted"]["reverted"], + rollback_json::hosted_reverted(&v), json!([IO_PURL]), "the other pin restores on its own; stdout=\n{stdout}" ); @@ -1809,12 +1819,17 @@ fn legacy_ledger_beside_a_live_pin_is_never_the_revert_source() { // hosted pin counts in the top-level `failed`. assert_eq!(v["status"], "error", "stdout=\n{stdout}"); assert_eq!(v["error"]["code"], "rollback_failed", "stdout=\n{stdout}"); - assert_eq!(v["failed"], 1, "stdout=\n{stdout}"); + assert_eq!(v["summary"]["failed"], 1, "stdout=\n{stdout}"); assert_eq!( - v["hosted"]["failed"][0]["purl"], LP_PURL, + rollback_json::hosted_failed(&v)[0]["purl"], + LP_PURL, + "stdout=\n{stdout}" + ); + assert_eq!( + rollback_json::hosted_reverted(&v), + json!([]), "stdout=\n{stdout}" ); - assert_eq!(v["hosted"]["reverted"], json!([]), "stdout=\n{stdout}"); assert_eq!( std::fs::read_to_string(tmp.path().join("yarn.lock")).unwrap(), yarn_lock_content(&yarn_redirected_block()), @@ -1837,7 +1852,7 @@ fn legacy_ledger_beside_a_live_pin_is_never_the_revert_source() { let v = parse_envelope(&stdout, &stderr); assert_eq!(v["status"], "success", "stdout=\n{stdout}"); assert_eq!( - v["hosted"]["reverted"], + rollback_json::hosted_reverted(&v), json!([LP_PURL]), "stdout=\n{stdout}" ); @@ -1912,7 +1927,7 @@ fn ecosystems_filter_narrows_hosted_scope() { assert_eq!(code, 0, "stdout=\n{stdout}\nstderr=\n{stderr}"); let v = parse_envelope(&stdout, &stderr); assert_eq!( - v["hosted"]["reverted"], + rollback_json::hosted_reverted(&v), json!([]), "a pypi-scoped run must not restore the npm pin; stdout=\n{stdout}" ); @@ -1930,7 +1945,7 @@ fn ecosystems_filter_narrows_hosted_scope() { assert_eq!(code, 0, "stdout=\n{stdout}\nstderr=\n{stderr}"); let v = parse_envelope(&stdout, &stderr); assert_eq!( - v["hosted"]["reverted"], + rollback_json::hosted_reverted(&v), json!([LP_PURL]), "the npm-scoped run must restore it; stdout=\n{stdout}" ); @@ -1970,7 +1985,7 @@ fn path_glob_selects_hosted_record() { ); let v = parse_envelope(&stdout, &stderr); assert_eq!( - v["hosted"]["reverted"], + rollback_json::hosted_reverted(&v), json!([LP_PURL]), "the path target must select the hosted pin; stdout=\n{stdout}" ); @@ -2011,14 +2026,16 @@ fn hosted_restore_write_failure_lands_in_hosted_failed() { "a lockfile write failure must exit 1; stdout=\n{stdout}\nstderr=\n{stderr}" ); let v = parse_envelope(&stdout, &stderr); - assert_eq!(v["status"], "partial_failure", "stdout=\n{stdout}"); - let failed = v["hosted"]["failed"].as_array().expect("failed array"); + assert_eq!(v["status"], "partialFailure", "stdout=\n{stdout}"); + let failed_view = rollback_json::hosted_failed(&v); + let failed = failed_view.as_array().expect("failed array"); assert!( - failed.iter().any(|f| f["purl"] == "files" + failed.iter().any(|f| f["purl"].is_null() + && f["errorCode"] == "hosted_write_failed" && f["error"] .as_str() .is_some_and(|e| e.contains("writing the restored lockfiles failed"))), - "the write failure must be reported under the 'files' key; stdout=\n{stdout}" + "the write failure must be an artifact-level hosted_write_failed event; stdout=\n{stdout}" ); assert_eq!( std::fs::read_to_string(project.join("yarn.lock")).unwrap(), @@ -2295,7 +2312,7 @@ fn gc_failure_warns_but_run_still_succeeds() { "the diffs sweep failure must be warned; stdout=\n{stdout}" ); assert_eq!( - v["manifest"]["removedEntries"], + rollback_json::manifest_removed(&v), json!([purl]), "the already-original entry still leaves the manifest; stdout=\n{stdout}" ); @@ -2350,13 +2367,13 @@ fn manifest_write_failure_warns_and_exits_one() { "a manifest write failure must exit 1; stdout=\n{stdout}\nstderr=\n{stderr}" ); let v = parse_envelope(&stdout, &stderr); - assert_eq!(v["status"], "partial_failure", "stdout=\n{stdout}"); + assert_eq!(v["status"], "partialFailure", "stdout=\n{stdout}"); assert!( warning_codes(&v).contains(&"manifest_write_failed".to_string()), "the write failure must be warned; stdout=\n{stdout}" ); assert_eq!( - v["manifest"]["removedEntries"], + rollback_json::manifest_removed(&v), json!([]), "nothing may be reported removed when the write failed; stdout=\n{stdout}" ); @@ -2526,7 +2543,7 @@ fn vendored_dry_run_json_previews_without_human_print() { let v = parse_envelope(&stdout, &stderr); assert_eq!(v["dryRun"], json!(true), "stdout=\n{stdout}"); assert_eq!( - v["vendoredReverted"], + rollback_json::vendored_reverted(&v), json!([V_PURL]), "the envelope must preview the revert; stdout=\n{stdout}" ); @@ -2549,8 +2566,8 @@ fn vendored_dry_run_json_previews_without_human_print() { /// The manifest vanishing while another process holds the apply lock: the /// pre-lock existence probe saw the file, but the under-lock read finds -/// it gone — rollback fails closed with the "Invalid manifest" error -/// (exit 1) rather than silently treating the run as empty. +/// it gone — rollback fails closed with `manifest_not_found` naming the +/// path (exit 1) rather than silently treating the run as empty. /// /// Choreography: hold the lock, let the CLI pass its probe and block, /// delete the manifest, release. If the CLI was slow enough to probe @@ -2558,7 +2575,7 @@ fn vendored_dry_run_json_previews_without_human_print() { /// instead — that alternative is detected and retried with a longer /// pre-delete grace (bounded; the first attempt lands in practice). #[test] -fn manifest_deleted_under_held_lock_fails_with_invalid_manifest() { +fn manifest_deleted_under_held_lock_fails_with_manifest_not_found() { use fs2::FileExt; for attempt in 1..=8u64 { @@ -2610,8 +2627,14 @@ fn manifest_deleted_under_held_lock_fails_with_invalid_manifest() { ); let v = parse_envelope(&stdout, &stderr); assert_eq!(v["status"], "error", "stdout=\n{stdout}"); + // Both interleavings are `manifest_not_found` (v5.0); the read + // under the lock names the path, the pre-lock probe does not. + assert_eq!( + v["error"]["code"], "manifest_not_found", + "stdout=\n{stdout}" + ); match v["error"]["message"].as_str() { - Some("Invalid manifest") => return, // target interleaving reached + Some(m) if m.starts_with("Manifest not found at ") => return, // target interleaving reached Some("Manifest not found") => continue, // probed after the delete — retry other => panic!( "unexpected error for the vanished manifest: {other:?}\nstdout=\n{stdout}\nstderr=\n{stderr}" @@ -2883,26 +2906,18 @@ fn identifier_scope_leaves_unrelated_vendored_entry_untouched() { "the identifier-scoped rollback must succeed; stdout=\n{stdout}\nstderr=\n{stderr}" ); let v = parse_envelope(&stdout, &stderr); - assert_eq!(v["rolledBack"], json!(1), "stdout=\n{stdout}"); + assert_eq!(v["summary"]["rolledBack"], json!(1), "stdout=\n{stdout}"); assert_eq!( - v["results"][0]["purl"], - json!(IO_PURL), + rollback_json::agent_rolled_back(&v), + json!([IO_PURL]), "only the named patch may be acted on; stdout=\n{stdout}" ); - for leg in [ - "vendoredReverted", - "vendoredPreserved", - "vendoredKept", - "vendoredFailed", - ] { - assert_eq!( - v[leg], - json!([]), - "the identifier must not leak into the vendored leg ({leg}); stdout=\n{stdout}" - ); - } + assert!( + rollback_json::events_with_mode(&v, "vendored").is_empty(), + "the identifier must not leak into the vendored leg; stdout=\n{stdout}" + ); assert_eq!( - v["manifest"]["removedEntries"], + rollback_json::manifest_removed(&v), json!([IO_PURL]), "only the named entry leaves the manifest; stdout=\n{stdout}" ); @@ -3057,7 +3072,8 @@ fn two_vendored_entries_each_cleanup_via_their_own_revert() { "the two-entry revert must succeed; stdout=\n{stdout}\nstderr=\n{stderr}" ); let v = parse_envelope(&stdout, &stderr); - let mut reverted: Vec = v["vendoredReverted"] + let reverted_view = rollback_json::vendored_reverted(&v); + let mut reverted: Vec = reverted_view .as_array() .expect("vendoredReverted array") .iter() @@ -3069,7 +3085,8 @@ fn two_vendored_entries_each_cleanup_via_their_own_revert() { vec![V_PURL.to_string(), RP_PURL.to_string()], "both entries must revert; stdout=\n{stdout}" ); - let mut removed: Vec = v["manifest"]["removedEntries"] + let removed_view = rollback_json::manifest_removed(&v); + let mut removed: Vec = removed_view .as_array() .expect("removedEntries array") .iter() @@ -3200,8 +3217,13 @@ fn pypi_variant_group_with_no_installed_match_attempts_every_variant() { // Both variants failed and nothing was rolled back: a total failure. assert_eq!(v["status"], json!("error"), "stdout=\n{stdout}"); assert_eq!(v["error"]["code"], "rollback_failed", "stdout=\n{stdout}"); - assert_eq!(v["failed"], json!(2), "stdout=\n{stdout}"); - let results = v["results"].as_array().expect("results array"); + assert_eq!(v["summary"]["failed"], json!(2), "stdout=\n{stdout}"); + let results: Vec<&Value> = v["events"] + .as_array() + .expect("events array") + .iter() + .filter(|e| e["action"] == "failed") + .collect(); let mut result_purls: Vec<&str> = results .iter() .map(|r| r["purl"].as_str().expect("purl string")) @@ -3214,10 +3236,10 @@ fn pypi_variant_group_with_no_installed_match_attempts_every_variant() { distribution — silent skipping is the bug this guards; stdout=\n{stdout}" ); for r in results { - assert_eq!(r["success"], json!(false), "stdout=\n{stdout}"); + assert_eq!(r["errorCode"], json!("hash_mismatch"), "stdout=\n{stdout}"); assert_eq!( - r["filesVerified"][0]["status"], - json!("hash_mismatch"), + r["details"]["filesVerified"][0]["status"], + json!("hashMismatch"), "the per-file verification must surface the drift; stdout=\n{stdout}" ); } @@ -3328,23 +3350,22 @@ fn discovered_local_go_redirect_drops_wiring_not_cache_copy() { "the discovered redirect rollback must succeed; stdout=\n{stdout}\nstderr=\n{stderr}" ); let v = parse_envelope(&stdout, &stderr); - assert_eq!(v["rolledBack"], json!(1), "stdout=\n{stdout}"); - let result = &v["results"][0]; - assert_eq!(result["purl"], json!(PURL), "stdout=\n{stdout}"); + assert_eq!(v["summary"]["rolledBack"], json!(1), "stdout=\n{stdout}"); + let result = rollback_json::agent_event(&v, "rolledBack", PURL); // The DISCOVERED route: the result names the module-cache copy — the // undiscovered fallback reports the project root instead, so this // pins which path ran. - let path = result["path"].as_str().expect("path string"); + let path = result["details"]["path"].as_str().expect("path string"); assert!( path.ends_with("discovered@v1.2.3") && path != root.display().to_string(), "the target must be the discovered cache dir; path={path}" ); assert!( - result["filesRolledBack"] + result["files"] .as_array() - .expect("filesRolledBack array") + .expect("files array") .iter() - .any(|f| f == "package/discovered.go"), + .any(|f| f["path"] == "package/discovered.go"), "the redirect teardown reports the patch's files; stdout=\n{stdout}" ); assert!( diff --git a/crates/socket-patch-cli/tests/covgap_commands_scan_hosted.rs b/crates/socket-patch-cli/tests/covgap_commands_scan_hosted.rs index aabccb264..a7119596a 100644 --- a/crates/socket-patch-cli/tests/covgap_commands_scan_hosted.rs +++ b/crates/socket-patch-cli/tests/covgap_commands_scan_hosted.rs @@ -16,6 +16,9 @@ //! be read back; no parent-process env is ever mutated, so no //! serialization is needed. +#[path = "common/rollback_json.rs"] +mod rollback_json; + use std::path::Path; use serde_json::{json, Value}; @@ -26,6 +29,37 @@ use wiremock::{Mock, MockServer, ResponseTemplate}; mod common; use common::envelope::codes_in; +/// The hosted `skipped` events as the pre-v5.0 `redirect.skipped[]` rows +/// (`{purl, uuid, reason: , detail?: }`). +fn hosted_skipped(doc: &Value) -> Vec { + doc["events"] + .as_array() + .into_iter() + .flatten() + .filter(|e| e["details"]["mode"] == "hosted" && e["action"] == "skipped") + .map(|e| { + let mut row = json!({"purl": e["purl"], "uuid": e["uuid"], "reason": e["errorCode"]}); + if e["reason"].is_string() { + row["detail"] = e["reason"].clone(); + } + row + }) + .collect() +} + +/// How many pins a hosted run wrote (`applied`) or would write (`verified`). +fn hosted_pinned(doc: &Value) -> u64 { + doc["events"] + .as_array() + .into_iter() + .flatten() + .filter(|e| { + e["details"]["mode"] == "hosted" + && (e["action"] == "applied" || e["action"] == "verified") + }) + .count() as u64 +} + const ORG: &str = "test-org"; const NAME: &str = "covgap-hosted"; const VERSION: &str = "1.0.0"; @@ -370,7 +404,7 @@ fn scan_hosted_json( /// The `detail` of the first warning carrying `code` (panics when absent). fn warning_detail<'a>(doc: &'a Value, code: &str) -> &'a str { - doc["redirect"]["warnings"] + doc["warnings"] .as_array() .into_iter() .flatten() @@ -413,11 +447,11 @@ async fn granted_reference_with_unparseable_purl_is_skipped_as_bad_purl() { let (code, doc) = scan_hosted_json(tmp.path(), &server.uri(), &[], &[]); assert_eq!(code, 0, "a fully-skipped redirect still exits 0: {doc:#}"); assert_eq!( - doc["redirect"]["skipped"], + Value::Array(hosted_skipped(&doc)), json!([{ "purl": "not-a-purl", "uuid": UUID, "reason": "bad_purl" }]), "the skipped entry must carry the SERVED purl and the bad_purl reason: {doc:#}" ); - assert_eq!(doc["redirect"]["redirected"], 0, "envelope: {doc:#}"); + assert_eq!(hosted_pinned(&doc), 0, "envelope: {doc:#}"); assert_eq!( std::fs::read(tmp.path().join("package-lock.json")).unwrap(), lock_before, @@ -459,11 +493,11 @@ async fn granted_reference_without_url_is_skipped_as_no_url() { let (code, doc) = scan_hosted_json(tmp.path(), &server.uri(), &[], &[]); assert_eq!(code, 0, "a fully-skipped redirect still exits 0: {doc:#}"); assert_eq!( - doc["redirect"]["skipped"], + Value::Array(hosted_skipped(&doc)), json!([{ "purl": PURL, "uuid": UUID, "reason": "no_url" }]), "the skipped entry must carry the no_url reason: {doc:#}" ); - assert_eq!(doc["redirect"]["redirected"], 0, "envelope: {doc:#}"); + assert_eq!(hosted_pinned(&doc), 0, "envelope: {doc:#}"); assert_eq!( std::fs::read(tmp.path().join("package-lock.json")).unwrap(), lock_before, @@ -512,15 +546,14 @@ async fn wet_takeover_refuses_unrevertable_vendored_flavor_fail_closed() { "the refusal must name the fail-closed outcome and the manual path: {detail}" ); assert!( - doc["redirect"]["skipped"] - .as_array() - .is_some_and(|s| s.iter().any(|e| e["purl"] == PURL - && e["uuid"] == UUID - && e["reason"] == "vendored_revert_failed")), + Some(&hosted_skipped(&doc)).is_some_and(|s| s.iter().any(|e| e["purl"] == PURL + && e["uuid"] == UUID + && e["reason"] == "vendored_revert_failed")), "the refusal must be accounted as skipped: {doc:#}" ); assert_eq!( - doc["redirect"]["redirected"], 0, + hosted_pinned(&doc), + 0, "a refused purl is never counted redirected: {doc:#}" ); // The refused-purl cleanup dropped the override: the rewrite landed @@ -734,8 +767,9 @@ async fn hosted_lock_held_refuses_before_any_write() { "no --lock-timeout: no waited clause; {doc:#}" ); assert_eq!( - doc["redirect"]["mode"], "hosted", - "the hosted error envelope keeps its redirect block: {doc:#}" + doc.get("redirect"), + None, + "a hosted error envelope has no redirect payload (nothing was rewritten): {doc:#}" ); // Wet human: the stderr line carries the stable code and the hint. @@ -754,17 +788,15 @@ async fn hosted_lock_held_refuses_before_any_write() { // nothing), so it previews the rewrite instead of contending. let (code, doc) = scan_hosted_json(root, &server.uri(), &["--dry-run"], &[]); assert_eq!(code, 0, "a dry run never contends: {doc:#}"); - assert_eq!(doc["redirect"]["dryRun"], true, "{doc:#}"); - assert_eq!(doc["redirect"]["redirected"], 1, "{doc:#}"); + assert_eq!(doc["dryRun"], true, "{doc:#}"); + assert_eq!(hosted_pinned(&doc), 1, "{doc:#}"); // Zero grants under the held lock: nothing to write, so no lock taken. let (code, doc) = scan_hosted_json(root, &no_grant.uri(), &[], &[]); assert_eq!(code, 0, "an all-skipped run never contends: {doc:#}"); - assert_eq!(doc["redirect"]["redirected"], 0, "{doc:#}"); + assert_eq!(hosted_pinned(&doc), 0, "{doc:#}"); assert!( - doc["redirect"]["skipped"] - .as_array() - .is_some_and(|s| s.iter().any(|e| e["reason"] == "not_found")), + Some(&hosted_skipped(&doc)).is_some_and(|s| s.iter().any(|e| e["reason"] == "not_found")), "{doc:#}" ); @@ -894,8 +926,9 @@ async fn hosted_lock_io_when_a_file_squats_on_socket_dir() { "the fault names the squatting path: {error}" ); assert_eq!( - doc["redirect"]["mode"], "hosted", - "the hosted error envelope keeps its redirect block: {doc:#}" + doc.get("redirect"), + None, + "a hosted error envelope has no redirect payload (nothing was rewritten): {doc:#}" ); let (code, _stdout, stderr) = scan_hosted(root, &server.uri(), &[], &[]); @@ -976,7 +1009,7 @@ async fn successful_wet_hosted_run_leaves_nothing_under_socket() { write_npm_project(root, NAME); let (code, doc) = scan_hosted_json(root, &server.uri(), &[], &[]); assert_eq!(code, 0, "{doc:#}"); - assert_eq!(doc["redirect"]["redirected"], 1, "{doc:#}"); + assert_eq!(hosted_pinned(&doc), 1, "{doc:#}"); assert!( !root.join(".socket/apply.lock").exists(), "apply.lock never outlives the run" @@ -1183,7 +1216,7 @@ async fn cargo_other_version_vendored_wiring_does_not_refuse_the_redirect() { &[], &[("CARGO_HOME", cargo_home_s.as_str())], ); - let codes: Vec<&str> = doc["redirect"]["warnings"] + let codes: Vec<&str> = doc["warnings"] .as_array() .into_iter() .flatten() @@ -1194,8 +1227,7 @@ async fn cargo_other_version_vendored_wiring_does_not_refuse_the_redirect() { "another version's wiring is not this version's lost ledger: {doc:#}" ); assert!( - !doc["redirect"]["skipped"] - .as_array() + !Some(&hosted_skipped(&doc)) .is_some_and(|s| s.iter().any(|e| e["reason"] == "vendored_revert_failed")), "{doc:#}" ); @@ -1286,12 +1318,12 @@ async fn ledgerless_cargo_wiring_refuses(manifest_wiring: bool) { "the refusal must name the missing ledger: {detail}" ); assert!( - doc["redirect"]["skipped"].as_array().is_some_and(|s| s + Some(&hosted_skipped(&doc)).is_some_and(|s| s .iter() .any(|e| e["purl"] == CPURL && e["reason"] == "vendored_revert_failed")), "the refusal must be accounted as skipped: {doc:#}" ); - assert_eq!(doc["redirect"]["redirected"], 0, "envelope: {doc:#}"); + assert_eq!(hosted_pinned(&doc), 0, "envelope: {doc:#}"); assert_eq!( std::fs::read(root.join(".cargo/config.toml")).ok(), config_before, @@ -1315,7 +1347,7 @@ async fn ledgerless_cargo_wiring_refuses(manifest_wiring: bool) { /// executable or installed dependencies. A fresh clone works immediately. /// No run writes a redirect ledger (v5), and `rollback` cannot restore a /// binary `bun.lockb` pin to its upstream entry: it refuses the pin -/// (`partial_failure`, exit 1) naming the `git checkout` remedy and leaves +/// (`rollback_failed`, exit 1) naming the `git checkout` remedy and leaves /// the lock byte-identical. #[tokio::test] async fn native_bun_lockb_hosting_dry_run_rerun_and_rollback_without_bun() { @@ -1351,7 +1383,7 @@ async fn native_bun_lockb_hosting_dry_run_rerun_and_rollback_without_bun() { let (code, preview) = scan_hosted_json(tmp.path(), &server.uri(), &["--dry-run"], &env); assert_eq!(code, 0, "{preview:#}"); - assert_eq!(preview["redirect"]["redirected"], 1, "{preview:#}"); + assert_eq!(hosted_pinned(&preview), 1, "{preview:#}"); assert!(preview["redirect"]["rewrittenFiles"] .as_array() .unwrap() @@ -1367,7 +1399,7 @@ async fn native_bun_lockb_hosting_dry_run_rerun_and_rollback_without_bun() { let (code, applied) = scan_hosted_json(tmp.path(), &server.uri(), &[], &env); assert_eq!(code, 0, "{applied:#}"); - assert_eq!(applied["redirect"]["redirected"], 1, "{applied:#}"); + assert_eq!(hosted_pinned(&applied), 1, "{applied:#}"); let patched = std::fs::read(tmp.path().join("bun.lockb")).unwrap(); assert_ne!(patched, original); assert!(patched @@ -1382,7 +1414,7 @@ async fn native_bun_lockb_hosting_dry_run_rerun_and_rollback_without_bun() { let (code, rerun) = scan_hosted_json(tmp.path(), &server.uri(), &[], &env); assert_eq!(code, 0, "{rerun:#}"); - assert_eq!(rerun["redirect"]["redirected"], 1, "{rerun:#}"); + assert_eq!(hosted_pinned(&rerun), 1, "{rerun:#}"); assert_eq!( std::fs::read(tmp.path().join("bun.lockb")).unwrap(), patched @@ -1408,10 +1440,9 @@ async fn native_bun_lockb_hosting_dry_run_rerun_and_rollback_without_bun() { // The refused pin is the only outcome: a total failure (#1066). assert_eq!(doc["status"], "error", "{doc:#}"); assert_eq!(doc["error"]["code"], "rollback_failed", "{doc:#}"); - assert_eq!(doc["failed"], 1, "{doc:#}"); - let failed = doc["hosted"]["failed"] - .as_array() - .unwrap_or_else(|| panic!("{doc:#}")); + assert_eq!(doc["summary"]["failed"], 1, "{doc:#}"); + let failed_view = rollback_json::hosted_failed(&doc); + let failed = failed_view.as_array().unwrap_or_else(|| panic!("{doc:#}")); assert_eq!(failed.len(), 1, "{doc:#}"); assert_eq!(failed[0]["purl"], purl, "{doc:#}"); let error = failed[0]["error"].as_str().unwrap_or_default(); @@ -1448,7 +1479,7 @@ async fn malformed_bun_lockb_reports_format_error_without_spawning_bun() { let (code, doc) = scan_hosted_json(tmp.path(), &server.uri(), extra, &[("PATH", path.as_str())]); assert_eq!(code, 0, "{doc:#}"); - assert_eq!(doc["redirect"]["redirected"], 0, "{doc:#}"); + assert_eq!(hosted_pinned(&doc), 0, "{doc:#}"); assert!(warning_detail(&doc, "redirect_bun_lockb_invalid").contains("bun.lockb")); assert_eq!( std::fs::read(tmp.path().join("bun.lockb")).unwrap(), @@ -1481,7 +1512,7 @@ async fn unreadable_bun_lockb_is_preserved_and_reports_the_io_error() { let (code, doc) = scan_hosted_json(tmp.path(), &server.uri(), &[], &[("PATH", path.as_str())]); std::fs::set_permissions(&lock, std::fs::Permissions::from_mode(0o644)).unwrap(); assert_eq!(code, 0, "{doc:#}"); - assert_eq!(doc["redirect"]["redirected"], 0, "{doc:#}"); + assert_eq!(hosted_pinned(&doc), 0, "{doc:#}"); assert!(warning_detail(&doc, "redirect_bun_lockb_invalid").contains("cannot read bun.lockb")); assert_eq!(std::fs::read(&lock).unwrap(), original); assert!(!tmp.path().join("bun.lock").exists()); @@ -1587,7 +1618,7 @@ async fn fifo_bun_lockb_refuses_before_spawning_bun_and_never_wedges() { ); let detail = warning_detail(&doc, "redirect_bun_lockb_invalid"); assert!(detail.contains("not a regular file"), "{extra:?}: {detail}"); - let codes = codes_in(&doc["redirect"]["warnings"]); + let codes = codes_in(&doc["warnings"]); assert_eq!( codes .iter() @@ -1600,7 +1631,7 @@ async fn fifo_bun_lockb_refuses_before_spawning_bun_and_never_wedges() { !codes.contains(&"redirect_npm_no_lockfile".to_string()), "{extra:?}: the existing binary lock must not be reported missing: {codes:?}" ); - assert_eq!(doc["redirect"]["redirected"], 0, "{extra:?}: {doc:#}"); + assert_eq!(hosted_pinned(&doc), 0, "{extra:?}: {doc:#}"); assert!( !marker.exists(), "{extra:?}: bun must never be spawned on a FIFO bun.lockb (it blocks on it too)" @@ -1629,9 +1660,10 @@ fn assert_sibling_lock_outcome( dry_run: bool, ) { assert!(warning_detail(doc, "redirect_bun_lockb_invalid").contains("bun.lockb")); - assert_eq!(doc["redirect"]["dryRun"], dry_run, "{doc:#}"); + assert_eq!(doc["dryRun"], dry_run, "{doc:#}"); assert_eq!( - doc["redirect"]["redirected"], 0, + hosted_pinned(&doc), + 0, "a failed primary binary lock must never be confirmed by {sibling}: {doc:#}" ); assert!( @@ -1815,7 +1847,7 @@ async fn unreadable_pnpm_workspace_gets_warning_only_guidance_in_a_live_run() { lock.contains(&format!("tarball: {HOSTED_URL}")), "the redirect must land despite the workspace fallback:\n{lock}" ); - assert_eq!(doc["redirect"]["redirected"], 1, "envelope: {doc:#}"); + assert_eq!(hosted_pinned(&doc), 1, "envelope: {doc:#}"); // FINDING-5 seam: the user's workspace file was NOT overwritten with the // root-only scaffold. assert_eq!( @@ -1915,7 +1947,8 @@ async fn live_hosted_overlap_fires_redirect_supersedes_vendored() { "the overlap warning never flips the exit code: {doc:#}" ); assert_eq!( - doc["redirect"]["redirected"], 1, + hosted_pinned(&doc), + 1, "anchor: Y must redirect normally: {doc:#}" ); let detail = warning_detail(&doc, "redirect_supersedes_vendored"); @@ -2055,8 +2088,8 @@ async fn human_vex_success_summary_names_statements_path_and_ledger_caveat() { } /// `--json` `--vex` run: VEX advisories are muted on stderr under --json, -/// so the hosted envelope's `vex.warnings` must carry them (same -/// skip-if-empty key as the agent arm), instead of dropping them. +/// so the hosted envelope's `vex.warnings` must carry them (after the +/// `vex_hosted_unverified` advisory every hosted attestation carries). #[tokio::test] async fn json_vex_block_carries_the_vex_run_warnings() { let server = MockServer::start().await; @@ -2078,10 +2111,13 @@ async fn json_vex_block_carries_the_vex_run_warnings() { let warnings = v["vex"]["warnings"] .as_array() .unwrap_or_else(|| panic!("{v}")); - assert_eq!(warnings.len(), 1, "{v}"); - assert_eq!(warnings[0]["code"], "product_not_iri", "{v}"); + // v5.0: a hosted attestation always leads with `vex_hosted_unverified` + // (its pins are attested from their records, not hash-verified). + assert_eq!(warnings.len(), 2, "{v}"); + assert_eq!(warnings[0]["code"], "vex_hosted_unverified", "{v}"); + assert_eq!(warnings[1]["code"], "product_not_iri", "{v}"); assert_eq!( - warnings[0]["detail"], + warnings[1]["detail"], "Product override \"consumer\" (--vex-product) is neither a PURL (pkg:...) nor an \ absolute IRI; it is emitted verbatim as the OpenVEX product @id, which the spec \ requires to be an IRI — strict consumers may reject the document. Prefer \ @@ -2104,7 +2140,13 @@ async fn json_vex_block_carries_the_vex_run_warnings() { &[], ); assert_eq!(code, 0, "{v}"); - assert!(v["vex"].get("warnings").is_none(), "{v}"); + let codes: Vec<&str> = v["vex"]["warnings"] + .as_array() + .unwrap_or_else(|| panic!("{v}")) + .iter() + .filter_map(|w| w["code"].as_str()) + .collect(); + assert_eq!(codes, ["vex_hosted_unverified"], "{v}"); } /// Human Rush run: the `redirect_rush_repo_state_stale` detail reaches @@ -2496,16 +2538,17 @@ async fn vlt_takeover_refusal_before_revert() { let (code, doc) = scan_hosted_json(tmp.path(), &server.uri(), &[], &[]); assert_eq!(code, 0, "{doc:#}"); - assert_eq!(doc["redirect"]["redirected"], 0, "{doc:#}"); + assert_eq!(hosted_pinned(&doc), 0, "{doc:#}"); assert!( - doc["redirect"]["skipped"].as_array().is_some_and(|s| s + Some(&hosted_skipped(&doc)).is_some_and(|s| s .iter() .any(|e| e["purl"] == PURL && e["reason"] == "vendored_revert_failed")), "{doc:#}" ); assert!(warning_detail(&doc, "redirect_vendored_revert_failed").contains("vlt-lock.json")); - assert!(!codes_in(&doc["redirect"]["warnings"]) - .contains(&"redirect_takeover_reverted_vendored".to_string())); + assert!( + !codes_in(&doc["warnings"]).contains(&"redirect_takeover_reverted_vendored".to_string()) + ); assert_eq!( std::fs::read(tmp.path().join(".socket/vendor/state.json")).unwrap(), state_before @@ -2583,8 +2626,7 @@ async fn vlt_decides_before_binary_bun_and_a_refused_uuid_is_never_confirmed() { let (refused, tmp) = run(true); assert!( - codes_in(&refused["redirect"]["warnings"]) - .contains(&"redirect_vlt_unsupported_lock_key".to_string()), + codes_in(&refused["warnings"]).contains(&"redirect_vlt_unsupported_lock_key".to_string()), "{refused:#}" ); assert!( @@ -2594,11 +2636,11 @@ async fn vlt_decides_before_binary_bun_and_a_refused_uuid_is_never_confirmed() { .contains(&json!("bun.lockb")), "the binary lock is still rewritten: {refused:#}" ); - assert_eq!(refused["redirect"]["redirected"], 0, "{refused:#}"); + assert_eq!(hosted_pinned(&refused), 0, "{refused:#}"); drop(tmp); let (confirmed, _tmp) = run(false); - assert_eq!(confirmed["redirect"]["redirected"], 1, "{confirmed:#}"); + assert_eq!(hosted_pinned(&confirmed), 1, "{confirmed:#}"); } /// REGRESSION (#899): npm 12 never reads npm-shrinkwrap.json. A project @@ -2635,7 +2677,7 @@ async fn shrinkwrap_only_project_warns_npm12_ignores_it_and_vex_omits_it() { &[], ); // Still redirected: npm <= 11 installs from the shrinkwrap. - assert_eq!(doc["redirect"]["redirected"], 1, "{doc:#}"); + assert_eq!(hosted_pinned(&doc), 1, "{doc:#}"); let lock = std::fs::read_to_string(tmp.path().join("npm-shrinkwrap.json")).unwrap(); assert!(lock.contains(HOSTED_URL), "{lock}"); assert!( @@ -2680,8 +2722,7 @@ async fn shrinkwrap_only_project_warns_npm12_ignores_it_and_vex_omits_it() { ); assert_eq!(code, 0, "{doc:#}"); assert!( - !codes_in(&doc["redirect"]["warnings"]) - .contains(&"redirect_npm_shrinkwrap_only".to_string()), + !codes_in(&doc["warnings"]).contains(&"redirect_npm_shrinkwrap_only".to_string()), "{doc:#}" ); assert_eq!(doc["vex"]["statements"], 1, "{doc:#}"); @@ -2797,7 +2838,7 @@ async fn hosted_yarn_classic_pin_warns_berry_migration_risk() { ] { let (code, doc) = scan_hosted_json(tmp.path(), &server.uri(), extra, &[]); assert_eq!(code, 0, "{package_manager:?} {label}: {doc:#}"); - let codes = codes_in(&doc["redirect"]["warnings"]); + let codes = codes_in(&doc["warnings"]); assert_eq!( codes .iter() @@ -2831,7 +2872,7 @@ async fn hosted_yarn_classic_pin_with_yarn1_package_manager_stays_silent() { let (code, doc) = scan_hosted_json(tmp.path(), &server.uri(), &[], &[]); assert_eq!(code, 0, "{doc:#}"); - let codes = codes_in(&doc["redirect"]["warnings"]); + let codes = codes_in(&doc["warnings"]); assert!( !codes .iter() diff --git a/crates/socket-patch-cli/tests/covgap_commands_scan_mod.rs b/crates/socket-patch-cli/tests/covgap_commands_scan_mod.rs index 33d3a9337..6817b7c00 100644 --- a/crates/socket-patch-cli/tests/covgap_commands_scan_mod.rs +++ b/crates/socket-patch-cli/tests/covgap_commands_scan_mod.rs @@ -463,10 +463,9 @@ async fn scan_hosted_prune_zero_package_json_carries_the_warning() { "classic keys stay schema-consistent" ); assert_eq!(v["redirect"]["mode"], "hosted"); - assert_eq!(v["redirect"]["redirected"], 0); - let warnings = v["redirect"]["warnings"] - .as_array() - .expect("redirect.warnings array"); + assert_eq!(v["summary"]["applied"], 0, "nothing pinned: {v}"); + // v5.0: warnings ride the envelope's top-level `warnings[]`. + let warnings = v["warnings"].as_array().expect("warnings array"); let prune_warning = warnings .iter() .find(|w| w["code"] == "redirect_prune_ignored") @@ -594,9 +593,15 @@ async fn scan_json_counts_paid_patches_separately() { let (code, stdout, stderr) = run_scan_human(tmp.path(), &mock.uri(), &["--json"]); 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["totalPatches"], 1); - assert_eq!(v["freePatches"], 0); - assert_eq!(v["paidPatches"], 1, "{v}"); + // v5.0: the tier counts derive from the discovery payload. + let tiers: Vec<&str> = v["packages"] + .as_array() + .unwrap() + .iter() + .flat_map(|p| p["patches"].as_array().unwrap()) + .map(|p| p["tier"].as_str().unwrap()) + .collect(); + assert_eq!(tiers, ["paid"], "{v}"); assert_eq!(v["canAccessPaidPatches"], false); } @@ -1811,9 +1816,9 @@ mod pty { let json: serde_json::Value = serde_json::from_str(json_text) .unwrap_or_else(|e| panic!("envelope must parse ({e}); got:\n{output}")); assert_eq!(json["status"], "success", "{json}"); - let planned = json["apply"]["patches"] + let planned = json["events"] .as_array() - .unwrap_or_else(|| panic!("apply.patches must be an array: {json}")); + .unwrap_or_else(|| panic!("events must be an array: {json}")); assert_eq!(planned.len(), 1, "exactly one patch selected: {json}"); assert_eq!( planned[0]["uuid"], second, @@ -2696,9 +2701,9 @@ async fn scan_vendored_ignores_a_degraded_pre_v5_vlt_ledger_edit() { /// `scan --mode agent --json` whose nested apply fails (the installed copy /// is a symlink to a first-party `packages/` directory, which apply refuses -/// to patch): the `apply` block must carry -/// the per-patch failure — `action: "failed"`, `errorCode`, `error` — and -/// count it in `failed`, not report the patch as a clean `added` (#424). +/// to patch): the envelope must carry the per-patch failure — a `failed` +/// event with `errorCode` and `error` — and count it in `summary.failed`, +/// not report the patch as cleanly applied (#424). #[cfg(unix)] #[tokio::test] async fn scan_agent_json_nested_apply_failure_reaches_the_apply_block() { @@ -2721,11 +2726,13 @@ async fn scan_agent_json_nested_apply_failure_reaches_the_apply_block() { let (code, stdout, stderr) = run_scan_agent(tmp.path(), &mock.uri(), &["--json"]); assert_eq!(code, 1, "stdout={stdout}\nstderr={stderr}"); let v: serde_json::Value = serde_json::from_str(stdout.trim()).expect("one JSON envelope"); - assert_eq!(v["status"], "partial_failure", "{v}"); - let apply = &v["apply"]; - assert_eq!(apply["failed"], 1, "the apply failure must be counted: {v}"); - assert_eq!(apply["applied"], 0, "{v}"); - let rec = &apply["patches"][0]; + assert_eq!(v["status"], "partialFailure", "{v}"); + assert_eq!( + v["summary"]["failed"], 1, + "the apply failure must be counted: {v}" + ); + assert_eq!(v["summary"]["applied"], 0, "{v}"); + let rec = common::envelope::find_event(&v, "failed", None); assert_eq!(rec["purl"], purl, "{v}"); assert_eq!(rec["action"], "failed", "{v}"); assert_eq!(rec["errorCode"], "apply_failed", "{v}"); @@ -2738,7 +2745,7 @@ async fn scan_agent_json_nested_apply_failure_reaches_the_apply_block() { /// `scan --mode agent --json` over an installed file a local edit changed /// (neither beforeHash nor afterHash): the default policy overwrites it, -/// and the `apply` block's `warnings[]` must report that overwrite — the +/// and the envelope's `warnings[]` must report that overwrite — the /// same `content_mismatch_overwritten` warning `apply --json` and the human /// scan print — instead of dropping it (#1004). #[tokio::test] @@ -2759,13 +2766,13 @@ async fn scan_agent_json_mismatch_overwrite_reaches_the_apply_block() { let (code, stdout, stderr) = run_scan_agent(tmp.path(), &mock.uri(), &["--json"]); assert_eq!(code, 0, "stdout={stdout}\nstderr={stderr}"); let v: serde_json::Value = serde_json::from_str(stdout.trim()).expect("one JSON envelope"); - let apply = &v["apply"]; - assert_eq!(apply["applied"], 1, "{v}"); - let warned = apply["warnings"].as_array().is_some_and(|ws| { - ws.iter().filter_map(|w| w.as_str()).any(|w| { - w.starts_with("(content_mismatch_overwritten) ") - && w.contains(purl) - && w.contains("package/index.js") + assert_eq!(v["summary"]["applied"], 1, "{v}"); + let warned = v["warnings"].as_array().is_some_and(|ws| { + ws.iter().any(|w| { + let d = w["detail"].as_str().unwrap_or_default(); + w["code"] == "content_mismatch_overwritten" + && d.contains(purl) + && d.contains("package/index.js") }) }); assert!(warned, "the overwrite must be reported: {v}"); @@ -2775,6 +2782,163 @@ async fn scan_agent_json_mismatch_overwrite_reaches_the_apply_block() { ); } +// --------------------------------------------------------------------------- +// v5.0 envelope: `scan --json` prints one shared Envelope +// --------------------------------------------------------------------------- + +/// A wet agent-mode `scan --json`: one envelope (`command: "scan"`), the +/// download as a `downloaded` event carrying the patch metadata, the nested +/// apply as an `applied` event, `summary` equal to the event counts, the +/// discovery payload beside them — and none of the retired keys. +#[tokio::test] +async fn scan_agent_json_is_one_envelope_with_events() { + let purl = "pkg:npm/minimist@1.2.2"; + let mock = MockServer::start().await; + mount_one_patch_api(&mock, purl, b"before\n").await; + let tmp = tempfile::tempdir().unwrap(); + write_root_package_json(tmp.path()); + write_npm_package(tmp.path(), "minimist", "1.2.2", b"before\n"); + + let (code, stdout, stderr) = run_scan_agent(tmp.path(), &mock.uri(), &["--json"]); + assert_eq!(code, 0, "stdout={stdout}\nstderr={stderr}"); + let v = common::parse_json_envelope(&stdout); + common::envelope::assert_envelope_invariants(&v, "scan"); + assert_eq!(v["status"], "success", "{v:#}"); + assert_eq!(v["dryRun"], false); + assert_eq!( + common::envelope::event_triples(&v), + vec![ + (purl.to_string(), "downloaded".to_string(), String::new()), + (purl.to_string(), "applied".to_string(), String::new()), + ], + "{v:#}" + ); + let downloaded = common::envelope::find_event(&v, "downloaded", None); + assert_eq!(downloaded["uuid"], UUID); + assert_eq!(downloaded["details"]["tier"], "free", "{v:#}"); + assert!( + downloaded["details"].get("mode").is_none(), + "agent events carry no mode" + ); + assert_eq!(v["scannedPackages"], 1); + assert_eq!(v["packages"][0]["purl"], purl); + for retired in [ + "apply", + "totalPatches", + "freePatches", + "paidPatches", + "packagesWithPatches", + "patches", + "found", + ] { + assert!(v.get(retired).is_none(), "retired key {retired}: {v:#}"); + } +} + +/// An agent-mode dry run previews without writing: a `verified` event per +/// patch a wet run would record, top-level `dryRun`, no nested one. +#[tokio::test] +async fn scan_agent_json_dry_run_previews_verified_events() { + let purl = "pkg:npm/minimist@1.2.2"; + let mock = MockServer::start().await; + mount_one_patch_api(&mock, purl, b"before\n").await; + let tmp = tempfile::tempdir().unwrap(); + write_root_package_json(tmp.path()); + write_npm_package(tmp.path(), "minimist", "1.2.2", b"before\n"); + seed_manifest(tmp.path(), &[(purl, OLD_UUID)]); + + let (code, stdout, stderr) = run_scan_agent(tmp.path(), &mock.uri(), &["--json", "--dry-run"]); + assert_eq!(code, 0, "stdout={stdout}\nstderr={stderr}"); + let v = common::parse_json_envelope(&stdout); + common::envelope::assert_envelope_invariants(&v, "scan"); + assert_eq!(v["dryRun"], true); + let e = common::envelope::find_event(&v, "verified", None); + assert_eq!(e["purl"], purl); + assert_eq!(e["uuid"], UUID); + assert_eq!( + e["oldUuid"], OLD_UUID, + "a would-be update names the uuid it replaces" + ); + assert_eq!(v["summary"]["verified"], 1); +} + +/// Agent mode with the apply lock held: the engine's hard error is the +/// run's ONE JSON document (v5.0 fixes a run that printed the engine's +/// error and then scan's own result). +#[tokio::test] +async fn scan_agent_json_lock_held_prints_one_document() { + let purl = "pkg:npm/minimist@1.2.2"; + let mock = MockServer::start().await; + mount_one_patch_api(&mock, purl, b"before\n").await; + let tmp = tempfile::tempdir().unwrap(); + write_root_package_json(tmp.path()); + write_npm_package(tmp.path(), "minimist", "1.2.2", b"before\n"); + let socket = tmp.path().join(".socket"); + std::fs::create_dir_all(&socket).unwrap(); + let _lock = + socket_patch_core::patch::apply_lock::acquire(&socket, std::time::Duration::ZERO).unwrap(); + + let (code, stdout, stderr) = run_scan_agent(tmp.path(), &mock.uri(), &["--json"]); + assert_eq!(code, 1, "stdout={stdout}\nstderr={stderr}"); + let v: serde_json::Value = serde_json::from_str(&stdout) + .unwrap_or_else(|e| panic!("stdout must be ONE JSON document ({e}): {stdout}")); + common::envelope::assert_envelope_invariants(&v, "scan"); + assert_eq!(v["status"], "error"); + assert_eq!(v["error"]["code"], "lock_held", "{v:#}"); +} + +/// Agent mode over a manifest that exists but does not parse fails closed +/// before any query, in both outputs: `manifest_invalid` (the shared code), +/// exit 1; report-only mode warns with the same code and goes on. +#[tokio::test] +async fn scan_unparseable_manifest_fails_agent_mode_and_warns_otherwise() { + let purl = "pkg:npm/minimist@1.2.2"; + let mock = MockServer::start().await; + mount_one_patch_api(&mock, purl, b"before\n").await; + let tmp = tempfile::tempdir().unwrap(); + write_root_package_json(tmp.path()); + write_npm_package(tmp.path(), "minimist", "1.2.2", b"before\n"); + std::fs::create_dir_all(tmp.path().join(".socket")).unwrap(); + std::fs::write(tmp.path().join(".socket/manifest.json"), "{ not json").unwrap(); + + let (code, stdout, stderr) = run_scan_agent(tmp.path(), &mock.uri(), &["--json"]); + assert_eq!(code, 1, "stdout={stdout}\nstderr={stderr}"); + let v = common::parse_json_envelope(&stdout); + common::envelope::assert_envelope_invariants(&v, "scan"); + assert_eq!(v["error"]["code"], "manifest_invalid", "{v:#}"); + assert_eq!(v["events"], serde_json::json!([])); + let (code, _, stderr) = run_scan_agent(tmp.path(), &mock.uri(), &[]); + assert_eq!(code, 1, "the human arm fails alike: {stderr}"); + + let (code, stdout, stderr) = + run_scan_human(tmp.path(), &mock.uri(), &["--prune", "--dry-run", "--json"]); + assert_eq!(code, 0, "stdout={stdout}\nstderr={stderr}"); + let v = common::parse_json_envelope(&stdout); + common::envelope::assert_envelope_invariants(&v, "scan"); + assert!( + common::envelope::warning_codes(&v).contains(&"manifest_invalid".to_string()), + "{v:#}" + ); +} + +/// `--offline` and usage errors print the full envelope too. +#[test] +fn scan_json_early_errors_print_full_envelopes() { + let tmp = tempfile::tempdir().unwrap(); + let (code, stdout, _) = run_scan(tmp.path(), &["--offline", "--json"]); + assert_eq!(code, 1); + let v = common::parse_json_envelope(&stdout); + common::envelope::assert_envelope_invariants(&v, "scan"); + assert_eq!(v["error"]["code"], "offline_unsupported"); + assert_eq!(v["summary"]["applied"], 0); + + let (code, stdout, _) = run_scan(tmp.path(), &["--sync", "--mode", "hosted", "--json"]); + assert_eq!(code, 2); + let v = common::parse_json_envelope(&stdout); + common::envelope::assert_envelope_invariants(&v, "scan"); + assert_eq!(v["error"]["code"], "invalid_args"); +} + // --------------------------------------------------------------------------- // #1062: human / JSON parity when every detail query succeeds but is empty // --------------------------------------------------------------------------- @@ -2797,8 +2961,9 @@ async fn mount_by_package_empty(mock: &MockServer, purl: &str) { /// #1062: when every detail query succeeds but returns no records, scan /// has nothing to select. That is a successful run with no applicable -/// patches in every mode, so the human arm must exit like the `--json` -/// arm (0), not report a fetch failure and exit 1. +/// patches in every mode — a successful empty answer is not a failure — +/// so the human arm exits like the `--json` arm (0), and neither reports a +/// fetch failure. #[tokio::test] async fn scan_empty_detail_results_exit_alike_in_human_and_json() { let purl = "pkg:npm/minimist@1.2.2"; @@ -2834,9 +2999,10 @@ async fn scan_empty_detail_results_exit_alike_in_human_and_json() { "{extra:?}: no query failed, so no fetch failure; stderr={stderr}" ); if json { - let v: serde_json::Value = - serde_json::from_str(stdout.trim()).expect("valid JSON"); + let v = common::parse_json_envelope(&stdout); + common::envelope::assert_envelope_invariants(&v, "scan"); assert_ne!(v["status"], "error", "{extra:?}: {v}"); + assert_eq!(v["events"], serde_json::json!([]), "{extra:?}: {v}"); } codes.push((code, stdout, stderr)); } @@ -2852,15 +3018,63 @@ async fn scan_empty_detail_results_exit_alike_in_human_and_json() { } } -/// #1062: a human `--dry-run --prune` previews the GC (once) where the -/// `--json` arm previews it (`gc` block). +/// #1062: report-only `scan --prune` fetches the detail records in both +/// outputs whenever a patch is downloadable, so every detail query failing +/// fails both alike (exit 1; the JSON arm used to skip the fetch and exit +/// 0). +#[tokio::test] +async fn scan_report_only_detail_failure_exits_alike_in_human_and_json() { + let purl = "pkg:npm/minimist@1.2.2"; + let mock = MockServer::start().await; + mount_batch_one(&mock, purl, UUID, "free", &[], false).await; + Mock::given(method("GET")) + .and(path(format!( + "/v0/orgs/{ORG_SLUG}/patches/by-package/{}", + encode_purl(purl) + ))) + .respond_with(ResponseTemplate::new(500)) + .mount(&mock) + .await; + let mut codes = Vec::new(); + for json in [false, true] { + let tmp = tempfile::tempdir().unwrap(); + write_root_package_json(tmp.path()); + write_npm_package(tmp.path(), "minimist", "1.2.2", b"x\n"); + let mut extra = vec!["--prune", "--dry-run"]; + if json { + extra.push("--json"); + } + let (code, stdout, stderr) = run_scan_human(tmp.path(), &mock.uri(), &extra); + if json { + let v = common::parse_json_envelope(&stdout); + common::envelope::assert_envelope_invariants(&v, "scan"); + assert_eq!(v["error"]["code"], "patch_details_failed", "{v:#}"); + } + codes.push((code, stderr)); + } + assert_eq!( + codes[0].0, codes[1].0, + "human and --json exit alike: {codes:?}" + ); + assert_eq!( + codes[0].0, 1, + "every detail query failing is a failure: {codes:?}" + ); +} + +/// #1062: a `--dry-run --prune` previews the GC in both outputs, in agent, +/// report-only and vendored mode alike: the human `[dry-run] GC would +/// prune …` line once, the JSON `verified` event (`details.manifest`) and +/// `gc`; nothing is written. #[tokio::test] async fn scan_dry_run_prune_previews_gc_in_human_and_json() { let purl = "pkg:npm/minimist@1.2.2"; let stale = "pkg:npm/left-pad@1.3.0"; - // Agent and report-only (no mode; `--prune` below). Vendored mode's - // human GC on early exits is #1127. - for mode in [&["--mode", "agent"][..], &[][..]] { + for mode in [ + &["--mode", "agent"][..], + &[][..], + &["--mode", "vendored"][..], + ] { let mock = MockServer::start().await; mount_batch_one(&mock, purl, UUID, "free", &[], false).await; mount_by_package(&mock, purl, UUID, serde_json::json!({})).await; @@ -2881,13 +3095,15 @@ async fn scan_dry_run_prune_previews_gc_in_human_and_json() { let (code, stdout, stderr) = run_scan_human(tmp.path(), &mock.uri(), &extra); assert_eq!(code, 0, "{extra:?}: stdout={stdout}; stderr={stderr}"); if json { - let v: serde_json::Value = serde_json::from_str(stdout.trim()).expect("valid JSON"); - assert!( - v["gc"]["prunedManifestEntries"] - .as_array() - .is_some_and(|a| a.iter().any(|p| p == stale)), - "{extra:?}: {v}" - ); + let v = common::parse_json_envelope(&stdout); + common::envelope::assert_envelope_invariants(&v, "scan"); + let pruned = common::envelope::events(&v) + .iter() + .find(|e| e["purl"] == stale) + .unwrap_or_else(|| panic!("{extra:?}: no prune preview: {v:#}")); + assert_eq!(pruned["action"], "verified", "{v:#}"); + assert_eq!(pruned["details"]["manifest"], true, "{v:#}"); + assert!(v["gc"].is_object(), "{extra:?}: {v:#}"); } else { assert_eq!( stdout @@ -2953,11 +3169,9 @@ async fn scan_partial_failure_with_empty_results_still_warns() { } let (code, stdout, stderr) = run_scan_human(tmp.path(), &mock.uri(), &extra); if json { - let v: serde_json::Value = serde_json::from_str(stdout.trim()).expect("valid JSON"); + let v = common::parse_json_envelope(&stdout); assert!( - v["warnings"] - .as_array() - .is_some_and(|w| w.iter().any(|w| w["code"] == "patch_details_failed")), + common::envelope::warning_codes(&v).contains(&"patch_details_failed".to_string()), "{v}" ); } else { diff --git a/crates/socket-patch-cli/tests/covgap_commands_vendor.rs b/crates/socket-patch-cli/tests/covgap_commands_vendor.rs index 83350e097..969a98cb1 100644 --- a/crates/socket-patch-cli/tests/covgap_commands_vendor.rs +++ b/crates/socket-patch-cli/tests/covgap_commands_vendor.rs @@ -321,9 +321,9 @@ fn corrupt_vendor_state_fails_revert_closed() { ); } -/// A present-but-corrupt manifest is `invalid_manifest`, exit 1 (the -/// documented vendor exit contract; distinct from the missing-manifest -/// clean no-op). +/// A present-but-corrupt manifest is `manifest_invalid` (the shared +/// manifest-load mapping, #931), exit 1 (the documented vendor exit +/// contract; distinct from the missing-manifest clean no-op). #[test] fn corrupt_manifest_fails_closed() { let fx = npm_fixture(); @@ -332,7 +332,7 @@ fn corrupt_manifest_fails_closed() { let (code, env) = vendor_cli(fx.root(), &[]); assert_eq!(code, 1, "corrupt manifest must fail the run: {env:#}"); assert_eq!(env["status"], "error"); - assert_eq!(env["error"]["code"], "invalid_manifest"); + assert_eq!(env["error"]["code"], "manifest_invalid"); assert_eq!(fx.lock_bytes(), fx.original_lock, "lock untouched"); } @@ -1432,7 +1432,7 @@ fn sri_of(bytes: &[u8]) -> String { } /// Human corrupt-manifest surface: the `Error: could not read manifest` -/// stderr line beside the `invalid_manifest` exit contract section 1 pins +/// stderr line beside the `manifest_invalid` exit contract section 1 pins /// under --json. #[test] fn human_corrupt_manifest_prints_could_not_read() { diff --git a/crates/socket-patch-cli/tests/covgap_commands_vex.rs b/crates/socket-patch-cli/tests/covgap_commands_vex.rs index 869528c8e..76d7f6496 100644 --- a/crates/socket-patch-cli/tests/covgap_commands_vex.rs +++ b/crates/socket-patch-cli/tests/covgap_commands_vex.rs @@ -123,7 +123,8 @@ fn write_ghost_npm_manifest(cwd: &Path, purl: &str) { } // ────────────────────────────────────────────────────────────────────── -// corrupt manifest → `manifest_unreadable`, exit 2 (read_manifest Err arm) +// corrupt manifest → `manifest_invalid`, exit 2 (read_manifest Err arm; +// the shared manifest-load mapping, #931) // // A PRESENT-but-corrupt `.socket/manifest.json` is the hard exit-2 error // (`read_manifest` → Err), distinct from the missing-manifest exit-2 @@ -203,13 +204,13 @@ fn corrupt_manifest_json_envelope_carries_code_and_removes_stale_doc() { assert_eq!( out.status.code(), Some(2), - "manifest_unreadable is a hard error in --json mode too. stdout:\n{}", + "manifest_invalid is a hard error in --json mode too. stdout:\n{}", String::from_utf8_lossy(&out.stdout) ); let env: Value = serde_json::from_slice(&out.stdout).expect("envelope JSON on stdout"); assert_eq!(env["command"], "vex", "{env}"); assert_eq!(env["status"], "error", "{env}"); - assert_eq!(env["error"]["code"], "manifest_unreadable", "{env}"); + assert_eq!(env["error"]["code"], "manifest_invalid", "{env}"); assert!( env["error"]["message"] .as_str() @@ -275,7 +276,7 @@ fn failed_run_does_not_block_on_a_fifo_at_output() { String::from_utf8_lossy(&out.stdout) ); let env: Value = serde_json::from_slice(&out.stdout).expect("envelope JSON on stdout"); - assert_eq!(env["error"]["code"], "manifest_unreadable", "{env}"); + assert_eq!(env["error"]["code"], "manifest_invalid", "{env}"); assert!( std::fs::symlink_metadata(&fifo) .unwrap() diff --git a/crates/socket-patch-cli/tests/docker_e2e_composer.rs b/crates/socket-patch-cli/tests/docker_e2e_composer.rs index 3da189230..0a165dde1 100644 --- a/crates/socket-patch-cli/tests/docker_e2e_composer.rs +++ b/crates/socket-patch-cli/tests/docker_e2e_composer.rs @@ -73,11 +73,11 @@ fn plain_sha256(content: &[u8]) -> String { /// This asserts on the *real structured output* of the run, not just a /// substring marker: /// - scan's JSON shows the monolog patch was discovered AND synced -/// (recorded in `.socket/manifest.json`; its `scan.json` record is -/// `added`, or `failed` with the error when scan's own in-place +/// (recorded in `.socket/manifest.json`; its `scan.json` event is +/// `downloaded`, plus `failed` with the error when scan's own in-place /// apply fails, #424). NOTE: scan's process exit code is /// deliberately NOT gated — with a transitive dep that has no patch, -/// scan reports `"status": "partial_failure"` / exit 1 even though +/// scan reports `"status": "partialFailure"` / exit 1 even though /// the monolog patch is found and synced. Gating exit==0 would fail a /// genuinely-working pipeline. /// - apply exited 0 and its JSON reports the patch was actually @@ -96,9 +96,9 @@ fn verify_snippet() -> &'static str { # --- scan: must have discovered and synced the monolog patch --- grep -qF 'pkg:composer/monolog/monolog@3.5.0' /tmp/scan.json || { echo "FAIL: scan json missing monolog purl" >&2; cat /tmp/scan.json >&2; exit 1; } -# Synced = recorded in the manifest. The patch record in scan.json may say -# `added` or, when scan's own in-place apply step fails on this fixture, -# `failed` with the apply error (#424); either way the record must be saved. +# Synced = recorded in the manifest. The patch event in scan.json is +# `downloaded`, plus a `failed` one (the apply error, #424) when scan's own +# in-place apply step fails here; either way the record must be saved. grep -qF '"pkg:composer/monolog/monolog@3.5.0"' .socket/manifest.json || { echo "FAIL: scan did not sync (record) the patch" >&2; cat /tmp/scan.json .socket/manifest.json >&2; exit 1; } diff --git a/crates/socket-patch-cli/tests/docker_e2e_gem.rs b/crates/socket-patch-cli/tests/docker_e2e_gem.rs index 458c7e105..68ce3289f 100644 --- a/crates/socket-patch-cli/tests/docker_e2e_gem.rs +++ b/crates/socket-patch-cli/tests/docker_e2e_gem.rs @@ -125,8 +125,8 @@ fn upstream_before_hash() -> String { /// This asserts on the *real structured output* of the run, not just a /// substring marker: /// - scan's JSON shows the colorize patch was discovered AND synced -/// (recorded in `.socket/manifest.json`; its `scan.json` record is -/// `added`, or `failed` with the error when scan's own in-place +/// (recorded in `.socket/manifest.json`; its `scan.json` event is +/// `downloaded`, plus `failed` with the error when scan's own in-place /// apply fails, #424). NOTE: scan's process exit code is /// deliberately NOT gated — a non-zero scan exit from an unrelated /// transitive package without a patch must not fail a pipeline whose @@ -142,9 +142,9 @@ fn verify_snippet() -> &'static str { # --- scan: must have discovered and synced the colorize patch --- grep -qF 'pkg:gem/colorize@1.1.0' /tmp/scan.json || { echo "FAIL: scan json missing colorize purl" >&2; cat /tmp/scan.json >&2; exit 1; } -# Synced = recorded in the manifest. The patch record in scan.json may say -# `added` or, when scan's own in-place apply step fails on this fixture, -# `failed` with the apply error (#424); either way the record must be saved. +# Synced = recorded in the manifest. The patch event in scan.json is +# `downloaded`, plus a `failed` one (the apply error, #424) when scan's own +# in-place apply step fails here; either way the record must be saved. grep -qF '"pkg:gem/colorize@1.1.0"' .socket/manifest.json || { echo "FAIL: scan did not sync (record) the patch" >&2; cat /tmp/scan.json .socket/manifest.json >&2; exit 1; } diff --git a/crates/socket-patch-cli/tests/e2e_bun_lockb.rs b/crates/socket-patch-cli/tests/e2e_bun_lockb.rs index ce183b5d7..5473397ac 100644 --- a/crates/socket-patch-cli/tests/e2e_bun_lockb.rs +++ b/crates/socket-patch-cli/tests/e2e_bun_lockb.rs @@ -10,6 +10,9 @@ //! SOCKET_PATCH_BUN_LOCKB_WRITER points at the writer when testing a newer //! reader against a binary lock from an older release. +#[path = "common/rollback_json.rs"] +mod rollback_json; + use std::collections::BTreeMap; use std::path::{Path, PathBuf}; use std::process::{Command, Output}; @@ -116,6 +119,18 @@ fn cli_code(project: &Path, args: &[&str]) -> (i32, Value) { (output.status.code().unwrap_or(-1), envelope) } +/// How many hosted pins a `scan --mode hosted --json` run wrote: its +/// `applied` events with `details.mode: "hosted"` (v5.0's retired +/// `redirect.redirected`). +fn hosted_pin_count(env: &Value) -> usize { + env["events"] + .as_array() + .into_iter() + .flatten() + .filter(|e| e["details"]["mode"] == "hosted" && e["action"] == "applied") + .count() +} + /// v5 keeps no hosted ledger, so undoing a hosted pin means restoring the /// entry's upstream registry form. For a binary `bun.lockb` only a vendor /// takeover rebuilds that record (it refuses a workspace-normalized lock), so @@ -139,27 +154,25 @@ fn rollback_refuses_binary_hosted_pin_then_checkout( // original, so the run is a partial failure; a hosted-only project has // no other outcome, so the run failed as a whole (`rollback_failed`). if copy_already_original { - assert_eq!(env["status"], "partial_failure", "{env}"); - assert_eq!(env["alreadyOriginal"], 1, "{env}"); + assert_eq!(env["status"], "partialFailure", "{env}"); + assert_eq!(rollback_json::already_original(&env), 1, "{env}"); } else { assert_eq!(env["status"], "error", "{env}"); assert_eq!(env["error"]["code"], "rollback_failed", "{env}"); - assert_eq!(env["alreadyOriginal"], 0, "{env}"); + assert_eq!(rollback_json::already_original(&env), 0, "{env}"); } // `failed` spans every leg (#1066): the refused hosted pin plus any // agent copy that could not be restored (a bundled copy the patch never - // touched reports `hash_mismatch`). - let agent_failed = env["results"] + // touched reports `hash_mismatch`); agent events carry no mode. + let agent_failed = env["events"] .as_array() .into_iter() .flatten() - .filter(|r| r["success"] == false) + .filter(|e| e["action"] == "failed" && e["details"]["mode"].is_null()) .count(); - assert_eq!(env["failed"], 1 + agent_failed, "{env}"); - let failed = env["hosted"]["failed"] - .as_array() - .cloned() - .unwrap_or_default(); + assert_eq!(env["summary"]["failed"], 1 + agent_failed, "{env}"); + let failed_view = rollback_json::hosted_failed(&env); + let failed = failed_view.as_array().cloned().unwrap_or_default(); assert!( failed.iter().any(|f| f["purl"] == PURL && f["error"] @@ -870,7 +883,7 @@ async fn native_binary_hosted_vendored_takeover_roundtrip() { assert_eq!(snapshot(project), before, "hosted dry run: {preview}"); let hosted = scan(project, &server, "hosted", &[]); assert_eq!( - hosted["redirect"]["redirected"], 1, + hosted["summary"]["applied"], 1, "lock-only hosted scan must discover minimist: {hosted}" ); let hosted_lock = fixture.lock(); @@ -878,10 +891,7 @@ async fn native_binary_hosted_vendored_takeover_roundtrip() { fixture.frozen("hosted", &fixture.patched, "minimist"); fixture.manifestless_vex("hosted", bun_vex::BunMode::Hosted, &server.uri()); let repeat = scan(project, &server, "hosted", &[]); - assert_eq!( - repeat["redirect"]["redirected"], 1, - "hosted rerun: {repeat}" - ); + assert_eq!(repeat["summary"]["applied"], 1, "hosted rerun: {repeat}"); assert_eq!(fixture.lock(), hosted_lock); assert!( !project.join(".socket/vendor/redirect-state.json").exists(), @@ -1094,7 +1104,7 @@ async fn native_binary_hosted_vendored_takeover_roundtrip() { ); let hosted = scan(project, &server, "hosted", &[]); assert_eq!( - hosted["redirect"]["redirected"], 1, + hosted["summary"]["applied"], 1, "vendored -> hosted: {hosted}" ); fixture.frozen("hosted-again", &fixture.patched, "minimist"); @@ -1123,7 +1133,7 @@ async fn binary_shared_bundled_record_hosted_pin_is_managed() { let uri = server.uri(); let hosted = scan(project, &server, "hosted", &[]); - assert_eq!(hosted["redirect"]["redirected"], 1, "hosted scan: {hosted}"); + assert_eq!(hosted_pin_count(&hosted), 1, "hosted scan: {hosted}"); let text = hosted.to_string(); let shared = text.contains("redirect_bun_bundled_instance_skipped") && text.contains("also bundled"); @@ -1205,10 +1215,7 @@ async fn binary_shared_bundled_record_hosted_pin_is_managed() { // `rollback` of the same pin refuses it as any binary hosted pin is // refused (the checkout remedy), not as contested wiring. let hosted = scan(project, &server, "hosted", &[]); - assert_eq!( - hosted["redirect"]["redirected"], 1, - "hosted again: {hosted}" - ); + assert_eq!(hosted_pin_count(&hosted), 1, "hosted again: {hosted}"); rollback_refuses_binary_hosted_pin_then_checkout(&fixture, &server, true); assert_eq!(fixture.lock(), fixture.original_lock); } @@ -1222,10 +1229,7 @@ async fn native_binary_scan_vendored() { let server = MockServer::start().await; mock_api(&server, &fixture, "minimist").await; let result = scan(&fixture.project, &server, "vendored", &[]); - assert_eq!( - result["vendor"]["summary"]["applied"], 1, - "scan vendored: {result}" - ); + assert_eq!(result["summary"]["applied"], 1, "scan vendored: {result}"); assert!( !fixture.project.join(".socket/manifest.json").exists(), "vendored scan must not write a manifest" @@ -1252,10 +1256,7 @@ async fn binary_vendored_revert_after_bun_remove() { let server = MockServer::start().await; mock_api(&server, &fixture, "minimist").await; let result = scan(&fixture.project, &server, "vendored", &[]); - assert_eq!( - result["vendor"]["summary"]["applied"], 1, - "scan vendored: {result}" - ); + assert_eq!(result["summary"]["applied"], 1, "scan vendored: {result}"); let mut remove = command(&fixture.reader, &fixture.project); remove .args(["remove", "minimist", "--ignore-scripts"]) @@ -1309,7 +1310,7 @@ async fn native_binary_alias_and_transitive() { let server = MockServer::start().await; mock_api(&server, &fixture, target).await; let result = scan(&fixture.project, &server, "hosted", &[]); - assert_eq!(result["redirect"]["redirected"], 1, "{shape}: {result}"); + assert_eq!(result["summary"]["applied"], 1, "{shape}: {result}"); fixture.frozen("shape-hosted", &fixture.patched, target); fixture.manifestless_vex( &format!("{shape}-hosted"), @@ -1387,7 +1388,7 @@ async fn vendored_text_migration_reverts_to_registry() { let result = if unwind == ["scan"] { // The hosted takeover reverts the vendored wiring first. let hosted = scan(project, &server, "hosted", &[]); - assert_eq!(hosted["redirect"]["redirected"], 1, "{hosted}"); + assert_eq!(hosted["summary"]["applied"], 1, "{hosted}"); hosted } else { cli(project, unwind) @@ -1449,7 +1450,7 @@ async fn vendor_then_migrate(fixture: &Fixture) -> Option<(String, MockServer)> let server = MockServer::start().await; mock_api(&server, fixture, "minimist").await; let vendored = scan(&fixture.project, &server, "vendored", &[]); - assert_eq!(vendored["vendor"]["summary"]["applied"], 1, "{vendored}"); + assert_eq!(vendored["summary"]["applied"], 1, "{vendored}"); let migrated = migrate_to_text_lock(fixture, &fixture.project, "vendored"); assert!( migrated.contains(&format!("minimist@.socket/vendor/npm/{UUID}/")), @@ -1699,7 +1700,7 @@ async fn workspace_text_migration_heals_on_rerun() { mock_api(&server, &fixture, "minimist").await; let project = &fixture.project; let hosted = scan(project, &server, "hosted", &[]); - assert_eq!(hosted["redirect"]["redirected"], 1, "{hosted}"); + assert_eq!(hosted["summary"]["applied"], 1, "{hosted}"); // Bun's own migration, as its prompt suggests. std::fs::remove_file(project.join("bunfig.toml")).unwrap(); @@ -1721,7 +1722,7 @@ async fn workspace_text_migration_heals_on_rerun() { assert!(migrated.contains("/patch/npm/minimist/"), "{migrated}"); let rerun = scan(project, &server, "hosted", &[]); - assert_eq!(rerun["redirect"]["redirected"], 1, "{rerun}"); + assert_eq!(rerun["summary"]["applied"], 1, "{rerun}"); let healed = std::fs::read_to_string(project.join("bun.lock")).unwrap(); assert_eq!( healed, diff --git a/crates/socket-patch-cli/tests/e2e_cargo.rs b/crates/socket-patch-cli/tests/e2e_cargo.rs index 8b3e00ecb..5cdb171e4 100644 --- a/crates/socket-patch-cli/tests/e2e_cargo.rs +++ b/crates/socket-patch-cli/tests/e2e_cargo.rs @@ -303,6 +303,18 @@ fn manifest_record(socket: &Path, uuid: &str, before: &[u8], after: &[u8]) -> se }) } +/// The purls a `scan --json --sync` run pruned (or, on `--dry-run`, would +/// prune) from the manifest: its `details.manifest: true` events. +fn manifest_pruned(json: &serde_json::Value) -> Vec { + json["events"] + .as_array() + .into_iter() + .flatten() + .filter(|e| e["details"]["manifest"] == true) + .map(|e| e["purl"].clone()) + .collect() +} + /// #1278: the project crawl only looks up the crates `Cargo.lock` /// resolves, so a patched crate the lock bumped away from is "not /// crawled". `scan --sync` must not prune its manifest entry (and GC its @@ -381,8 +393,8 @@ async fn sync_keeps_entry_whose_shared_cache_copy_is_still_patched() { let json: serde_json::Value = serde_json::from_str(&stdout) .unwrap_or_else(|e| panic!("scan --dry-run --sync JSON ({e}):\n{stdout}")); assert_eq!( - json["gc"]["prunedManifestEntries"], - serde_json::json!([ryu]), + manifest_pruned(&json), + vec![serde_json::json!(ryu)], "the preview must keep the still-patched cargo entry: {json:#}" ); @@ -400,14 +412,11 @@ async fn sync_keeps_entry_whose_shared_cache_copy_is_still_patched() { ); let json: serde_json::Value = serde_json::from_str(&stdout).unwrap(); assert_eq!( - json["gc"]["prunedManifestEntries"], - serde_json::json!([ryu]), + manifest_pruned(&json), + vec![serde_json::json!(ryu)], "only the pristine dropped crate is pruned: {json:#}" ); - let warnings = json["gc"]["warnings"] - .as_array() - .cloned() - .unwrap_or_default(); + let warnings = json["warnings"].as_array().cloned().unwrap_or_default(); assert!( warnings .iter() @@ -507,8 +516,8 @@ async fn sync_keeps_shared_cache_entry_behind_a_cargo_vendor_dir() { assert!(out.status.success(), "scan --sync must exit 0:\n{stdout}"); let json: serde_json::Value = serde_json::from_str(&stdout).unwrap(); assert_eq!( - json["gc"]["prunedManifestEntries"], - serde_json::json!([]), + manifest_pruned(&json), + Vec::::new(), "{json:#}" ); let m: serde_json::Value = diff --git a/crates/socket-patch-cli/tests/e2e_composer_version_identity.rs b/crates/socket-patch-cli/tests/e2e_composer_version_identity.rs index 80812dc0e..0051d2bcd 100644 --- a/crates/socket-patch-cli/tests/e2e_composer_version_identity.rs +++ b/crates/socket-patch-cli/tests/e2e_composer_version_identity.rs @@ -278,8 +278,8 @@ async fn vendor_wires_and_attests_a_padded_composer_patch() { &["scan", "--mode", "vendored", "--vendor-source", "service"], ); assert_eq!(code, 0, "scan --mode vendored must succeed: {env:#}"); - assert_eq!(env["vendor"]["summary"]["applied"], 1, "{env:#}"); - assert_eq!(env["vendor"]["summary"]["failed"], 0, "{env:#}"); + assert_eq!(env["summary"]["applied"], 1, "{env:#}"); + assert_eq!(env["summary"]["failed"], 0, "{env:#}"); let copy_rel = format!(".socket/vendor/composer/{UUID}/psr/log@3.0.2.0"); assert_eq!( @@ -360,8 +360,8 @@ async fn hosted_redirect_repoints_a_padded_composer_patch() { let (code, env) = run_json(tmp.path(), &server.uri(), &["scan", "--mode", "hosted"]); assert_eq!(code, 0, "scan --mode hosted must succeed: {env:#}"); - assert_eq!(env["redirect"]["redirected"], 1, "{env:#}"); - let codes: Vec<&str> = env["redirect"]["warnings"] + assert_eq!(env["summary"]["applied"], 1, "{env:#}"); + let codes: Vec<&str> = env["warnings"] .as_array() .map(|w| w.iter().filter_map(|w| w["code"].as_str()).collect()) .unwrap_or_default(); diff --git a/crates/socket-patch-cli/tests/e2e_golang_hosted_build.rs b/crates/socket-patch-cli/tests/e2e_golang_hosted_build.rs index 6adcb5a7a..4d4273eaa 100644 --- a/crates/socket-patch-cli/tests/e2e_golang_hosted_build.rs +++ b/crates/socket-patch-cli/tests/e2e_golang_hosted_build.rs @@ -658,7 +658,7 @@ async fn golang_get_uuid_hosted_day2_machine_builds() { "envelope: {envelope}" ); assert_eq!( - envelope["redirect"]["redirected"], 1, + envelope["summary"]["applied"], 1, "exactly one dep redirected: {envelope}" ); assert_eq!( @@ -666,9 +666,8 @@ async fn golang_get_uuid_hosted_day2_machine_builds() { serde_json::json!(["go.mod", "go.sum"]), "exactly the two committed files change: {envelope}" ); - assert_eq!( - envelope["redirect"]["warnings"], - serde_json::json!([]), + assert!( + envelope["warnings"].as_array().is_none_or(|w| w.is_empty()), "no rewriter warnings: {envelope}" ); diff --git a/crates/socket-patch-cli/tests/e2e_golang_hosted_state.rs b/crates/socket-patch-cli/tests/e2e_golang_hosted_state.rs index 0451e6975..d41f404e0 100644 --- a/crates/socket-patch-cli/tests/e2e_golang_hosted_state.rs +++ b/crates/socket-patch-cli/tests/e2e_golang_hosted_state.rs @@ -7,6 +7,9 @@ //! patch API. No go toolchain is needed: every assertion is on the files //! the CLI writes and on its JSON envelope. +#[path = "common/rollback_json.rs"] +mod rollback_json; + #[path = "prebuilt_common/mod.rs"] mod prebuilt_common; @@ -203,9 +206,9 @@ async fn refused_go_rewrite_is_not_confirmed_by_another_lockfile() { mount_hosted_grant(&server).await; let env = get_hosted(&consumer, &server, &tmp.path().join("modcache")); - assert_eq!(env["redirect"]["redirected"], 0, "envelope: {env}"); + assert_eq!(hosted_pinned(&env), 0, "envelope: {env}"); assert!( - env["redirect"]["warnings"] + env["warnings"] .as_array() .unwrap() .iter() @@ -242,9 +245,9 @@ async fn leftover_go_sum_lines_do_not_confirm_a_refused_rewrite() { mount_hosted_grant(&server).await; let env = get_hosted(&consumer, &server, &tmp.path().join("modcache")); - assert_eq!(env["redirect"]["redirected"], 0, "envelope: {env}"); + assert_eq!(hosted_pinned(&env), 0, "envelope: {env}"); assert!( - env["redirect"]["warnings"] + env["warnings"] .as_array() .unwrap() .iter() @@ -343,7 +346,7 @@ async fn hosted_takeover_of_vendored_module_removes_vendored_state() { ); let env = get_hosted(&consumer, &server, &modcache); - assert_eq!(env["redirect"]["redirected"], 1, "envelope: {env}"); + assert_eq!(hosted_pinned(&env), 1, "envelope: {env}"); let go_mod = std::fs::read_to_string(consumer.join("go.mod")).unwrap(); assert_eq!( @@ -389,7 +392,7 @@ async fn hosted_rollback_restores_go_sum_byte_for_byte() { let server = MockServer::start().await; mount_hosted_grant(&server).await; let env = get_hosted(&consumer, &server, &modcache); - assert_eq!(env["redirect"]["redirected"], 1, "envelope: {env}"); + assert_eq!(hosted_pinned(&env), 1, "envelope: {env}"); assert!( !consumer.join(".socket/vendor/redirect-state.json").exists(), "hosted mode keeps no ledger: go.mod/go.sum are the record" @@ -418,9 +421,13 @@ async fn hosted_rollback_restores_go_sum_byte_for_byte() { "offline must refuse\nstdout:\n{stdout}\nstderr:\n{stderr}" ); let doc: serde_json::Value = serde_json::from_str(&stdout).unwrap(); - assert_eq!(doc["hosted"]["failed"][0]["purl"], UPURL, "{doc}"); + assert_eq!( + rollback_json::hosted_failed(&doc)[0]["purl"], + UPURL, + "{doc}" + ); assert!( - doc["hosted"]["failed"][0]["error"] + rollback_json::hosted_failed(&doc)[0]["error"] .as_str() .is_some_and(|e| e.contains("this run is offline") && e.contains("go.mod")), "{doc}" @@ -443,7 +450,7 @@ async fn hosted_rollback_restores_go_sum_byte_for_byte() { ); let doc: serde_json::Value = serde_json::from_str(&stdout).unwrap(); assert_eq!( - doc["hosted"]["reverted"], + rollback_json::hosted_reverted(&doc), serde_json::json!([UPURL]), "{doc}" ); @@ -473,7 +480,7 @@ async fn vendored_takeover_of_hosted_module_unwinds_the_redirect() { let server = MockServer::start().await; mount_hosted_grant(&server).await; let env = get_hosted(&consumer, &server, &modcache); - assert_eq!(env["redirect"]["redirected"], 1, "envelope: {env}"); + assert_eq!(hosted_pinned(&env), 1, "envelope: {env}"); let ledger_path = consumer.join(".socket/vendor/redirect-state.json"); assert!( std::fs::read_to_string(consumer.join("go.mod")) @@ -561,3 +568,17 @@ fn run_with_prebuilt( } common::run_with_env(cwd, &args, &env) } + +/// How many hosted pins the run wrote (v5.0: the `applied` / `verified` +/// events with `details.mode: "hosted"`, formerly `redirect.redirected`). +fn hosted_pinned(env: &serde_json::Value) -> u64 { + env["events"] + .as_array() + .into_iter() + .flatten() + .filter(|e| { + e["details"]["mode"] == "hosted" + && (e["action"] == "applied" || e["action"] == "verified") + }) + .count() as u64 +} diff --git a/crates/socket-patch-cli/tests/e2e_golang_workspace_build.rs b/crates/socket-patch-cli/tests/e2e_golang_workspace_build.rs index e1716f69f..fed6d3392 100644 --- a/crates/socket-patch-cli/tests/e2e_golang_workspace_build.rs +++ b/crates/socket-patch-cli/tests/e2e_golang_workspace_build.rs @@ -607,7 +607,7 @@ fn go_work_hosted_members_build_patched_and_attest_without_manifest() { code, 0, "get --mode hosted in a go.work root: {env}\n{stderr}" ); - assert_eq!(env["redirect"]["redirected"], 1, "{env}"); + assert_eq!(env["summary"]["applied"], 1, "{env}"); assert_eq!( env["redirect"]["rewrittenFiles"], serde_json::json!(["go.mod", "go.sum"]), diff --git a/crates/socket-patch-cli/tests/e2e_gradle_discovery_build.rs b/crates/socket-patch-cli/tests/e2e_gradle_discovery_build.rs index ec60af503..139713c9c 100644 --- a/crates/socket-patch-cli/tests/e2e_gradle_discovery_build.rs +++ b/crates/socket-patch-cli/tests/e2e_gradle_discovery_build.rs @@ -88,7 +88,8 @@ impl Scan { .map(str::to_string) } - /// The `level` of the run-level warning `code`. + /// The `level` of the run-level warning `code` (v5.0: warnings are + /// exactly `{code, detail}`, so always `None`). fn warning_level(&self, code: &str) -> Option { self.env["warnings"] .as_array()? @@ -392,8 +393,9 @@ fn gradle_only_build_ignores_m2_and_says_so() { assert!(detail.contains(M2_ONLY), "{detail}"); assert!(!detail.contains(COMMONS_TEXT), "{detail}"); assert_eq!( - scan.warning_level("gradle_build_ignores_m2").as_deref(), - Some("warn") + scan.warning_level("gradle_build_ignores_m2"), + None, + "v5.0 warnings carry no level" ); fx.write( @@ -427,9 +429,9 @@ fn undetermined_maven_local_keeps_m2_with_a_note() { .unwrap_or_else(|| panic!("no gradle_maven_local_undetermined: {}", scan.env)); assert!(detail.contains("mavenLocal()"), "{detail}"); assert_eq!( - scan.warning_level("gradle_maven_local_undetermined") - .as_deref(), - Some("info") + scan.warning_level("gradle_maven_local_undetermined"), + None, + "v5.0 warnings carry no level" ); } diff --git a/crates/socket-patch-cli/tests/e2e_hosted_production.rs b/crates/socket-patch-cli/tests/e2e_hosted_production.rs index f293592a0..6357650a2 100644 --- a/crates/socket-patch-cli/tests/e2e_hosted_production.rs +++ b/crates/socket-patch-cli/tests/e2e_hosted_production.rs @@ -81,6 +81,9 @@ //! cargo test -p socket-patch-cli --test e2e_hosted_production -- --ignored //! ``` +#[path = "common/rollback_json.rs"] +mod rollback_json; + #[path = "common/mod.rs"] mod common; use common::binary; @@ -379,7 +382,7 @@ fn published_view_record_blocking(uuid: &str) -> serde_json::Value { /// Assert the hosted redirect actually rewrote something, and return the list /// of rewritten files. /// -/// `redirected >= 1` is the anti-vacuity guard: a run that discovered nothing +/// At least one hosted pin is the anti-vacuity guard: a run that discovered nothing /// also exits 0 with `"status": "success"`, so without this a broken crawler /// would look identical to a working redirect. fn assert_redirected(env: &serde_json::Value, expect_file: &str) -> Vec { @@ -395,7 +398,7 @@ fn assert_redirected(env: &serde_json::Value, expect_file: &str) -> Vec Some("hosted"), "redirect sub-object missing or not hosted mode:\n{env:#}" ); - let n = redirect["redirected"].as_u64().unwrap_or(0); + let n = hosted_pin_count(env); assert!( n >= 1, "hosted redirect rewrote nothing — the patch is published and the \ @@ -417,18 +420,24 @@ fn assert_redirected(env: &serde_json::Value, expect_file: &str) -> Vec files } -/// How many dependencies a hosted run redirected. +/// How many dependencies a hosted run redirected: its `applied` (or, on a +/// dry run, `verified`) events with `details.mode: "hosted"` (v5.0: the +/// retired `redirect.redirected` counter). /// -/// `scan --mode hosted` omits the whole `redirect` sub-object when discovery -/// turned up nothing — a plain scan envelope comes back instead. For the -/// documented-unsupported ecosystems (golang, deno) "no redirect object" and -/// `"redirected": 0` are the same verdict, so normalize them. -fn redirected_count(env: &serde_json::Value) -> u64 { - let redirect = &env["redirect"]; - if redirect.is_null() { - return 0; - } - redirect["redirected"].as_u64().unwrap_or(0) +/// `scan --mode hosted` reports no hosted event when discovery turned up +/// nothing — a plain scan envelope comes back instead. For the +/// documented-unsupported ecosystems (golang, deno) that is the same +/// verdict as zero pins. +fn hosted_pin_count(env: &serde_json::Value) -> u64 { + env["events"] + .as_array() + .into_iter() + .flatten() + .filter(|e| { + e["details"]["mode"] == "hosted" + && (e["action"] == "applied" || e["action"] == "verified") + }) + .count() as u64 } // --------------------------------------------------------------------------- @@ -1062,7 +1071,7 @@ fn npm_package_lock_hosted_install_proof() { let rb: serde_json::Value = serde_json::from_str(&stdout) .unwrap_or_else(|e| panic!("{LEG}: rollback --json is not JSON ({e}):\n{stdout}")); assert_eq!( - rb["hosted"]["reverted"], + rollback_json::hosted_reverted(&rb), serde_json::json!([NPM_PURL]), "{LEG}: rollback restores the hosted pin: {rb:#}" ); @@ -2466,7 +2475,7 @@ fn golang_hosted_redirects_only_via_goproxy_override() { .expect("write go.mod"); let env_json = scan_hosted(&proj, &["--ecosystems", "golang"]); - let redirected = redirected_count(&env_json); + let redirected = hosted_pin_count(&env_json); if redirected == 0 { println!( "{LEG}: production publishes no golang hosted modules (or none \ @@ -2506,7 +2515,7 @@ fn deno_hosted_is_unsupported() { let env_json = scan_hosted(&proj, &["--ecosystems", "deno"]); assert_eq!( - redirected_count(&env_json), + hosted_pin_count(&env_json), 0, "{LEG}: deno hosted mode redirected something, but hosted mode is \ documented as unsupported for deno:\n{env_json:#}" diff --git a/crates/socket-patch-cli/tests/e2e_npm.rs b/crates/socket-patch-cli/tests/e2e_npm.rs index 4b9b03c63..74051c673 100644 --- a/crates/socket-patch-cli/tests/e2e_npm.rs +++ b/crates/socket-patch-cli/tests/e2e_npm.rs @@ -424,9 +424,9 @@ fn test_npm_global_lifecycle() { "scan should match patch {NPM_UUID} for minimist, got patches: {patches:#?}" ); assert!( - scan["packagesWithPatches"].as_u64().unwrap_or(0) >= 1, - "packagesWithPatches should be >= 1, got: {}", - scan["packagesWithPatches"] + !packages.is_empty(), + "packages (with patches) should be >= 1, got: {}", + scan["packages"] ); // -- GET: download + apply patch globally -------------------------------- @@ -684,26 +684,17 @@ fn test_npm_macos_global_auto_discovery() { scan["scannedPackages"] ) }); - let with_patches = scan["packagesWithPatches"].as_u64().unwrap_or_else(|| { - panic!( - "packagesWithPatches should be a number, got: {}", - scan["packagesWithPatches"] - ) - }); let packages = scan["packages"] .as_array() .expect("scan -g should emit a packages array"); + // v5.0 dropped `packagesWithPatches`: `packages` lists exactly them. + let with_patches = packages.len() as u64; // Discovery invariant: every package-with-a-patch was a scanned package, // and the `packages` list (packages carrying patches) cannot exceed the // total scanned count. assert!( with_patches <= scanned, - "packagesWithPatches ({with_patches}) must not exceed scannedPackages ({scanned})" - ); - assert_eq!( - packages.len() as u64, - with_patches, - "packages array length should equal packagesWithPatches" + "packages with patches ({with_patches}) must not exceed scannedPackages ({scanned})" ); } diff --git a/crates/socket-patch-cli/tests/e2e_nuget_dotnet_build.rs b/crates/socket-patch-cli/tests/e2e_nuget_dotnet_build.rs index b18f014e5..766194472 100644 --- a/crates/socket-patch-cli/tests/e2e_nuget_dotnet_build.rs +++ b/crates/socket-patch-cli/tests/e2e_nuget_dotnet_build.rs @@ -798,7 +798,7 @@ fn nuget_hosted_dotnet_restore_then_manifestless_vex() { Some(0), "SDK {sdk} scan --mode hosted: {env:#}\n{stderr}" ); - assert_eq!(env["redirect"]["redirected"], 1, "{env:#}"); + assert_eq!(env["summary"]["applied"], 1, "{env:#}"); let stale_dir = pkg_dir(&store_fx); let warned = env.to_string(); assert!( diff --git a/crates/socket-patch-cli/tests/e2e_pypi.rs b/crates/socket-patch-cli/tests/e2e_pypi.rs index 5c23ed778..e60325246 100644 --- a/crates/socket-patch-cli/tests/e2e_pypi.rs +++ b/crates/socket-patch-cli/tests/e2e_pypi.rs @@ -816,12 +816,9 @@ fn test_pypi_macos_global_auto_discovery() { // numeric, the packages array must be well-formed, and the patched-subset // count cannot exceed the total scanned. These hold regardless of host and // reject a malformed/partial envelope that happens to carry a number. - for field in [ - "packagesWithPatches", - "totalPatches", - "freePatches", - "paidPatches", - ] { + // v5.0 dropped the derivable discovery counters (`packagesWithPatches`, + // `totalPatches`, `freePatches`, `paidPatches`). + for field in ["scannedPackages", "lockfileOnlyPackages"] { assert!( scan[field].is_u64(), "{field} should be a number, got: {}", @@ -831,15 +828,10 @@ fn test_pypi_macos_global_auto_discovery() { let packages = scan["packages"] .as_array() .expect("packages should be an array"); - let with_patches = scan["packagesWithPatches"].as_u64().unwrap(); - assert_eq!( - packages.len() as u64, - with_patches, - "packages array length must equal packagesWithPatches" - ); + let with_patches = packages.len() as u64; assert!( with_patches <= scanned, - "packagesWithPatches ({with_patches}) cannot exceed scannedPackages ({scanned})" + "packages with patches ({with_patches}) cannot exceed scannedPackages ({scanned})" ); } diff --git a/crates/socket-patch-cli/tests/e2e_redirect_bun_build.rs b/crates/socket-patch-cli/tests/e2e_redirect_bun_build.rs index 4f05eb596..6dc2cc1a3 100644 --- a/crates/socket-patch-cli/tests/e2e_redirect_bun_build.rs +++ b/crates/socket-patch-cli/tests/e2e_redirect_bun_build.rs @@ -963,10 +963,7 @@ async fn bun_hosted_project_with( ) }); assert_eq!(env["status"], "success", "envelope: {env}"); - assert_eq!( - env["redirect"]["redirected"], 1, - "one dep redirected: {env}" - ); + assert_eq!(env["summary"]["applied"], 1, "one dep redirected: {env}"); match driver { HostedDriver::ScanVex => { // In-run VEX (step 3 of the module doc): the envelope's vex block @@ -977,8 +974,10 @@ async fn bun_hosted_project_with( assert_eq!(env["vex"]["path"], "out.vex.json", "vex block: {env}"); assert_eq!(env["vex"]["statements"], 1, "vex block: {env}"); assert_eq!(env["vex"]["format"], "openvex-0.2.0", "vex block: {env}"); - assert_eq!( - env["vex"]["verified"], false, + assert!( + env["vex"]["warnings"] + .as_array() + .is_some_and(|w| w.iter().any(|w| w["code"] == "vex_hosted_unverified")), "in-run redirect VEX is attested from the fetched record, not hash-verified: {env}" ); let vex_doc: serde_json::Value = @@ -1005,14 +1004,14 @@ async fn bun_hosted_project_with( ); } HostedDriver::GetUuid => { - // get's envelope nests the same redirect block into its own base - // shape; nothing is downloaded into `.socket/` (the lock IS the + // get records the same hosted events and `redirect` payload; + // nothing is downloaded into `.socket/` (the lock IS the // persistence — parity with `scan --mode hosted`). assert_eq!(env["redirect"]["mode"], "hosted", "envelope: {env}"); - assert_eq!(env["found"], 1, "get keeps its found count: {env}"); - assert!( - env.get("downloaded").is_none() && env.get("applied").is_none(), - "hosted get downloads/applies nothing — those keys must be absent: {env}" + assert_eq!(env["command"], "get", "envelope: {env}"); + assert_eq!( + env["summary"]["downloaded"], 0, + "hosted get downloads nothing: {env}" ); assert!( !proj.join(".socket").join("manifest.json").exists(), @@ -1838,8 +1837,8 @@ async fn bun_redirect_survives_a_digest_dropping_lock_resave() { let env: serde_json::Value = serde_json::from_str(&stdout) .unwrap_or_else(|e| panic!("repeat scan output is not JSON: {e}\nstdout:\n{stdout}")); assert_eq!(env["status"], "success", "{env:#}"); - assert_eq!(env["redirect"]["redirected"], 1, "{env:#}"); - let codes: Vec<&str> = env["redirect"]["warnings"] + assert_eq!(env["summary"]["applied"], 1, "{env:#}"); + let codes: Vec<&str> = env["warnings"] .as_array() .into_iter() .flatten() diff --git a/crates/socket-patch-cli/tests/e2e_redirect_cargo_build.rs b/crates/socket-patch-cli/tests/e2e_redirect_cargo_build.rs index 65c2b53b6..e8e601bf4 100644 --- a/crates/socket-patch-cli/tests/e2e_redirect_cargo_build.rs +++ b/crates/socket-patch-cli/tests/e2e_redirect_cargo_build.rs @@ -566,27 +566,30 @@ async fn redirect_scanned_project( // confirmed_cargo_uuids — a grant only counts once the three-file // rewrite actually landed. assert_eq!( - env["redirect"]["redirected"], 1, + env["summary"]["applied"], 1, "exactly one dep redirected: {env}" ); match driver { Driver::ScanVex => { assert_eq!(env["vex"]["path"], "out.vex.json", "vex block: {env}"); assert_eq!(env["vex"]["statements"], 1, "vex block: {env}"); - assert_eq!( - env["vex"]["verified"], false, + assert!( + env["vex"]["warnings"] + .as_array() + .is_some_and(|w| w.iter().any(|w| w["code"] == "vex_hosted_unverified")), "in-run hosted VEX is attested from the fetched record, not hash-verified: {env}" ); } Driver::GetUuid => { - // Get's base envelope wraps the nested redirect block: found - // counts the resolved patch; downloaded/applied are ABSENT - // (nothing lands in .socket/) and get has no --vex. - assert_eq!(env["found"], 1, "get envelope: {env}"); + // Get records the resolved patch's hosted pin as its one + // event; nothing is downloaded into .socket/ and get has no + // --vex. + assert_eq!(env["command"], "get", "get envelope: {env}"); + assert_eq!(env["events"].as_array().map(Vec::len), Some(1), "{env}"); assert!(env["vex"].is_null(), "get has no --vex: {env}"); - assert!( - env["downloaded"].is_null() && env["applied"].is_null(), - "hosted get must not report downloaded/applied — nothing \ + assert_eq!( + env["summary"]["downloaded"], 0, + "hosted get must not report a download — nothing \ is persisted under .socket/: {env}" ); } diff --git a/crates/socket-patch-cli/tests/e2e_redirect_cargo_shapes.rs b/crates/socket-patch-cli/tests/e2e_redirect_cargo_shapes.rs index f47813c15..169c36e1b 100644 --- a/crates/socket-patch-cli/tests/e2e_redirect_cargo_shapes.rs +++ b/crates/socket-patch-cli/tests/e2e_redirect_cargo_shapes.rs @@ -562,11 +562,11 @@ async fn run_shape(shape: Shape) -> Option<()> { ); let env: serde_json::Value = serde_json::from_str(&stdout).unwrap(); if let Some(code) = shape.refused { - assert_eq!(env["redirect"]["redirected"], 0, "{}: {env}", shape.tag); - let codes: Vec<&str> = env["redirect"]["warnings"] + assert_eq!(hosted_pinned(&env), 0, "{}: {env}", shape.tag); + let codes: Vec<&str> = env["warnings"] .as_array() - .unwrap() - .iter() + .into_iter() + .flatten() .filter_map(|w| w["code"].as_str()) .collect(); assert_eq!( @@ -594,15 +594,14 @@ async fn run_shape(shape: Shape) -> Option<()> { return Some(()); } assert_eq!( - env["redirect"]["redirected"], - shape.patches.len(), + hosted_pinned(&env), + shape.patches.len() as u64, "{}: every patch redirected: {env}", shape.tag ); - assert_eq!( - env["redirect"]["warnings"], - serde_json::json!([]), - "{}: {env}", + assert!( + env.get("warnings").is_none(), + "{}: no warning: {env}", shape.tag ); @@ -1329,3 +1328,17 @@ async fn cargo_hosted_contested_by_a_later_crates_io_copy() { }; let _ = run_shape(shape).await; } + +/// How many hosted pins the run wrote (v5.0: the `applied` / `verified` +/// events with `details.mode: "hosted"`, formerly `redirect.redirected`). +fn hosted_pinned(env: &serde_json::Value) -> u64 { + env["events"] + .as_array() + .into_iter() + .flatten() + .filter(|e| { + e["details"]["mode"] == "hosted" + && (e["action"] == "applied" || e["action"] == "verified") + }) + .count() as u64 +} diff --git a/crates/socket-patch-cli/tests/e2e_redirect_composer_build.rs b/crates/socket-patch-cli/tests/e2e_redirect_composer_build.rs index 0524af9a9..27980f321 100644 --- a/crates/socket-patch-cli/tests/e2e_redirect_composer_build.rs +++ b/crates/socket-patch-cli/tests/e2e_redirect_composer_build.rs @@ -53,6 +53,9 @@ //! release. `#[ignore]`-gated like the vendored twin: the unpinned `test` job //! skips it; the e2e job runs it with a pinned toolchain via `--ignored`. +#[path = "common/rollback_json.rs"] +mod rollback_json; + use std::io::Write as _; use std::path::{Path, PathBuf}; use std::process::Command; @@ -501,7 +504,7 @@ async fn redirected_project( ); } RedirectCli::ScanRedirectVex => { - assert_eq!(env["redirect"]["redirected"], 1, "one redirect: {env}"); + assert_eq!(env["summary"]["applied"], 1, "one redirect: {env}"); assert_eq!( env["redirect"]["rewrittenFiles"][0], "composer.lock", "{env}" @@ -512,7 +515,7 @@ async fn redirected_project( assert_attested(&doc, &purl, UUID, Marker::Redirected, &[(GHSA, &[CVE])]); } RedirectCli::GetUuidHosted => { - assert_eq!(env["found"], 1, "{env}"); + assert_eq!(env["command"], "get", "{env}"); assert!(env.get("vex").is_none(), "get has no --vex: {env}"); } } @@ -782,7 +785,7 @@ fn assert_rollback_restores_upstream(fx: &Fixture, tag: &str) { if fx.major >= 2 { assert_eq!(code, 0, "[{tag}] rollback: {env}\n{stderr}"); assert_eq!( - env["hosted"]["reverted"], + rollback_json::hosted_reverted(&env), serde_json::json!([fx.purl]), "[{tag}] {env}" ); @@ -795,10 +798,13 @@ fn assert_rollback_restores_upstream(fx: &Fixture, tag: &str) { // Composer 1 resolved from an inline `package` repository: not // packagist, so the restore refuses rather than guess. assert_eq!(code, 1, "[{tag}] rollback: {env}\n{stderr}"); - assert_eq!(env["hosted"]["failed"][0]["purl"], fx.purl, "[{tag}] {env}"); - let why = env["hosted"]["failed"][0]["error"] - .as_str() - .unwrap_or_default(); + assert_eq!( + rollback_json::hosted_failed(&env)[0]["purl"], + fx.purl, + "[{tag}] {env}" + ); + let why_view = rollback_json::hosted_failed(&env); + let why = why_view[0]["error"].as_str().unwrap_or_default(); assert!( why.contains("git checkout -- composer.lock"), "[{tag}] the refusal names the remedy: {env}" diff --git a/crates/socket-patch-cli/tests/e2e_redirect_gem_build.rs b/crates/socket-patch-cli/tests/e2e_redirect_gem_build.rs index da164ce60..93cebb4f4 100644 --- a/crates/socket-patch-cli/tests/e2e_redirect_gem_build.rs +++ b/crates/socket-patch-cli/tests/e2e_redirect_gem_build.rs @@ -1224,7 +1224,7 @@ async fn redirect_scanned_project( assert_eq!(env["status"], "success", "envelope: {env}"); assert_eq!(env["redirect"]["mode"], "hosted", "envelope: {env}"); assert_eq!( - env["redirect"]["redirected"], 1, + env["summary"]["applied"], 1, "exactly one dep redirected: {env}" ); let rewritten: Vec<&str> = env["redirect"]["rewrittenFiles"] @@ -1237,10 +1237,11 @@ async fn redirect_scanned_project( rewritten.contains(&gemfile_name), "the {gemfile_name} rewrite must be reported: {env}" ); - let warning_codes: Vec<&str> = env["redirect"]["warnings"] + // `warnings` is omitted when the run has none. + let warning_codes: Vec<&str> = env["warnings"] .as_array() - .expect("warnings") - .iter() + .into_iter() + .flatten() .filter_map(|w| w["code"].as_str()) .collect(); if checksums_lock { @@ -1276,8 +1277,10 @@ async fn redirect_scanned_project( | Driver::ScanVexSourceBlock | Driver::ScanVexTrailingSemicolonDeclaration => { assert_eq!(env["vex"]["statements"], 1, "vex block: {env}"); - assert_eq!( - env["vex"]["verified"], false, + assert!( + env["vex"]["warnings"] + .as_array() + .is_some_and(|w| w.iter().any(|w| w["code"] == "vex_hosted_unverified")), "in-run hosted VEX is attested from this run's fetched record, not hash-verified: {env}" ); } @@ -1302,19 +1305,18 @@ async fn redirect_scanned_project( unreachable!("asserted and returned above") } Driver::GetUuid => { - // get's hosted envelope (CLI_CONTRACT.md "get --mode and - // installed narrowing"): `found` counts the resolved patch; - // `downloaded`/`applied` are ABSENT — nothing lands in - // `.socket/`, the lockfile IS the persistence — and no - // `vex` key (get has no --vex). - assert_eq!(env["found"], 1, "envelope: {env}"); - assert!( - env.get("downloaded").is_none(), - "hosted get downloads nothing into .socket/: {env}" + // get's hosted envelope: the resolved patch's hosted pin is its + // one event; nothing is downloaded into `.socket/` (the + // lockfile IS the persistence) and no `vex` key (get has no + // --vex). + assert_eq!( + env["events"].as_array().map(Vec::len), + Some(1), + "envelope: {env}" ); - assert!( - env.get("applied").is_none(), - "hosted get applies nothing in place: {env}" + assert_eq!( + env["summary"]["downloaded"], 0, + "hosted get downloads nothing into .socket/: {env}" ); assert!(env.get("vex").is_none(), "get has no --vex: {env}"); } @@ -1411,7 +1413,7 @@ fn assert_custom_lockfile_redirects_nothing( ) { let env: serde_json::Value = serde_json::from_str(stdout) .unwrap_or_else(|e| panic!("not JSON: {e}\nstdout:\n{stdout}\nstderr:\n{stderr}")); - let warning_codes: Vec<&str> = env["redirect"]["warnings"] + let warning_codes: Vec<&str> = env["warnings"] .as_array() .map(|a| a.iter().filter_map(|w| w["code"].as_str()).collect()) .unwrap_or_default(); @@ -1420,10 +1422,7 @@ fn assert_custom_lockfile_redirects_nothing( "the {refusal} refusal must be reported: {env}" ); assert_ne!(code, 0, "nothing was patched or attested: {env}"); - assert_eq!( - env["redirect"]["redirected"], 0, - "nothing redirected: {env}" - ); + assert_eq!(env["summary"]["applied"], 0, "nothing redirected: {env}"); assert!( env["vex"]["statements"].as_u64().unwrap_or(0) == 0, "no in-run attestation for a lock that was never pinned: {env}" @@ -1458,7 +1457,7 @@ fn assert_dual_boot_redirects_nothing( pristine_gemfile: &[u8], pristine_lock: &[u8], ) { - let warning_codes: Vec<&str> = env["redirect"]["warnings"] + let warning_codes: Vec<&str> = env["warnings"] .as_array() .map(|a| a.iter().filter_map(|w| w["code"].as_str()).collect()) .unwrap_or_default(); @@ -1470,10 +1469,7 @@ fn assert_dual_boot_redirects_nothing( !warning_codes.contains(&"redirect_gem_no_gemfile"), "the refusal names its real cause, not a missing Gemfile: {env}" ); - assert_eq!( - env["redirect"]["redirected"], 0, - "nothing redirected: {env}" - ); + assert_eq!(env["summary"]["applied"], 0, "nothing redirected: {env}"); assert!( env["vex"]["statements"].as_u64().unwrap_or(0) == 0, "no in-run attestation for a gem bundler installs unpatched: {env}" @@ -2484,16 +2480,20 @@ fn vendor_takeover_keeps_the_hosted_group_pin( let env: serde_json::Value = serde_json::from_str(&stdout) .unwrap_or_else(|e| panic!("{label}: not JSON: {e}\nstdout:\n{stdout}\nstderr:\n{stderr}")); if dry_run { - // The ledger-classification preview: the refusal is a - // `would_refuse` row (which never flips the exit code), never - // `would_vendor`. + // The ledger-classification preview: the refusal is a `skipped` + // event carrying the refusal's code (which never flips the exit + // code), never a `verified` would-vendor event. assert_eq!(code, 0, "{label}: {env}"); - let row = &env["vendor"]["patches"][0]; - assert_eq!(row["action"], "would_refuse", "{label}: {env}"); + let row = &env["events"][0]; + assert_eq!(row["action"], "skipped", "{label}: {env}"); assert_eq!( row["errorCode"], "gemfile_declaration_not_editable", "{label}: {env}" ); + assert_eq!( + env["summary"]["verified"], 0, + "{label}: nothing to vendor: {env}" + ); } else { assert_eq!(code, 1, "{label} must refuse: {env}\nstderr:\n{stderr}"); assert!( @@ -2938,7 +2938,7 @@ async fn gem_hosted_mirror_rescan_requires_verified_installed_bytes() { stdout.contains("redirect_gem_mirror_overrides_source"), "{env}" ); - assert_eq!(env["redirect"]["redirected"], 0, "{env}"); + assert_eq!(env["summary"]["applied"], 0, "{env}"); if installed && !no_verify { assert_eq!(code, 0, "{env}\n{stderr}"); assert_eq!( @@ -3159,7 +3159,7 @@ async fn gem_hosted_rotated_grant_rescan_refreshes_source_block_and_installs() { "same-grant re-scan failed.\nstdout:\n{stdout}\nstderr:\n{stderr}" ); let env: serde_json::Value = serde_json::from_str(&stdout).expect("re-scan envelope JSON"); - assert_eq!(env["redirect"]["redirected"], 1, "envelope: {env}"); + assert_eq!(env["summary"]["applied"], 1, "envelope: {env}"); assert_eq!( std::fs::read_to_string(fx.proj.join("Gemfile")) .expect("read Gemfile after same-grant re-scan"), @@ -3180,7 +3180,7 @@ async fn gem_hosted_rotated_grant_rescan_refreshes_source_block_and_installs() { "rotated-grant re-scan failed.\nstdout:\n{stdout}\nstderr:\n{stderr}" ); let env: serde_json::Value = serde_json::from_str(&stdout).expect("rotation envelope JSON"); - assert_eq!(env["redirect"]["redirected"], 1, "envelope: {env}"); + assert_eq!(env["summary"]["applied"], 1, "envelope: {env}"); let gemfile = std::fs::read_to_string(fx.proj.join("Gemfile")) .expect("read Gemfile after rotated-grant re-scan"); assert_eq!( diff --git a/crates/socket-patch-cli/tests/e2e_redirect_gem_stale_install.rs b/crates/socket-patch-cli/tests/e2e_redirect_gem_stale_install.rs index 8ed9448d5..697b44a2c 100644 --- a/crates/socket-patch-cli/tests/e2e_redirect_gem_stale_install.rs +++ b/crates/socket-patch-cli/tests/e2e_redirect_gem_stale_install.rs @@ -230,11 +230,25 @@ fn hosted_scan_json(proj: &Path, api: &str) -> (i32, String, String) { ) } +/// How many hosted pins the run wrote (v5.0: the `applied` / `verified` +/// events with `details.mode: "hosted"`, formerly `redirect.redirected`). +fn hosted_pinned(env: &serde_json::Value) -> u64 { + env["events"] + .as_array() + .into_iter() + .flatten() + .filter(|e| { + e["details"]["mode"] == "hosted" + && (e["action"] == "applied" || e["action"] == "verified") + }) + .count() as u64 +} + fn stale_warnings(env: &serde_json::Value) -> Vec { - env["redirect"]["warnings"] + env["warnings"] .as_array() - .expect("redirect.warnings") - .iter() + .into_iter() + .flatten() .filter(|w| w["code"] == "redirect_gem_stale_install") .map(|w| w["detail"].as_str().unwrap_or_default().to_string()) .collect() @@ -259,7 +273,7 @@ async fn gem_hosted_redirect_over_stale_install_warns_loudly() { "hosted scan must succeed.\nstdout:\n{stdout}\nstderr:\n{stderr}" ); let env = common::parse_json_envelope(&stdout); - assert_eq!(env["redirect"]["redirected"], 1, "envelope: {env}"); + assert_eq!(hosted_pinned(&env), 1, "envelope: {env}"); let gemfile = std::fs::read_to_string(proj.join("Gemfile")).unwrap(); assert!( gemfile.contains("/patch-registry/gem/"), @@ -356,7 +370,7 @@ async fn gem_hosted_redirect_over_patched_install_stays_quiet() { "hosted scan must succeed.\nstdout:\n{stdout}\nstderr:\n{stderr}" ); let env = common::parse_json_envelope(&stdout); - assert_eq!(env["redirect"]["redirected"], 1, "envelope: {env}"); + assert_eq!(hosted_pinned(&env), 1, "envelope: {env}"); assert!( stale_warnings(&env).is_empty(), "an already-patched materialization must never trip the stale warning: {env}" @@ -381,7 +395,7 @@ async fn gem_hosted_redirect_fresh_checkout_stays_quiet() { "hosted scan must succeed.\nstdout:\n{stdout}\nstderr:\n{stderr}" ); let env = common::parse_json_envelope(&stdout); - assert_eq!(env["redirect"]["redirected"], 1, "envelope: {env}"); + assert_eq!(hosted_pinned(&env), 1, "envelope: {env}"); assert!( stale_warnings(&env).is_empty(), "a fresh checkout must not trip the stale warning: {env}" @@ -413,13 +427,14 @@ async fn gem_hosted_scan_never_pins_a_version_the_lock_does_not_resolve() { let (_code, stdout, stderr) = hosted_scan_json(&proj, &server.uri()); let env = common::parse_json_envelope(&stdout); assert_eq!( - env["redirect"]["redirected"], 0, + hosted_pinned(&env), + 0, "nothing may be redirected: {env}\nstderr:\n{stderr}" ); - let codes: Vec<&str> = env["redirect"]["warnings"] + let codes: Vec<&str> = env["warnings"] .as_array() - .expect("redirect.warnings") - .iter() + .into_iter() + .flatten() .filter_map(|w| w["code"].as_str()) .collect(); assert!( @@ -461,13 +476,13 @@ async fn gem_hosted_scan_without_a_lock_pins_nothing() { let (code, stdout, stderr) = hosted_scan_json(&proj, &server.uri()); let env = common::parse_json_envelope(&stdout); assert_eq!(code, 0, "{env}\nstderr:\n{stderr}"); - assert_eq!( - env["redirect"]["redirected"], 0, + assert!( + common::envelope::hosted_pins(&env).is_empty(), "nothing may be redirected: {env}\nstderr:\n{stderr}" ); - let hit: Vec<&serde_json::Value> = env["redirect"]["warnings"] + let hit: Vec<&serde_json::Value> = env["warnings"] .as_array() - .expect("redirect.warnings") + .expect("warnings") .iter() .filter(|w| w["code"] == "redirect_gem_no_lockfile") .collect(); @@ -547,10 +562,10 @@ async fn gem_hosted_rescan_with_failing_record_fetch_reports_it_and_keeps_the_wi let (code, stdout, _) = hosted_scan_json(&proj, &server.uri()); assert_eq!(code, 0, "{stdout}"); let env = common::parse_json_envelope(&stdout); - let failed = env["redirect"]["warnings"] + let failed = env["warnings"] .as_array() - .expect("warnings") - .iter() + .into_iter() + .flatten() .find(|w| w["code"] == "record_fetch_failed") .unwrap_or_else(|| panic!("the transient fetch failure is surfaced: {env}")); assert_eq!( @@ -689,7 +704,7 @@ async fn gem_hosted_stale_bundler4_standalone_install_warns_and_is_not_attested( &[], ); let env = common::parse_json_envelope(&stdout); - assert_eq!(env["redirect"]["redirected"], 1, "envelope: {env}"); + assert_eq!(hosted_pinned(&env), 1, "envelope: {env}"); let details = stale_warnings(&env); assert_eq!( details.len(), @@ -762,10 +777,7 @@ async fn gem_hosted_stale_dot_bundle_install_warns_and_is_not_attested() { &[], ); let env = common::parse_json_envelope(&stdout); - assert_eq!( - env["redirect"]["redirected"], 1, - "{config}: envelope: {env}" - ); + assert_eq!(hosted_pinned(&env), 1, "{config}: envelope: {env}"); let details = stale_warnings(&env); assert_eq!( details.len(), @@ -1069,7 +1081,7 @@ async fn gem_hosted_global_gemfile_setting_is_refused() { &[("HOME", home.to_str().unwrap())], ); let envelope = common::parse_json_envelope(&stdout); - let warnings: Vec<&serde_json::Value> = envelope["redirect"]["warnings"] + let warnings: Vec<&serde_json::Value> = envelope["warnings"] .as_array() .map(|a| a.iter().collect()) .unwrap_or_default(); @@ -1085,7 +1097,8 @@ async fn gem_hosted_global_gemfile_setting_is_refused() { "the refusal must name the global setting and its remedy: {detail}" ); assert_eq!( - envelope["redirect"]["redirected"], 0, + hosted_pinned(&envelope), + 0, "nothing redirected: {envelope}" ); assert_eq!( @@ -1157,7 +1170,7 @@ async fn gem_hosted_empty_gemfile_setting_shadows_global_alternative() { ); assert_eq!(code, 0, "{label}: stdout:\n{stdout}\nstderr:\n{stderr}"); let envelope = common::parse_json_envelope(&stdout); - assert_eq!(envelope["redirect"]["redirected"], 1, "{label}: {envelope}"); + assert_eq!(hosted_pinned(&envelope), 1, "{label}: {envelope}"); assert!( std::fs::read_to_string(proj.join("Gemfile")) .unwrap() diff --git a/crates/socket-patch-cli/tests/e2e_redirect_gradle_build.rs b/crates/socket-patch-cli/tests/e2e_redirect_gradle_build.rs index f98f1f00b..3f9fe7ca1 100644 --- a/crates/socket-patch-cli/tests/e2e_redirect_gradle_build.rs +++ b/crates/socket-patch-cli/tests/e2e_redirect_gradle_build.rs @@ -461,7 +461,7 @@ impl Cell { let (code, json) = self.scan_in(&self.proj, &[]); assert_eq!(code, Some(0), "scan --mode hosted: {json}"); assert_eq!(json["redirect"]["mode"], "hosted", "{json}"); - assert_eq!(json["redirect"]["redirected"], 1, "{json}"); + assert_eq!(json["summary"]["applied"], 1, "{json}"); json } @@ -658,7 +658,7 @@ fn mount_grants(s: &Server, grants: &[Grant<'_>]) { /// The warning codes of an envelope's `redirect` block and top level. fn codes(json: &serde_json::Value) -> Vec { let mut out = Vec::new(); - for list in [&json["warnings"], &json["redirect"]["warnings"]] { + for list in [&json["warnings"], &json["warnings"]] { for w in list.as_array().into_iter().flatten() { if let Some(c) = w["code"].as_str() { out.push(c.to_string()); @@ -710,7 +710,7 @@ fn gradle_hosted_direct() { let (code, again) = c.scan_in(&c.proj, &[]); assert_eq!(code, Some(0), "{again}"); assert_eq!( - again["redirect"]["redirected"], 1, + again["summary"]["applied"], 1, "rescan still confirms: {again}" ); assert_eq!(snapshot(&c.proj), before, "a rescan writes nothing"); @@ -1793,7 +1793,7 @@ fn gradle_hosted_429_autocrlf_clone() { let before = snapshot(&clone); let (code, json) = c.scan_in(&clone, &[]); assert_eq!(code, Some(0), "{json}"); - assert_eq!(json["redirect"]["redirected"], 1, "{json}"); + assert_eq!(json["summary"]["applied"], 1, "{json}"); assert_eq!( snapshot(&clone), before, @@ -1824,7 +1824,7 @@ fn gradle_hosted_non_utf8_settings_refused_untouched() { c.serve_leaf(&Served::new(true)); let (code, json) = c.scan_in(&c.proj, &[]); assert_eq!(code, Some(0), "{json}"); - assert_eq!(json["redirect"]["redirected"], 0, "{json}"); + assert_eq!(json["summary"]["applied"], 0, "{json}"); assert!( has(&json, "redirect_gradle_build_file_unreadable"), "{json}" @@ -1852,7 +1852,7 @@ fn gradle_hosted_pasted_snippet_not_attested() { c.serve_leaf(&Served::new(true)); let (code, json) = c.scan_in(&c.proj, &[]); assert_eq!(code, Some(0), "{json}"); - assert_eq!(json["redirect"]["redirected"], 0, "{json}"); + assert_eq!(json["summary"]["applied"], 0, "{json}"); assert!(has(&json, "redirect_gradle_classifier_declared"), "{json}"); let snippet = snippet_code(&json); let build = c.proj.join("build.gradle"); @@ -1866,7 +1866,7 @@ fn gradle_hosted_pasted_snippet_not_attested() { /// The code of the fallback snippet a refusal printed. fn snippet_code(json: &serde_json::Value) -> String { - let detail = json["redirect"]["warnings"] + let detail = json["warnings"] .as_array() .into_iter() .flatten() @@ -2208,7 +2208,7 @@ fn gradle_hosted_takeover_refusal_keeps_vendored() { "{json}" ); assert!(!has(&json, "redirect_takeover_reverted_vendored"), "{json}"); - assert_eq!(json["redirect"]["redirected"], 0, "{json}"); + assert_eq!(json["summary"]["applied"], 0, "{json}"); assert_eq!(snapshot(&c.proj), before, "nothing was reverted or written"); let out = c.build(&[]); let (jar, _) = c.victim_on(&out, "still vendored"); diff --git a/crates/socket-patch-cli/tests/e2e_redirect_maven_build.rs b/crates/socket-patch-cli/tests/e2e_redirect_maven_build.rs index 195411475..75f519336 100644 --- a/crates/socket-patch-cli/tests/e2e_redirect_maven_build.rs +++ b/crates/socket-patch-cli/tests/e2e_redirect_maven_build.rs @@ -52,6 +52,9 @@ //! `SOCKET_PATCH_MAVEN_E2E_{MVN,VERSION,REQUIRED}` gates in //! `maven_build_common`. +#[path = "common/rollback_json.rs"] +mod rollback_json; + #[path = "hosted_maven_common/mod.rs"] mod hosted_maven_common; #[path = "maven_build_common/mod.rs"] @@ -256,9 +259,9 @@ fn maven_scan_hosted_fresh_checkout_install_and_manifestless_vex() { ); assert_eq!(code, Some(0), "scan --mode hosted: {env}\n{stderr}"); assert_eq!(env["redirect"]["mode"], "hosted", "{env}"); - assert_eq!(env["redirect"]["redirected"], 1, "{env}"); + assert_eq!(env["summary"]["applied"], 1, "{env}"); assert_eq!(env["vex"]["statements"], 1, "{env}"); - let unenforced = env["redirect"]["warnings"] + let unenforced = env["warnings"] .as_array() .into_iter() .flatten() @@ -504,7 +507,7 @@ fn maven_scan_hosted_fresh_checkout_install_and_manifestless_vex() { ); assert_eq!(code, Some(0), "rollback --offline: {env}\n{stderr}"); assert_eq!( - env["hosted"]["reverted"], + rollback_json::hosted_reverted(&env), serde_json::json!([purl()]), "rollback restores the hosted pin: {env}" ); diff --git a/crates/socket-patch-cli/tests/e2e_redirect_npm_build.rs b/crates/socket-patch-cli/tests/e2e_redirect_npm_build.rs index c03e29c3a..33e19fe9c 100644 --- a/crates/socket-patch-cli/tests/e2e_redirect_npm_build.rs +++ b/crates/socket-patch-cli/tests/e2e_redirect_npm_build.rs @@ -58,6 +58,9 @@ //! cannot reach the registry — unless `SOCKET_PATCH_NPM_E2E_REQUIRED` is set; //! every assertion after that is hard. +#[path = "common/rollback_json.rs"] +mod rollback_json; + #[path = "common/mod.rs"] mod common; use common::binary; @@ -568,7 +571,7 @@ async fn redirect_scanned_project( "redirect sub-object is mode-tagged: {env}" ); assert_eq!( - env["redirect"]["redirected"], 1, + env["summary"]["applied"], 1, "exactly one dep redirected: {env}" ); match cli { @@ -576,25 +579,33 @@ async fn redirect_scanned_project( assert_eq!(env["vex"]["path"], "out.vex.json", "vex block: {env}"); assert_eq!(env["vex"]["statements"], 1, "vex block: {env}"); assert_eq!(env["vex"]["format"], "openvex-0.2.0", "vex block: {env}"); - assert_eq!( - env["vex"]["verified"], false, + assert!( + env["vex"]["warnings"] + .as_array() + .is_some_and(|w| w.iter().any(|w| w["code"] == "vex_hosted_unverified")), "in-run redirect VEX is attested from this run's fetched record, not hash-verified: {env}" ); } RedirectCli::GetUuidHosted => { assert_eq!( - env["found"], 1, - "uuid get resolves exactly one patch: {env}" - ); - assert_eq!( - env["patches"], - serde_json::json!([]), - "the UUID path is exempt from narrowing — no skip records: {env}" + env["events"].as_array().map(Vec::len), + Some(1), + "uuid get resolves exactly one patch, and the UUID path is exempt \ + from narrowing — no skip events: {env}" ); } RedirectCli::GetGhsaHosted => { - assert_eq!(env["found"], 2, "both fan-out versions were found: {env}"); - let skips = env["patches"].as_array().expect("patches array"); + assert_eq!( + env["events"].as_array().map(Vec::len), + Some(2), + "both fan-out versions were found: {env}" + ); + let skips: Vec<&serde_json::Value> = env["events"] + .as_array() + .expect("events array") + .iter() + .filter(|e| e["action"] == "skipped") + .collect(); assert_eq!( skips.len(), 1, @@ -642,7 +653,7 @@ async fn redirect_scanned_project( ); } // A lockfileVersion 1 lock (npm <= 6) gets the npm 6 install caveat. - let legacy_warned = env["redirect"]["warnings"] + let legacy_warned = env["warnings"] .as_array() .is_some_and(|w| w.iter().any(|w| w["code"] == "redirect_npm_legacy_client")); assert_eq!( @@ -653,7 +664,7 @@ async fn redirect_scanned_project( // Every npm major gets the npm >= 12 install caveat (the run cannot know // which npm the project's CI uses). assert!( - env["redirect"]["warnings"] + env["warnings"] .as_array() .is_some_and(|w| w.iter().any(|w| w["code"] == "redirect_npm_allow_remote")), "the npm >= 12 allow-remote caveat must be emitted: {env}" @@ -662,7 +673,7 @@ async fn redirect_scanned_project( // ...and the run AUTO-CONFIGURED it: a new project `.npmrc` holding // exactly `allow-remote=all`, said so in the warning (`rollback` removes // the file while it is still byte-identical to what the run created). - let allow_remote = env["redirect"]["warnings"] + let allow_remote = env["warnings"] .as_array() .and_then(|w| w.iter().find(|w| w["code"] == "redirect_npm_allow_remote")) .and_then(|w| w["detail"].as_str()) @@ -770,7 +781,7 @@ fn rollback_removes_npmrc(fx: &RedirectFixture) { let env: serde_json::Value = serde_json::from_str(&stdout) .unwrap_or_else(|e| panic!("rollback --json is not JSON: {e}\n{stdout}")); assert_eq!( - env["hosted"]["reverted"], + rollback_json::hosted_reverted(&env), serde_json::json!([PURL]), "rollback restores the hosted pin to its upstream entry: {env}" ); diff --git a/crates/socket-patch-cli/tests/e2e_redirect_pnpm_build.rs b/crates/socket-patch-cli/tests/e2e_redirect_pnpm_build.rs index d6402b32c..815fe0b19 100644 --- a/crates/socket-patch-cli/tests/e2e_redirect_pnpm_build.rs +++ b/crates/socket-patch-cli/tests/e2e_redirect_pnpm_build.rs @@ -274,7 +274,7 @@ fn parse_envelope(stdout: &str) -> serde_json::Value { } fn warning_codes(env: &serde_json::Value) -> Vec { - env["redirect"]["warnings"] + env["warnings"] .as_array() .map(|ws| { ws.iter() @@ -601,7 +601,7 @@ async fn redirect_scanned_pnpm_project( let env = parse_envelope(&stdout); if pm == "pnpm@1.0.0" { assert_eq!( - env["redirect"]["redirected"], 0, + env["summary"]["applied"], 0, "unsafe legacy lock must be refused: {env}" ); assert!( @@ -634,7 +634,7 @@ async fn redirect_scanned_pnpm_project( assert_eq!(env["status"], "success", "envelope: {env}"); assert_eq!( - env["redirect"]["redirected"], 1, + env["summary"]["applied"], 1, "exactly one dep redirected: {env}" ); // The zero-touch trustLockfile auto-config fires only for root v9 locks @@ -662,7 +662,7 @@ async fn redirect_scanned_pnpm_project( warning_codes(&env).contains(&"redirect_pnpm_trust_lockfile".to_string()), "a landed pnpm rewrite must warn about pnpm >=11 installs: {env}" ); - let trust_detail = env["redirect"]["warnings"] + let trust_detail = env["warnings"] .as_array() .unwrap() .iter() @@ -762,7 +762,7 @@ async fn redirect_scanned_pnpm_project( ); let env2 = parse_envelope(&stdout); assert_eq!( - env2["redirect"]["redirected"], 1, + env2["summary"]["applied"], 1, "an already-redirected dep still counts: {env2}" ); assert_eq!( @@ -1394,7 +1394,7 @@ async fn pnpm_pinned_matrix_install_verify_revert_and_tamper() { // fresh checkout, including the legacy shrinkwrap filename. let (code, stdout, stderr) = run_hosted(HostedDriver::Scan, &fx.proj, &fx._server.uri(), &[]); assert_eq!(code, 0, "lock-only scan failed: {stdout}\n{stderr}"); - assert_eq!(parse_envelope(&stdout)["redirect"]["redirected"], 1); + assert_eq!(parse_envelope(&stdout)["summary"]["applied"], 1); // Every major must reject a hosted tarball whose bytes disagree with its // lockfile pin, even when pnpm >=11 uses trustLockfile. @@ -1627,7 +1627,7 @@ async fn pnpm_v5_lock_key_rewrite_splices_in_place() { let env = parse_envelope(&stdout); assert_eq!(env["status"], "success", "envelope: {env}"); assert_eq!( - env["redirect"]["redirected"], 1, + env["summary"]["applied"], 1, "the v5 path-style key must be redirectable: {env}" ); assert_eq!( @@ -1690,7 +1690,7 @@ async fn pnpm_v6_plain_lock_key_rewrite_stays_supported() { let env = parse_envelope(&stdout); assert_eq!(env["status"], "success", "envelope: {env}"); assert_eq!( - env["redirect"]["redirected"], 1, + env["summary"]["applied"], 1, "the plain v6 key must stay redirectable: {env}" ); assert_eq!( @@ -1712,7 +1712,7 @@ async fn pnpm_v6_plain_lock_key_rewrite_stays_supported() { !tmp.path().join("pnpm-workspace.yaml").exists(), "a v6-lock scan must not auto-write pnpm-workspace.yaml: {env}" ); - let v6_detail = env["redirect"]["warnings"] + let v6_detail = env["warnings"] .as_array() .unwrap() .iter() @@ -1869,11 +1869,7 @@ async fn pnpm_pinned_matrix_workspace_peer_instances() { mount_tarball_route(&server, patched_tarball).await; let (code, stdout, stderr) = run_hosted(HostedDriver::Scan, &proj, &server.uri(), &[]); assert_eq!(code, 0, "workspace redirect: {stdout}\n{stderr}"); - assert_eq!( - parse_envelope(&stdout)["redirect"]["redirected"], - 1, - "{stdout}" - ); + assert_eq!(parse_envelope(&stdout)["summary"]["applied"], 1, "{stdout}"); let lock_after = std::fs::read_to_string(proj.join("pnpm-lock.yaml")).unwrap(); // Both peer contexts must be represented before the rewrite; v9 factors // their common resolution into packages and keeps contexts in snapshots. diff --git a/crates/socket-patch-cli/tests/e2e_redirect_vlt_build.rs b/crates/socket-patch-cli/tests/e2e_redirect_vlt_build.rs index b71d70629..1b89b499f 100644 --- a/crates/socket-patch-cli/tests/e2e_redirect_vlt_build.rs +++ b/crates/socket-patch-cli/tests/e2e_redirect_vlt_build.rs @@ -11,6 +11,9 @@ //! Run: `SOCKET_PATCH_VLT_E2E_JS= cargo test -p socket-patch-cli //! --test e2e_redirect_vlt_build -- --include-ignored vlt_pinned_matrix`. +#[path = "common/rollback_json.rs"] +mod rollback_json; + use std::path::Path; use serde_json::{json, Value}; @@ -62,7 +65,7 @@ async fn vlt_pinned_matrix_hosted_scan_fresh_ci() { let fx = Fixture::build(leg, Shape::left_pad()).await; let out = fx.scan_vex(&[]); let doc = out.json(); - assert_eq!(doc["redirect"]["redirected"], 1, "{doc:#}"); + assert_eq!(doc["summary"]["applied"], 1, "{doc:#}"); fx.assert_lock_warnings(&doc); fx.assert_in_run_vex(&out, fx.t(), fx.in_run_vex_attests()); assert_pinned(&fx.proj, &fx.svc, fx.t()); @@ -410,7 +413,7 @@ async fn vlt_pinned_matrix_hosted_scoped() { }; let fx = Fixture::build(leg, shape).await; let doc = fx.scan(&[]); - assert_eq!(doc["redirect"]["redirected"], 1, "{doc:#}"); + assert_eq!(doc["summary"]["applied"], 1, "{doc:#}"); assert_pinned(&fx.proj, &fx.svc, fx.t()); fx.assert_fresh_locked_install("fresh-scoped"); fx.leg.ran(); @@ -465,7 +468,7 @@ async fn vlt_pinned_matrix_hosted_peer_workspace_instances() { String::from_utf8_lossy(&fx.lock_before) ); let doc = fx.scan(&[]); - assert_eq!(doc["redirect"]["redirected"], 1, "{doc:#}"); + assert_eq!(doc["summary"]["applied"], 1, "{doc:#}"); assert_pinned(&fx.proj, &fx.svc, &t); let co = fx.checkout("fresh-peers"); fx.vlt_ok_profile(&co, &fx.leg.locked_install_args(), "fresh-peers"); @@ -509,7 +512,7 @@ async fn vlt_pinned_matrix_hosted_peer_rekey_rollback() { let pinned_id = node_id(&before, &t.name, &t.version); assert!(pinned_id.contains("~peer."), "{before:#}"); let doc = fx.scan(&[]); - assert_eq!(doc["redirect"]["redirected"], 1, "{doc:#}"); + assert_eq!(doc["summary"]["applied"], 1, "{doc:#}"); fx.vlt_ok(&fx.proj, &["install", "react@18.3.1"]); let rekeyed_id = node_id(&read_lock(&fx.proj), &t.name, &t.version); assert_ne!(rekeyed_id, pinned_id, "vlt re-keys the peer context"); @@ -1044,7 +1047,7 @@ async fn vlt_pinned_matrix_hosted_default_registry_alias() { let id = node_id(&lock, LP.0, LP.1); assert!(id.starts_with("~acme~"), "{id}"); let doc = fx.scan(&[]); - assert_eq!(doc["redirect"]["redirected"], 1, "{doc:#}"); + assert_eq!(doc["summary"]["applied"], 1, "{doc:#}"); assert_pinned(&fx.proj, &fx.svc, fx.t()); fx.assert_fresh_locked_install("fresh-alias"); fx.leg.ran(); @@ -1070,7 +1073,7 @@ async fn vlt_pinned_matrix_hosted_registry_from_env() { }; let fx = Fixture::build(leg, shape).await; let doc = fx.scan(&[]); - assert_eq!(doc["redirect"]["redirected"], 1, "{doc:#}"); + assert_eq!(doc["summary"]["applied"], 1, "{doc:#}"); assert_pinned(&fx.proj, &fx.svc, fx.t()); fx.assert_fresh_locked_install("fresh-env"); fx.leg.ran(); @@ -1088,7 +1091,7 @@ async fn vlt_pinned_matrix_hosted_registry_from_user_config() { shape.user_config = vec!["default", "fresh-user"]; let fx = Fixture::build(leg, shape).await; let doc = fx.scan(&[]); - assert_eq!(doc["redirect"]["redirected"], 1, "{doc:#}"); + assert_eq!(doc["summary"]["applied"], 1, "{doc:#}"); assert_pinned(&fx.proj, &fx.svc, fx.t()); fx.assert_fresh_locked_install("fresh-user"); fx.leg.ran(); @@ -1307,7 +1310,7 @@ async fn vlt_pinned_matrix_hosted_ts_written_lock() { let out = rollback_upstream(&proj, ®.url(), None, &[]); assert_eq!(out.code, 0, "{out}"); assert_eq!( - out.json()["hosted"]["reverted"], + rollback_json::hosted_reverted(&out.json()), json!(["pkg:npm/left-pad@1.3.0"]), "{out}" ); diff --git a/crates/socket-patch-cli/tests/e2e_redirect_yarn_berry_build.rs b/crates/socket-patch-cli/tests/e2e_redirect_yarn_berry_build.rs index 0223f2c30..fd0e194a5 100644 --- a/crates/socket-patch-cli/tests/e2e_redirect_yarn_berry_build.rs +++ b/crates/socket-patch-cli/tests/e2e_redirect_yarn_berry_build.rs @@ -533,10 +533,7 @@ async fn berry_hosted_project_with( panic!("{driver:?} --mode hosted --json output is not JSON: {e}\nstdout:\n{stdout}") }); assert_eq!(env["status"], "success", "envelope: {env}"); - assert_eq!( - env["redirect"]["redirected"], 1, - "one dep redirected: {env}" - ); + assert_eq!(env["summary"]["applied"], 1, "one dep redirected: {env}"); if driver == HostedDriver::Scan { // In-run VEX (step 3 of the module doc): the envelope's vex block plus // the document's unverified `(redirected)` attestation. Without these, @@ -546,8 +543,10 @@ async fn berry_hosted_project_with( assert_eq!(env["vex"]["path"], "out.vex.json", "vex block: {env}"); assert_eq!(env["vex"]["statements"], 1, "vex block: {env}"); assert_eq!(env["vex"]["format"], "openvex-0.2.0", "vex block: {env}"); - assert_eq!( - env["vex"]["verified"], false, + assert!( + env["vex"]["warnings"] + .as_array() + .is_some_and(|w| w.iter().any(|w| w["code"] == "vex_hosted_unverified")), "in-run redirect VEX is attested from this run's fetched record, not hash-verified: {env}" ); let vex_doc: serde_json::Value = diff --git a/crates/socket-patch-cli/tests/e2e_redirect_yarn_classic_build.rs b/crates/socket-patch-cli/tests/e2e_redirect_yarn_classic_build.rs index bbcc9e1e0..e5dead3d2 100644 --- a/crates/socket-patch-cli/tests/e2e_redirect_yarn_classic_build.rs +++ b/crates/socket-patch-cli/tests/e2e_redirect_yarn_classic_build.rs @@ -570,7 +570,7 @@ async fn classic_hosted_project( // #364: the mirror would serve the upstream tarball under the hosted // URL's basename, so nothing is pinned, counted or attested. assert_eq!( - env["redirect"]["redirected"], 0, + env["summary"]["applied"], 0, "a mirrored project must not count a redirect: {env}" ); assert!( @@ -604,10 +604,7 @@ async fn classic_hosted_project( "{driver:?} --mode hosted failed.\nstdout:\n{stdout}\nstderr:\n{stderr}" ); assert_eq!(env["status"], "success", "envelope: {env}"); - assert_eq!( - env["redirect"]["redirected"], 1, - "one dep redirected: {env}" - ); + assert_eq!(env["summary"]["applied"], 1, "one dep redirected: {env}"); // Lockfile pin: hosted URL + #sha1 fragment + the recomputed integrity. let lock = std::fs::read_to_string(proj.join("yarn.lock")).unwrap(); diff --git a/crates/socket-patch-cli/tests/e2e_safety_yarn_pnp.rs b/crates/socket-patch-cli/tests/e2e_safety_yarn_pnp.rs index baf98cc35..f89791226 100644 --- a/crates/socket-patch-cli/tests/e2e_safety_yarn_pnp.rs +++ b/crates/socket-patch-cli/tests/e2e_safety_yarn_pnp.rs @@ -897,11 +897,16 @@ fn scan_pnp_mode_case(mode: &str) { if mode == "hosted" { assert_eq!( env.get("redirect") - .and_then(|r| r.get("redirected")) - .and_then(|v| v.as_u64()), + .and_then(|r| r.get("rewrittenFiles")) + .and_then(|v| v.as_array()) + .map(Vec::len), Some(0), "mode {mode}: hosted envelope keeps its (empty) redirect block.\nenvelope: {env}" ); + assert_eq!( + env["summary"]["applied"], 0, + "mode {mode}: nothing pinned: {env}" + ); } assert!( !dir.path().join(".socket").exists(), diff --git a/crates/socket-patch-cli/tests/e2e_sbt_build.rs b/crates/socket-patch-cli/tests/e2e_sbt_build.rs index c4c15dfae..8e1a90abe 100644 --- a/crates/socket-patch-cli/tests/e2e_sbt_build.rs +++ b/crates/socket-patch-cli/tests/e2e_sbt_build.rs @@ -253,7 +253,7 @@ fn wired(extra: &str) -> Option<(Sbt, Workspace, Server)> { ws.project.join(HOSTED_FILE).is_file(), "socket-patch.sbt not written: {json}" ); - assert_eq!(json["redirect"]["redirected"], 1, "{json}"); + assert_eq!(json["summary"]["applied"], 1, "{json}"); Some((sbt, ws, server)) } @@ -536,7 +536,7 @@ fn sbt_hosted_dep_edit_rechecks_after_update() { !all_codes(&json).contains(&"redirect_sbt_pin_unverifiable".to_string()), "{json}" ); - assert_eq!(json["redirect"]["redirected"], 1, "{json}"); + assert_eq!(json["summary"]["applied"], 1, "{json}"); let after = std::fs::read(ws.project.join(HOSTED_FILE)).unwrap(); assert_ne!(after, before, "the row's digest is refreshed"); // ...and the next run is quiet. @@ -574,7 +574,7 @@ fn sbt_hosted_declared_bump_fails_closed() { all_codes(&json).contains(&"redirect_sbt_pin_declared_newer".to_string()), "{json}" ); - assert_eq!(json["redirect"]["redirected"], 0, "{json}"); + assert_eq!(json["summary"]["applied"], 0, "{json}"); // Declaring the base again is fine. ws.write("build.sbt", &build); assert_patched(&sbt, &ws); diff --git a/crates/socket-patch-cli/tests/e2e_sbt_hosted.rs b/crates/socket-patch-cli/tests/e2e_sbt_hosted.rs index b7033fdea..46b47491d 100644 --- a/crates/socket-patch-cli/tests/e2e_sbt_hosted.rs +++ b/crates/socket-patch-cli/tests/e2e_sbt_hosted.rs @@ -268,7 +268,7 @@ fn hosted_mixed_root_confirms_the_pom_pin_when_sbt_refuses() { ); let pom = std::fs::read_to_string(project.join("pom.xml")).unwrap(); assert!(pom.contains(SV), "{pom}"); - assert_eq!(json["redirect"]["redirected"], 1, "{json}"); + assert_eq!(json["summary"]["applied"], 1, "{json}"); } #[test] @@ -290,7 +290,7 @@ fn hosted_grant_writes_the_owned_file_and_reruns_idempotently() { "hosted writes only socket-patch.sbt (sbt downloads at load)" ); let redirect = &json["redirect"]; - assert_eq!(redirect["redirected"], 1, "{json}"); + assert_eq!(json["summary"]["applied"], 1, "{json}"); assert!( redirect["rewrittenFiles"] .as_array() @@ -312,7 +312,7 @@ fn hosted_grant_writes_the_owned_file_and_reruns_idempotently() { std::fs::read_to_string(project.join(HOSTED_FILE)).unwrap(), written ); - assert_eq!(again["redirect"]["redirected"], 1, "{again}"); + assert_eq!(again["summary"]["applied"], 1, "{again}"); } /// A `socket-patch.sbt` on disk that is not UTF-8 is not absent: the run @@ -370,7 +370,7 @@ fn hosted_version_conflict_is_refused() { "{json}" ); assert!(!project.join(HOSTED_FILE).exists()); - assert_eq!(json["redirect"]["redirected"], 0, "{json}"); + assert_eq!(json["summary"]["applied"], 0, "{json}"); } /// A committed `socket-patch.sbt` pinning gson, no evidence of it. @@ -481,7 +481,7 @@ fn hosted_rerun_checks_the_pin_by_content_not_location() { post_update_evidence(&project, &jar); let (_, json) = get_hosted(&home, &project, &api, UUID); - assert_eq!(json["redirect"]["redirected"], 1, "{json}"); + assert_eq!(json["summary"]["applied"], 1, "{json}"); assert!( !all_codes(&json) .iter() diff --git a/crates/socket-patch-cli/tests/e2e_scan.rs b/crates/socket-patch-cli/tests/e2e_scan.rs index 727c99dd4..0487bccb0 100644 --- a/crates/socket-patch-cli/tests/e2e_scan.rs +++ b/crates/socket-patch-cli/tests/e2e_scan.rs @@ -154,6 +154,35 @@ fn parse_scan_json(stdout: &str) -> serde_json::Value { .unwrap_or_else(|e| panic!("scan emitted invalid JSON: {e}\nstdout:\n{stdout}")) } +/// The free patches discovery reported (`packages[].patches[]` with +/// `tier: "free"`; v5.0 dropped the `freePatches` counter). +fn free_patches(v: &serde_json::Value) -> usize { + v["packages"] + .as_array() + .into_iter() + .flatten() + .flat_map(|p| p["patches"].as_array().into_iter().flatten()) + .filter(|p| p["tier"] == "free") + .count() +} + +/// The envelope's per-patch download events (`downloaded` / `updated` / +/// `skipped`, the v5.0 replacement for `apply.patches[]`). +fn patch_events(v: &serde_json::Value) -> Vec { + v["events"].as_array().cloned().unwrap_or_default() +} + +/// The manifest entries the GC pruned (`removed` events, `verified` on a +/// dry run, with `details.manifest: true`; v5.0 replaced +/// `gc.prunedManifestEntries`). +fn pruned_entries(v: &serde_json::Value) -> Vec { + patch_events(v) + .into_iter() + .filter(|e| e["details"]["manifest"] == true) + .map(|e| e["purl"].clone()) + .collect() +} + /// Parse the persisted `.socket/manifest.json`. Panics with a useful /// message if it doesn't exist or is malformed. fn read_manifest_file(cwd: &Path) -> serde_json::Value { @@ -233,18 +262,16 @@ fn test_scan_apply_json_adds_new_patch() { v["scannedPackages"] ); assert!( - v["freePatches"].as_u64().unwrap_or(0) >= 1, + free_patches(&v) >= 1, "API must have returned at least one free patch; got {}", - v["freePatches"] + free_patches(&v) ); - let patches = v["apply"]["patches"] - .as_array() - .expect("apply.patches array"); + let patches = patch_events(&v); let minimist = patches .iter() .find(|p| p["purl"] == NPM_PURL) - .expect("apply.patches should include minimist"); - assert_eq!(minimist["action"], "added"); + .expect("events should include minimist"); + assert_eq!(minimist["action"], "downloaded"); let reported_uuid = minimist["uuid"].as_str().expect("uuid must be present"); assert!(!reported_uuid.is_empty(), "uuid must be non-empty"); @@ -301,14 +328,13 @@ fn test_scan_apply_json_skips_existing() { ); let v = parse_scan_json(&stdout); - let patches = v["apply"]["patches"] - .as_array() - .expect("apply.patches array"); + let patches = patch_events(&v); let minimist = patches .iter() .find(|p| p["purl"] == NPM_PURL) - .expect("apply.patches should include minimist on re-run"); + .expect("events should include minimist on re-run"); assert_eq!(minimist["action"], "skipped"); + assert_eq!(minimist["errorCode"], "already_in_manifest"); // The re-run is a no-op: the file must be exactly what the first run // produced. assert_eq!( @@ -338,13 +364,11 @@ fn test_scan_apply_json_updates_existing() { ); let v = parse_scan_json(&stdout); - let patches = v["apply"]["patches"] - .as_array() - .expect("apply.patches array"); + let patches = patch_events(&v); let minimist = patches .iter() .find(|p| p["purl"] == NPM_PURL) - .expect("apply.patches should include minimist"); + .expect("events should include minimist"); assert_eq!(minimist["action"], "updated"); assert_eq!(minimist["oldUuid"], FAKE_OLD_UUID); assert!( @@ -430,9 +454,9 @@ fn test_scan_json_read_only_no_mutation() { v["scannedPackages"] ); assert!( - v["freePatches"].as_u64().unwrap_or(0) >= 1, + free_patches(&v) >= 1, "read-only scan must surface at least one free patch; got {}", - v["freePatches"] + free_patches(&v) ); let packages = v["packages"].as_array().expect("packages array"); assert!( @@ -453,8 +477,8 @@ fn test_scan_json_read_only_no_mutation() { /// When a previously-patched package is uninstalled, passing `--prune` /// (or `--sync`) on the next `scan --mode agent --yes` prunes its manifest -/// entry and sweeps the orphan blobs. JSON output reports it in -/// `gc.prunedManifestEntries`. +/// entry and sweeps the orphan blobs. JSON output reports it as a +/// `removed` event with `details.manifest: true`. #[test] #[ignore] fn test_scan_apply_prune_prunes_uninstalled_package() { @@ -484,9 +508,7 @@ fn test_scan_apply_prune_prunes_uninstalled_package() { ); let v = parse_scan_json(&stdout); - let pruned = v["gc"]["prunedManifestEntries"] - .as_array() - .expect("gc.prunedManifestEntries array"); + let pruned = pruned_entries(&v); assert!( pruned.iter().any(|p| p == NPM_PURL), "minimist should be pruned from manifest after uninstall; got {pruned:?}" @@ -537,9 +559,8 @@ fn test_scan_apply_default_keeps_uninstalled_entries() { v["scannedPackages"] ); assert!( - v["apply"]["patches"].is_array(), - "an apply run must emit the apply.patches array; got {}", - v["apply"] + v["events"].is_array(), + "an apply run must emit the events array; got {v}" ); assert!( @@ -674,9 +695,7 @@ fn test_scan_dry_run_sync_previews_apply_and_gc() { let v = parse_scan_json(&stdout); // Preview output present. - let prunable = v["gc"]["prunedManifestEntries"] - .as_array() - .expect("gc.prunedManifestEntries array"); + let prunable = pruned_entries(&v); assert!( prunable.iter().any(|p| p == NPM_PURL), "preview should list minimist as prunable; got {prunable:?}" @@ -685,13 +704,12 @@ fn test_scan_dry_run_sync_previews_apply_and_gc() { v["gc"]["removedBlobs"].as_u64().unwrap_or(0) >= 1, "preview should count at least 1 orphan blob" ); - assert_eq!(v["apply"]["dryRun"], true); - // The apply preview must still emit the stable `patches[]` shape even - // when nothing is selectable, so a bot can parse it unconditionally. + assert_eq!(v["dryRun"], true); + // The preview still emits the stable `events` array even when nothing + // is selectable, so a bot can parse it unconditionally. assert!( - v["apply"]["patches"].is_array(), - "dry-run apply must emit a patches array; got {}", - v["apply"] + v["events"].is_array(), + "dry-run apply must emit an events array; got {v}" ); // Verify non-mutation. @@ -767,20 +785,18 @@ fn test_scan_sync_yes_full_lifecycle() { "first --sync apply", ); let v1 = parse_scan_json(&stdout1); - let patches = v1["apply"]["patches"] - .as_array() - .expect("first sync should populate apply.patches"); + let patches = patch_events(&v1); assert!( patches .iter() - .any(|p| p["purl"] == NPM_PURL && p["action"] == "added"), + .any(|p| p["purl"] == NPM_PURL && p["action"] == "downloaded"), "first sync should add the minimist patch" ); assert_eq!(v1["status"], "success"); - // gc field should be present (--sync implies --prune). It must be a real GC - // result, not the `{"skipped": true}` short-circuit (which `is_object()` - // would also accept), and on this first run there is nothing installed-then- - // uninstalled, so it must prune nothing. + // gc field should be present (--sync implies --prune): a skipped pass + // has no `gc` at all (only a `gc_skipped` warning), and on this first + // run there is nothing installed-then-uninstalled, so it must prune + // nothing. let gc1 = v1["gc"] .as_object() .expect("gc must be emitted under --sync"); @@ -789,9 +805,7 @@ fn test_scan_sync_yes_full_lifecycle() { "GC must not be skipped on a --sync run that scanned packages; got {:?}", gc1 ); - let pruned1 = gc1["prunedManifestEntries"] - .as_array() - .expect("first-run gc must report prunedManifestEntries"); + let pruned1 = pruned_entries(&v1); assert!( pruned1.is_empty(), "first --sync run must prune nothing (minimist is still installed); got {pruned1:?}" @@ -811,9 +825,7 @@ fn test_scan_sync_yes_full_lifecycle() { "second --sync after uninstall", ); let v2 = parse_scan_json(&stdout2); - let pruned = v2["gc"]["prunedManifestEntries"] - .as_array() - .expect("gc.prunedManifestEntries array"); + let pruned = pruned_entries(&v2); assert!( pruned.iter().any(|p| p == NPM_PURL), "minimist should be pruned by --sync after uninstall; got {pruned:?}" diff --git a/crates/socket-patch-cli/tests/e2e_socket_yml_policy.rs b/crates/socket-patch-cli/tests/e2e_socket_yml_policy.rs index dfe9bc7d1..200c40de4 100644 --- a/crates/socket-patch-cli/tests/e2e_socket_yml_policy.rs +++ b/crates/socket-patch-cli/tests/e2e_socket_yml_policy.rs @@ -137,6 +137,21 @@ const P_ALPHA_MERGED_NEW: Patch = Patch { published: "2024-03-01T00:00:00Z", }; +/// How many hosted pins a run wrote (dry run: would write): its +/// `applied` / `verified` events with `details.mode: "hosted"` (v5.0's +/// `redirect.redirected`). +fn hosted_pinned(doc: &Value) -> u64 { + doc["events"] + .as_array() + .into_iter() + .flatten() + .filter(|e| { + e["details"]["mode"] == "hosted" + && (e["action"] == "applied" || e["action"] == "verified") + }) + .count() as u64 +} + fn catalog() -> Vec { vec![P_ALPHA, P_BETA, P_LEFTPAD, P_GAMMA, P_DELTA, P_RACK] } @@ -557,7 +572,7 @@ async fn hosted_filters_by_ecosystem_package_and_severity() { ); } } - assert_eq!(doc["redirect"]["redirected"], 1, "{:#}", doc["redirect"]); + assert_eq!(hosted_pinned(&doc), 1, "{doc:#}"); } #[tokio::test] @@ -577,11 +592,7 @@ async fn hosted_dry_run_makes_the_same_decisions_and_writes_nothing() { ); assert_eq!(code, 0, "{doc:#}"); assert_eq!(repo.snapshot(), before, "a dry run changes no bytes"); - assert_eq!( - doc["redirect"]["redirected"], 2, - "alpha and left-pad: {:#}", - doc["redirect"] - ); + assert_eq!(hosted_pinned(&doc), 2, "alpha and left-pad: {doc:#}"); assert_eq!( filtered_reason(&doc, "pkg:npm/beta@1.0.0")["reason"], "policy_severity" @@ -722,10 +733,7 @@ async fn severity_flag_and_env_override_the_file() { doc["policy"]["minSeverity"], json!({"value": null, "source": "flag"}) ); - assert_eq!( - doc["redirect"]["redirected"], 3, - "beta too once the floor is lifted" - ); + assert_eq!(hosted_pinned(&doc), 3, "beta too once the floor is lifted"); let (code, doc) = scan_json( &web, @@ -738,7 +746,7 @@ async fn severity_flag_and_env_override_the_file() { doc["policy"]["minSeverity"], json!({"value": "critical", "source": "env"}) ); - assert_eq!(doc["redirect"]["redirected"], 1); + assert_eq!(hosted_pinned(&doc), 1); // The flag beats the env; an empty env value is unset. let (_, doc) = scan_json( @@ -847,7 +855,7 @@ async fn enabled_false_reports_and_writes_nothing() { !reasons.is_empty() && reasons.iter().all(|r| r == "policy_disabled"), "{reasons:?}" ); - assert_eq!(doc["redirect"]["redirected"], 0); + assert_eq!(hosted_pinned(&doc), 0); } #[tokio::test] @@ -1034,10 +1042,11 @@ async fn agent_mode_applies_only_admitted_patches() { let web = repo.dir("services/web"); let (code, doc) = scan_json(&web, &server.uri(), &["--mode", "agent", "--dry-run"], &[]); assert_eq!(code, 0, "{doc:#}"); - let planned: Vec<&str> = doc["apply"]["patches"] + let planned: Vec<&str> = doc["events"] .as_array() .unwrap() .iter() + .filter(|e| e["action"] == "verified") .map(|p| p["purl"].as_str().unwrap()) .collect(); assert_eq!(planned, ["pkg:npm/alpha@1.0.0"], "{doc:#}"); @@ -1228,10 +1237,11 @@ async fn vendored_dry_run_previews_only_admitted_patches() { ); assert_eq!(code, 0, "{doc:#}"); assert_eq!(repo.snapshot(), before); - let previewed: Vec<&str> = doc["vendor"]["patches"] + let previewed: Vec<&str> = doc["events"] .as_array() .unwrap_or_else(|| panic!("{doc:#}")) .iter() + .filter(|e| e["details"]["mode"] == "vendored") .filter_map(|p| p["purl"].as_str()) .collect(); assert_eq!(previewed, ["pkg:npm/left-pad@1.0.0"], "{doc:#}"); @@ -1274,16 +1284,11 @@ async fn get_bypasses_the_policy_with_a_warning() { let (code, stdout, stderr) = run_cli(&web, &args, &[]); assert_eq!(code, 0, "stdout:\n{stdout}\nstderr:\n{stderr}"); let doc: Value = serde_json::from_str(&stdout).unwrap(); - let warnings: Vec<&str> = doc["warnings"] - .as_array() - .unwrap() - .iter() - .filter_map(Value::as_str) - .collect(); assert!( - warnings - .iter() - .any(|w| w.starts_with("(policy_bypassed)") && w.contains("alpha")), + doc["warnings"].as_array().unwrap().iter().any(|w| { + w["code"] == "policy_bypassed" + && w["detail"].as_str().is_some_and(|d| d.contains("alpha")) + }), "{doc:#}" ); @@ -1325,7 +1330,7 @@ async fn get_by_uuid_bypasses_the_policy_with_a_warning_in_every_mode() { let (code, stdout, stderr) = run_cli(&web, &args, &[]); assert_eq!(code, 0, "{mode}: stdout:\n{stdout}\nstderr:\n{stderr}"); assert!( - stdout.contains("(policy_bypassed)") && stdout.contains("alpha"), + stdout.contains("\"policy_bypassed\"") && stdout.contains("alpha"), "{mode}: the envelope must carry the policy_bypassed warning: {stdout}" ); } diff --git a/crates/socket-patch-cli/tests/e2e_vendor_bun_build.rs b/crates/socket-patch-cli/tests/e2e_vendor_bun_build.rs index cc7cbe6fe..28012eedd 100644 --- a/crates/socket-patch-cli/tests/e2e_vendor_bun_build.rs +++ b/crates/socket-patch-cli/tests/e2e_vendor_bun_build.rs @@ -1370,20 +1370,17 @@ async fn bun_get_uuid_vendored_fresh_checkout_frozen_install() { ); let env = parse_json_envelope(&stdout); assert_eq!(env["status"], "success", "envelope: {env}"); - assert_eq!(env["found"], 1, "envelope: {env}"); - assert_eq!(env["downloaded"], 1, "envelope: {env}"); + assert_eq!(env["command"], "get", "envelope: {env}"); + assert_eq!(env["summary"]["downloaded"], 1, "envelope: {env}"); assert!( env.get("applied").is_none(), "vendored get drops the applied key (nothing is applied in place): {env}" ); assert_eq!( - env["vendor"]["summary"]["applied"], 1, + env["summary"]["applied"], 1, "the nested vendor envelope must report the one vendored package: {env}" ); - assert_eq!( - env["vendor"]["summary"]["failed"], 0, - "no vendor failures: {env}" - ); + assert_eq!(env["summary"]["failed"], 0, "no vendor failures: {env}"); // Anti-vacuity oracle: the record really came from the mocked view // endpoint, not from any pre-existing local state. diff --git a/crates/socket-patch-cli/tests/e2e_vendor_cargo_build.rs b/crates/socket-patch-cli/tests/e2e_vendor_cargo_build.rs index 26a90d725..b0f2c71a4 100644 --- a/crates/socket-patch-cli/tests/e2e_vendor_cargo_build.rs +++ b/crates/socket-patch-cli/tests/e2e_vendor_cargo_build.rs @@ -1134,14 +1134,14 @@ async fn cargo_get_uuid_vendored_fresh_checkout_locked_build() { ); let env = parse_json_envelope(&stdout); assert_eq!(env["status"], "success", "envelope: {env}"); - assert_eq!(env["found"], 1, "envelope: {env}"); - assert_eq!(env["downloaded"], 1, "envelope: {env}"); + assert_eq!(env["command"], "get", "envelope: {env}"); + assert_eq!(env["summary"]["downloaded"], 1, "envelope: {env}"); assert!( env["applied"].is_null(), "vendored get drops `applied` — nothing applies in place: {env}" ); assert_eq!( - env["vendor"]["summary"]["failed"], 0, + env["summary"]["failed"], 0, "nested vendor envelope must report no failures: {env}" ); diff --git a/crates/socket-patch-cli/tests/e2e_vendor_composer_build.rs b/crates/socket-patch-cli/tests/e2e_vendor_composer_build.rs index dd2a68c01..371c94a57 100644 --- a/crates/socket-patch-cli/tests/e2e_vendor_composer_build.rs +++ b/crates/socket-patch-cli/tests/e2e_vendor_composer_build.rs @@ -833,19 +833,16 @@ async fn composer_get_uuid_vendored_fresh_checkout_install() { // "applied" (structurally zero — the nested apply never runs). let env = parse_envelope(&stdout); assert_eq!(env["status"], "success", "envelope: {env}"); - assert_eq!(env["found"], 1, "envelope: {env}"); - assert_eq!(env["downloaded"], 1, "envelope: {env}"); + assert_eq!(env["command"], "get", "envelope: {env}"); + assert_eq!(env["summary"]["downloaded"], 1, "envelope: {env}"); assert!( env.get("applied").is_none(), "vendored get must drop 'applied': {env}" ); - assert_eq!( - env["vendor"]["summary"]["applied"], 1, - "one package vendored: {env}" - ); - assert_eq!(env["vendor"]["summary"]["failed"], 0, "no failures: {env}"); + assert_eq!(env["summary"]["applied"], 1, "one package vendored: {env}"); + assert_eq!(env["summary"]["failed"], 0, "no failures: {env}"); assert!( - env["vendor"]["events"] + env["events"] .as_array() .expect("vendor.events[]") .iter() diff --git a/crates/socket-patch-cli/tests/e2e_vendor_composer_crlf.rs b/crates/socket-patch-cli/tests/e2e_vendor_composer_crlf.rs index 8f4531caf..d60b87408 100644 --- a/crates/socket-patch-cli/tests/e2e_vendor_composer_crlf.rs +++ b/crates/socket-patch-cli/tests/e2e_vendor_composer_crlf.rs @@ -310,7 +310,7 @@ async fn scan_vendor_keeps_a_crlf_lock_and_reverts_it_byte_identically() { let (code, env) = run_json(root, &args); assert_eq!(code, 0, "scan --mode vendored must succeed: {env:#}"); - assert_eq!(env["vendor"]["summary"]["applied"], 1, "{env:#}"); + assert_eq!(env["summary"]["applied"], 1, "{env:#}"); let vendored = assert_wired_crlf(root); assert_rerun_and_revert(root, &args, &vendored); } diff --git a/crates/socket-patch-cli/tests/e2e_vendor_gem_build.rs b/crates/socket-patch-cli/tests/e2e_vendor_gem_build.rs index f6e8aa18c..e24906ee4 100644 --- a/crates/socket-patch-cli/tests/e2e_vendor_gem_build.rs +++ b/crates/socket-patch-cli/tests/e2e_vendor_gem_build.rs @@ -1204,8 +1204,8 @@ async fn gem_get_uuid_vendored_fresh_checkout_bundle_install() { ); let env = parse_json_envelope(&stdout); assert_eq!(env["status"], "success", "envelope: {env}"); - assert_eq!(env["found"], 1, "envelope: {env}"); - assert_eq!(env["downloaded"], 1, "envelope: {env}"); + assert_eq!(env["command"], "get", "envelope: {env}"); + assert_eq!(env["summary"]["downloaded"], 1, "envelope: {env}"); // get's vendored envelope (CLI_CONTRACT.md "get --mode and installed // narrowing"): `applied` is dropped (structurally zero — nothing is // applied in place), and the vendor Envelope nests under "vendor". @@ -1213,17 +1213,12 @@ async fn gem_get_uuid_vendored_fresh_checkout_bundle_install() { env.get("applied").is_none(), "get --mode vendored must drop the `applied` key: {env}" ); - assert_eq!(env["patches"][0]["purl"], purl.as_str(), "envelope: {env}"); - assert_eq!(env["patches"][0]["uuid"], UUID, "envelope: {env}"); - assert_eq!( - env["vendor"]["summary"]["applied"], 1, - "one package vendored: {env}" - ); - assert_eq!( - env["vendor"]["summary"]["failed"], 0, - "no vendor failures: {env}" - ); - let applied = env["vendor"]["events"] + assert_eq!(env["events"][0]["action"], "downloaded", "envelope: {env}"); + assert_eq!(env["events"][0]["purl"], purl.as_str(), "envelope: {env}"); + assert_eq!(env["events"][0]["uuid"], UUID, "envelope: {env}"); + assert_eq!(env["summary"]["applied"], 1, "one package vendored: {env}"); + assert_eq!(env["summary"]["failed"], 0, "no vendor failures: {env}"); + let applied = env["events"] .as_array() .expect("vendor events array") .iter() diff --git a/crates/socket-patch-cli/tests/e2e_vendor_golang_build.rs b/crates/socket-patch-cli/tests/e2e_vendor_golang_build.rs index e24d1ea89..5f91be529 100644 --- a/crates/socket-patch-cli/tests/e2e_vendor_golang_build.rs +++ b/crates/socket-patch-cli/tests/e2e_vendor_golang_build.rs @@ -654,19 +654,16 @@ async fn go_get_uuid_vendored_fresh_checkout_offline_build() { ); let env = parse_json_envelope(&stdout); assert_eq!(env["status"], "success", "envelope: {env}"); - assert_eq!(env["downloaded"], 1, "one record downloaded: {env}"); + assert_eq!( + env["summary"]["downloaded"], 1, + "one record downloaded: {env}" + ); assert!( env.get("applied").is_none(), "vendored get drops `applied` (nothing is applied in place): {env}" ); - assert_eq!( - env["vendor"]["status"], "success", - "nested vendor envelope: {env}" - ); - assert_eq!( - env["vendor"]["summary"]["failed"], 0, - "no vendor failures: {env}" - ); + assert_eq!(env["status"], "success", "nested vendor envelope: {env}"); + assert_eq!(env["summary"]["failed"], 0, "no vendor failures: {env}"); // The record came over the wire — the view endpoint was actually hit. let view_hits = server diff --git a/crates/socket-patch-cli/tests/e2e_vendor_npm_build.rs b/crates/socket-patch-cli/tests/e2e_vendor_npm_build.rs index ca9126054..69bf6b9a1 100644 --- a/crates/socket-patch-cli/tests/e2e_vendor_npm_build.rs +++ b/crates/socket-patch-cli/tests/e2e_vendor_npm_build.rs @@ -932,17 +932,14 @@ async fn npm_get_uuid_vendored_fresh_checkout_npm_ci() { ); let env = parse_envelope(&stdout); assert_eq!(env["status"], "success", "envelope: {env}"); - assert_eq!(env["found"], 1, "envelope: {env}"); - assert_eq!(env["downloaded"], 1, "envelope: {env}"); + assert_eq!(env["command"], "get", "envelope: {env}"); + assert_eq!(env["summary"]["downloaded"], 1, "envelope: {env}"); assert!( env.get("applied").is_none(), "vendored get drops `applied` — nothing is applied in place: {env}" ); - assert_eq!( - env["vendor"]["summary"]["applied"], 1, - "one package vendored: {env}" - ); - assert_eq!(env["vendor"]["summary"]["failed"], 0, "no failures: {env}"); + assert_eq!(env["summary"]["applied"], 1, "one package vendored: {env}"); + assert_eq!(env["summary"]["failed"], 0, "no failures: {env}"); // Committed state: artifact + ledger (a detached entry carrying the // record), NO manifest, NO blobs — vendored mode is manifest-free. diff --git a/crates/socket-patch-cli/tests/e2e_vendor_pnpm_build.rs b/crates/socket-patch-cli/tests/e2e_vendor_pnpm_build.rs index 4aae4fd63..a158155e7 100644 --- a/crates/socket-patch-cli/tests/e2e_vendor_pnpm_build.rs +++ b/crates/socket-patch-cli/tests/e2e_vendor_pnpm_build.rs @@ -497,17 +497,20 @@ async fn run_pnpm_capstone(pm: &str, driver: VendorDriver) { ); let env = parse_envelope(&stdout); assert_eq!(env["status"], "success", "envelope: {env}"); - // The vendor envelope is get's nested `vendor` sub-object; `vendor --json` - // prints it at the top level. + // get merges the vendor engine's events into its own envelope (v5.0); + // `vendor --json` prints the engine's envelope directly. let venv = match driver { VendorDriver::VendorCli => env.clone(), VendorDriver::GetUuid => { - assert_eq!(env["downloaded"], 1, "one record downloaded: {env}"); + assert_eq!( + env["summary"]["downloaded"], 1, + "one record downloaded: {env}" + ); assert!( !env.as_object().unwrap().contains_key("applied"), "vendored get drops `applied` (the nested apply never runs): {env}" ); - env["vendor"].clone() + env.clone() } }; assert_eq!( diff --git a/crates/socket-patch-cli/tests/e2e_vendor_pypi_build.rs b/crates/socket-patch-cli/tests/e2e_vendor_pypi_build.rs index 6804c99be..637bb40b3 100644 --- a/crates/socket-patch-cli/tests/e2e_vendor_pypi_build.rs +++ b/crates/socket-patch-cli/tests/e2e_vendor_pypi_build.rs @@ -1348,13 +1348,13 @@ async fn uv_get_uuid_vendored_fresh_checkout_frozen_offline() { // "applied" (structurally zero — the nested apply never runs). let env = parse_envelope(&stdout); assert_eq!(env["status"], "success", "envelope: {env}"); - assert_eq!(env["found"], 1, "envelope: {env}"); - assert_eq!(env["downloaded"], 1, "envelope: {env}"); + assert_eq!(env["command"], "get", "envelope: {env}"); + assert_eq!(env["summary"]["downloaded"], 1, "envelope: {env}"); assert!( env.get("applied").is_none(), "vendored get must drop 'applied': {env}" ); - assert_vendored_applied(&env["vendor"]); + assert_vendored_applied(&env); // get wrote NO manifest and NO blobs: the ledger's detached entry, keyed // by the suite's bare pypi purl, is the record. 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 0e8848f2a..0806235cf 100644 --- a/crates/socket-patch-cli/tests/e2e_vendor_vlt_build.rs +++ b/crates/socket-patch-cli/tests/e2e_vendor_vlt_build.rs @@ -1236,11 +1236,16 @@ async fn dependency_left_lock(name: &'static str, step: &'static str) { 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:#}" - ); + // The prune GC's reverts are `removed` / `vendor_reverted` events + // (v5.0's `gc.revertedVendoredEntries`). + let reverted: Vec<&Value> = doc["events"] + .as_array() + .into_iter() + .flatten() + .filter(|e| e["errorCode"] == "vendor_reverted") + .map(|e| &e["purl"]) + .collect(); + assert_eq!(reverted, vec![&json!(purl)], "{step}: {doc:#}"); assert!(unwired(&doc).is_empty(), "{step}: {doc:#}"); assert!( !uuid_dir(&fx.proj, fx.t()).exists(), diff --git a/crates/socket-patch-cli/tests/e2e_vendor_yarn_berry_build.rs b/crates/socket-patch-cli/tests/e2e_vendor_yarn_berry_build.rs index 6555371a1..73fc4b30f 100644 --- a/crates/socket-patch-cli/tests/e2e_vendor_yarn_berry_build.rs +++ b/crates/socket-patch-cli/tests/e2e_vendor_yarn_berry_build.rs @@ -432,17 +432,20 @@ async fn run_berry_capstone(driver: VendorDriver, yarnrc_extra: &str) { ); let env = parse_envelope(&stdout); assert_eq!(env["status"], "success", "envelope: {env}"); - // The vendor envelope is get's nested `vendor` sub-object; `vendor --json` - // prints it at the top level. + // get merges the vendor engine's events into its own envelope (v5.0); + // `vendor --json` prints the engine's envelope directly. let venv = match driver { VendorDriver::VendorCli => env.clone(), VendorDriver::GetUuid => { - assert_eq!(env["downloaded"], 1, "one record downloaded: {env}"); + assert_eq!( + env["summary"]["downloaded"], 1, + "one record downloaded: {env}" + ); assert!( !env.as_object().unwrap().contains_key("applied"), "vendored get drops `applied` (the nested apply never runs): {env}" ); - env["vendor"].clone() + env.clone() } }; assert_eq!( diff --git a/crates/socket-patch-cli/tests/e2e_vendor_yarn_classic_build.rs b/crates/socket-patch-cli/tests/e2e_vendor_yarn_classic_build.rs index eea20c50a..45957cb4a 100644 --- a/crates/socket-patch-cli/tests/e2e_vendor_yarn_classic_build.rs +++ b/crates/socket-patch-cli/tests/e2e_vendor_yarn_classic_build.rs @@ -744,10 +744,7 @@ fn yarn_classic_detached_scan_vendored_fresh_checkout_manifestless_vex() { ); let env = parse_json_envelope(&stdout); assert_eq!(env["status"], "success", "envelope: {env}"); - assert_eq!( - env["vendor"]["summary"]["applied"], 1, - "one package vendored: {env}" - ); + assert_eq!(env["summary"]["applied"], 1, "one package vendored: {env}"); assert!( !proj.join(".socket/manifest.json").exists(), "vendored mode must never write the manifest" diff --git a/crates/socket-patch-cli/tests/e2e_vendored_production.rs b/crates/socket-patch-cli/tests/e2e_vendored_production.rs index 715a1332b..25d865281 100644 --- a/crates/socket-patch-cli/tests/e2e_vendored_production.rs +++ b/crates/socket-patch-cli/tests/e2e_vendored_production.rs @@ -396,13 +396,10 @@ fn scan_vendored(cwd: &Path, extra: &[&str]) -> serde_json::Value { /// to the production catalog's future patches. The `applied` event's purl may /// carry qualifiers (`?artifact_id=…`), so a substring match is used. fn assert_vendor_applied(env: &serde_json::Value, purl_needle: &str, leg: &str) { - let vendor = &env["vendor"]; - assert!( - !vendor.is_null(), - "{leg}: scan --mode vendored emitted no `vendor` sub-object — the CLI omits it \ - when discovery found nothing, so this means the crawler did not see the \ - installed dependency.\nenvelope:\n{env:#}" - ); + // v5.0: the vendor engine's events and counts are merged into scan's + // own envelope (`details.mode: "vendored"`), no nested `vendor` object. + let vendor = env; + assert_eq!(vendor["command"], "scan", "{leg}: envelope:\n{env:#}"); let applied = vendor["summary"]["applied"].as_u64().unwrap_or(0); assert!( applied >= 1, @@ -434,7 +431,7 @@ fn vendor_events_for<'a>( purl_needle: &str, action: &str, ) -> Vec<&'a serde_json::Value> { - env["vendor"]["events"] + env["events"] .as_array() .map(|events| { events @@ -457,12 +454,6 @@ fn vendor_events_for<'a>( /// the fixture must not red this leg — so run-wide `summary` counts are /// deliberately not asserted. fn assert_vendor_applied_for(env: &serde_json::Value, purl_needle: &str, leg: &str) { - assert!( - !env["vendor"].is_null(), - "{leg}: scan --mode vendored emitted no `vendor` sub-object — the CLI omits it \ - when discovery found nothing, so this means the crawler did not see the \ - installed dependency.\nenvelope:\n{env:#}" - ); assert!( !vendor_events_for(env, purl_needle, "applied").is_empty(), "{leg}: no `applied` event for a purl containing `{purl_needle}`.\nenvelope:\n{env:#}" @@ -475,12 +466,12 @@ fn assert_vendor_applied_for(env: &serde_json::Value, purl_needle: &str, leg: &s /// Assert the download phase resolved one of the expected patch UUIDs. fn assert_download_uuid(env: &serde_json::Value, uuids: &[&str], leg: &str) { - let patches = env["download"]["patches"] - .as_array() - .cloned() - .unwrap_or_default(); + // The download phase's events (`downloaded` / `failed`, tagged + // `details.mode: "vendored"`). + let patches = env["events"].as_array().cloned().unwrap_or_default(); let found: Vec = patches .iter() + .filter(|p| p["action"] == "downloaded") .filter_map(|p| p["uuid"].as_str().map(str::to_string)) .collect(); assert!( @@ -895,7 +886,7 @@ fn npm_package_lock_vendored_install_proof() { // Idempotency: a re-run is an `already_vendored` no-op with a stable lock. let env2 = scan_vendored(&proj, &[]); assert_eq!( - env2["vendor"]["summary"]["applied"].as_u64().unwrap_or(99), + env2["summary"]["applied"].as_u64().unwrap_or(99), 0, "{LEG}: re-run must vendor nothing new:\n{env2:#}" ); @@ -1031,7 +1022,7 @@ fn pnpm_vendored_install_proof() { let env2 = scan_vendored(&proj, &[]); assert_eq!( - env2["vendor"]["summary"]["applied"].as_u64().unwrap_or(99), + env2["summary"]["applied"].as_u64().unwrap_or(99), 0, "{LEG}: re-run must vendor nothing new:\n{env2:#}" ); @@ -1271,7 +1262,7 @@ fn yarn_classic_vendored_install_proof() { let env2 = scan_vendored(&proj, &[]); assert_eq!( - env2["vendor"]["summary"]["applied"].as_u64().unwrap_or(99), + env2["summary"]["applied"].as_u64().unwrap_or(99), 0, "{LEG}: re-run must vendor nothing new:\n{env2:#}" ); @@ -1383,7 +1374,7 @@ fn yarn_berry_vendored_install_proof() { let env2 = scan_vendored(&proj, &[]); assert_eq!( - env2["vendor"]["summary"]["applied"].as_u64().unwrap_or(99), + env2["summary"]["applied"].as_u64().unwrap_or(99), 0, "{LEG}: re-run must vendor nothing new:\n{env2:#}" ); @@ -1645,7 +1636,7 @@ fn bun_vendored_install_proof() { let env2 = scan_vendored(&proj, &[]); assert_eq!( - env2["vendor"]["summary"]["applied"].as_u64().unwrap_or(99), + env2["summary"]["applied"].as_u64().unwrap_or(99), 0, "{LEG}: re-run must vendor nothing new:\n{env2:#}" ); @@ -1955,7 +1946,7 @@ fn pypi_requirements_txt_vendored_install_proof() { let reqs_wired = std::fs::read(proj.join("requirements.txt")).unwrap(); let env2 = scan_vendored(&proj, &["--ecosystems", "pypi"]); assert_eq!( - env2["vendor"]["summary"]["applied"].as_u64().unwrap_or(99), + env2["summary"]["applied"].as_u64().unwrap_or(99), 0, "{LEG}: re-run must vendor nothing new:\n{env2:#}" ); @@ -2104,7 +2095,7 @@ fn pypi_uv_lock_vendored_install_proof() { let env2 = scan_vendored(&proj, &["--ecosystems", "pypi"]); assert_eq!( - env2["vendor"]["summary"]["applied"].as_u64().unwrap_or(99), + env2["summary"]["applied"].as_u64().unwrap_or(99), 0, "{LEG}: re-run must vendor nothing new:\n{env2:#}" ); @@ -2222,11 +2213,12 @@ fn gem_bundler_vendored_install_proof() { assert_download_uuid(&env_json, &gem_uuids, LEG); // Which pinned patch did the resolver wire? The marker probes below are // per-patch — each advisory's diff marks a different file. - let wired_uuid = env_json["download"]["patches"] + let wired_uuid = env_json["events"] .as_array() .cloned() .unwrap_or_default() .iter() + .filter(|p| p["action"] == "downloaded") .filter_map(|p| p["uuid"].as_str()) .find(|u| gem_uuids.contains(u)) .expect("assert_download_uuid guarantees a pinned uuid is downloaded") @@ -2237,9 +2229,14 @@ fn gem_bundler_vendored_install_proof() { .map(|(_, f)| *f) .unwrap(); let pristine = pristine_by_file[patched_file_rel].clone(); - assert_eq!( - env_json["download"]["failed"].as_u64().unwrap_or(99), - 0, + assert!( + !env_json["events"] + .as_array() + .into_iter() + .flatten() + .any(|e| { + e["action"] == "failed" && e["purl"].as_str().is_some_and(|p| p.contains(GEM_NAME)) + }), "{LEG}: the gem patch download failed.\nenvelope:\n{env_json:#}" ); // Purl-scoped (NOT run-wide counts): a future free patch on one of the @@ -2253,7 +2250,7 @@ fn gem_bundler_vendored_install_proof() { // fallback expectation the moment the depscan stub fix deploys and the // rebuilt artifacts serve valid stubs (and catches both-or-neither as a // defect either way). - let route_markers = env_json["vendor"]["events"] + let route_markers = env_json["events"] .as_array() .cloned() .unwrap_or_default() @@ -2539,18 +2536,16 @@ fn golang_vendored_finds_no_free_patches() { .expect("write go.mod"); let env_json = scan_vendored(&proj, &["--ecosystems", "golang"]); - let applied = env_json["vendor"]["summary"]["applied"] - .as_u64() - .unwrap_or(0); + let applied = env_json["summary"]["applied"].as_u64().unwrap_or(0); assert_eq!( applied, 0, "{LEG}: golang vendored something, but production publishes no free golang patches. \ Either production changed (extend this suite with a real golang delivery proof) or \ this is a bug.\nenvelope:\n{env_json:#}" ); - let patches = env_json["download"]["patches"] + let patches = env_json["events"] .as_array() - .map(|a| a.len()) + .map(|a| a.iter().filter(|e| e["action"] == "downloaded").count()) .unwrap_or(0); if patches > 0 { println!( @@ -2581,9 +2576,7 @@ fn deno_vendored_is_unsupported() { .expect("write deno.json"); let env_json = scan_vendored(&proj, &["--ecosystems", "deno"]); - let applied = env_json["vendor"]["summary"]["applied"] - .as_u64() - .unwrap_or(0); + let applied = env_json["summary"]["applied"].as_u64().unwrap_or(0); assert_eq!( applied, 0, "{LEG}: deno vendored something, but vendored mode is not supported for deno:\n{env_json:#}" diff --git a/crates/socket-patch-cli/tests/e2e_vex_build/deno.rs b/crates/socket-patch-cli/tests/e2e_vex_build/deno.rs index 977e0fb65..762e2b060 100644 --- a/crates/socket-patch-cli/tests/e2e_vex_build/deno.rs +++ b/crates/socket-patch-cli/tests/e2e_vex_build/deno.rs @@ -405,9 +405,9 @@ fn deno_hosted_and_vendored_never_attest_manifest_mode_unchanged() { Some(0), "deno {v}: scan --mode hosted: {env:#}\n{stderr}" ); - assert_eq!(env["redirect"]["redirected"], 0, "deno {v}: {env:#}"); + assert_eq!(env["summary"]["applied"], 0, "deno {v}: {env:#}"); assert!( - env["redirect"]["warnings"] + env["warnings"] .as_array() .is_some_and(|w| w.iter().any(|w| w["code"] == "redirect_npm_no_lockfile")), "deno {v}: the npm rewriter found no npm lockfile to pin: {env:#}" @@ -438,7 +438,7 @@ fn deno_hosted_and_vendored_never_attest_manifest_mode_unchanged() { "deno {v}: scan --mode vendored: {env:#}\n{stderr}" ); assert!( - env["vendor"]["events"].as_array().is_some_and(|e| e + env["events"].as_array().is_some_and(|e| e .iter() .any(|e| e["action"] == "failed" && e["errorCode"] == "vendor_lockfile_missing")), "deno {v}: vendoring refuses without an npm-family lockfile: {env:#}" @@ -453,7 +453,10 @@ fn deno_hosted_and_vendored_never_attest_manifest_mode_unchanged() { // vendor ledger alone carries the records), and the refused vendor step // recorded nothing in that ledger either — so the failed run left no // record anywhere for `vex` to attest from. - assert_eq!(env["download"]["detached"], true, "deno {v}: {env:#}"); + assert!( + !project.join(".socket/manifest.json").exists(), + "deno {v}: the vendored download writes no manifest: {env:#}" + ); let ledger = project.join(".socket/vendor/state.json"); if let Ok(state) = std::fs::read_to_string(&ledger) { assert!( diff --git a/crates/socket-patch-cli/tests/e2e_vex_build/hatch.rs b/crates/socket-patch-cli/tests/e2e_vex_build/hatch.rs index b44febffd..48b96ae4b 100644 --- a/crates/socket-patch-cli/tests/e2e_vex_build/hatch.rs +++ b/crates/socket-patch-cli/tests/e2e_vex_build/hatch.rs @@ -247,7 +247,7 @@ fn flow(flavor: Flavor, mode: Mode) { if mode == Mode::Vendored && flavor == Flavor::HatchTomlEnv && vtuple(&version) < (1, 2) { assert_ne!(code, Some(0), "{what}: must refuse: {env}"); - let codes: Vec<&str> = env["vendor"]["events"] + let codes: Vec<&str> = env["events"] .as_array() .into_iter() .flatten() @@ -630,11 +630,8 @@ fn existing_env_flow(mode: Mode) { let (code, env, stderr) = socket_scan(&project, &api, &scan_mode_args(mode), &case_envs); assert_eq!(code, Some(0), "{what}: scan failed: {env}\n{stderr}"); let (warnings, code_name) = match mode { - Mode::Hosted => ( - env["redirect"]["warnings"].clone(), - "redirect_pypi_stale_install", - ), - Mode::Vendored => (env["vendor"]["events"].clone(), "pypi_hatch_stale_install"), + Mode::Hosted => (env["warnings"].clone(), "redirect_pypi_stale_install"), + Mode::Vendored => (env["events"].clone(), "pypi_hatch_stale_install"), }; let details: Vec = warnings .as_array() diff --git a/crates/socket-patch-cli/tests/e2e_vex_build/main.rs b/crates/socket-patch-cli/tests/e2e_vex_build/main.rs index 0ff2448f6..b2d5a5ea0 100644 --- a/crates/socket-patch-cli/tests/e2e_vex_build/main.rs +++ b/crates/socket-patch-cli/tests/e2e_vex_build/main.rs @@ -15,6 +15,9 @@ //! The hermetic twins of these suites are the same-named modules of //! `tests/e2e_vex_lockfile/`. +#[path = "../common/rollback_json.rs"] +mod rollback_json; + #[path = "../vex_e2e_common/mod.rs"] mod vex_e2e_common; #[cfg(unix)] diff --git a/crates/socket-patch-cli/tests/e2e_vex_build/poetry.rs b/crates/socket-patch-cli/tests/e2e_vex_build/poetry.rs index 883d506fe..c4406c679 100644 --- a/crates/socket-patch-cli/tests/e2e_vex_build/poetry.rs +++ b/crates/socket-patch-cli/tests/e2e_vex_build/poetry.rs @@ -773,7 +773,7 @@ fn poetry_hosted_fresh_install_then_manifestless_vex() { ], ); assert_eq!(code, Some(0), "scan --mode hosted: {env}"); - assert_eq!(env["redirect"]["redirected"], 1, "{env}"); + assert_eq!(env["summary"]["applied"], 1, "{env}"); let lock = std::fs::read_to_string(project.join("poetry.lock")).unwrap(); let sha = hex::encode(Sha256::digest(&wheel)); assert!( @@ -798,7 +798,7 @@ fn poetry_hosted_fresh_install_then_manifestless_vex() { // Poetry < 1.4 keeps an already-installed same-version package; the // writer says so (and, for lock 1.0, names the pip window below). - let stale_risk = env["redirect"]["warnings"] + let stale_risk = env["warnings"] .as_array() .into_iter() .flatten() @@ -886,7 +886,7 @@ fn poetry_hosted_fresh_install_then_manifestless_vex() { ); assert_eq!(code, Some(0), "rollback: {env}"); assert_eq!( - env["hosted"]["reverted"], + crate::rollback_json::hosted_reverted(&env), json!([PURL]), "rollback restores the hosted pin: {env}" ); @@ -1110,7 +1110,13 @@ fn poetry_vendored_revendors_to_a_superseding_patch() { let wired_a = std::fs::read_to_string(project.join("poetry.lock")).unwrap(); let (code, env) = vendor(&service_b, true); assert_eq!(code, Some(0), "dry-run re-vendor: {env}"); - assert!(env.to_string().contains("would_revendor"), "{env}"); + // A re-vendor preview is a `verified` event naming the uuid it replaces. + assert!( + env["events"].as_array().is_some_and(|e| e + .iter() + .any(|e| e["action"] == "verified" && e["oldUuid"].is_string())), + "{env}" + ); assert_eq!( std::fs::read_to_string(project.join("poetry.lock")).unwrap(), wired_a, diff --git a/crates/socket-patch-cli/tests/e2e_vex_lockfile/bun.rs b/crates/socket-patch-cli/tests/e2e_vex_lockfile/bun.rs index 7153bee5d..0947c009c 100644 --- a/crates/socket-patch-cli/tests/e2e_vex_lockfile/bun.rs +++ b/crates/socket-patch-cli/tests/e2e_vex_lockfile/bun.rs @@ -843,7 +843,7 @@ fn scan_hosted(cwd: &Path, flavor: Flavor, api: &Api) -> Vec { ); let what = format!("{flavor:?} scan --mode hosted --vex"); assert_eq!(code, Some(0), "{what}: {env}"); - assert_eq!(env["redirect"]["redirected"], 1, "{what}: {env}"); + assert_eq!(hosted_pinned(&env), 1, "{what}: {env}"); assert_eq!(env["vex"]["statements"], 1, "{what}: {env}"); let stmt = &doc(cwd)["statements"][0]; assert_eq!(stmt["vulnerability"]["name"], GHSA, "{what}"); @@ -890,7 +890,7 @@ fn scan_vendored(cwd: &Path, flavor: Flavor, api: &Api) -> Vec { let (code, env) = socket_json(cwd, api, &args); let what = format!("{flavor:?} scan --mode vendored --vex"); assert_eq!(code, Some(0), "{what}: {env}"); - assert_eq!(env["vendor"]["summary"]["applied"], 1, "{what}: {env}"); + assert_eq!(env["summary"]["applied"], 1, "{what}: {env}"); assert_eq!(env["vex"]["statements"], 1, "{what}: {env}"); let stmt = &doc(cwd)["statements"][0]; assert_eq!( @@ -1671,3 +1671,17 @@ fn apply_vex_attests_lockfile_patches_without_a_manifest() { fn doc_placeholder() -> String { json!({ "@context": "https://openvex.dev/ns/v0.2.0", "statements": [] }).to_string() } + +/// How many hosted pins the run wrote (v5.0: the `applied` / `verified` +/// events with `details.mode: "hosted"`, formerly `redirect.redirected`). +fn hosted_pinned(env: &serde_json::Value) -> u64 { + env["events"] + .as_array() + .into_iter() + .flatten() + .filter(|e| { + e["details"]["mode"] == "hosted" + && (e["action"] == "applied" || e["action"] == "verified") + }) + .count() as u64 +} diff --git a/crates/socket-patch-cli/tests/e2e_vex_lockfile/hatch.rs b/crates/socket-patch-cli/tests/e2e_vex_lockfile/hatch.rs index b47a50fd4..99741cbcb 100644 --- a/crates/socket-patch-cli/tests/e2e_vex_lockfile/hatch.rs +++ b/crates/socket-patch-cli/tests/e2e_vex_lockfile/hatch.rs @@ -186,7 +186,7 @@ fn vendored_dependency_group_is_refused_and_never_attested() { &["--vex", vex_out.to_str().unwrap(), "--vex-product", PRODUCT], ); assert_ne!(code, Some(0), "{env}\n{stderr}"); - let codes: Vec<&str> = env["vendor"]["events"] + let codes: Vec<&str> = env["events"] .as_array() .into_iter() .flatten() diff --git a/crates/socket-patch-cli/tests/e2e_vex_lockfile/nuget.rs b/crates/socket-patch-cli/tests/e2e_vex_lockfile/nuget.rs index e4ea8e88d..618394daf 100644 --- a/crates/socket-patch-cli/tests/e2e_vex_lockfile/nuget.rs +++ b/crates/socket-patch-cli/tests/e2e_vex_lockfile/nuget.rs @@ -1376,7 +1376,7 @@ fn nuget_scan_hosted_wiring_reattests_without_manifest_or_ledger() { let api = serve_hosted_api(golden, NUGET_PURL, NUGET_HOSTED_UUID, nuget_hosted_view()); let (code, env, stderr) = scan_hosted_vex(&fx, &api); assert_eq!(code, Some(0), "scan --mode hosted --vex: {env}\n{stderr}"); - assert_eq!(env["redirect"]["redirected"], 1, "{env}"); + assert_eq!(hosted_pinned(&env), 1, "{env}"); // The in-run VEX attests on the fresh pin (`assume_applied`: the // restore that consumes it has not run yet), never on the pristine // shared-folder copy. @@ -1489,3 +1489,17 @@ fn nuget_apply_vex_attests_but_agent_mode_needs_the_manifest() { assert!(!fx.cwd.join("nuget.config").exists()); assert!(!fx.cwd.join("packages.lock.json").exists()); } + +/// How many hosted pins the run wrote (v5.0: the `applied` / `verified` +/// events with `details.mode: "hosted"`, formerly `redirect.redirected`). +fn hosted_pinned(env: &serde_json::Value) -> u64 { + env["events"] + .as_array() + .into_iter() + .flatten() + .filter(|e| { + e["details"]["mode"] == "hosted" + && (e["action"] == "applied" || e["action"] == "verified") + }) + .count() as u64 +} diff --git a/crates/socket-patch-cli/tests/e2e_vex_lockfile/pip.rs b/crates/socket-patch-cli/tests/e2e_vex_lockfile/pip.rs index efef9f43b..9552d95fb 100644 --- a/crates/socket-patch-cli/tests/e2e_vex_lockfile/pip.rs +++ b/crates/socket-patch-cli/tests/e2e_vex_lockfile/pip.rs @@ -319,8 +319,12 @@ fn vendored_extras_pin_is_refused_and_nothing_is_attested() { let api = ScanApi::start(VENDORED_UUID); let (code, env, stderr) = run_scan(&cwd, &api, &extras, Mode::Vendored, &[]); assert_ne!(code, Some(0), "{env}\n{stderr}"); - assert_eq!( - env["vendor"]["events"][0]["errorCode"], "pypi_extras_unsupported", + // v5.0: the vendor engine's events are merged into scan's envelope, + // after the download phase's `downloaded` event. + assert!( + env["events"].as_array().is_some_and(|e| e + .iter() + .any(|e| e["action"] == "failed" && e["errorCode"] == "pypi_extras_unsupported")), "{env}" ); for (rel, native) in &extras.native { diff --git a/crates/socket-patch-cli/tests/e2e_vex_lockfile/pnpm.rs b/crates/socket-patch-cli/tests/e2e_vex_lockfile/pnpm.rs index 9ab6a52da..c7bb1fb3a 100644 --- a/crates/socket-patch-cli/tests/e2e_vex_lockfile/pnpm.rs +++ b/crates/socket-patch-cli/tests/e2e_vex_lockfile/pnpm.rs @@ -306,7 +306,7 @@ fn hosted_project(tmp: &Path, version: &str, lock_name: &str) -> (PathBuf, Strin ); drop(scan_api); assert_eq!(code, Some(0), "[{version}] scan: {env}\n{stderr}"); - assert_eq!(env["redirect"]["redirected"], 1, "[{version}] scan: {env}"); + assert_eq!(hosted_pinned(&env), 1, "[{version}] scan: {env}"); let lock = std::fs::read_to_string(root.join(lock_name)).unwrap(); assert!( lock.contains(&url) && lock.contains(&pin), @@ -535,3 +535,17 @@ fn hand_wired_legacy_pnpm_locks_attest_the_committed_tarball() { eprintln!("hand-wired pnpm {version}: OK"); } } + +/// How many hosted pins the run wrote (v5.0: the `applied` / `verified` +/// events with `details.mode: "hosted"`, formerly `redirect.redirected`). +fn hosted_pinned(env: &serde_json::Value) -> u64 { + env["events"] + .as_array() + .into_iter() + .flatten() + .filter(|e| { + e["details"]["mode"] == "hosted" + && (e["action"] == "applied" || e["action"] == "verified") + }) + .count() as u64 +} diff --git a/crates/socket-patch-cli/tests/e2e_yarn4_pnpm_linker_build.rs b/crates/socket-patch-cli/tests/e2e_yarn4_pnpm_linker_build.rs index bdb172089..e7e5d2137 100644 --- a/crates/socket-patch-cli/tests/e2e_yarn4_pnpm_linker_build.rs +++ b/crates/socket-patch-cli/tests/e2e_yarn4_pnpm_linker_build.rs @@ -543,13 +543,10 @@ async fn yarn4_pnpm_linker_hosted_redirect_fresh_checkout_installs_patched_bytes }); assert_eq!(env["status"], "success", "envelope: {env}"); assert!( - env["packagesWithPatches"].as_u64() >= Some(1), + env["packages"].as_array().map(Vec::len) >= Some(1), "discovery must find the dep through the pnpm-linker layout: {env}" ); - assert_eq!( - env["redirect"]["redirected"], 1, - "one dep redirected: {env}" - ); + assert_eq!(env["summary"]["applied"], 1, "one dep redirected: {env}"); let lock = std::fs::read_to_string(proj.join("yarn.lock")).unwrap(); assert!( diff --git a/crates/socket-patch-cli/tests/e2e_yarn4_workspaces_build.rs b/crates/socket-patch-cli/tests/e2e_yarn4_workspaces_build.rs index 145c70ce9..bd794f286 100644 --- a/crates/socket-patch-cli/tests/e2e_yarn4_workspaces_build.rs +++ b/crates/socket-patch-cli/tests/e2e_yarn4_workspaces_build.rs @@ -540,7 +540,7 @@ async fn yarn4_workspaces_hosted_redirect_rewires_member_dep_from_root_scan() { }); assert_eq!(env["status"], "success", "envelope: {env}"); assert_eq!( - env["redirect"]["redirected"], 1, + env["summary"]["applied"], 1, "the member's dep must be redirected from a root scan: {env}" ); diff --git a/crates/socket-patch-cli/tests/e2e_yarn_legacy_cachekey_refusal_build.rs b/crates/socket-patch-cli/tests/e2e_yarn_legacy_cachekey_refusal_build.rs index 07623f327..364fe06d8 100644 --- a/crates/socket-patch-cli/tests/e2e_yarn_legacy_cachekey_refusal_build.rs +++ b/crates/socket-patch-cli/tests/e2e_yarn_legacy_cachekey_refusal_build.rs @@ -22,11 +22,11 @@ //! and then asserts the FULL refusal contract against the built binary: //! //! * `scan --mode hosted`: exit 0, envelope `status: success`, -//! `redirect.redirected == 0`, no rewritten files, a per-file warning with +//! no `applied` event (`summary.applied == 0`), no rewritten files, a per-file warning with //! code `redirect_yarn_berry_cache_unsupported` (the CODE, not human //! text), and `yarn.lock` + `package.json` byte-identical. -//! * `scan --mode vendored`: exit 1, envelope `status: partial_failure`, -//! a failed DOWNLOAD record (errorCode +//! * `scan --mode vendored`: exit 1, envelope `status: partialFailure`, +//! a `failed` download event (errorCode //! `vendor_yarn_berry_cache_unsupported`) produced before any view //! fetch, with no vendor-step event for the package, zero mutations to //! `yarn.lock` / `package.json`, and no `.socket/vendor` artifacts. @@ -347,7 +347,7 @@ async fn refusal_case(tag: &str, yarn_pm: &str, compression_zero: bool, expected }); assert_eq!(env["status"], "success", "({tag}) envelope: {env}"); assert_eq!( - env["redirect"]["redirected"], 0, + env["summary"]["applied"], 0, "({tag}) nothing may be redirected on a legacy-cacheKey lock: {env}" ); assert_eq!( @@ -355,9 +355,9 @@ async fn refusal_case(tag: &str, yarn_pm: &str, compression_zero: bool, expected Some(0), "({tag}) no file may be rewritten: {env}" ); - let warnings = env["redirect"]["warnings"] + let warnings = env["warnings"] .as_array() - .unwrap_or_else(|| panic!("({tag}) redirect.warnings must be an array: {env}")); + .unwrap_or_else(|| panic!("({tag}) warnings must be an array: {env}")); let warning = warnings .iter() .find(|w| w["code"] == "redirect_yarn_berry_cache_unsupported") @@ -408,16 +408,20 @@ async fn refusal_case(tag: &str, yarn_pm: &str, compression_zero: bool, expected let env: serde_json::Value = serde_json::from_str(&stdout).unwrap_or_else(|e| { panic!("({tag}) scan --mode vendored --json output is not JSON: {e}\nstdout:\n{stdout}") }); - assert_eq!(env["status"], "partial_failure", "({tag}) envelope: {env}"); + assert_eq!(env["status"], "partialFailure", "({tag}) envelope: {env}"); assert_eq!( - (&env["download"]["downloaded"], &env["download"]["failed"]), + (&env["summary"]["downloaded"], &env["summary"]["failed"]), (&serde_json::json!(0), &serde_json::json!(1)), "({tag}) the lock-text refusal fires before the view fetch: {env}" ); - let failed = env["download"]["patches"] + let events_for_purl: Vec<&serde_json::Value> = env["events"] .as_array() - .and_then(|p| p.iter().find(|r| r["purl"] == PURL)) - .unwrap_or_else(|| panic!("({tag}) expected a download record for {PURL}: {env}")); + .map(|events| events.iter().filter(|e| e["purl"] == PURL).collect()) + .unwrap_or_default(); + let failed = *events_for_purl + .first() + .unwrap_or_else(|| panic!("({tag}) expected a download event for {PURL}: {env}")); + assert_eq!(failed["details"]["mode"], "vendored", "({tag}) {failed}"); assert_eq!(failed["action"], "failed", "({tag}) {failed}"); assert_eq!( failed["errorCode"], "vendor_yarn_berry_cache_unsupported", @@ -431,21 +435,14 @@ async fn refusal_case(tag: &str, yarn_pm: &str, compression_zero: bool, expected "({tag}) the refusal detail is the backend's own, naming the cacheKey: {failed}" ); assert_eq!( - env["vendor"]["summary"]["applied"], 0, + env["summary"]["applied"], 0, "({tag}) nothing may be vendored: {env}" ); - let vendor_events_for_purl: Vec<&serde_json::Value> = env["vendor"]["events"] - .as_array() - .map(|events| events.iter().filter(|e| e["purl"] == PURL).collect()) - .unwrap_or_default(); - assert!( - vendor_events_for_purl.is_empty(), - "({tag}) the vendor step must emit nothing for the package refused in the \ - download phase: {env}" - ); assert_eq!( - env["vendor"]["summary"]["failed"], 0, - "({tag}) the refusal is not double-counted by the vendor step: {env}" + events_for_purl.len(), + 1, + "({tag}) the vendor step must emit nothing for the package refused in the \ + download phase (the refusal is not double-counted): {env}" ); assert_eq!( std::fs::read(&lock_path).unwrap(), @@ -576,13 +573,11 @@ async fn refusal_case(tag: &str, yarn_pm: &str, compression_zero: bool, expected out.envelope["error"]["code"], "manifest_not_found", "({tag}) scan --vex: {out}" ); - assert_eq!(out.envelope["redirect"]["redirected"], 0, "({tag}): {out}"); + assert_eq!(out.envelope["summary"]["applied"], 0, "({tag}): {out}"); assert!( - out.envelope["redirect"]["warnings"] - .as_array() - .is_some_and(|w| w - .iter() - .any(|w| w["code"] == "redirect_yarn_berry_cache_unsupported")), + out.envelope["warnings"].as_array().is_some_and(|w| w + .iter() + .any(|w| w["code"] == "redirect_yarn_berry_cache_unsupported")), "({tag}) scan --vex: still refused: {out}" ); assert!(out.doc.is_none(), "({tag}) scan --vex: no document: {out}"); diff --git a/crates/socket-patch-cli/tests/ecosystem_dispatch_e2e.rs b/crates/socket-patch-cli/tests/ecosystem_dispatch_e2e.rs index 7597acf49..059c4101a 100644 --- a/crates/socket-patch-cli/tests/ecosystem_dispatch_e2e.rs +++ b/crates/socket-patch-cli/tests/ecosystem_dispatch_e2e.rs @@ -29,7 +29,7 @@ //! a file inside it whose on-disk bytes hash to `afterHash`, and asserts //! the rollback actually (a) discovered the package via that ecosystem's //! crawler, (b) restored the file's original bytes on disk, and (c) -//! reported `rolledBack == 1` for that exact PURL. A broken/removed +//! reported `summary.rolledBack == 1` for that exact PURL. A broken/removed //! rollback dispatch branch yields zero discovered packages → the //! assertions fail loudly. @@ -466,24 +466,28 @@ fn assert_rollback_restored(cwd: &Path, ecosystem: &str, fixture: &RollbackFixtu "rollback --ecosystems={ecosystem}: expected success; env={env}" ); assert_eq!( - env["rolledBack"].as_u64(), + env["summary"]["rolledBack"].as_u64(), Some(1), "rollback --ecosystems={ecosystem}: must roll back exactly the one installed package; env={env}" ); assert_eq!( - env["failed"].as_u64(), + env["summary"]["failed"].as_u64(), Some(0), "rollback --ecosystems={ecosystem}: no failures expected; env={env}" ); assert_eq!( - env["alreadyOriginal"].as_u64(), + env["summary"]["skipped"].as_u64(), Some(0), "rollback --ecosystems={ecosystem}: package was patched, not already-original; env={env}" ); - let results = env["results"] + let results: Vec<&Value> = env["events"] .as_array() - .unwrap_or_else(|| panic!("rollback --ecosystems={ecosystem}: results missing; env={env}")); + .unwrap_or_else(|| panic!("rollback --ecosystems={ecosystem}: events missing; env={env}")) + .iter() + // The restore events, not the manifest-removal ones. + .filter(|e| e["details"]["manifest"] != true) + .collect(); assert_eq!( results.len(), 1, @@ -495,11 +499,11 @@ fn assert_rollback_restored(cwd: &Path, ecosystem: &str, fixture: &RollbackFixtu "rollback --ecosystems={ecosystem}: rolled-back PURL mismatch; env={env}" ); assert_eq!( - results[0]["success"], true, + results[0]["action"], "rolledBack", "rollback --ecosystems={ecosystem}: per-package rollback must succeed; env={env}" ); assert!( - results[0]["filesRolledBack"] + results[0]["files"] .as_array() .is_some_and(|a| !a.is_empty()), "rollback --ecosystems={ecosystem}: must list at least one rolled-back file; env={env}" @@ -532,18 +536,22 @@ fn assert_rollback_not_dispatched(cwd: &Path, ecosystem: &str, fixture: &Rollbac "rollback --ecosystems={ecosystem}: out-of-scope rollback should be a clean no-op (exit 0); env={env}" ); assert_eq!( - env["rolledBack"].as_u64(), + env["summary"]["rolledBack"].as_u64(), Some(0), "rollback --ecosystems={ecosystem}: out-of-scope package must NOT be rolled back; env={env}" ); assert_eq!( - env["alreadyOriginal"].as_u64(), + env["summary"]["skipped"].as_u64(), Some(0), "rollback --ecosystems={ecosystem}: out-of-scope package must not be discovered at all; env={env}" ); - let results = env["results"] + let results: Vec<&Value> = env["events"] .as_array() - .unwrap_or_else(|| panic!("rollback --ecosystems={ecosystem}: results missing; env={env}")); + .unwrap_or_else(|| panic!("rollback --ecosystems={ecosystem}: events missing; env={env}")) + .iter() + // The restore events, not the manifest-removal ones. + .filter(|e| e["details"]["manifest"] != true) + .collect(); assert!( results.is_empty(), "rollback --ecosystems={ecosystem}: expected no results for out-of-scope PURL, got {}; env={env}", diff --git a/crates/socket-patch-cli/tests/get/coverage_fix_get_double_json.rs b/crates/socket-patch-cli/tests/get/coverage_fix_get_double_json.rs index 905a9268d..dedd25813 100644 --- a/crates/socket-patch-cli/tests/get/coverage_fix_get_double_json.rs +++ b/crates/socket-patch-cli/tests/get/coverage_fix_get_double_json.rs @@ -1,10 +1,9 @@ //! Subprocess regression test: agent-mode `get --json` must //! print exactly ONE JSON document when the download engine hits a HARD //! error (here: an unreadable manifest). The engine's fail-closed paths -//! in `download_and_apply_patches_with` print the `{status: "error"}` -//! envelope themselves via `report_error` and return it; `run()`'s agent -//! path must not pretty-print the SAME envelope again (get's `--json` -//! contract is one document on stdout). +//! in `download_and_apply_patches_into` record the error into the caller's +//! envelope and never print, so `run()` prints exactly one document (get's +//! `--json` contract is one document on stdout). //! //! Subprocess (not in-process) because the contract is what the spawned //! binary PRINTS. Same harness recipe as `get_modes_e2e.rs`. @@ -83,11 +82,14 @@ async fn search_get_json_engine_hard_error_prints_one_document() { let v: serde_json::Value = serde_json::from_str(stdout.trim()).unwrap_or_else(|e| { panic!("stdout must be exactly one JSON document: {e}\nstdout:\n{stdout}") }); + common::envelope::assert_envelope_invariants(&v, "get"); assert_eq!(v["status"], "error", "envelope drifted: {v}"); + // The shared manifest-load code (v5.0): unparseable is `manifest_invalid`. + assert_eq!(v["error"]["code"], "manifest_invalid", "{v}"); assert!( v["error"]["message"] .as_str() - .is_some_and(|m| m.contains("Failed to read manifest")), + .is_some_and(|m| m.contains("Invalid manifest at")), "error message drifted: {v}" ); } diff --git a/crates/socket-patch-cli/tests/get/get_batch_paths_e2e.rs b/crates/socket-patch-cli/tests/get/get_batch_paths_e2e.rs index bf9fa8f1b..883c6f73c 100644 --- a/crates/socket-patch-cli/tests/get/get_batch_paths_e2e.rs +++ b/crates/socket-patch-cli/tests/get/get_batch_paths_e2e.rs @@ -107,7 +107,7 @@ async fn get_by_purl_with_multiple_patches_emits_selection_required() { ); let v: serde_json::Value = serde_json::from_str(stdout.trim()).expect("valid JSON envelope"); assert_eq!( - v["status"], "selection_required", + v["status"], "selectionRequired", "must surface selection_required; got {}", v["status"] ); @@ -142,9 +142,9 @@ async fn get_by_purl_with_multiple_patches_emits_selection_required() { o["tier"] ); assert!( - o["published_at"].as_str().is_some_and(|s| !s.is_empty()), + o["publishedAt"].as_str().is_some_and(|s| !s.is_empty()), "each option must carry a non-empty published_at; got {}", - o["published_at"] + o["publishedAt"] ); } @@ -237,13 +237,13 @@ async fn get_uuid_returning_404_emits_not_found() { // 404 means "patch absent", which is a clean no-op: exit 0. assert_eq!(code, 0, "404 (patch absent) must exit 0; stdout={stdout}"); let v: serde_json::Value = serde_json::from_str(stdout.trim()).expect("valid JSON"); - assert_eq!(v["status"], "not_found", "404 must surface as not_found"); + assert_eq!(v["status"], "notFound", "404 must surface as not_found"); // The empty-result envelope shape is part of the contract. - assert_eq!(v["found"], 0); - assert_eq!(v["downloaded"], 0); - assert_eq!(v["applied"], 0); + assert_eq!(v["events"], serde_json::json!([])); + assert_eq!(v["summary"]["downloaded"], 0); + assert_eq!(v["summary"]["applied"], 0); assert!( - v["patches"].as_array().expect("patches array").is_empty(), + v["events"].as_array().expect("patches array").is_empty(), "not_found must carry an empty patches list" ); } @@ -333,15 +333,21 @@ async fn get_by_cve_with_no_patches_emits_no_match() { ); let v: serde_json::Value = serde_json::from_str(stdout.trim()).expect("valid JSON"); assert_eq!( - v["status"], "not_found", + v["status"], "notFound", "empty CVE search must emit not_found (NOT no_match, which is the \ fuzzy package-name path); got {}", v["status"] ); - assert_eq!(v["found"], 0); - assert_eq!(v["downloaded"], 0, "no patches downloaded on empty search"); - assert_eq!(v["applied"], 0, "no patches applied on empty search"); - assert!(v["patches"].as_array().expect("patches array").is_empty()); + assert_eq!(v["events"], serde_json::json!([])); + assert_eq!( + v["summary"]["downloaded"], 0, + "no patches downloaded on empty search" + ); + assert_eq!( + v["summary"]["applied"], 0, + "no patches applied on empty search" + ); + assert!(v["events"].as_array().expect("patches array").is_empty()); } /// GHSA search returning empty patch list → `not_found` envelope, exit 0. @@ -368,13 +374,19 @@ async fn get_by_ghsa_with_no_patches_emits_no_match() { ); let v: serde_json::Value = serde_json::from_str(stdout.trim()).expect("valid JSON"); assert_eq!( - v["status"], "not_found", + v["status"], "notFound", "empty GHSA search must emit not_found (NOT no_match, which is the \ fuzzy package-name path); got {}", v["status"] ); - assert_eq!(v["found"], 0); - assert_eq!(v["downloaded"], 0, "no patches downloaded on empty search"); - assert_eq!(v["applied"], 0, "no patches applied on empty search"); - assert!(v["patches"].as_array().expect("patches array").is_empty()); + assert_eq!(v["events"], serde_json::json!([])); + assert_eq!( + v["summary"]["downloaded"], 0, + "no patches downloaded on empty search" + ); + assert_eq!( + v["summary"]["applied"], 0, + "no patches applied on empty search" + ); + assert!(v["events"].as_array().expect("patches array").is_empty()); } diff --git a/crates/socket-patch-cli/tests/get/get_edge_cases_e2e.rs b/crates/socket-patch-cli/tests/get/get_edge_cases_e2e.rs index 64071ecb8..d10d32833 100644 --- a/crates/socket-patch-cli/tests/get/get_edge_cases_e2e.rs +++ b/crates/socket-patch-cli/tests/get/get_edge_cases_e2e.rs @@ -132,12 +132,11 @@ async fn get_with_id_flag_selects_specific_patch() { ); let v: serde_json::Value = serde_json::from_str(stdout.trim()).expect("valid JSON"); assert_eq!(v["status"], "success", "stdout={stdout}"); - assert_eq!(v["found"], 1, "exactly one patch fetched; stdout={stdout}"); assert_eq!( - v["downloaded"], 1, + v["summary"]["downloaded"], 1, "the patch must be downloaded; stdout={stdout}" ); - let patches = v["patches"].as_array().expect("patches array"); + let patches = v["events"].as_array().expect("patches array"); assert_eq!( patches.len(), 1, @@ -153,7 +152,7 @@ async fn get_with_id_flag_selects_specific_patch() { patches[0]["uuid"], UUID_A, "must not have fallen back to the by-package first match; stdout={stdout}" ); - assert_eq!(patches[0]["action"], "added", "stdout={stdout}"); + assert_eq!(patches[0]["action"], "downloaded", "stdout={stdout}"); // Prove the route, not just the payload: --id must fetch view/{UUID_B} // directly and must NEVER consult the by-package listing (which is mounted @@ -218,11 +217,11 @@ async fn get_with_no_matching_purl_emits_not_found() { "an empty (but successful) lookup is exit 0, not an error" ); let v: serde_json::Value = serde_json::from_str(stdout.trim()).expect("valid JSON"); - assert_eq!(v["status"], "not_found", "stdout={stdout}"); - assert_eq!(v["found"], 0, "stdout={stdout}"); - assert_eq!(v["downloaded"], 0, "stdout={stdout}"); + assert_eq!(v["status"], "notFound", "stdout={stdout}"); + assert_eq!(v["events"], serde_json::json!([]), "stdout={stdout}"); + assert_eq!(v["summary"]["downloaded"], 0, "stdout={stdout}"); assert_eq!( - v["patches"].as_array().expect("patches array").len(), + v["events"].as_array().expect("patches array").len(), 0, "no patches on not_found; stdout={stdout}" ); @@ -284,20 +283,19 @@ async fn get_by_package_with_single_paid_patch_emits_paid_required() { // The mock returned exactly one paid patch and canAccessPaidPatches=false, // so the deterministic outcome is paid_required — not a vague "anything // but success". The patch must NOT have been downloaded. - assert_eq!(v["status"], "paid_required", "stdout={stdout}"); - assert_eq!(v["found"], 1, "the paid patch was found; stdout={stdout}"); + assert_eq!(v["status"], "paidRequired", "stdout={stdout}"); assert_eq!( - v["downloaded"], 0, + v["summary"]["downloaded"], 0, "must not download a paid patch; stdout={stdout}" ); assert_eq!( - v["applied"], 0, + v["summary"]["applied"], 0, "must not apply a paid patch; stdout={stdout}" ); - let patches = v["patches"].as_array().expect("patches array"); + let patches = v["events"].as_array().expect("patches array"); assert_eq!(patches.len(), 1, "stdout={stdout}"); assert_eq!(patches[0]["uuid"], UUID_A, "stdout={stdout}"); - assert_eq!(patches[0]["tier"], "paid", "stdout={stdout}"); + assert_eq!(patches[0]["details"]["tier"], "paid", "stdout={stdout}"); // paid_required must be the verdict of a real proxy lookup, and the binary // must NOT have attempted to download the paid blob via any view endpoint. let paths = received_paths(&mock).await; @@ -355,9 +353,9 @@ async fn get_with_invalid_search_purl_falls_through() { let v: serde_json::Value = serde_json::from_str(stdout.trim()).expect("valid JSON"); // Deterministic outcome: the un-typed identifier fell through to the // package search, which found nothing installed. - assert_eq!(v["status"], "no_packages", "stdout={stdout}"); + assert_eq!(v["status"], "noPackages", "stdout={stdout}"); assert_eq!( - v["patches"].as_array().expect("patches array").len(), + v["events"].as_array().expect("patches array").len(), 0, "stdout={stdout}" ); @@ -426,18 +424,17 @@ async fn get_uuid_returns_paid_patch_with_token_succeeds() { ); let v: serde_json::Value = serde_json::from_str(stdout.trim()).expect("valid JSON"); assert_eq!(v["status"], "success", "stdout={stdout}"); - assert_eq!(v["found"], 1, "stdout={stdout}"); assert_eq!( - v["downloaded"], 1, + v["summary"]["downloaded"], 1, "authenticated paid fetch must actually download; stdout={stdout}" ); - let patches = v["patches"].as_array().expect("patches array"); + let patches = v["events"].as_array().expect("patches array"); assert_eq!(patches.len(), 1, "stdout={stdout}"); assert_eq!( patches[0]["uuid"], UUID_A, "must return the requested UUID; stdout={stdout}" ); - assert_eq!(patches[0]["action"], "added", "stdout={stdout}"); + assert_eq!(patches[0]["action"], "downloaded", "stdout={stdout}"); // The authenticated path must reach the org-scoped view endpoint directly // (bypassing the public proxy), proving the download was a real fetch. let paths = received_paths(&mock).await; @@ -549,13 +546,17 @@ async fn get_on_vendored_purl_warns_about_uuid_drift() { assert_eq!(code, 0, "explicit get still succeeds; stdout={stdout}"); let v: serde_json::Value = serde_json::from_str(stdout.trim()).expect("valid JSON"); assert_eq!(v["status"], "success", "stdout={stdout}"); - assert_eq!(v["patches"][0]["action"], "added", "stdout={stdout}"); + assert_eq!(v["events"][0]["action"], "downloaded", "stdout={stdout}"); let warnings = v["warnings"] .as_array() .unwrap_or_else(|| panic!("uuid drift must surface a warning; stdout={stdout}")); assert_eq!(warnings.len(), 1, "stdout={stdout}"); - let w = warnings[0].as_str().expect("warning string"); + assert_eq!( + warnings[0]["code"], "vendored_uuid_drift", + "stdout={stdout}" + ); + let w = warnings[0]["detail"].as_str().expect("warning detail"); assert!( w.contains("is vendored at patch") && w.contains(UUID_A) && w.contains(UUID_B), "warning must name both uuids; got: {w}" @@ -639,20 +640,20 @@ async fn get_uuid_replacing_existing_manifest_entry_reports_updated() { let v: serde_json::Value = serde_json::from_str(stdout.trim()).expect("valid JSON"); assert_eq!(v["status"], "success", "stdout={stdout}"); assert_eq!( - v["downloaded"], 1, - "an update is a real download; stdout={stdout}" + v["summary"]["updated"], 1, + "an update is counted as `updated` (v5.0: not also `downloaded`); stdout={stdout}" ); assert_eq!( - v["patches"][0]["action"], "updated", + v["events"][0]["action"], "updated", "a different uuid at the same purl is `updated`, not `added`; stdout={stdout}" ); assert_eq!( - v["patches"][0]["oldUuid"], UUID_A, + v["events"][0]["oldUuid"], UUID_A, "`updated` must carry the uuid it replaced; stdout={stdout}" ); // Contract: the metadata block rides `added` AND `updated` records. assert_eq!( - v["patches"][0]["description"], "Newer patch for the same purl", + v["events"][0]["details"]["description"], "Newer patch for the same purl", "updated records must carry patch metadata; stdout={stdout}" ); // And the manifest really moved to the new uuid. diff --git a/crates/socket-patch-cli/tests/get/get_envelope_shape.rs b/crates/socket-patch-cli/tests/get/get_envelope_shape.rs new file mode 100644 index 000000000..527fcfb93 --- /dev/null +++ b/crates/socket-patch-cli/tests/get/get_envelope_shape.rs @@ -0,0 +1,140 @@ +//! v5.0: `get --json` prints one shared envelope (`command: "get"`) on +//! every path — statuses from the `Status` enum (camelCase), per-patch +//! outcomes as events, `summary` equal to the event counts, and every +//! failure (usage errors included) as a full envelope. + +use wiremock::matchers::{method, path}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +use crate::common; +use crate::common::envelope::{assert_envelope_invariants, event_triples}; + +const ORG: &str = "test-org"; +const UUID_A: &str = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; +const UUID_B: &str = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"; + +fn get(cwd: &std::path::Path, server: &str, args: &[&str]) -> (i32, serde_json::Value) { + let mut argv = vec!["get"]; + argv.extend_from_slice(args); + argv.extend([ + "--json", + "--api-url", + server, + "--api-token", + "fake", + "--org", + ORG, + ]); + let (code, stdout, stderr) = + common::run_with_env(cwd, &argv, &[("SOCKET_TELEMETRY_DISABLED", "1")]); + let v: serde_json::Value = serde_json::from_str(&stdout).unwrap_or_else(|e| { + panic!("stdout must be ONE JSON document ({e}):\n{stdout}\nstderr:\n{stderr}") + }); + assert_envelope_invariants(&v, "get"); + (code, v) +} + +fn patch(uuid: &str, purl: &str, published: &str) -> serde_json::Value { + serde_json::json!({ + "uuid": uuid, "purl": purl, "publishedAt": published, + "description": "fixture", "license": "MIT", "tier": "free", + "vulnerabilities": {} + }) +} + +/// Usage and `--offline` errors print the full envelope (exit 2 / 1). +#[test] +fn get_early_errors_print_full_envelopes() { + let tmp = tempfile::tempdir().unwrap(); + let (code, v) = get(tmp.path(), "http://127.0.0.1:9", &["x", "--id", "--cve"]); + assert_eq!(code, 2); + assert_eq!(v["error"]["code"], "invalid_args", "{v:#}"); + assert_eq!(v["events"], serde_json::json!([])); + let (code, v) = get(tmp.path(), "http://127.0.0.1:9", &["lodash", "--offline"]); + assert_eq!(code, 1); + assert_eq!(v["error"]["code"], "offline_unsupported", "{v:#}"); +} + +/// A uuid nobody publishes is `notFound`, exit 0, no events. +#[tokio::test] +async fn get_unknown_uuid_is_not_found() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path(format!("/v0/orgs/{ORG}/patches/view/{UUID_A}"))) + .respond_with(ResponseTemplate::new(404)) + .mount(&server) + .await; + let tmp = tempfile::tempdir().unwrap(); + let (code, v) = get(tmp.path(), &server.uri(), &[UUID_A]); + assert_eq!(code, 0); + assert_eq!(v["status"], "notFound", "{v:#}"); + assert_eq!(v["events"], serde_json::json!([])); +} + +/// Several free patches for one package in agent mode: `selectionRequired` +/// (exit 1) with its coded `error`, the package and the camelCase options. +#[tokio::test] +async fn get_selection_required_is_a_status_with_options() { + let purl = "pkg:npm/multi@1.0.0"; + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path(format!( + "/v0/orgs/{ORG}/patches/by-package/pkg%3Anpm%2Fmulti%401.0.0" + ))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "patches": [ + patch(UUID_A, purl, "Mon, 01 Jan 2024 00:00:00 GMT"), + patch(UUID_B, purl, "Thu, 01 Feb 2024 00:00:00 GMT"), + ], + "canAccessPaidPatches": false, + }))) + .mount(&server) + .await; + let tmp = tempfile::tempdir().unwrap(); + let (code, v) = get(tmp.path(), &server.uri(), &[purl, "--mode", "agent"]); + assert_eq!(code, 1, "{v:#}"); + assert_eq!(v["status"], "selectionRequired"); + assert_eq!(v["error"]["code"], "selection_required"); + assert_eq!(v["purl"], purl); + let options = v["options"].as_array().unwrap(); + assert_eq!(options.len(), 2, "{v:#}"); + for o in options { + assert!(o["publishedAt"].is_string(), "camelCase publishedAt: {o}"); + assert!(o.get("published_at").is_none(), "{o}"); + } +} + +/// Agent-mode `--dry-run`: one `verified` event per patch a wet run would +/// record, top-level `dryRun`, nothing written. +#[tokio::test] +async fn get_agent_dry_run_previews_verified_events() { + let purl = "pkg:npm/one@1.0.0"; + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path(format!( + "/v0/orgs/{ORG}/patches/by-package/pkg%3Anpm%2Fone%401.0.0" + ))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "patches": [patch(UUID_A, purl, "Mon, 01 Jan 2024 00:00:00 GMT")], + "canAccessPaidPatches": false, + }))) + .mount(&server) + .await; + let tmp = tempfile::tempdir().unwrap(); + let (code, v) = get( + tmp.path(), + &server.uri(), + &[purl, "--mode", "agent", "--dry-run"], + ); + assert_eq!(code, 0, "{v:#}"); + assert_eq!(v["dryRun"], true); + assert_eq!( + event_triples(&v), + vec![(purl.to_string(), "verified".to_string(), String::new())] + ); + assert_eq!(v["summary"]["verified"], 1); + assert!( + !tmp.path().join(".socket").exists(), + "a dry run writes nothing" + ); +} diff --git a/crates/socket-patch-cli/tests/get/get_invariants.rs b/crates/socket-patch-cli/tests/get/get_invariants.rs index c3c860002..2f1642b53 100644 --- a/crates/socket-patch-cli/tests/get/get_invariants.rs +++ b/crates/socket-patch-cli/tests/get/get_invariants.rs @@ -120,11 +120,11 @@ async fn get_by_uuid_not_found_emits_envelope() { "not_found is a clean (non-error) outcome; stderr={stderr}" ); let v: serde_json::Value = serde_json::from_str(stdout.trim()).expect("valid JSON"); - assert_eq!(v["status"], "not_found"); - assert_eq!(v["found"], 0); - assert_eq!(v["downloaded"], 0); - assert_eq!(v["applied"], 0); - assert_eq!(v["patches"].as_array().expect("patches array").len(), 0); + assert_eq!(v["status"], "notFound"); + assert_eq!(v["events"], serde_json::json!([])); + assert_eq!(v["summary"]["downloaded"], 0); + assert_eq!(v["summary"]["applied"], 0); + assert_eq!(v["events"].as_array().expect("patches array").len(), 0); // A 404 must never leave a manifest behind. assert!( !tmp.path().join(".socket/manifest.json").exists(), @@ -221,18 +221,20 @@ fn assert_blob_written(root: &Path, after_hash: &str, expected: &[u8]) { /// downloaded, or a silent auto-apply) from masquerading as success. fn assert_single_save_only_success(v: &serde_json::Value, purl: &str, uuid: &str) { assert_eq!(v["status"], "success", "expected success envelope; got {v}"); - assert_eq!(v["found"], 1, "exactly one patch must be found; got {v}"); - assert_eq!(v["downloaded"], 1, "the patch must be downloaded; got {v}"); assert_eq!( - v["applied"], 0, + v["summary"]["downloaded"], 1, + "the patch must be downloaded; got {v}" + ); + assert_eq!( + v["summary"]["applied"], 0, "--save-only must not apply the patch; got {v}" ); - let patches = v["patches"].as_array().expect("patches array"); + let patches = v["events"].as_array().expect("patches array"); assert_eq!(patches.len(), 1, "exactly one patch record; got {v}"); assert_eq!(patches[0]["purl"], purl, "record must echo purl; got {v}"); assert_eq!(patches[0]["uuid"], uuid, "record must echo uuid; got {v}"); assert_eq!( - patches[0]["action"], "added", + patches[0]["action"], "downloaded", "a freshly saved patch must be reported as added; got {v}" ); } @@ -254,8 +256,8 @@ async fn get_by_cve_no_match_emits_not_found() { let (code, stdout, stderr) = run_get(tmp.path(), &mock.uri(), cve, &[]); assert_eq!(code, 0, "empty CVE search is not an error; stderr={stderr}"); let v: serde_json::Value = serde_json::from_str(stdout.trim()).expect("valid JSON"); - assert_eq!(v["status"], "not_found"); - assert_eq!(v["found"], 0); + assert_eq!(v["status"], "notFound"); + assert_eq!(v["events"], serde_json::json!([])); assert!( !tmp.path().join(".socket/manifest.json").exists(), "empty CVE search must not write a manifest" @@ -401,7 +403,7 @@ async fn get_multiple_patches_in_json_mode_returns_selection_required() { ); let v: serde_json::Value = serde_json::from_str(stdout.trim()).expect("valid JSON"); assert_eq!( - v["status"], "selection_required", + v["status"], "selectionRequired", "multi-patch JSON path must emit selection_required, never success/auto-pick; got {v}" ); assert_eq!(v["purl"], purl, "envelope must echo the queried purl"); @@ -487,16 +489,15 @@ async fn get_uuid_paid_patch_via_public_proxy_emits_paid_required_envelope() { "paid_required must exit 0; stdout={stdout}; stderr={stderr}" ); assert_eq!( - v["status"], "paid_required", + v["status"], "paidRequired", "UUID-fetched paid patch via public proxy must emit paid_required; got {v}" ); - assert_eq!(v["found"], 1); - assert_eq!(v["downloaded"], 0); - assert_eq!(v["applied"], 0); - let patches = v["patches"].as_array().expect("patches array"); + assert_eq!(v["summary"]["downloaded"], 0); + assert_eq!(v["summary"]["applied"], 0); + let patches = v["events"].as_array().expect("patches array"); assert_eq!(patches.len(), 1); assert_eq!(patches[0]["uuid"], UUID); - assert_eq!(patches[0]["tier"], "paid"); + assert_eq!(patches[0]["details"]["tier"], "paid"); // A paid patch is never downloaded, so no manifest may be written. assert!( !tmp.path().join(".socket/manifest.json").exists(), @@ -561,19 +562,18 @@ async fn get_paid_patch_via_public_proxy_returns_paid_required() { "paid_required must exit 0; stdout={stdout}; stderr={stderr}" ); assert_eq!( - v["status"], "paid_required", + v["status"], "paidRequired", "paid patch without token must emit paid_required; got: {v}" ); assert_eq!( - v["found"], 1, - "the one paid patch must be counted as found; got {v}" + v["summary"]["downloaded"], 0, + "paid patch must not be downloaded; got {v}" ); assert_eq!( - v["downloaded"], 0, - "paid patch must not be downloaded; got {v}" + v["summary"]["applied"], 0, + "paid patch must not be applied; got {v}" ); - assert_eq!(v["applied"], 0, "paid patch must not be applied; got {v}"); - let patches = v["patches"].as_array().expect("patches array"); + let patches = v["events"].as_array().expect("patches array"); assert_eq!( patches.len(), 1, @@ -582,7 +582,7 @@ async fn get_paid_patch_via_public_proxy_returns_paid_required() { assert_eq!(patches[0]["purl"], purl); assert_eq!(patches[0]["uuid"], UUID); assert_eq!( - patches[0]["tier"], "paid", + patches[0]["details"]["tier"], "paid", "reported patch must be flagged paid; got {v}" ); // Nothing was downloaded, so no manifest may be written. diff --git a/crates/socket-patch-cli/tests/get/get_modes_e2e.rs b/crates/socket-patch-cli/tests/get/get_modes_e2e.rs index 5670a841e..50173165b 100644 --- a/crates/socket-patch-cli/tests/get/get_modes_e2e.rs +++ b/crates/socket-patch-cli/tests/get/get_modes_e2e.rs @@ -221,10 +221,10 @@ async fn requests_containing(server: &MockServer, fragment: &str) -> usize { // (1) hosted envelope // --------------------------------------------------------------------------- -/// `get --mode hosted --json` emits ONE JSON object: get's base -/// envelope (`status`/`found`/`patches`, NO `downloaded`/`applied` — nothing -/// lands in `.socket/`) with scan's `redirect` sub-object nested in. Exact -/// whole-envelope equality so an additive key can't sneak in unnoticed. +/// `get --mode hosted --json` emits ONE envelope (v5.0): the pin as a +/// hosted `applied` event, the `redirect: {mode, rewrittenFiles}` payload +/// and the engine's warnings at the top level — nothing downloaded into +/// `.socket/`. #[tokio::test] async fn get_uuid_hosted_json_envelope_nests_redirect() { let server = MockServer::start().await; @@ -251,7 +251,8 @@ async fn get_uuid_hosted_json_envelope_nests_redirect() { ); let v = parse_single_json_doc(&stdout); - let allow_remote = v["redirect"]["warnings"][0]["detail"] + crate::common::envelope::assert_envelope_invariants(&v, "get"); + let allow_remote = v["warnings"][0]["detail"] .as_str() .unwrap_or_default() .to_string(); @@ -260,33 +261,29 @@ async fn get_uuid_hosted_json_envelope_nests_redirect() { && allow_remote.contains("lets npm install ANY url-resolved"), "{v}" ); - let expected = serde_json::json!({ - "status": "success", - "found": 1, - "patches": [], - "redirect": { - "mode": "hosted", - "redirected": 1, - "rewrittenFiles": ["package-lock.json"], - "skipped": [], - "patches": [{ "purl": PURL1, "uuid": UUID1, "action": "pinned" }], - "warnings": [{ "code": "redirect_npm_allow_remote", "detail": allow_remote }], - "dryRun": false, - }, - }); + assert_eq!(v["status"], "success", "{v:#}"); + assert_eq!(v["dryRun"], false, "{v:#}"); assert_eq!( - v, - expected, - "hosted get JSON envelope drifted.\nexpected:\n{}\ngot:\n{}", - serde_json::to_string_pretty(&expected).unwrap(), - serde_json::to_string_pretty(&v).unwrap(), + v["events"], + serde_json::json!([ + { "action": "applied", "purl": PURL1, "uuid": UUID1, "details": { "mode": "hosted" } } + ]), + "{v:#}" ); - // Belt-and-suspenders on the keys the contract calls out by name, in - // case the exact-equality pin above is ever loosened in maintenance. - assert!( - v.get("downloaded").is_none() && v.get("applied").is_none(), - "hosted mode downloads/applies nothing into .socket — neither key \ - may appear at top level; got {v}" + assert_eq!( + v["redirect"], + serde_json::json!({ "mode": "hosted", "rewrittenFiles": ["package-lock.json"] }), + "{v:#}" + ); + assert_eq!( + v["warnings"], + serde_json::json!([{ "code": "redirect_npm_allow_remote", "detail": allow_remote }]), + "{v:#}" + ); + assert_eq!(v["summary"]["applied"], 1, "{v:#}"); + assert_eq!( + v["summary"]["downloaded"], 0, + "hosted mode downloads nothing into .socket; got {v}" ); // The envelope must describe a rewrite that actually happened: the @@ -305,11 +302,9 @@ async fn get_uuid_hosted_json_envelope_nests_redirect() { // --------------------------------------------------------------------------- /// `get --mode vendored --json` (local `--vendor-source build`, so no -/// vendoring-service mocks): the detached download envelope — the same -/// vocabulary scan's `download` block uses (`downloaded`, `patches[].action: -/// "downloaded"`, `detached: true`), no `applied` (nothing is applied in -/// place) — with scan's full vendor `Envelope` nested under `vendor` -/// (camelCase keys/statuses). +/// vendoring-service mocks): one envelope (v5.0) holding the detached +/// download's `downloaded` event and the vendor engine's events, all tagged +/// `details.mode: "vendored"` — no nested `vendor` envelope. #[tokio::test] async fn get_uuid_vendored_json_envelope_nests_vendor() { let server = MockServer::start().await; @@ -340,46 +335,35 @@ async fn get_uuid_vendored_json_envelope_nests_vendor() { let v = parse_single_json_doc(&stdout); assert_eq!(v["status"], "success", "envelope={v}"); - assert_eq!(v["found"], 1, "envelope={v}"); - assert_eq!(v["downloaded"], 1, "envelope={v}"); - assert_eq!(v["skipped"], 0, "envelope={v}"); - assert_eq!(v["failed"], 0, "envelope={v}"); + assert_eq!(v["summary"]["downloaded"], 1, "envelope={v}"); + assert_eq!(v["summary"]["skipped"], 0, "envelope={v}"); + assert_eq!(v["summary"]["failed"], 0, "envelope={v}"); assert_eq!( - v["detached"], true, - "vendored get is the detached download phase; got {v}" + v["events"][0]["details"]["mode"], "vendored", + "vendored get's events carry the leg tag; got {v}" ); - assert_eq!(v["patches"][0]["purl"], PURL1, "envelope={v}"); - assert_eq!(v["patches"][0]["uuid"], UUID1, "envelope={v}"); + assert_eq!(v["events"][0]["purl"], PURL1, "envelope={v}"); + assert_eq!(v["events"][0]["uuid"], UUID1, "envelope={v}"); assert_eq!( - v["patches"][0]["action"], "downloaded", + v["events"][0]["action"], "downloaded", "the detached vocabulary: the record was fetched into memory, not added to a manifest; got {v}" ); + // The vendor engine's events are merged into the one envelope (no + // nested `vendor` envelope), tagged `details.mode: "vendored"`. assert!( - v.get("applied").is_none(), - "vendored mode has no top-level `applied` key (nothing is applied in place); got {v}" + v.get("vendor").is_none(), + "no nested vendor envelope; got {v}" ); - - // The nested vendor Envelope: the unified `--json` shape the standalone - // `vendor` command emits — command tag, camelCase status + dryRun, - // events[], pre-aggregated camelCase summary. - let venv = v["vendor"] - .as_object() - .unwrap_or_else(|| panic!("vendored envelope must nest a vendor Envelope; got {v}")); - assert_eq!(venv["command"], "vendor", "vendor={venv:?}"); - assert_eq!(venv["status"], "success", "vendor={venv:?}"); - assert_eq!(venv["dryRun"], false, "vendor={venv:?}"); assert_eq!( - venv["summary"]["applied"], 1, - "summary must count the fresh vendoring (camelCase keys); vendor={venv:?}" + v["summary"]["applied"], 1, + "summary must count the fresh vendoring; got {v}" ); - let events = venv["events"] - .as_array() - .unwrap_or_else(|| panic!("vendor Envelope must carry events[]; got {venv:?}")); + let events = v["events"].as_array().unwrap(); assert!( - events - .iter() - .any(|e| e["purl"] == PURL1 && e["action"] == "applied"), - "events must record the vendored purl with a camelCase action; got {events:?}" + events.iter().any(|e| e["purl"] == PURL1 + && e["action"] == "applied" + && e["details"]["mode"] == "vendored"), + "events must record the vendored purl; got {events:?}" ); // Anti-vacuity: the envelope reflects the real vendored result — @@ -425,29 +409,27 @@ async fn get_ghsa_all_uninstalled_emits_not_installed_envelope() { ); let v = parse_single_json_doc(&stdout); - let expected = serde_json::json!({ - "status": "not_installed", - "found": 2, - "downloaded": 0, - "applied": 0, - "patches": [ - { - "purl": PURL1, "uuid": UUID1, - "action": "skipped", "errorCode": "package_not_installed", - }, - { - "purl": PURL2, "uuid": UUID2, - "action": "skipped", "errorCode": "package_not_installed", - }, - ], - }); + crate::common::envelope::assert_envelope_invariants(&v, "get"); + assert_eq!(v["status"], "notInstalled", "{v:#}"); assert_eq!( - v, - expected, - "not_installed envelope drifted.\nexpected:\n{}\ngot:\n{}", - serde_json::to_string_pretty(&expected).unwrap(), - serde_json::to_string_pretty(&v).unwrap(), + crate::common::envelope::event_triples(&v), + vec![ + ( + PURL1.to_string(), + "skipped".to_string(), + "package_not_installed".to_string() + ), + ( + PURL2.to_string(), + "skipped".to_string(), + "package_not_installed".to_string() + ), + ], + "{v:#}" ); + assert_eq!(v["events"][0]["uuid"], UUID1, "{v:#}"); + assert_eq!(v["events"][1]["uuid"], UUID2, "{v:#}"); + assert_eq!(v["summary"]["skipped"], 2, "{v:#}"); assert_eq!( requests_containing(&server, "/patches/view/").await, @@ -612,7 +594,8 @@ async fn get_hosted_dry_run_json_envelope() { assert_eq!(code, 0, "stdout:\n{stdout}\nstderr:\n{stderr}"); let v = parse_single_json_doc(&stdout); - let allow_remote = v["redirect"]["warnings"][0]["detail"] + crate::common::envelope::assert_envelope_invariants(&v, "get"); + let allow_remote = v["warnings"][0]["detail"] .as_str() .unwrap_or_default() .to_string(); @@ -621,26 +604,24 @@ async fn get_hosted_dry_run_json_envelope() { && allow_remote.contains("lets npm install ANY url-resolved"), "{v}" ); - let expected = serde_json::json!({ - "status": "success", - "found": 1, - "patches": [], - "redirect": { - "mode": "hosted", - "redirected": 1, - "rewrittenFiles": ["package-lock.json"], - "skipped": [], - "patches": [{ "purl": PURL1, "uuid": UUID1, "action": "would_pin" }], - "warnings": [{ "code": "redirect_npm_allow_remote", "detail": allow_remote }], - "dryRun": true, - }, - }); + assert_eq!(v["status"], "success", "{v:#}"); + assert_eq!(v["dryRun"], true, "{v:#}"); + assert_eq!( + v["events"], + serde_json::json!([ + { "action": "verified", "purl": PURL1, "uuid": UUID1, "details": { "mode": "hosted" } } + ]), + "{v:#}" + ); assert_eq!( - v, - expected, - "hosted dry-run envelope drifted.\nexpected:\n{}\ngot:\n{}", - serde_json::to_string_pretty(&expected).unwrap(), - serde_json::to_string_pretty(&v).unwrap(), + v["redirect"], + serde_json::json!({ "mode": "hosted", "rewrittenFiles": ["package-lock.json"] }), + "{v:#}" + ); + assert_eq!( + v["warnings"], + serde_json::json!([{ "code": "redirect_npm_allow_remote", "detail": allow_remote }]), + "{v:#}" ); assert_eq!( @@ -680,23 +661,19 @@ async fn get_vendored_dry_run_json_envelope() { assert_eq!(code, 0, "stdout:\n{stdout}\nstderr:\n{stderr}"); let v = parse_single_json_doc(&stdout); - let expected = serde_json::json!({ - "status": "success", - "found": 1, - "patches": [], - "vendor": { - "dryRun": true, - "patches": [ - { "purl": PURL1, "uuid": UUID1, "action": "would_vendor" }, - ], - }, - }); + crate::common::envelope::assert_envelope_invariants(&v, "get"); + assert_eq!(v["status"], "success", "{v:#}"); + assert_eq!(v["dryRun"], true, "{v:#}"); + assert_eq!( + v["events"], + serde_json::json!([ + { "action": "verified", "purl": PURL1, "uuid": UUID1, "details": { "mode": "vendored" } } + ]), + "{v:#}" + ); assert_eq!( - v, - expected, - "vendored dry-run envelope drifted.\nexpected:\n{}\ngot:\n{}", - serde_json::to_string_pretty(&expected).unwrap(), - serde_json::to_string_pretty(&v).unwrap(), + v["summary"]["downloaded"], 0, + "the download phase never ran: {v:#}" ); assert_eq!( @@ -768,7 +745,11 @@ async fn get_vendored_then_hosted_takes_over_cleanly() { ); assert_eq!(code, 0, "hosted takeover failed: {stderr}\n{stdout}"); let envelope = parse_single_json_doc(&stdout); - assert_eq!(envelope["redirect"]["redirected"], 1, "got: {envelope}"); + assert_eq!( + crate::common::envelope::hosted_pins(&envelope).len(), + 1, + "got: {envelope}" + ); let lock = std::fs::read_to_string(tmp.path().join("package-lock.json")).unwrap(); assert!( @@ -1029,8 +1010,8 @@ async fn get_vendored_refusal_visible_under_silent() { } /// The vendored dry-run preview names what the wet run would refuse: -/// `would_refuse` + `errorCode` + `error` (additive) instead of -/// `would_vendor`, on both identifier kinds — exit 0, `status:"success"`, +/// a `skipped` event carrying the refusal's `errorCode` (and its detail as +/// `reason`) instead of a `verified` one, on both identifier kinds — exit 0, `status:"success"`, /// nothing written, exactly like every other vendored preview. #[tokio::test] async fn get_vendored_dry_run_reports_bun_refusal() { @@ -1058,15 +1039,15 @@ async fn get_vendored_dry_run_reports_bun_refusal() { assert_eq!(code, 0, "{label}: stdout={stdout}\nstderr={stderr}"); let v = parse_single_json_doc(&stdout); assert_eq!(v["status"], "success", "{label}: {v}"); - assert_eq!(v["found"], 1, "{label}: {v}"); - assert_eq!(v["vendor"]["dryRun"], true, "{label}: {v}"); - let rec = &v["vendor"]["patches"][0]; + assert_eq!(v["dryRun"], true, "{label}: {v}"); + let rec = &v["events"][0]; assert_eq!(rec["purl"], PURL1, "{label}: {v}"); assert_eq!(rec["uuid"], UUID1, "{label}: {v}"); - assert_eq!(rec["action"], "would_refuse", "{label}: {v}"); + assert_eq!(rec["action"], "skipped", "{label}: {v}"); assert_eq!(rec["errorCode"], BUN_WS_CODE, "{label}: {v}"); + assert_eq!(rec["details"]["mode"], "vendored", "{label}: {v}"); assert!( - rec["error"].as_str().is_some_and(|d| !d.is_empty()), + rec["reason"].as_str().is_some_and(|d| !d.is_empty()), "{label}: {v}" ); assert_eq!( @@ -1099,8 +1080,8 @@ async fn get_save_only_agent_ignores_bun_preflight() { assert_eq!(code, 0, "stdout={stdout}\nstderr={stderr}"); let v = parse_single_json_doc(&stdout); assert_eq!(v["status"], "success", "{v}"); - assert_eq!(v["patches"][0]["action"], "added", "{v}"); - assert!(v["patches"][0].get("errorCode").is_none(), "{v}"); + assert_eq!(v["events"][0]["action"], "downloaded", "{v}"); + assert!(v["events"][0].get("errorCode").is_none(), "{v}"); assert_eq!(requests_containing(&server, "/patches/view/").await, 1); let manifest: serde_json::Value = serde_json::from_str( &std::fs::read_to_string(tmp.path().join(".socket/manifest.json")).unwrap(), @@ -1202,8 +1183,8 @@ async fn get_vendored_dry_run_reports_vlt_refusal() { ); assert_eq!(code, 0, "{label}: stdout={stdout}\nstderr={stderr}"); let v = parse_single_json_doc(&stdout); - let rec = &v["vendor"]["patches"][0]; - assert_eq!(rec["action"], "would_refuse", "{label}: {v}"); + let rec = &v["events"][0]; + assert_eq!(rec["action"], "skipped", "{label}: {v}"); assert_eq!(rec["errorCode"], VLT_SYNC_CODE, "{label}: {v}"); assert!(!tmp.path().join(".socket").exists(), "{label}"); } @@ -1220,8 +1201,8 @@ async fn get_save_only_agent_ignores_vlt_preflight() { run_get(tmp.path(), &server.uri(), &[PURL1, "--save-only", "--json"]); assert_eq!(code, 0, "stdout={stdout}\nstderr={stderr}"); let v = parse_single_json_doc(&stdout); - assert_eq!(v["patches"][0]["action"], "added", "{v}"); - assert!(v["patches"][0].get("errorCode").is_none(), "{v}"); + assert_eq!(v["events"][0]["action"], "downloaded", "{v}"); + assert!(v["events"][0].get("errorCode").is_none(), "{v}"); } /// Manifest-less VEX over what `get --mode hosted` and `get diff --git a/crates/socket-patch-cli/tests/get/get_nested_apply_api_flags_e2e.rs b/crates/socket-patch-cli/tests/get/get_nested_apply_api_flags_e2e.rs index 009c4751a..2fc1046f4 100644 --- a/crates/socket-patch-cli/tests/get/get_nested_apply_api_flags_e2e.rs +++ b/crates/socket-patch-cli/tests/get/get_nested_apply_api_flags_e2e.rs @@ -183,7 +183,7 @@ async fn get_by_uuid_nested_apply_uses_api_flags_not_env() { let v: serde_json::Value = serde_json::from_str(stdout.trim()) .unwrap_or_else(|e| panic!("valid JSON expected: {e}\nstdout={stdout}")); assert_eq!(v["status"], "success", "stdout={stdout}"); - assert_eq!(v["applied"], 1, "stdout={stdout}"); + assert_eq!(v["summary"]["applied"], 1, "stdout={stdout}"); let patched = tmp.path().join("node_modules").join(PKG).join("index.js"); assert_eq!( @@ -235,7 +235,7 @@ async fn get_by_purl_nested_apply_uses_api_flags_not_env() { let v: serde_json::Value = serde_json::from_str(stdout.trim()) .unwrap_or_else(|e| panic!("valid JSON expected: {e}\nstdout={stdout}")); assert_eq!(v["status"], "success", "stdout={stdout}"); - assert_eq!(v["applied"], 1, "stdout={stdout}"); + assert_eq!(v["summary"]["applied"], 1, "stdout={stdout}"); let patched = tmp.path().join("node_modules").join(PKG).join("index.js"); assert_eq!( diff --git a/crates/socket-patch-cli/tests/get/global_packages_e2e.rs b/crates/socket-patch-cli/tests/get/global_packages_e2e.rs index a2d1b723d..8be886344 100644 --- a/crates/socket-patch-cli/tests/get/global_packages_e2e.rs +++ b/crates/socket-patch-cli/tests/get/global_packages_e2e.rs @@ -189,9 +189,9 @@ fn assert_apply_applied(stdout: &str, purl: &str) { /// none can be reverted, but the run is clean — not a failure). /// /// A manifest entry with no matching installed package surfaces as an -/// additive marker record in `results[]` — `{purl, path: null, skipped: -/// "package_not_installed"}`, no `success`/`error` keys — and never -/// counts toward `rolledBack`/`failed` or flips the status. +/// `skipped` event with `errorCode: "package_not_installed"` (plus the +/// entry's manifest `removed` event) — and never counts toward +/// `summary.rolledBack`/`failed` or flips the status. fn assert_rollback_noop(stdout: &str) { let v: serde_json::Value = serde_json::from_str(stdout.trim()).expect("rollback --global must emit valid JSON"); @@ -199,22 +199,18 @@ fn assert_rollback_noop(stdout: &str) { v["status"], "success", "empty rollback must report success; envelope={v}" ); - assert_eq!(v["rolledBack"], 0, "envelope={v}"); - assert_eq!(v["alreadyOriginal"], 0, "envelope={v}"); - assert_eq!(v["failed"], 0, "envelope={v}"); + assert_eq!(v["command"], "rollback", "envelope={v}"); + assert_eq!(v["summary"]["rolledBack"], 0, "envelope={v}"); + assert_eq!(v["summary"]["failed"], 0, "envelope={v}"); assert_eq!(v["dryRun"], false, "envelope={v}"); - for r in v["results"].as_array().expect("results must be an array") { + for e in v["events"].as_array().expect("events must be an array") { + if e["action"] == "removed" && e["details"]["manifest"] == true { + continue; + } + assert_eq!(e["action"], "skipped", "envelope={v}"); assert_eq!( - r["skipped"], "package_not_installed", - "a no-op rollback may carry only not-installed markers; envelope={v}" - ); - assert!( - r["path"].is_null(), - "marker path must be null; envelope={v}" - ); - assert!( - r.get("success").is_none() && r.get("error").is_none(), - "markers carry no success/error keys; envelope={v}" + e["errorCode"], "package_not_installed", + "a no-op rollback may carry only not-installed skips; envelope={v}" ); } } @@ -375,17 +371,25 @@ fn rollback_global_prefix_uses_explicit_path() { let v: serde_json::Value = serde_json::from_str(stdout.trim()).expect("rollback must emit valid JSON"); assert_eq!(v["status"], "success", "envelope={v}"); - assert_eq!(v["failed"], 0, "envelope={v}"); - let results = v["results"].as_array().expect("results must be an array"); + assert_eq!(v["summary"]["failed"], 0, "envelope={v}"); + // The installed copy's event (the one naming `details.path`). + let results: Vec<&serde_json::Value> = v["events"] + .as_array() + .expect("events must be an array") + .iter() + .filter(|e| e["details"]["path"].is_string()) + .collect(); assert_eq!( results.len(), 1, "the seeded package must surface exactly one result; envelope={v}" ); - let r = &results[0]; + let r = results[0]; assert_eq!(r["purl"], PREFIX_PURL, "envelope={v}"); - assert_eq!(r["success"], true, "envelope={v}"); - let path = r["path"].as_str().expect("result must carry a path"); + assert_ne!(r["action"], "failed", "envelope={v}"); + let path = r["details"]["path"] + .as_str() + .expect("result must carry a path"); assert!( Path::new(path).starts_with(&global_dir), "result path must live inside the explicit prefix {}; got {path}; envelope={v}", diff --git a/crates/socket-patch-cli/tests/get/main.rs b/crates/socket-patch-cli/tests/get/main.rs index a06c0915a..02697da19 100644 --- a/crates/socket-patch-cli/tests/get/main.rs +++ b/crates/socket-patch-cli/tests/get/main.rs @@ -14,6 +14,7 @@ mod cli_get_silent_errors; mod coverage_fix_get_double_json; mod get_batch_paths_e2e; mod get_edge_cases_e2e; +mod get_envelope_shape; mod get_invariants; mod get_modes_e2e; mod get_nested_apply_api_flags_e2e; diff --git a/crates/socket-patch-cli/tests/global_scope_project_state.rs b/crates/socket-patch-cli/tests/global_scope_project_state.rs index 307287ded..0a918bf8f 100644 --- a/crates/socket-patch-cli/tests/global_scope_project_state.rs +++ b/crates/socket-patch-cli/tests/global_scope_project_state.rs @@ -196,7 +196,14 @@ fn global_rollback_leaves_hosted_project_pins() { assert_eq!(code, 0, "{args:?}: stdout={stdout}\nstderr={stderr}"); let v = parse(&stdout, &stderr); assert_eq!(v["status"], "success", "{v}"); - assert_eq!(v["hosted"]["reverted"], json!([]), "{args:?}: {v}"); + assert!( + !v["events"] + .as_array() + .expect("events") + .iter() + .any(|e| e["details"]["mode"] == "hosted"), + "{args:?}: {v}" + ); assert_hosted_untouched(tmp.path(), &format!("{args:?}")); } } @@ -394,7 +401,14 @@ fn global_rollback_leaves_vendored_project_state() { ); assert_eq!(code, 0, "stdout={stdout}\nstderr={stderr}"); let v = parse(&stdout, &stderr); - assert_eq!(v["vendoredReverted"], json!([]), "{v}"); + assert!( + !v["events"] + .as_array() + .expect("events") + .iter() + .any(|e| e["details"]["mode"] == "vendored"), + "{v}" + ); project.assert_untouched("rollback --global-prefix"); assert!( project.manifest()["patches"].get(V_PURL).is_some(), diff --git a/crates/socket-patch-cli/tests/hosted_memory_common/mod.rs b/crates/socket-patch-cli/tests/hosted_memory_common/mod.rs index a68e10a64..7713a940c 100644 --- a/crates/socket-patch-cli/tests/hosted_memory_common/mod.rs +++ b/crates/socket-patch-cli/tests/hosted_memory_common/mod.rs @@ -295,6 +295,9 @@ pub async fn run_engine( /// (new or byte-changed, relative to the input). pub struct DiskRun { pub envelope: Value, + /// The hosted outcome rebuilt in the in-memory engine's `redirect` + /// shape ([`legacy_redirect`]; `{mode: "hosted"}` for an error run). + pub redirect: Value, pub changed: BTreeMap>, pub stderr: String, } @@ -371,8 +374,14 @@ fn run_disk_in_with( .into_iter() .filter(|(rel, bytes)| files.get(rel) != Some(bytes)) .collect(); + let redirect = if envelope["status"] == "error" { + serde_json::json!({ "mode": "hosted" }) + } else { + legacy_redirect(&envelope) + }; DiskRun { envelope, + redirect, changed, stderr, } @@ -470,3 +479,110 @@ pub fn describe(map: &BTreeMap>) -> String { .collect::>() .join("\n") } + +/// Warning codes `scan` itself raises (discovery, policy, rollout, VEX, +/// the API) rather than the hosted engine: v5.0 puts every warning on the +/// envelope's top-level `warnings[]`, so [`legacy_redirect`] leaves these +/// out to rebuild the engine's own list. +const SCAN_LEVEL_WARNING_PREFIXES: &[&str] = &[ + "policy_", + "rollout_", + "api_batch_failed", + "patch_details_failed", + "gradle_", + "path_scope_", + "vendor_ledger_entry_unwired", + "manifest_", + "api_auth_fallback", + "yarn_pnp_unsupported", + "pnpm_pnp_unsupported", + "gem_lock_unsupported", + "bun_lockb_invalid", + "vex_", + "lockfile_", +]; + +/// The disk run's hosted outcome in the in-memory engine's `redirect` +/// shape (`{mode, redirected, rewrittenFiles, skipped, patches, warnings, +/// dryRun}`, the library API the depscan pipeline consumes), rebuilt from +/// the v5.0 `scan --json` envelope — its `details.mode: "hosted"` events, +/// its `redirect.rewrittenFiles` and its non-scan-level warnings — so a +/// parity test stays one comparison. `skipped[]` comes back purl-sorted; +/// compare against [`sorted_redirect`] of the engine's block. +pub fn legacy_redirect(envelope: &Value) -> Value { + let dry_run = envelope["dryRun"].as_bool().unwrap_or(false); + let events: Vec<&Value> = envelope["events"] + .as_array() + .into_iter() + .flatten() + .filter(|e| e["details"]["mode"] == "hosted") + .collect(); + let s = |e: &Value, k: &str| e[k].as_str().unwrap_or_default().to_string(); + let mut patches = Vec::new(); + let mut skipped = Vec::new(); + let mut redirected = 0; + for e in &events { + let (purl, uuid) = (s(e, "purl"), s(e, "uuid")); + match e["action"].as_str() { + Some("applied") | Some("verified") => { + redirected += 1; + let action = if dry_run { "would_pin" } else { "pinned" }; + patches.push(serde_json::json!({"purl": purl, "uuid": uuid, "action": action})); + } + Some("skipped") if e["errorCode"] == "redirect_unconfirmed" => { + patches.push(serde_json::json!({ + "purl": purl, "uuid": uuid, "action": "unpinned", + "errorCode": "redirect_unconfirmed", + "error": "no lockfile entry pinning it could be rewritten", + })); + } + Some("skipped") => { + let mut row = serde_json::json!({ + "purl": purl, "uuid": uuid, "action": "skipped", "errorCode": e["errorCode"], + }); + let mut skip = + serde_json::json!({"purl": purl, "uuid": uuid, "reason": e["errorCode"]}); + if let Some(detail) = e["reason"].as_str() { + row["error"] = Value::from(detail); + skip["detail"] = Value::from(detail); + } + patches.push(row); + skipped.push(skip); + } + _ => {} + } + } + let warnings: Vec = envelope["warnings"] + .as_array() + .into_iter() + .flatten() + .filter(|w| { + let code = w["code"].as_str().unwrap_or_default(); + !SCAN_LEVEL_WARNING_PREFIXES + .iter() + .any(|p| code.starts_with(p)) + }) + .cloned() + .collect(); + sorted_redirect(&serde_json::json!({ + "mode": "hosted", + "redirected": redirected, + "rewrittenFiles": envelope["redirect"]["rewrittenFiles"].clone(), + "skipped": skipped, + "patches": patches, + "warnings": warnings, + "dryRun": dry_run, + })) +} + +/// An engine `redirect` block with `skipped[]` sorted by purl then uuid +/// (the envelope's events are purl-sorted; the engine keeps its own order). +pub fn sorted_redirect(redirect: &Value) -> Value { + let mut redirect = redirect.clone(); + if let Some(skipped) = redirect.get_mut("skipped").and_then(Value::as_array_mut) { + skipped.sort_by(|a, b| { + (a["purl"].as_str(), a["uuid"].as_str()).cmp(&(b["purl"].as_str(), b["uuid"].as_str())) + }); + } + redirect +} diff --git a/crates/socket-patch-cli/tests/hosted_memory_parity.rs b/crates/socket-patch-cli/tests/hosted_memory_parity.rs index 563a5c0de..48a964fae 100644 --- a/crates/socket-patch-cli/tests/hosted_memory_parity.rs +++ b/crates/socket-patch-cli/tests/hosted_memory_parity.rs @@ -107,15 +107,15 @@ async fn assert_parity(case: Case) -> Value { case.fixture, project.error ); - let disk_redirect = disk - .envelope - .get("redirect") - .cloned() - .unwrap_or(Value::Null); + let disk_redirect = disk.redirect.clone(); assert_eq!( - project.redirect, disk_redirect, + sorted_redirect(&project.redirect), + disk_redirect, "{}: redirect block differs\nmemory: {:#}\ndisk: {:#}\nstderr: {}", - case.fixture, project.redirect, disk_redirect, disk.stderr + case.fixture, + project.redirect, + disk_redirect, + disk.stderr ); let memory_changed = engine_changed(&memory); let expected_changed = if case.dry_run { @@ -312,8 +312,8 @@ async fn assert_native_parity( let project = &memory.projects[0]; assert!(project.error.is_none(), "{:?}", project.error); assert_eq!( - without_pipenv_advice(&project.redirect), - without_pipenv_advice(&disk.envelope["redirect"]), + without_pipenv_advice(&sorted_redirect(&project.redirect)), + without_pipenv_advice(&disk.redirect), "{}", disk.stderr ); @@ -448,7 +448,7 @@ async fn parity_nested_monorepo_roots_match_their_own_disk_runs() { for (root, files) in [("apps/web", &web), ("services/api", &svc)] { let disk = run_disk(&server, files, false); let project = memory.projects.iter().find(|p| p.root == root).unwrap(); - assert_eq!(project.redirect, disk.envelope["redirect"], "{root}"); + assert_eq!(sorted_redirect(&project.redirect), disk.redirect, "{root}"); let prefixed: BTreeMap> = changed .iter() .filter_map(|(k, v)| { @@ -503,7 +503,8 @@ async fn parity_uv_with_hosted_wheel_metadata() { let memory = run_engine(&server, build_input(&files, &[], options(false))).await; let project = &memory.projects[0]; assert_eq!( - project.redirect, disk.envelope["redirect"], + sorted_redirect(&project.redirect), + disk.redirect, "{}", disk.stderr ); @@ -580,7 +581,8 @@ async fn parity_cargo_patch_path_under_vendor_through_selection() { let project = &memory.projects[0]; assert!(project.error.is_none(), "{:?}", project.error); assert_eq!( - project.redirect, disk.envelope["redirect"], + sorted_redirect(&project.redirect), + disk.redirect, "{}", disk.stderr ); @@ -659,7 +661,8 @@ async fn parity_cargo_vendor_tree_is_not_fetched() { let project = &memory.projects[0]; assert!(project.error.is_none(), "{:?}", project.error); assert_eq!( - project.redirect, disk.envelope["redirect"], + sorted_redirect(&project.redirect), + disk.redirect, "{}", disk.stderr ); @@ -802,7 +805,7 @@ async fn excluded_nested_cargo_project_is_its_own_root_through_selection() { .find(|p| p.root == "tools/fuzz") .unwrap(); assert!(fuzz_project.error.is_none(), "{:?}", fuzz_project.error); - assert_eq!(fuzz_project.redirect, disk.envelope["redirect"]); + assert_eq!(sorted_redirect(&fuzz_project.redirect), disk.redirect); let fuzz_changed: BTreeMap> = changed .iter() .filter_map(|(k, v)| { @@ -995,7 +998,7 @@ async fn parity_socket_yml_filters_the_same_roots_and_packages() { ); disk_filtered.extend(filtered_set(&disk.envelope["policy"])); if let Some(project) = memory.projects.iter().find(|p| p.root == root) { - assert_eq!(project.redirect, disk.envelope["redirect"], "{root}"); + assert_eq!(sorted_redirect(&project.redirect), disk.redirect, "{root}"); let prefix = format!("{root}/"); let memory_changed: BTreeMap> = engine_changed(&memory) .into_iter() @@ -1048,7 +1051,7 @@ async fn parity_socket_yml_severity_floor() { assert!(engine_changed(&memory).is_empty()); let disk = run_disk_in(&server, &repo, "apps/web", false); assert!(disk.changed.is_empty()); - assert_eq!(disk.envelope["redirect"], web.redirect); + assert_eq!(disk.redirect, sorted_redirect(&web.redirect)); let memory_web: std::collections::BTreeSet<_> = filtered_set(memory.policy.as_ref().unwrap()) .into_iter() .filter(|(project, _, _)| project == "apps/web") @@ -1304,7 +1307,7 @@ async fn memory_negation_reincludes_a_default_ignored_root() { // Disk patches the same root the same way. let disk = run_disk_in(&server, &repo, "e2e/tests", false); assert_eq!(disk.envelope["status"], "success", "{}", disk.stderr); - assert_eq!(memory.projects[0].redirect, disk.envelope["redirect"]); + assert_eq!(sorted_redirect(&memory.projects[0].redirect), disk.redirect); let memory_changed = engine_changed(&memory); assert_eq!( memory_changed, @@ -1407,7 +1410,8 @@ async fn assert_pnpm_workspace_parity_with_roots( .unwrap_or_else(|| panic!("{how}: no workspace root project")); assert!(project.error.is_none(), "{how}: {:?}", project.error); assert_eq!( - project.redirect, disk.envelope["redirect"], + sorted_redirect(&project.redirect), + disk.redirect, "{how}: redirect block differs\nstderr: {}", disk.stderr ); @@ -1423,7 +1427,7 @@ async fn assert_pnpm_workspace_parity_with_roots( describe(&disk.changed) ); } - (disk.envelope["redirect"].clone(), disk.changed) + (disk.redirect.clone(), disk.changed) } /// #492: under `sharedWorkspaceLockfile: false` (or pnpm 7's `.npmrc` diff --git a/crates/socket-patch-cli/tests/hosted_memory_rollout.rs b/crates/socket-patch-cli/tests/hosted_memory_rollout.rs index 8ff0921dd..30111b92e 100644 --- a/crates/socket-patch-cli/tests/hosted_memory_rollout.rs +++ b/crates/socket-patch-cli/tests/hosted_memory_rollout.rs @@ -15,7 +15,7 @@ use wiremock::{Mock, MockServer, Request, Respond, ResponseTemplate}; #[path = "hosted_memory_common/mod.rs"] mod common; -use common::{build_input, run_disk_args, run_disk_with, run_engine, ORG}; +use common::{build_input, run_disk_args, run_disk_with, run_engine, sorted_redirect, ORG}; const TOKEN: &str = "22222222-2222-4222-8222-222222222222"; @@ -326,8 +326,8 @@ async fn one_root_disk_and_memory_admit_and_defer_the_same_rows_until_converged( disk.stderr ); assert_eq!( - project.redirect, - disk.envelope["redirect"], + sorted_redirect(&project.redirect), + disk.redirect, "run {}: the redirect blocks agree", run + 1 ); diff --git a/crates/socket-patch-cli/tests/hosted_superseding_pypi.rs b/crates/socket-patch-cli/tests/hosted_superseding_pypi.rs index abd67f7ab..9dd8b0e59 100644 --- a/crates/socket-patch-cli/tests/hosted_superseding_pypi.rs +++ b/crates/socket-patch-cli/tests/hosted_superseding_pypi.rs @@ -187,10 +187,7 @@ async fn assert_superseded(root: &Path, files: &[&str]) { let url_a = offer(&server, UUID_A, GRANT_A, 1).await; let (code, env) = hosted_scan(root, &server); assert_eq!(code, 0, "first hosted scan: {env:#}"); - assert_eq!( - env["redirect"]["redirected"], 1, - "first hosted scan: {env:#}" - ); + assert_eq!(env["summary"]["applied"], 1, "first hosted scan: {env:#}"); for f in files { let text = std::fs::read_to_string(root.join(f)).unwrap(); assert!(text.contains(&url_a), "{f} wired to uuid A:\n{text}"); @@ -217,7 +214,7 @@ async fn assert_superseded(root: &Path, files: &[&str]) { "{code} refused the upgrade: {env:#}" ); } - assert_eq!(env["redirect"]["redirected"], 1, "re-pinned: {env:#}"); + assert_eq!(env["summary"]["applied"], 1, "re-pinned: {env:#}"); for f in files { let text = std::fs::read_to_string(root.join(f)).unwrap(); assert!(text.contains(&url_b), "{f} re-pinned to uuid B:\n{text}"); diff --git a/crates/socket-patch-cli/tests/in_process_get_update_count.rs b/crates/socket-patch-cli/tests/in_process_get_update_count.rs index e7264a48a..838720388 100644 --- a/crates/socket-patch-cli/tests/in_process_get_update_count.rs +++ b/crates/socket-patch-cli/tests/in_process_get_update_count.rs @@ -40,7 +40,8 @@ async fn download_and_apply_patches( lock_timeout: None, verbose: false, }; - download_and_apply_patches_with(selected, params, &run).await + let (code, env) = download_and_apply_patches_with(selected, params, &run).await; + (code, env.to_value()) } const ORG: &str = "test-org"; @@ -121,16 +122,16 @@ async fn failed_update_fetch_is_not_counted_as_updated() { let (code, json) = download_and_apply_patches(&selected, ¶ms(tmp.path()), &server).await; assert_eq!(code, 1, "a failed detail fetch must exit 1; json={json}"); - assert_eq!(json["status"], "partial_failure", "json={json}"); + assert_eq!(json["status"], "partialFailure", "json={json}"); assert_eq!( - json["failed"], 1, + json["summary"]["failed"], 1, "the fetch failure must be counted; json={json}" ); assert_eq!( - json["updated"], 0, + json["summary"]["updated"], 0, "a patch that never downloaded must not be counted as updated; json={json}" ); - assert_eq!(json["downloaded"], 0, "json={json}"); + assert_eq!(json["summary"]["downloaded"], 0, "json={json}"); // The manifest entry must be left at the OLD uuid — nothing was replaced. let body = std::fs::read_to_string(tmp.path().join(".socket/manifest.json")).unwrap(); @@ -177,14 +178,15 @@ async fn successful_update_is_counted_once() { assert_eq!(code, 0, "save-only update should succeed; json={json}"); assert_eq!(json["status"], "success", "json={json}"); assert_eq!( - json["updated"], 1, + json["summary"]["updated"], 1, "the replacement must be counted once; json={json}" ); - assert_eq!(json["downloaded"], 1, "json={json}"); - assert_eq!(json["failed"], 0, "json={json}"); + // v5.0: an update is `updated`, not also `downloaded`. + assert_eq!(json["summary"]["downloaded"], 0, "json={json}"); + assert_eq!(json["summary"]["failed"], 0, "json={json}"); - // The per-patch record is an `updated` action carrying the prior uuid. - let patches = json["patches"].as_array().unwrap(); + // The per-patch event is `updated` carrying the prior uuid. + let patches = json["events"].as_array().unwrap(); assert_eq!(patches.len(), 1); assert_eq!(patches[0]["action"], "updated", "json={json}"); assert_eq!(patches[0]["oldUuid"], OLD_UUID, "json={json}"); diff --git a/crates/socket-patch-cli/tests/in_process_redirect.rs b/crates/socket-patch-cli/tests/in_process_redirect.rs index 26c229f38..05a048bd1 100644 --- a/crates/socket-patch-cli/tests/in_process_redirect.rs +++ b/crates/socket-patch-cli/tests/in_process_redirect.rs @@ -8,6 +8,9 @@ //! This is the CLI counterpart of the depscan-side install-verify e2e; the //! rewriter bytes themselves are pinned by the shared golden fixtures. +#[path = "common/rollback_json.rs"] +mod rollback_json; + use std::collections::HashMap; use std::path::Path; @@ -855,7 +858,7 @@ async fn scan_redirect_rewrites_crlf_and_bom_yarn_berry_locks_and_rollback_resto &server.uri(), &["--patch-server-url", &server.uri()], ); - assert_eq!(env["redirect"]["redirected"], 1, "{label}: {env:#}"); + assert_eq!(vlt_hosted_common::redirected(&env), 1, "{label}: {env:#}"); assert!( warning_codes(&env).is_empty(), "{label}: no line-ending refusal (or any other warning): {env:#}" @@ -881,7 +884,7 @@ async fn scan_redirect_rewrites_crlf_and_bom_yarn_berry_locks_and_rollback_resto &server.uri(), &["--patch-server-url", &server.uri()], ); - assert_eq!(env["redirect"]["redirected"], 1, "{label}: {env:#}"); + assert_eq!(vlt_hosted_common::redirected(&env), 1, "{label}: {env:#}"); assert_eq!( std::fs::read_to_string(&lock_path).unwrap(), lock, @@ -892,7 +895,7 @@ async fn scan_redirect_rewrites_crlf_and_bom_yarn_berry_locks_and_rollback_resto let (code, env) = rollback_json_with_origin(tmp.path(), &server, &server.uri()); assert_eq!(code, Some(0), "{label}: rollback: {env:#}"); assert_eq!( - env["hosted"]["reverted"], + rollback_json::hosted_reverted(&env), serde_json::json!([PURL]), "{label}: {env:#}" ); @@ -1002,7 +1005,7 @@ async fn yarn_berry_pin_drops_the_implicit_node_gyp_and_rollback_restores_it() { &server.uri(), &["--patch-server-url", &server.uri()], ); - assert_eq!(env["redirect"]["redirected"], 1, "{label}: {env:#}"); + assert_eq!(vlt_hosted_common::redirected(&env), 1, "{label}: {env:#}"); assert!(warning_codes(&env).is_empty(), "{label}: {env:#}"); let lock = std::fs::read_to_string(&lock_path).unwrap(); assert!( @@ -1092,7 +1095,7 @@ async fn yarn_berry_catalog_dependency_is_pinned_and_rolled_back() { &server.uri(), &["--patch-server-url", &server.uri()], ); - assert_eq!(env["redirect"]["redirected"], 1, "{env:#}"); + assert_eq!(vlt_hosted_common::redirected(&env), 1, "{env:#}"); assert!(warning_codes(&env).is_empty(), "{env:#}"); let pkg: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(&pkg_path).unwrap()).unwrap(); @@ -1113,7 +1116,7 @@ async fn yarn_berry_catalog_dependency_is_pinned_and_rolled_back() { let (code, env) = rollback_json_with_origin(tmp.path(), &server, &server.uri()); assert_eq!(code, Some(0), "rollback: {env:#}"); assert_eq!( - env["hosted"]["reverted"], + rollback_json::hosted_reverted(&env), serde_json::json!([PURL]), "{env:#}" ); @@ -1175,7 +1178,7 @@ async fn yarn_berry_legacy_archive_url_pin_is_rolled_back_and_repinned() { let (code, env) = rollback_json_with_origin(tmp.path(), &server, &server.uri()); assert_eq!(code, Some(0), "rollback: {env:#}"); assert_eq!( - env["hosted"]["reverted"], + rollback_json::hosted_reverted(&env), serde_json::json!([PURL]), "{env:#}" ); @@ -1196,7 +1199,7 @@ async fn yarn_berry_legacy_archive_url_pin_is_rolled_back_and_repinned() { &server.uri(), &["--patch-server-url", &server.uri()], ); - assert_eq!(env["redirect"]["redirected"], 1, "{env:#}"); + assert_eq!(vlt_hosted_common::redirected(&env), 1, "{env:#}"); let lock = std::fs::read_to_string(&lock_path).unwrap(); assert!( lock.contains(&format!("\n resolution: \"{NAME}@{hosted_url}\"\n")) @@ -1228,7 +1231,7 @@ async fn scan_redirect_refuses_a_mixed_line_ending_yarn_berry_lock() { let before = std::fs::read(&lock_path).unwrap(); let env = run_redirect_subprocess(tmp.path(), &server.uri()); - assert_eq!(env["redirect"]["redirected"], 0, "{env:#}"); + assert_eq!(vlt_hosted_common::redirected(&env), 0, "{env:#}"); assert!( warning_codes(&env).contains(&"redirect_yarn_berry_mixed_line_endings".to_string()), "{env:#}" @@ -1276,7 +1279,7 @@ async fn scan_redirect_refuses_a_mixed_line_ending_yarn_berry_manifest() { ); let env = run_redirect_subprocess(tmp.path(), &server.uri()); - assert_eq!(env["redirect"]["redirected"], 0, "{env:#}"); + assert_eq!(vlt_hosted_common::redirected(&env), 0, "{env:#}"); let detail = redirect_warning_detail(&env, "redirect_yarn_berry_mixed_line_endings"); assert!(detail.contains("package.json"), "names the file: {detail}"); assert!(detail.contains("yarn install"), "remedy named: {detail}"); @@ -1723,7 +1726,8 @@ async fn scan_redirect_refuses_bun_lock_v3() { let env = run_redirect_subprocess(tmp.path(), &server.uri()); assert_eq!( - env["redirect"]["redirected"], 0, + vlt_hosted_common::redirected(&env), + 0, "an unsupported lock version must redirect nothing: {env}" ); assert!( @@ -1791,7 +1795,7 @@ async fn scan_redirect_heals_digestless_bun_tuple_and_rollback_restores_the_regi for drop_again_before_rollback in [false, true] { let env = run_redirect_subprocess(tmp.path(), &server.uri()); - assert_eq!(env["redirect"]["redirected"], 1, "{env:#}"); + assert_eq!(vlt_hosted_common::redirected(&env), 1, "{env:#}"); let wired = std::fs::read_to_string(&lock_path).unwrap(); let wired_line = bun_packages_line(&wired, NAME); assert!( @@ -1809,7 +1813,8 @@ async fn scan_redirect_heals_digestless_bun_tuple_and_rollback_restores_the_regi let env = run_redirect_subprocess(tmp.path(), &server.uri()); assert_eq!(env["status"], "success", "{env:#}"); assert_eq!( - env["redirect"]["redirected"], 1, + vlt_hosted_common::redirected(&env), + 1, "the wired dep still counts as redirected: {env:#}" ); let codes = warning_codes(&env); @@ -1826,7 +1831,7 @@ async fn scan_redirect_heals_digestless_bun_tuple_and_rollback_restores_the_regi // A third run over the healed lock is a no-op. let env = run_redirect_subprocess(tmp.path(), &server.uri()); - assert_eq!(env["redirect"]["redirected"], 1, "{env:#}"); + assert_eq!(vlt_hosted_common::redirected(&env), 1, "{env:#}"); assert!(warning_codes(&env).is_empty(), "{env:#}"); assert_eq!(std::fs::read_to_string(&lock_path).unwrap(), wired); @@ -1897,7 +1902,7 @@ async fn bun_rollback_keeps_the_bunfig_registry_tarball_url() { .unwrap(); let env = run_redirect_subprocess(tmp.path(), &server.uri()); - assert_eq!(env["redirect"]["redirected"], 1, "{mirror}: {env:#}"); + assert_eq!(vlt_hosted_common::redirected(&env), 1, "{mirror}: {env:#}"); assert!( std::fs::read_to_string(&lock_path) .unwrap() @@ -1908,7 +1913,7 @@ async fn bun_rollback_keeps_the_bunfig_registry_tarball_url() { let (code, env) = rollback_json(tmp.path(), &server); assert_eq!(code, Some(0), "{mirror}: rollback: {env:#}"); assert_eq!( - env["hosted"]["reverted"], + rollback_json::hosted_reverted(&env), serde_json::json!([PURL]), "{mirror}: {env:#}" ); @@ -2120,7 +2125,7 @@ fn scan_redirect_json_with_path( /// The `detail` of the first redirect warning carrying `code`. fn redirect_warning_detail(env: &serde_json::Value, code: &str) -> String { - env["redirect"]["warnings"] + env["warnings"] .as_array() .into_iter() .flatten() @@ -2233,7 +2238,7 @@ async fn malformed_binary_lock_never_spawns_bun_or_changes_format() { let (code, env, stderr) = scan_redirect_json_with_path(tmp.path(), &server.uri(), &path_with_first(&bin)); assert_eq!(code, Some(0), "{env:#}\n{stderr}"); - assert_eq!(env["redirect"]["redirected"], 0, "{env:#}"); + assert_eq!(vlt_hosted_common::redirected(&env), 0, "{env:#}"); assert!(!redirect_warning_detail(&env, "redirect_bun_lockb_invalid").is_empty()); assert!(!warning_codes(&env).contains(&"redirect_npm_no_lockfile".to_string())); assert_eq!(std::fs::read(tmp.path().join("bun.lockb")).unwrap(), bytes); @@ -2259,7 +2264,7 @@ async fn native_binary_no_matching_version_preserves_exact_bytes() { let (code, env, stderr) = scan_redirect_json_with_path(tmp.path(), &server.uri(), std::ffi::OsStr::new("")); assert_eq!(code, Some(0), "{env:#}\n{stderr}"); - assert_eq!(env["redirect"]["redirected"], 0, "{env:#}"); + assert_eq!(vlt_hosted_common::redirected(&env), 0, "{env:#}"); assert_eq!(std::fs::read(tmp.path().join("bun.lockb")).unwrap(), bytes); assert!(!tmp.path().join("bun.lock").exists()); assert!(!tmp @@ -2687,7 +2692,7 @@ fn run_redirect_subprocess_with(cwd: &Path, api_url: &str, extra: &[&str]) -> se /// Collect the `code` field of every warning in the redirect envelope. fn warning_codes(env: &serde_json::Value) -> Vec { - env["redirect"]["warnings"] + env["warnings"] .as_array() .map(|arr| { arr.iter() @@ -2760,7 +2765,8 @@ async fn redirect_inbundle_only_dep_is_skipped_not_confirmed() { let env = run_redirect_subprocess(tmp.path(), &server.uri()); assert_eq!( - env["redirect"]["redirected"], 0, + vlt_hosted_common::redirected(&env), + 0, "a bundled-only dep must NOT be counted redirected: {env}" ); let codes = warning_codes(&env); @@ -2832,7 +2838,8 @@ packages: let env = run_redirect_subprocess(tmp.path(), &server.uri()); assert_eq!( - env["redirect"]["redirected"], 0, + vlt_hosted_common::redirected(&env), + 0, "a residual-instance dep must NOT be counted redirected: {env}" ); let codes = warning_codes(&env); @@ -3187,7 +3194,7 @@ async fn rush_pnpm_trust_warning_gives_rush_remedy() { write_rush_project(tmp.path(), false); let env = run_redirect_subprocess(tmp.path(), &server.uri()); assert_eq!(env["status"], "success", "envelope: {env}"); - let detail = env["redirect"]["warnings"] + let detail = env["warnings"] .as_array() .and_then(|arr| { arr.iter() @@ -3253,7 +3260,7 @@ async fn rush_rerun_on_redirected_locks_reissues_the_rush_remedy() { redirected, "the re-run must leave the redirected Rush lock byte-identical" ); - let detail = env["redirect"]["warnings"] + let detail = env["warnings"] .as_array() .and_then(|arr| { arr.iter() @@ -3317,7 +3324,8 @@ async fn rush_stale_warning_requires_an_actual_lock_edit() { let env = run_redirect_subprocess(tmp.path(), &server.uri()); assert_eq!(env["status"], "success", "envelope: {env}"); assert_eq!( - env["redirect"]["redirected"], 0, + vlt_hosted_common::redirected(&env), + 0, "nothing pins the patch, so nothing may count as redirected: {env}" ); assert!( @@ -3379,7 +3387,8 @@ async fn pnpm_lock_redirect_autoconfigures_trust_lockfile_and_says_so() { let env = run_redirect_subprocess(pnpm.path(), &server.uri()); assert_eq!(env["status"], "success", "envelope: {env}"); assert_eq!( - env["redirect"]["redirected"], 1, + vlt_hosted_common::redirected(&env), + 1, "anchor: the pnpm lock must have been redirected: {env}" ); // The zero-touch write itself: a fresh pnpm-workspace.yaml with the @@ -3404,7 +3413,7 @@ async fn pnpm_lock_redirect_autoconfigures_trust_lockfile_and_says_so() { "a rewritten pnpm-lock.yaml must warn about the pnpm >=11 policy; got warnings {:?}", warning_codes(&env) ); - let detail = env["redirect"]["warnings"] + let detail = env["warnings"] .as_array() .unwrap() .iter() @@ -3495,7 +3504,8 @@ async fn pnpm_trust_opt_out_writes_nothing_and_keeps_manual_guidance() { run_redirect_subprocess_with(tmp.path(), &server.uri(), &["--no-trust-lockfile-config"]); assert_eq!(env["status"], "success", "envelope: {env}"); assert_eq!( - env["redirect"]["redirected"], 1, + vlt_hosted_common::redirected(&env), + 1, "the opt-out must not stop the redirect itself: {env}" ); assert!( @@ -3508,7 +3518,7 @@ async fn pnpm_trust_opt_out_writes_nothing_and_keeps_manual_guidance() { "only the lock may be rewritten under the opt-out: {env}" ); vlt_hosted_common::assert_no_ledger(tmp.path()); - let detail = env["redirect"]["warnings"] + let detail = env["warnings"] .as_array() .unwrap() .iter() @@ -3551,7 +3561,8 @@ async fn pnpm_trust_respects_an_explicit_user_false() { let env = run_redirect_subprocess(tmp.path(), &server.uri()); assert_eq!(env["status"], "success", "envelope: {env}"); assert_eq!( - env["redirect"]["redirected"], 1, + vlt_hosted_common::redirected(&env), + 1, "the redirect itself must still land: {env}" ); assert_eq!( @@ -3559,7 +3570,7 @@ async fn pnpm_trust_respects_an_explicit_user_false() { user_ws, "an explicit trustLockfile: false must be left byte-identical" ); - let detail = env["redirect"]["warnings"] + let detail = env["warnings"] .as_array() .unwrap() .iter() @@ -3754,7 +3765,8 @@ async fn pnpm_warning_strips_userinfo_and_names_only_spliced_hosts() { let env = run_redirect_subprocess(tmp.path(), &server.uri()); assert_eq!(env["status"], "success", "envelope: {env}"); assert_eq!( - env["redirect"]["redirected"], 2, + vlt_hosted_common::redirected(&env), + 2, "anchor: both locks must have been redirected: {env}" ); // Anchors: the credentialed URL really was spliced into the pnpm lock @@ -3772,7 +3784,7 @@ async fn pnpm_warning_strips_userinfo_and_names_only_spliced_hosts() { "package-lock.json must carry the sibling URL; got:\n{npm_lock}" ); - let detail = env["redirect"]["warnings"] + let detail = env["warnings"] .as_array() .unwrap() .iter() @@ -3825,7 +3837,8 @@ async fn clean_success_warning_set_is_exact_for_npm_and_pnpm() { let env = run_redirect_subprocess(npm.path(), &server.uri()); assert_eq!(env["status"], "success", "envelope: {env}"); assert_eq!( - env["redirect"]["redirected"], 1, + vlt_hosted_common::redirected(&env), + 1, "anchor: the npm lock must have been redirected: {env}" ); assert_eq!( @@ -3845,7 +3858,7 @@ async fn clean_success_warning_set_is_exact_for_npm_and_pnpm() { std::fs::write(npm.path().join(".npmrc"), "allow-remote=all\n").unwrap(); let env = run_redirect_subprocess(npm.path(), &server.uri()); assert_eq!(env["status"], "success", "envelope: {env}"); - assert_eq!(env["redirect"]["redirected"], 1, "anchor: {env}"); + assert_eq!(vlt_hosted_common::redirected(&env), 1, "anchor: {env}"); assert_eq!( warning_codes(&env), vec!["redirect_npm_allow_remote".to_string()], @@ -3859,7 +3872,8 @@ async fn clean_success_warning_set_is_exact_for_npm_and_pnpm() { let env = run_redirect_subprocess(pnpm.path(), &server.uri()); assert_eq!(env["status"], "success", "envelope: {env}"); assert_eq!( - env["redirect"]["redirected"], 1, + vlt_hosted_common::redirected(&env), + 1, "anchor: the pnpm lock must have been redirected: {env}" ); assert_eq!( @@ -4030,8 +4044,12 @@ async fn redirect_json_mode_failures_emit_error_envelope() { "{leg}: envelope must carry the error message; stdout=\n{stdout}" ); assert_eq!( - v["redirect"]["mode"], "hosted", - "{leg}: envelope must identify the mode; stdout=\n{stdout}" + v["command"], "scan", + "{leg}: envelope must identify the command; stdout=\n{stdout}" + ); + assert!( + v.get("redirect").is_none(), + "{leg}: nothing was rewritten, so no redirect payload; stdout=\n{stdout}" ); }; @@ -4136,8 +4154,12 @@ fn assert_write_failure_envelope(out: &std::process::Output, leg: &str) { "{leg}: envelope must carry the error message; stdout=\n{stdout}" ); assert_eq!( - v["redirect"]["mode"], "hosted", - "{leg}: envelope must identify the mode; stdout=\n{stdout}" + v["command"], "scan", + "{leg}: envelope must identify the command; stdout=\n{stdout}" + ); + assert!( + v.get("redirect").is_none(), + "{leg}: nothing was rewritten, so no redirect payload; stdout=\n{stdout}" ); } @@ -4230,7 +4252,7 @@ async fn readonly_socket_vendor_does_not_block_a_hosted_run() { String::from_utf8_lossy(&out.stderr) ); let v: serde_json::Value = serde_json::from_str(&stdout).expect("parseable envelope"); - assert_eq!(v["redirect"]["redirected"], 1, "{v:#}"); + assert_eq!(vlt_hosted_common::redirected(&v), 1, "{v:#}"); let lock = std::fs::read_to_string(tmp.path().join("package-lock.json")).unwrap(); assert!(lock.contains(HOSTED_URL), "{lock}"); vlt_hosted_common::assert_no_ledger(tmp.path()); @@ -4265,7 +4287,7 @@ async fn corrupt_pre_v5_ledger_is_ignored_and_left_untouched() { ); let v: serde_json::Value = serde_json::from_str(&stdout).expect("parseable envelope"); assert_eq!(v["status"], "success", "{v:#}"); - assert_eq!(v["redirect"]["redirected"], 1, "{v:#}"); + assert_eq!(vlt_hosted_common::redirected(&v), 1, "{v:#}"); assert!( !stdout.contains("redirect-state.json") && !stderr.contains("malformed"), "the legacy ledger is never mentioned; stdout=\n{stdout}\nstderr=\n{stderr}" @@ -4453,7 +4475,7 @@ async fn hosted_prune_emits_explicit_ignored_warning() { "hosted mode must not run (or claim to run) GC: {env_json}" ); // The redirect itself is unaffected by the ignored flag. - assert_eq!(env_json["redirect"]["redirected"], 1, "{env_json}"); + assert_eq!(vlt_hosted_common::redirected(&env_json), 1, "{env_json}"); } // ── composer ───────────────────────────────────────────────────────────── @@ -4603,7 +4625,8 @@ async fn composer_redirect_is_confirmed_and_recorded() { let env = run_redirect_subprocess(tmp.path(), &server.uri()); assert_eq!(env["status"], "success", "envelope: {env}"); assert_eq!( - env["redirect"]["redirected"], 1, + vlt_hosted_common::redirected(&env), + 1, "the composer redirect must be CONFIRMED, not silently unconfirmed: {env}" ); assert_eq!( @@ -4914,8 +4937,8 @@ async fn cargo_transitive_only_crate_is_refused_loudly_and_not_attested() { // Without --vex the run succeeds, reporting nothing redirected and the // transitive-only warning in the envelope. let env = run_redirect_subprocess(tmp.path(), &server.uri()); - assert_eq!(env["redirect"]["redirected"], 0, "{env}"); - let warning = env["redirect"]["warnings"] + assert_eq!(vlt_hosted_common::redirected(&env), 0, "{env}"); + let warning = env["warnings"] .as_array() .unwrap() .iter() @@ -4998,8 +5021,8 @@ async fn cargo_member_outside_the_project_or_behind_a_symlink_refuses_the_crate( write_vendored_crate(&app, "cfg-if", "1.0.0"); let env = run_redirect_subprocess(&app, &server.uri()); - assert_eq!(env["redirect"]["redirected"], 0, "{shape}: {env}"); - let warning = env["redirect"]["warnings"] + assert_eq!(vlt_hosted_common::redirected(&env), 0, "{shape}: {env}"); + let warning = env["warnings"] .as_array() .unwrap() .iter() @@ -5231,7 +5254,7 @@ async fn yarn_berry_rollback_restores_the_registry_archive_url_binding() { &server.uri(), &["--patch-server-url", &server.uri()], ); - assert_eq!(env["redirect"]["redirected"], 1, "{env:#}"); + assert_eq!(vlt_hosted_common::redirected(&env), 1, "{env:#}"); let pinned = std::fs::read_to_string(&lock_path).unwrap(); assert!( pinned.contains(&format!("\n resolution: \"{NAME}@{hosted_url}\"\n")), @@ -5241,7 +5264,7 @@ async fn yarn_berry_rollback_restores_the_registry_archive_url_binding() { let (code, env) = rollback_json_with_origin(tmp.path(), &server, &server.uri()); assert_eq!(code, Some(0), "rollback: {env:#}"); assert_eq!( - env["hosted"]["reverted"], + rollback_json::hosted_reverted(&env), serde_json::json!([PURL]), "{env:#}" ); @@ -5287,7 +5310,7 @@ async fn yarn_berry_rollback_keeps_a_bare_locator_for_conventional_urls() { &server.uri(), &["--patch-server-url", &server.uri()], ); - assert_eq!(env["redirect"]["redirected"], 1, "{env:#}"); + assert_eq!(vlt_hosted_common::redirected(&env), 1, "{env:#}"); let (code, env) = rollback_json_with_origin(tmp.path(), &server, &server.uri()); assert_eq!(code, Some(0), "rollback: {env:#}"); let restored = std::fs::read_to_string(&lock_path).unwrap(); @@ -5364,12 +5387,12 @@ async fn yarn_berry_rollback_reads_the_tarball_from_the_project_registry() { &server.uri(), &["--patch-server-url", &server.uri()], ); - assert_eq!(env["redirect"]["redirected"], 1, "{env:#}"); + assert_eq!(vlt_hosted_common::redirected(&env), 1, "{env:#}"); let (code, env) = rollback_json_with_origin(tmp.path(), &server, &server.uri()); assert_eq!(code, Some(0), "rollback: {env:#}"); assert_eq!( - env["hosted"]["reverted"], + rollback_json::hosted_reverted(&env), serde_json::json!([PURL]), "{env:#}" ); @@ -5408,13 +5431,13 @@ fn pnpm_pin_and_rollback(root: &Path, server: &MockServer) -> String { /// envelope. fn pnpm_pin_and_rollback_env(root: &Path, server: &MockServer) -> (String, serde_json::Value) { let env = run_redirect_subprocess(root, &server.uri()); - assert_eq!(env["redirect"]["redirected"], 1, "{env:#}"); + assert_eq!(vlt_hosted_common::redirected(&env), 1, "{env:#}"); let pinned = std::fs::read_to_string(root.join("pnpm-lock.yaml")).unwrap(); assert!(pinned.contains("patch.test"), "{pinned}"); let (code, env) = rollback_json(root, server); assert_eq!(code, Some(0), "rollback: {env:#}"); assert_eq!( - env["hosted"]["reverted"], + rollback_json::hosted_reverted(&env), serde_json::json!([PURL]), "{env:#}" ); @@ -5647,7 +5670,7 @@ async fn pnpm_rollback_reads_the_scope_registry_for_a_scoped_name() { let (code, env) = rollback_json(tmp.path(), &server); assert_eq!(code, Some(0), "rollback: {env:#}"); assert_eq!( - env["hosted"]["reverted"], + rollback_json::hosted_reverted(&env), serde_json::json!(["pkg:npm/@socktest/scoped-pkg@1.0.0"]), "{env:#}" ); @@ -5689,11 +5712,11 @@ async fn pnpm_rollback_falls_back_from_an_unreadable_mirror_and_warns() { .unwrap(); let env = run_redirect_subprocess(tmp.path(), &server.uri()); - assert_eq!(env["redirect"]["redirected"], 1, "{env:#}"); + assert_eq!(vlt_hosted_common::redirected(&env), 1, "{env:#}"); let (code, env) = rollback_json(tmp.path(), &server); assert_eq!(code, Some(0), "rollback: {env:#}"); assert_eq!( - env["hosted"]["reverted"], + rollback_json::hosted_reverted(&env), serde_json::json!([PURL]), "{env:#}" ); @@ -5773,7 +5796,7 @@ async fn pnpm_remove_stays_bare_when_pnpm12_ignores_npmrc_include_tarball_url() let lock_path = tmp.path().join("pnpm-lock.yaml"); let pristine = std::fs::read_to_string(&lock_path).unwrap(); let env = run_redirect_subprocess(tmp.path(), &server.uri()); - assert_eq!(env["redirect"]["redirected"], 1, "{env:#}"); + assert_eq!(vlt_hosted_common::redirected(&env), 1, "{env:#}"); assert!(std::fs::read_to_string(&lock_path) .unwrap() .contains("patch.test")); @@ -6196,9 +6219,8 @@ async fn cargo_hosted_scan_from_workspace_member_refuses() { assert!(!member.join(".socket").exists()); } -/// `redirect.patches[]` reports every selected patch per purl (audit B12): -/// a pinned dep is a `pinned` row, so a consumer no longer has to infer -/// which patches the `redirected` count covers. +/// Every selected patch is an event (audit B12; v5.0 envelope): a pinned +/// dep is an `applied` event tagged `details.mode: "hosted"`. #[tokio::test] #[serial] async fn hosted_json_reports_a_pinned_row_per_patch() { @@ -6210,18 +6232,20 @@ async fn hosted_json_reports_a_pinned_row_per_patch() { write_project(tmp.path()); let env = run_redirect_subprocess(tmp.path(), &server.uri()); - assert_eq!(env["redirect"]["redirected"], 1, "{env:#}"); + assert_eq!(vlt_hosted_common::redirected(&env), 1, "{env:#}"); assert_eq!( - env["redirect"]["patches"], - serde_json::json!([{ "purl": PURL, "uuid": UUID, "action": "pinned" }]), + env["events"], + serde_json::json!([{ + "action": "applied", "purl": PURL, "uuid": UUID, "details": {"mode": "hosted"}, + }]), "{env:#}" ); } /// A granted patch that no lockfile entry pins (here: no lockfile at all) /// used to vanish from `--json` — it is neither redirected nor skipped, and -/// only the human output named it ("Not hosted"). It is now an `unpinned` -/// row with `errorCode: redirect_unconfirmed`. The exit code is unchanged +/// only the human output named it ("Not hosted"). It is now a `skipped` +/// event with `errorCode: redirect_unconfirmed`. The exit code is unchanged /// (0): the hosted exit policy is an open maintainer decision (#704). #[tokio::test] #[serial] @@ -6247,13 +6271,14 @@ async fn hosted_json_reports_an_unpinned_row_for_a_granted_patch_nothing_pins() .unwrap(); let env = run_redirect_subprocess(tmp.path(), &server.uri()); - assert_eq!(env["redirect"]["redirected"], 0, "{env:#}"); - let rows = env["redirect"]["patches"].as_array().expect("patches[]"); + assert_eq!(vlt_hosted_common::redirected(&env), 0, "{env:#}"); + let rows = env["events"].as_array().expect("events[]"); assert_eq!(rows.len(), 1, "{env:#}"); assert_eq!(rows[0]["purl"], PURL, "{env:#}"); assert_eq!(rows[0]["uuid"], UUID, "{env:#}"); - assert_eq!(rows[0]["action"], "unpinned", "{env:#}"); + assert_eq!(rows[0]["action"], "skipped", "{env:#}"); assert_eq!(rows[0]["errorCode"], "redirect_unconfirmed", "{env:#}"); + assert_eq!(rows[0]["details"]["mode"], "hosted", "{env:#}"); } // ── #1017: the berry restore reads the registry from every yarn source ────── @@ -6354,7 +6379,11 @@ async fn yarn_berry_rollback_reads_the_registry_from_every_yarn_source() { &server.uri(), &["--patch-server-url", &server.uri()], ); - assert_eq!(env["redirect"]["redirected"], 1, "{source:?}: {env:#}"); + assert_eq!( + vlt_hosted_common::redirected(&env), + 1, + "{source:?}: {env:#}" + ); let out = scrubbed_cli() .args([ @@ -6425,7 +6454,7 @@ async fn yarn_berry_rollback_warns_when_the_registry_cannot_be_known() { &server.uri(), &["--patch-server-url", &server.uri()], ); - assert_eq!(env["redirect"]["redirected"], 1, "{env:#}"); + assert_eq!(vlt_hosted_common::redirected(&env), 1, "{env:#}"); std::fs::write( tmp.path().join(".yarnrc.yml"), "nodeLinker: node-modules\nnpmRegistryServer: \"${SOCKET_PATCH_TEST_UNSET_REG}\"\n", @@ -6529,7 +6558,7 @@ async fn yarn_berry_rollback_restores_the_registry_bin_spelling() { let (code, env) = rollback_json_with_origin(tmp.path(), &server, &server.uri()); assert_eq!(code, Some(0), "rollback: {env:#}"); assert_eq!( - env["hosted"]["reverted"], + rollback_json::hosted_reverted(&env), serde_json::json!([PURL]), "{env:#}" ); diff --git a/crates/socket-patch-cli/tests/in_process_redirect/vlt.rs b/crates/socket-patch-cli/tests/in_process_redirect/vlt.rs index a78d10282..7030733eb 100644 --- a/crates/socket-patch-cli/tests/in_process_redirect/vlt.rs +++ b/crates/socket-patch-cli/tests/in_process_redirect/vlt.rs @@ -11,12 +11,16 @@ use wiremock::{Mock, MockServer, ResponseTemplate}; use crate::vlt_hosted_common::*; +/// The codes of the hosted `skipped` events (v5.0: replaces +/// `redirect.skipped[].reason`). fn skipped_reasons(doc: &Value) -> Vec { - doc["redirect"]["skipped"] + doc["events"] .as_array() .into_iter() .flatten() - .filter_map(|s| s["reason"].as_str().map(str::to_string)) + .filter(|e| e["action"] == "skipped" && e["details"]["mode"] == "hosted") + .filter(|e| e["errorCode"] != "redirect_unconfirmed") + .filter_map(|e| e["errorCode"].as_str().map(str::to_string)) .collect() } diff --git a/crates/socket-patch-cli/tests/in_process_redirect_pdm.rs b/crates/socket-patch-cli/tests/in_process_redirect_pdm.rs index fbf288438..eb5bfd816 100644 --- a/crates/socket-patch-cli/tests/in_process_redirect_pdm.rs +++ b/crates/socket-patch-cli/tests/in_process_redirect_pdm.rs @@ -709,13 +709,11 @@ async fn scan_json( } fn stale_warning(json: &serde_json::Value) -> bool { - json["redirect"]["warnings"] - .as_array() - .is_some_and(|warnings| { - warnings - .iter() - .any(|warning| warning["code"] == "redirect_pypi_stale_install") - }) + json["warnings"].as_array().is_some_and(|warnings| { + warnings + .iter() + .any(|warning| warning["code"] == "redirect_pypi_stale_install") + }) } /// Lay `urllib3 1.26.18` with `bytes` as its `response.py` into `site`. diff --git a/crates/socket-patch-cli/tests/in_process_redirect_pipenv.rs b/crates/socket-patch-cli/tests/in_process_redirect_pipenv.rs index e4187d707..4b1682cfb 100644 --- a/crates/socket-patch-cli/tests/in_process_redirect_pipenv.rs +++ b/crates/socket-patch-cli/tests/in_process_redirect_pipenv.rs @@ -821,7 +821,7 @@ async fn dotenv_selected_pipenv_install_is_checked_before_vex() { }); assert_eq!(out.status.code(), Some(1), "{case}: {json}"); assert!( - json["redirect"]["warnings"] + json["warnings"] .as_array() .unwrap() .iter() @@ -930,7 +930,7 @@ async fn legacy_dotenv_workon_install_is_checked_before_vex() { "forward={forward_reference}: {json}" ); assert!( - json["redirect"]["warnings"] + json["warnings"] .as_array() .unwrap() .iter() diff --git a/crates/socket-patch-cli/tests/in_process_redirect_pnpm.rs b/crates/socket-patch-cli/tests/in_process_redirect_pnpm.rs index 5fba7db1e..f7c136dee 100644 --- a/crates/socket-patch-cli/tests/in_process_redirect_pnpm.rs +++ b/crates/socket-patch-cli/tests/in_process_redirect_pnpm.rs @@ -1150,7 +1150,8 @@ specifiers: assert_eq!(code, Some(0), "fail-closed diagnostics still exit 0: {doc}"); assert_eq!( - doc["redirect"]["redirected"], 1, + hosted_pin_count(&doc), + 1, "legacy package must redirect: {doc}" ); assert!(!doc.to_string().contains("redirect_npm_no_lockfile")); @@ -1240,7 +1241,7 @@ snapshots: let (code, doc) = run_hosted_json(root, &server.uri()); assert_eq!(code, Some(0), "fail-closed diagnostics still exit 0: {doc}"); - let warnings = doc["redirect"]["warnings"].as_array().unwrap(); + let warnings = doc["warnings"].as_array().unwrap(); assert!( warnings .iter() @@ -1260,7 +1261,7 @@ snapshots: !doc.to_string().contains("redirect_pnpm_entry_not_found"), "the not-locked wording must be gone for a vendored dep: {doc}" ); - assert_eq!(doc["redirect"]["redirected"], 0, "nothing redirects: {doc}"); + assert_eq!(hosted_pin_count(&doc), 0, "nothing redirects: {doc}"); assert_eq!( std::fs::read_to_string(root.join("pnpm-lock.yaml")).unwrap(), lock, @@ -1288,10 +1289,11 @@ async fn hosted_partial_pnpm_redirect_is_not_confirmed_by_url_presence() { let (code, doc) = run_hosted_json(tmp.path(), &server.uri()); assert_eq!(code, Some(0), "{doc}"); assert_eq!( - doc["redirect"]["redirected"], 0, + hosted_pin_count(&doc), + 0, "incomplete package must not be confirmed: {doc}" ); - assert!(doc["redirect"]["warnings"] + assert!(doc["warnings"] .as_array() .unwrap() .iter() @@ -1444,7 +1446,7 @@ async fn hosted_scan_from_pnpm_workspace_member_refuses() { // From the workspace root the same patch is pinned. let (code, doc) = run_hosted_json(tmp.path(), &server.uri()); assert_eq!(code, Some(0), "{doc}"); - assert_eq!(doc["redirect"]["redirected"], 1, "{doc}"); + assert_eq!(hosted_pin_count(&doc), 1, "{doc}"); assert!(std::fs::read_to_string(&lock).unwrap().contains(HOSTED_URL)); } @@ -1550,7 +1552,7 @@ fn member_root(tmp: &tempfile::TempDir) -> std::path::PathBuf { /// Windows `\`, so a path would never match. fn warning_texts(doc: &serde_json::Value) -> String { let mut out = String::new(); - for warning in doc["redirect"]["warnings"].as_array().into_iter().flatten() { + for warning in doc["warnings"].as_array().into_iter().flatten() { for value in warning.as_object().into_iter().flat_map(|o| o.values()) { if let Some(text) = value.as_str() { out.push_str(text); @@ -1620,7 +1622,7 @@ async fn hosted_scan_from_pnpm_member_with_own_lock_never_nests_trust_config() { std::fs::write(&root_ws, &ws_trusted).unwrap(); let (code, doc) = run_hosted_json(&member, &server.uri()); assert_eq!(code, Some(0), "{doc}"); - assert_eq!(doc["redirect"]["redirected"], 1, "{doc}"); + assert_eq!(hosted_pin_count(&doc), 1, "{doc}"); assert!(std::fs::read_to_string(&lock).unwrap().contains(HOSTED_URL)); assert!( !member.join("pnpm-workspace.yaml").exists(), @@ -1663,7 +1665,7 @@ async fn hosted_scan_from_pnpm_project_outside_workspace_globs_pins_and_nests_tr let (code, doc) = run_hosted_json(&demo, &server.uri()); assert_eq!(code, Some(0), "{doc}"); - assert_eq!(doc["redirect"]["redirected"], 1, "{doc}"); + assert_eq!(hosted_pin_count(&doc), 1, "{doc}"); let lock = std::fs::read_to_string(demo.join("pnpm-lock.yaml")).unwrap(); assert!(lock.contains(HOSTED_URL), "{lock}"); let nested = std::fs::read_to_string(demo.join("pnpm-workspace.yaml")).unwrap(); @@ -1692,7 +1694,7 @@ async fn hosted_scan_from_pnpm_member_respects_root_trust_opt_out() { let (code, doc) = run_hosted_json(&member, &server.uri()); assert_eq!(code, Some(0), "{doc}"); - assert_eq!(doc["redirect"]["redirected"], 1, "{doc}"); + assert_eq!(hosted_pin_count(&doc), 1, "{doc}"); assert!(!member.join("pnpm-workspace.yaml").exists()); assert_eq!(std::fs::read_to_string(&root_ws).unwrap(), ws); let warnings = warning_texts(&doc); @@ -2190,7 +2192,7 @@ async fn hosted_scan_pins_every_member_lock_with_shared_workspace_lockfile_false let (code, doc) = run_hosted_json(root, &server.uri()); assert_eq!(code, Some(0), "{doc}"); assert_eq!(doc["status"], "success", "{doc}"); - assert_eq!(doc["redirect"]["redirected"], 1, "{doc}"); + assert_eq!(hosted_pin_count(&doc), 1, "{doc}"); for lock in &locks { let text = std::fs::read_to_string(lock).unwrap(); assert!( @@ -2324,13 +2326,13 @@ async fn hosted_scan_pins_pnpm7_member_locks_without_a_root_lock() { ); let (code, doc) = run_hosted_json(root, &server.uri()); assert_eq!(code, Some(0), "{doc}"); - assert_eq!(doc["redirect"]["redirected"], 1, "{doc}"); + assert_eq!(hosted_pin_count(&doc), 1, "{doc}"); for member in ["a", "b"] { let text = std::fs::read_to_string(root.join(format!("packages/{member}/pnpm-lock.yaml"))) .unwrap(); assert!(text.contains(HOSTED_URL), "{member}:\n{text}"); } - let codes: Vec<&str> = doc["redirect"]["warnings"] + let codes: Vec<&str> = doc["warnings"] .as_array() .into_iter() .flatten() @@ -2395,13 +2397,13 @@ async fn hosted_scan_refuses_a_git_branch_lockfile_project() { let (code, doc) = run_hosted_json(root, &server.uri()); assert_eq!(code, Some(0), "{case}: {doc}"); - assert_eq!(doc["redirect"]["redirected"], 0, "{case}: {doc}"); + assert_eq!(hosted_pin_count(&doc), 0, "{case}: {doc}"); assert_eq!( doc["redirect"]["rewrittenFiles"], serde_json::json!([]), "{case}" ); - let codes: Vec<&str> = doc["redirect"]["warnings"] + let codes: Vec<&str> = doc["warnings"] .as_array() .into_iter() .flatten() @@ -2622,3 +2624,18 @@ async fn hosted_scan_from_bun_or_vlt_member_with_stray_lock_refuses() { } } } + +/// How many hosted pins the run wrote (would write, on a dry run): the +/// envelope's `applied` / `verified` events tagged `details.mode: "hosted"` +/// (v5.0: replaces `redirect.redirected`). +fn hosted_pin_count(doc: &serde_json::Value) -> u64 { + doc["events"] + .as_array() + .unwrap_or_else(|| panic!("no events: {doc:#}")) + .iter() + .filter(|e| { + e["details"]["mode"] == "hosted" + && (e["action"] == "applied" || e["action"] == "verified") + }) + .count() as u64 +} diff --git a/crates/socket-patch-cli/tests/in_process_redirect_poetry.rs b/crates/socket-patch-cli/tests/in_process_redirect_poetry.rs index b37795ca1..952b8456e 100644 --- a/crates/socket-patch-cli/tests/in_process_redirect_poetry.rs +++ b/crates/socket-patch-cli/tests/in_process_redirect_poetry.rs @@ -429,10 +429,11 @@ fn envelope(out: &std::process::Output) -> serde_json::Value { } fn stale_warning(value: &serde_json::Value) -> bool { - value["redirect"]["warnings"] + // Top-level `warnings` is omitted when empty. + value["warnings"] .as_array() - .unwrap() - .iter() + .into_iter() + .flatten() .any(|warning| warning["code"] == "redirect_pypi_stale_install") } @@ -449,7 +450,7 @@ async fn stale_python_install_warns_and_cannot_attest_even_on_rescan() { let out = scan_output(tmp.path(), &server, &["--json"]).await; let json = envelope(&out); assert!(out.status.success(), "{json}"); - assert_eq!(json["redirect"]["redirected"], 1, "{json}"); + assert_eq!(hosted_pin_count(&json), 1, "{json}"); assert!(stale_warning(&json), "{json}"); let redirected = read(&tmp.path().join("poetry.lock")); let vex = tmp.path().join("out.vex.json"); @@ -706,3 +707,18 @@ fn manifestless_vex_after_hosted_redirect() { assert_eq!(out.code, Some(0), "{out}"); drop(server); } + +/// How many hosted pins the run wrote (would write, on a dry run): the +/// envelope's `applied` / `verified` events tagged `details.mode: "hosted"` +/// (v5.0: replaces `redirect.redirected`). +fn hosted_pin_count(doc: &serde_json::Value) -> u64 { + doc["events"] + .as_array() + .unwrap_or_else(|| panic!("no events: {doc:#}")) + .iter() + .filter(|e| { + e["details"]["mode"] == "hosted" + && (e["action"] == "applied" || e["action"] == "verified") + }) + .count() as u64 +} diff --git a/crates/socket-patch-cli/tests/in_process_rollback_hosted.rs b/crates/socket-patch-cli/tests/in_process_rollback_hosted.rs index 702258197..6d1ce8c70 100644 --- a/crates/socket-patch-cli/tests/in_process_rollback_hosted.rs +++ b/crates/socket-patch-cli/tests/in_process_rollback_hosted.rs @@ -27,6 +27,9 @@ //! `#[serial]`: every command's `run` mirrors env toggles into //! process-global env vars (`apply_env_toggles`). +#[path = "common/rollback_json.rs"] +mod rollback_json; + use std::collections::HashMap; use std::path::Path; @@ -723,7 +726,7 @@ async fn npm_hosted_round_trip_envelope() { assert_eq!(code, 0, "bare rollback should exit 0: {envelope}"); assert_eq!(envelope["status"], "success", "{envelope}"); assert_eq!( - envelope["hosted"]["reverted"], + rollback_json::hosted_reverted(&envelope), serde_json::json!([PURL]), "the unwound purl must be reported: {envelope}" ); @@ -731,10 +734,12 @@ async fn npm_hosted_round_trip_envelope() { envelope["hosted"]["editedFiles"].as_u64().unwrap_or(0) >= 1, "at least the lockfile was rewritten: {envelope}" ); - assert_eq!(envelope["hosted"]["failed"], serde_json::json!([])); - assert_eq!(envelope["hosted"]["unsupported"], serde_json::json!([])); assert_eq!( - envelope["manifest"]["removedEntries"], + rollback_json::hosted_failed(&envelope), + serde_json::json!([]) + ); + assert_eq!( + rollback_json::manifest_removed(&envelope), serde_json::json!([]), "hosted state lives in the lockfile, not the manifest: {envelope}" ); @@ -992,11 +997,14 @@ async fn scoped_rollback_restores_only_the_named_pin() { assert_eq!(code, 0, "{envelope}"); assert_eq!(envelope["status"], "success", "{envelope}"); assert_eq!( - envelope["hosted"]["reverted"], + rollback_json::hosted_reverted(&envelope), serde_json::json!([LP_PURL]), "only the named pin is restored: {envelope}" ); - assert_eq!(envelope["hosted"]["failed"], serde_json::json!([])); + assert_eq!( + rollback_json::hosted_failed(&envelope), + serde_json::json!([]) + ); assert_eq!( std::fs::read_to_string(tmp.path().join("yarn.lock")).unwrap(), yarn_lock_content(&format!( @@ -1068,7 +1076,7 @@ async fn legacy_ledger_edits_of_any_kind_are_never_replayed() { let (code, envelope) = run_rollback_subprocess_online(tmp.path(), &server, targets); assert_eq!(code, 0, "scoped={scoped}: {envelope}"); assert_eq!( - envelope["hosted"]["reverted"], + rollback_json::hosted_reverted(&envelope), serde_json::json!([LP_PURL]), "scoped={scoped}: {envelope}" ); @@ -1118,10 +1126,14 @@ async fn a_refused_pin_fails_closed_beside_a_restored_one() { // counters span the hosted leg (#1066). assert_eq!(envelope["status"], "error", "{envelope}"); assert_eq!(envelope["error"]["code"], "rollback_failed", "{envelope}"); - assert_eq!(envelope["failed"], 2, "{envelope}"); - assert_eq!(envelope["rolledBack"], 0, "{envelope}"); - assert_eq!(envelope["hosted"]["reverted"], serde_json::json!([])); - let failed: Vec<&str> = envelope["hosted"]["failed"] + assert_eq!(envelope["summary"]["failed"], 2, "{envelope}"); + assert_eq!(envelope["summary"]["rolledBack"], 0, "{envelope}"); + assert_eq!( + rollback_json::hosted_reverted(&envelope), + serde_json::json!([]) + ); + let failed_view = rollback_json::hosted_failed(&envelope); + let failed: Vec<&str> = failed_view .as_array() .unwrap() .iter() @@ -1129,7 +1141,7 @@ async fn a_refused_pin_fails_closed_beside_a_restored_one() { .collect(); assert_eq!(failed, [IO_PURL, LP_PURL], "{envelope}"); assert!( - envelope["hosted"]["failed"][0]["error"] + rollback_json::hosted_failed(&envelope)[0]["error"] .as_str() .is_some_and(|e| e.contains("this run is offline") && e.contains( @@ -1146,16 +1158,19 @@ async fn a_refused_pin_fails_closed_beside_a_restored_one() { // Online, left-pad unanswered (404): is-odd restores on its own. let (code, envelope) = run_rollback_subprocess_online(tmp.path(), &server, &[]); assert_eq!(code, 1, "{envelope}"); - assert_eq!(envelope["status"], "partial_failure", "{envelope}"); + assert_eq!(envelope["status"], "partialFailure", "{envelope}"); // The top-level counters span the hosted leg (#1066): they were 0/0. - assert_eq!(envelope["rolledBack"], 1, "{envelope}"); - assert_eq!(envelope["failed"], 1, "{envelope}"); - assert_eq!(envelope["hosted"]["reverted"], serde_json::json!([IO_PURL])); + assert_eq!(envelope["summary"]["rolledBack"], 1, "{envelope}"); + assert_eq!(envelope["summary"]["failed"], 1, "{envelope}"); + assert_eq!( + rollback_json::hosted_reverted(&envelope), + serde_json::json!([IO_PURL]) + ); assert_eq!( - envelope["hosted"]["failed"][0]["purl"], LP_PURL, + rollback_json::hosted_failed(&envelope)[0]["purl"], + LP_PURL, "{envelope}" ); - assert_eq!(envelope["hosted"]["unsupported"], serde_json::json!([])); assert_eq!( std::fs::read_to_string(tmp.path().join("yarn.lock")).unwrap(), yarn_lock_content(&format!( @@ -1192,17 +1207,22 @@ async fn a_git_pattern_hosted_pin_is_refused_not_restored_to_the_registry() { let (code, envelope) = run_rollback_subprocess_online(tmp.path(), &server, &[]); assert_eq!(code, 1, "{envelope}"); - assert_eq!(envelope["status"], "partial_failure", "{envelope}"); - assert_eq!(envelope["hosted"]["reverted"], serde_json::json!([IO_PURL])); + assert_eq!(envelope["status"], "partialFailure", "{envelope}"); + assert_eq!( + rollback_json::hosted_reverted(&envelope), + serde_json::json!([IO_PURL]) + ); // Discovery already refuses to attribute the git-wired entry, so the // pin fails closed as contested wiring before any restore is planned. assert_eq!( - envelope["hosted"]["failed"].as_array().map(Vec::len), + rollback_json::hosted_failed(&envelope) + .as_array() + .map(Vec::len), Some(1), "{envelope}" ); assert!( - envelope["hosted"]["failed"][0]["error"] + rollback_json::hosted_failed(&envelope)[0]["error"] .as_str() .is_some_and( |e| e.contains("installs from git") && e.contains("`git checkout -- yarn.lock`") @@ -1244,10 +1264,13 @@ async fn a_non_registry_keyed_hosted_pin_is_refused_not_restored_to_the_registry let (code, envelope) = run_rollback_subprocess_online(tmp.path(), &server, &[]); assert_eq!(code, 1, "{envelope}"); - assert_eq!(envelope["status"], "partial_failure", "{envelope}"); - assert_eq!(envelope["hosted"]["reverted"], serde_json::json!([IO_PURL])); + assert_eq!(envelope["status"], "partialFailure", "{envelope}"); + assert_eq!( + rollback_json::hosted_reverted(&envelope), + serde_json::json!([IO_PURL]) + ); assert!( - envelope["hosted"]["failed"][0]["error"] + rollback_json::hosted_failed(&envelope)[0]["error"] .as_str() .is_some_and(|e| e.contains("non-registry source")), "{envelope}" @@ -1346,7 +1369,7 @@ async fn preserve_state_still_unwinds_hosted() { ); assert_eq!(envelope["status"], "success", "{envelope}"); assert_eq!( - envelope["hosted"]["reverted"], + rollback_json::hosted_reverted(&envelope), serde_json::json!([LP_PURL]), "the pin must still be restored under --preserve-state: {envelope}" ); @@ -1355,11 +1378,12 @@ async fn preserve_state_still_unwinds_hosted() { "restoring hosted pins under --preserve-state must be surfaced: {envelope}" ); assert_eq!( - envelope["manifest"]["preserved"], true, + rollback_json::manifest_removed(&envelope), + serde_json::json!([]), "manifest cleanup must be skipped: {envelope}" ); - assert_eq!( - envelope["gc"]["skipped"], true, + assert!( + envelope.get("gc").is_none(), "GC must be skipped under --preserve-state: {envelope}" ); @@ -1502,7 +1526,7 @@ async fn yarn_classic_hosted_pin_beside_a_git_copy_rolls_back() { let (code, envelope) = run_rollback_subprocess_online(tmp.path(), &server, &[]); assert_eq!(code, 0, "rollback must restore the pin: {envelope}"); assert_eq!( - envelope["hosted"]["reverted"], + rollback_json::hosted_reverted(&envelope), serde_json::json!([LP_PURL]), "{envelope}" ); diff --git a/crates/socket-patch-cli/tests/in_process_rollback_hosted/vlt.rs b/crates/socket-patch-cli/tests/in_process_rollback_hosted/vlt.rs index cc0067b93..89a96a7e3 100644 --- a/crates/socket-patch-cli/tests/in_process_rollback_hosted/vlt.rs +++ b/crates/socket-patch-cli/tests/in_process_rollback_hosted/vlt.rs @@ -112,7 +112,7 @@ async fn vlt_hosted_round_trip() { assert_eq!(read(root, "vlt-lock.json"), pristine, "scoped={scoped}"); assert_eq!( - doc["hosted"]["reverted"], + crate::rollback_json::hosted_reverted(&doc), serde_json::json!([PURL]), "{doc:#}" ); @@ -458,7 +458,7 @@ async fn vlt_scoped_rollback_of_one_of_two_heals_only_that_package() { vlt_lock(Era::V1, &[restored_node(TILDE_ID), other_pinned]) ); assert_eq!( - doc["hosted"]["reverted"], + crate::rollback_json::hosted_reverted(&doc), serde_json::json!([PURL]), "{doc:#}" ); @@ -550,13 +550,17 @@ async fn vlt_heal_follows_the_restored_pin_when_another_pin_refuses() { let (code, doc, _) = run_verb_raw(root, &server, "rollback", &[]); assert_ne!(code, 0, "the refused right-pad pin fails the run"); - assert_eq!(doc["status"], "partial_failure", "{doc:#}"); + assert_eq!(doc["status"], "partialFailure", "{doc:#}"); assert_eq!( - doc["hosted"]["reverted"], + crate::rollback_json::hosted_reverted(&doc), serde_json::json!([PURL]), "{doc:#}" ); - assert_eq!(doc["hosted"]["failed"][0]["purl"], OTHER_PURL, "{doc:#}"); + assert_eq!( + crate::rollback_json::hosted_failed(&doc)[0]["purl"], + OTHER_PURL, + "{doc:#}" + ); assert_eq!( read(root, "vlt-lock.json"), vlt_lock(Era::V1, &[restored_node(TILDE_ID), other_pinned]), diff --git a/crates/socket-patch-cli/tests/in_process_rollback_vendored.rs b/crates/socket-patch-cli/tests/in_process_rollback_vendored.rs index 42715a561..dae29d8d1 100644 --- a/crates/socket-patch-cli/tests/in_process_rollback_vendored.rs +++ b/crates/socket-patch-cli/tests/in_process_rollback_vendored.rs @@ -24,6 +24,9 @@ //! `--offline` INTO the env, which every test here wants anyway), so none //! need `#[serial]` — each runs in its own tempdir. +#[path = "common/rollback_json.rs"] +mod rollback_json; + #[path = "prebuilt_common/mod.rs"] mod prebuilt_common; @@ -356,15 +359,14 @@ async fn preserve_state_unwires_but_keeps_artifact_and_ledger() { assert_eq!(code, 0, "preserve rollback exits 0: {env:#}"); assert_eq!(env["status"], "success", "{env:#}"); assert_eq!( - env["vendoredPreserved"], + rollback_json::vendored_preserved(&env), json!([PURL]), "the unwired-but-kept purl rides vendoredPreserved: {env:#}" ); - assert_eq!(env["vendoredReverted"], json!([]), "{env:#}"); - assert_eq!(env["vendoredKept"], json!([]), "{env:#}"); - assert_eq!(env["manifest"]["preserved"], json!(true), "{env:#}"); - assert_eq!(env["manifest"]["removedEntries"], json!([]), "{env:#}"); - assert_eq!(env["gc"], json!({ "skipped": true }), "{env:#}"); + assert_eq!(rollback_json::vendored_reverted(&env), json!([]), "{env:#}"); + assert_eq!(rollback_json::vendored_kept(&env), json!([]), "{env:#}"); + assert_eq!(rollback_json::manifest_removed(&env), json!([]), "{env:#}"); + assert!(env.get("gc").is_none(), "{env:#}"); assert_eq!(fx2.lock_bytes(), fx2.original_lock, "lock restored"); assert!(fx2.tgz_path().is_file(), "artifact kept"); assert!( @@ -538,8 +540,9 @@ async fn drift_keep_exits_partial_failure_and_holds_manifest() { // in the top-level `failed` (#1066). assert_eq!(env["status"], "error", "{env:#}"); assert_eq!(env["error"]["code"], "rollback_failed", "{env:#}"); - assert_eq!(env["failed"], 1, "{env:#}"); - let kept = env["vendoredKept"].as_array().expect("vendoredKept array"); + assert_eq!(env["summary"]["failed"], 1, "{env:#}"); + let kept_view = rollback_json::vendored_kept(&env); + let kept = kept_view.as_array().expect("vendoredKept array"); assert_eq!(kept.len(), 1, "{env:#}"); assert_eq!(kept[0]["purl"], DRIFT_PURL, "{env:#}"); assert!( @@ -548,9 +551,9 @@ async fn drift_keep_exits_partial_failure_and_holds_manifest() { .is_some_and(|r| r.contains("drifted")), "the kept reason must name the drift: {env:#}" ); - assert_eq!(env["vendoredReverted"], json!([]), "{env:#}"); + assert_eq!(rollback_json::vendored_reverted(&env), json!([]), "{env:#}"); assert_eq!( - env["manifest"]["removedEntries"], + rollback_json::manifest_removed(&env), json!([]), "a drift-kept purl's manifest entry is never removed: {env:#}" ); @@ -603,14 +606,18 @@ async fn detached_entries_reverted_by_unscoped_default() { assert_eq!(code, 0, "detached revert exits 0: {env:#}"); assert_eq!(env["status"], "success", "{env:#}"); assert_eq!( - env["vendoredReverted"], + rollback_json::vendored_reverted(&env), json!([PURL]), "the detached entry must ride vendoredReverted: {env:#}" ); - assert_eq!(env["vendoredPreserved"], json!([]), "{env:#}"); - assert_eq!(env["vendoredKept"], json!([]), "{env:#}"); assert_eq!( - env["manifest"]["removedEntries"], + rollback_json::vendored_preserved(&env), + json!([]), + "{env:#}" + ); + assert_eq!(rollback_json::vendored_kept(&env), json!([]), "{env:#}"); + assert_eq!( + rollback_json::manifest_removed(&env), json!([]), "detached entries have no manifest record to remove: {env:#}" ); diff --git a/crates/socket-patch-cli/tests/in_process_vendor.rs b/crates/socket-patch-cli/tests/in_process_vendor.rs index f3f5a6838..b48e2d855 100644 --- a/crates/socket-patch-cli/tests/in_process_vendor.rs +++ b/crates/socket-patch-cli/tests/in_process_vendor.rs @@ -19,6 +19,9 @@ //! No test mutates this process's environment, so none of them need //! `#[serial]` — each runs in its own tempdir. +#[path = "common/rollback_json.rs"] +mod rollback_json; + #[path = "prebuilt_common/mod.rs"] mod prebuilt_common; @@ -298,6 +301,29 @@ fn events(envelope: &Value) -> &Vec { /// The single event matching `action` (+ optional `errorCode`), or panic /// with the envelope. +/// How many hosted pins the run wrote (`applied`) or, on a dry run, would +/// write (`verified`): the `details.mode: "hosted"` events (v5.0's +/// `redirect.redirected`). +fn hosted_pin_count(envelope: &Value) -> usize { + events(envelope) + .iter() + .filter(|e| { + e["details"]["mode"] == "hosted" + && (e["action"] == "applied" || e["action"] == "verified") + }) + .count() +} + +/// The hosted `skipped` events as `{purl, reason}` rows (v5.0's +/// `redirect.skipped[]`: the reason is the event's `errorCode`). +fn hosted_skips(envelope: &Value) -> Vec { + events(envelope) + .iter() + .filter(|e| e["details"]["mode"] == "hosted" && e["action"] == "skipped") + .map(|e| json!({"purl": e["purl"], "reason": e["errorCode"]})) + .collect() +} + fn find_event<'a>(envelope: &'a Value, action: &str, error_code: Option<&str>) -> &'a Value { events(envelope) .iter() @@ -1682,7 +1708,7 @@ async fn berry_crlf_takeovers_round_trip_both_directions() { stage_berry_project(root, &pkg, &lock); let (code, env) = hosted_scan_cli(root, &server.uri()); assert_eq!(code, 0, "hosted scan: {env:#}"); - assert_eq!(env["redirect"]["redirected"], 1, "{env:#}"); + assert_eq!(hosted_pin_count(&env), 1, "{env:#}"); let hosted_lock = std::fs::read_to_string(root.join("yarn.lock")).unwrap(); assert!(hosted_lock.contains(&encoded), "{hosted_lock:?}"); assert_crlf(root, "hosted"); @@ -1741,7 +1767,7 @@ async fn berry_crlf_takeovers_round_trip_both_directions() { .await; let (code, env) = hosted_scan_cli(root, &server.uri()); assert_eq!(code, 0, "hosted scan over the vendored pair: {env:#}"); - assert_eq!(env["redirect"]["redirected"], 1, "{env:#}"); + assert_eq!(hosted_pin_count(&env), 1, "{env:#}"); assert!( env.to_string() .contains("redirect_takeover_reverted_vendored"), @@ -1945,11 +1971,8 @@ async fn berry_takeovers_refuse_before_reverting_the_old_mode() { "{ctx}: no takeover ({announced}): {env:#}" ); } - assert_eq!(env["redirect"]["redirected"], 0, "{ctx}: {env:#}"); - let skipped = env["redirect"]["skipped"] - .as_array() - .cloned() - .unwrap_or_default(); + assert_eq!(hosted_pin_count(&env), 0, "{ctx}: {env:#}"); + let skipped = hosted_skips(&env); assert!( skipped .iter() @@ -2004,7 +2027,7 @@ async fn berry_takeovers_refuse_before_reverting_the_old_mode() { stage_berry_project(root, &pkg, &lock); let (exit, env) = hosted_scan_cli_with(root, &server.uri(), &[]); assert_eq!(exit, 0, "{ctx}: hosted scan: {env:#}"); - assert_eq!(env["redirect"]["redirected"], 1, "{ctx}: {env:#}"); + assert_eq!(hosted_pin_count(&env), 1, "{ctx}: {env:#}"); breakage(root, rel); let before = berry_wiring_snapshot(root); let extra: &[&str] = if dry { &["--dry-run"] } else { &[] }; @@ -2077,7 +2100,7 @@ async fn classic_vendored_to_hosted_takeover_refuses_with_offline_mirror() { "{ctx}: no takeover ({announced}): {env:#}" ); } - assert_eq!(env["redirect"]["redirected"], 0, "{ctx}: {env:#}"); + assert_eq!(hosted_pin_count(&env), 0, "{ctx}: {env:#}"); assert_eq!( snapshot(), before, @@ -2125,11 +2148,8 @@ async fn berry_vendored_to_hosted_takeover_keeps_vendored_without_berry_checksum "{ctx}: no takeover ({announced}): {env:#}" ); } - assert_eq!(env["redirect"]["redirected"], 0, "{ctx}: {env:#}"); - let skipped = env["redirect"]["skipped"] - .as_array() - .cloned() - .unwrap_or_default(); + assert_eq!(hosted_pin_count(&env), 0, "{ctx}: {env:#}"); + let skipped = hosted_skips(&env); assert!( skipped .iter() @@ -2204,7 +2224,7 @@ async fn berry_hosted_to_vendored_takeover_runs_package_gates_first() { stage_berry_project(root, BERRY_WIN_PKG, &berry_win_lock()); let (exit, env) = hosted_scan_cli_with(root, &server.uri(), &[]); assert_eq!(exit, 0, "{ctx}: hosted scan: {env:#}"); - assert_eq!(env["redirect"]["redirected"], 1, "{ctx}: {env:#}"); + assert_eq!(hosted_pin_count(&env), 1, "{ctx}: {env:#}"); breakage(root); let before = berry_wiring_snapshot(root); // The hosted pin's origin must count as the patch server, or @@ -2362,8 +2382,8 @@ async fn vendored_npm_purl_skipped_even_without_installed_tree() { // ───────────────────────────────────────────────────────────────────── /// `rollback` reverts vendor-owned purls (lock restored byte-for-byte, -/// artifact + ledger entry gone, manifest entry removed), surfaced in -/// `vendoredReverted`; both the unscoped and identifier-scoped spellings +/// artifact + ledger entry gone, manifest entry removed), surfaced as a +/// `rolledBack` event with `details.mode: "vendored"`; both the unscoped and identifier-scoped spellings /// act. #[tokio::test] async fn vendored_purl_excluded_from_rollback() { @@ -2404,18 +2424,18 @@ async fn vendored_purl_excluded_from_rollback() { assert_eq!(code, 0, "vendored rollback exits 0: {out:#}"); assert_eq!(out["status"], "success", "{out:#}"); assert_eq!( - out["vendored"], + rollback_json::skipped_with(&out, "vendored"), json!([]), "no benign skip remains — the vendored leg acted: {out:#}" ); assert_eq!( - out["vendoredReverted"], + rollback_json::vendored_reverted(&out), json!([PURL]), "the revert must be surfaced: {out:#}" ); - assert_eq!(out["failed"], 0, "{out:#}"); + assert_eq!(out["summary"]["failed"], 0, "{out:#}"); assert_eq!( - out["manifest"]["removedEntries"], + rollback_json::manifest_removed(&out), json!([PURL]), "the manifest entry leaves with the vendored state: {out:#}" ); @@ -2472,6 +2492,7 @@ async fn remove_reverts_vendoring() { assert_eq!(env["status"], "success"); let reverted = find_event(&env, "removed", Some("vendor_reverted")); assert_eq!(reverted["purl"], PURL); + assert_eq!(reverted["details"]["mode"], "vendored", "{env:#}"); assert_eq!( env["summary"]["removed"], 1, "summary.removed counts manifest entries only: {env:#}" @@ -2575,6 +2596,7 @@ async fn remove_detached_only_purl_reverts() { assert_eq!(env["status"], "success"); let reverted = find_event(&env, "removed", Some("vendor_reverted")); assert_eq!(reverted["purl"], PURL); + assert_eq!(reverted["details"]["mode"], "vendored", "{env:#}"); assert_eq!(env["summary"]["removed"], 1, "{env:#}"); assert_eq!(fx.lock_bytes(), fx.original_lock, "lock restored"); @@ -3360,9 +3382,9 @@ async fn scan_vendor_gem_end_to_end_and_reverts() { let (code, env) = run_scan_vendor(fx.root(), &mock.uri(), &[]); assert_eq!(code, 0, "scan --mode vendored must succeed: {env:#}"); assert_eq!(env["status"], "success", "envelope: {env:#}"); - assert_eq!(env["download"]["downloaded"], 1, "envelope: {env:#}"); - assert_eq!(env["vendor"]["summary"]["applied"], 1, "envelope: {env:#}"); - assert_eq!(env["vendor"]["summary"]["failed"], 0, "envelope: {env:#}"); + assert_eq!(env["summary"]["downloaded"], 1, "envelope: {env:#}"); + assert_eq!(env["summary"]["applied"], 1, "envelope: {env:#}"); + assert_eq!(env["summary"]["failed"], 0, "envelope: {env:#}"); // Artifact: patched bytes + the stub gemspec a path source needs. assert_eq!( @@ -3415,9 +3437,9 @@ async fn scan_vendor_gem_end_to_end_and_reverts() { let lock_wired = std::fs::read(fx.lock_path()).unwrap(); let (code, env2) = run_scan_vendor(fx.root(), &mock.uri(), &[]); assert_eq!(code, 0, "re-run must succeed: {env2:#}"); - assert_eq!(env2["vendor"]["summary"]["applied"], 0, "{env2:#}"); + assert_eq!(env2["summary"]["applied"], 0, "{env2:#}"); assert!( - env2["vendor"]["events"] + env2["events"] .as_array() .unwrap() .iter() @@ -3529,14 +3551,14 @@ async fn scan_vendor_gem_qualified_platform_ruby_purl_vendors() { "scan --mode vendored must succeed on the qualified purl: {env:#}" ); assert_eq!(env["status"], "success", "envelope: {env:#}"); - assert_eq!(env["download"]["downloaded"], 1, "envelope: {env:#}"); - assert_eq!(env["vendor"]["summary"]["applied"], 1, "envelope: {env:#}"); - assert_eq!(env["vendor"]["summary"]["failed"], 0, "envelope: {env:#}"); + assert_eq!(env["summary"]["downloaded"], 1, "envelope: {env:#}"); + assert_eq!(env["summary"]["applied"], 1, "envelope: {env:#}"); + assert_eq!(env["summary"]["failed"], 0, "envelope: {env:#}"); // Positive assertion — an `applied` event for the qualified purl itself // (a `.all(errorCode != platform_gem_unsupported)` check would pass // vacuously on an empty event list). assert!( - env["vendor"]["events"] + env["events"] .as_array() .unwrap() .iter() @@ -3616,7 +3638,7 @@ async fn scan_vendor_gem_detached_qualified_purl_reverts() { code, 0, "scan --mode vendored must succeed on the qualified purl: {env:#}" ); - assert_eq!(env["vendor"]["summary"]["applied"], 1, "envelope: {env:#}"); + assert_eq!(env["summary"]["applied"], 1, "envelope: {env:#}"); assert!( !fx.root().join(".socket/manifest.json").exists(), @@ -3663,7 +3685,7 @@ async fn scan_vendor_gem_detached_writes_no_manifest_and_reverts() { let (code, env) = run_scan_vendor(fx.root(), &mock.uri(), &[]); assert_eq!(code, 0, "scan --mode vendored must succeed: {env:#}"); - assert_eq!(env["vendor"]["summary"]["applied"], 1, "envelope: {env:#}"); + assert_eq!(env["summary"]["applied"], 1, "envelope: {env:#}"); assert!( !fx.root().join(".socket/manifest.json").exists(), diff --git a/crates/socket-patch-cli/tests/in_process_vendor_bun_takeover.rs b/crates/socket-patch-cli/tests/in_process_vendor_bun_takeover.rs index b02169380..700bf8ed2 100644 --- a/crates/socket-patch-cli/tests/in_process_vendor_bun_takeover.rs +++ b/crates/socket-patch-cli/tests/in_process_vendor_bun_takeover.rs @@ -45,6 +45,9 @@ //! Every child process gets the ambient `SOCKET_*` vars scrubbed and //! telemetry hard-disabled; each test runs in its own tempdir. +#[path = "common/rollback_json.rs"] +mod rollback_json; + #[path = "prebuilt_common/mod.rs"] mod prebuilt_common; @@ -536,7 +539,7 @@ async fn bun_hosted_then_scan_vendored_takeover_round_trips_to_registry() { // A: hosted redirect — registry 4-tuple → URL 3-tuple; no ledger. let (code, env) = scan_mode(root, &server.uri(), "hosted", &[]); assert_eq!(code, 0, "scan --mode hosted must succeed: {env:#}"); - assert_eq!(env["redirect"]["redirected"], 1, "{env:#}"); + assert_eq!(hosted_pin_count(&env), 1, "{env:#}"); let hosted_lock = read(root, "bun.lock"); assert_eq!( lock_line(&hosted_lock, NAME), @@ -579,7 +582,7 @@ async fn bun_hosted_then_scan_vendored_takeover_round_trips_to_registry() { "scan --mode vendored over the hosted bun project must succeed: {env:#}" ); assert_eq!(env["status"], "success", "{env:#}"); - let vendor = &env["vendor"]; + let vendor = &env; assert_eq!(vendor["summary"]["applied"], 1, "{env:#}"); assert_eq!(vendor["summary"]["failed"], 0, "{env:#}"); find_event(vendor, "skipped", Some("vendor_takeover_reverted_redirect")); @@ -606,8 +609,8 @@ async fn bun_hosted_then_scan_vendored_takeover_round_trips_to_registry() { // C: a re-run is an in-sync no-op with no second takeover. let (code, env) = scan_mode(root, &server.uri(), "vendored", &[]); assert_eq!(code, 0, "{env:#}"); - find_event(&env["vendor"], "skipped", Some("already_vendored")); - assert_no_event_code(&env["vendor"], "vendor_takeover_reverted_redirect"); + find_event(&env, "skipped", Some("already_vendored")); + assert_no_event_code(&env, "vendor_takeover_reverted_redirect"); // D: `vendor --revert` restores the REGISTRY lock byte-exactly — the // pre-redirect resolution the takeover carried forward, not the @@ -692,7 +695,7 @@ async fn bun_lockfile_only_scan_vendored_takes_over_the_hosted_pin() { env["scannedPackages"], 1, "the hosted pin must be seen: {env:#}" ); - let vendor = &env["vendor"]; + let vendor = &env; assert_eq!(vendor["summary"]["applied"], 1, "{env:#}"); assert_eq!(vendor["summary"]["failed"], 0, "{env:#}"); find_event(vendor, "skipped", Some("vendor_takeover_reverted_redirect")); @@ -735,7 +738,7 @@ fn drop_digest_in_lock(root: &Path, key: &str) -> String { } fn redirect_warning_codes(env: &Value) -> Vec { - env["redirect"]["warnings"] + env["warnings"] .as_array() .map(|arr| { arr.iter() @@ -769,7 +772,7 @@ async fn bun_digestless_hosted_line_is_taken_over_by_scan_vendored_and_reverts_t let (code, env) = scan_mode(root, &server.uri(), "vendored", &[]); assert_eq!(code, 0, "takeover over a digest-less hosted line: {env:#}"); assert_eq!(env["status"], "success", "{env:#}"); - let vendor = &env["vendor"]; + let vendor = &env; assert_eq!(vendor["summary"]["applied"], 1, "{env:#}"); assert_eq!(vendor["summary"]["failed"], 0, "{env:#}"); find_event(vendor, "skipped", Some("vendor_takeover_reverted_redirect")); @@ -806,7 +809,7 @@ async fn bun_digestless_vendored_line_is_taken_over_by_scan_hosted_and_rolls_bac let (code, env) = scan_mode(root, &server.uri(), "vendored", &[]); assert_eq!(code, 0, "{env:#}"); - assert_eq!(env["vendor"]["summary"]["applied"], 1, "{env:#}"); + assert_eq!(env["summary"]["applied"], 1, "{env:#}"); let digestless = drop_digest_in_lock(root, NAME); assert!( digestless.contains(&vendored_rel_tgz()), @@ -821,7 +824,7 @@ async fn bun_digestless_vendored_line_is_taken_over_by_scan_hosted_and_rolls_bac "takeover over a digest-less vendored line: {env:#}" ); assert_eq!(env["status"], "success", "{env:#}"); - assert_eq!(env["redirect"]["redirected"], 1, "{env:#}"); + assert_eq!(hosted_pin_count(&env), 1, "{env:#}"); let codes = redirect_warning_codes(&env); assert!( codes @@ -899,8 +902,12 @@ fn bun_scoped_rollback_and_remove_of_a_digestless_hosted_record_unwind_only_that "scoped {verb} over a digest-less hosted line must succeed: {env:#}" ); if verb == "rollback" { - assert_eq!(env["hosted"]["reverted"], json!([PURL]), "{env:#}"); - assert_eq!(env["hosted"]["failed"], json!([]), "{env:#}"); + assert_eq!( + rollback_json::hosted_reverted(&env), + json!([PURL]), + "{env:#}" + ); + assert_eq!(rollback_json::hosted_failed(&env), json!([]), "{env:#}"); } else { assert!(env["error"].is_null(), "{env:#}"); } @@ -1028,8 +1035,12 @@ fn bun_scoped_rollback_of_one_of_two_hosted_records_unwinds_only_that_purl() { ); assert_eq!(code, 0, "scoped rollback must succeed: {env:#}"); assert_eq!(env["status"], "success", "{env:#}"); - assert_eq!(env["hosted"]["reverted"], json!([PURL]), "{env:#}"); - assert_eq!(env["hosted"]["failed"], json!([]), "{env:#}"); + assert_eq!( + rollback_json::hosted_reverted(&env), + json!([PURL]), + "{env:#}" + ); + assert_eq!(rollback_json::hosted_failed(&env), json!([]), "{env:#}"); assert_only_left_pad_unwound(root, &pristine); // The last pin out: pristine lock, no ledger anywhere. @@ -1128,8 +1139,8 @@ async fn bun_hosted_refusal_preserves_vendored_v0_workspace() { for extra in [&["--dry-run"][..], &[][..]] { let (code, env) = scan_mode(root, &server.uri(), "hosted", extra); assert_eq!(code, 0, "{env:#}"); - assert_eq!(env["redirect"]["redirected"], 0, "{env:#}"); - let warnings = env["redirect"]["warnings"].as_array().unwrap(); + assert_eq!(hosted_pin_count(&env), 0, "{env:#}"); + let warnings = env["warnings"].as_array().unwrap(); assert!( warnings .iter() @@ -1473,17 +1484,13 @@ async fn bun_scan_prune_revert_advises_a_forced_reinstall() { let (code, env) = scan_mode(root, &server.uri(), "vendored", &["--prune"]); assert_eq!(code, 0, "{env:#}"); - assert_eq!( - env["gc"]["revertedVendoredEntries"], - json!([PURL]), - "{env:#}" - ); + assert_eq!(reverted_purls(&env), json!([PURL]), "{env:#}"); assert_eq!( read(root, "bun.lock"), pristine, "back to the registry line" ); - let advised = env["gc"]["warnings"].as_array().is_some_and(|ws| { + let advised = env["warnings"].as_array().is_some_and(|ws| { ws.iter().any(|w| { w["code"] == "vendor_bun_reinstall_required" && w["detail"].as_str().is_some_and(|d| { @@ -1493,3 +1500,30 @@ async fn bun_scan_prune_revert_advises_a_forced_reinstall() { }); assert!(advised, "{env:#}"); } + +/// How many hosted pins the run wrote (`applied`) or, on a dry run, would +/// write (`verified`): the `details.mode: "hosted"` events (v5.0's +/// `redirect.redirected`). +fn hosted_pin_count(envelope: &serde_json::Value) -> usize { + envelope["events"] + .as_array() + .expect("events array") + .iter() + .filter(|e| { + e["details"]["mode"] == "hosted" + && (e["action"] == "applied" || e["action"] == "verified") + }) + .count() +} + +/// The purls the prune GC reverted: its `vendor_reverted` events (v5.0's +/// `gc.revertedVendoredEntries`). +fn reverted_purls(envelope: &serde_json::Value) -> serde_json::Value { + envelope["events"] + .as_array() + .expect("events array") + .iter() + .filter(|e| e["errorCode"] == "vendor_reverted") + .map(|e| e["purl"].clone()) + .collect() +} diff --git a/crates/socket-patch-cli/tests/in_process_vendor_bun_takeover/vlt.rs b/crates/socket-patch-cli/tests/in_process_vendor_bun_takeover/vlt.rs index cfe446022..89c2faf20 100644 --- a/crates/socket-patch-cli/tests/in_process_vendor_bun_takeover/vlt.rs +++ b/crates/socket-patch-cli/tests/in_process_vendor_bun_takeover/vlt.rs @@ -452,10 +452,13 @@ async fn vlt_vendored_preflight_refuses_before_the_hosted_revert() { assert_intact("scan --mode vendored"); let (code, env, _) = scan(root, &server, "vendored", &["--dry-run"]); assert_eq!(code, 0, "[scan --dry-run] {env:#}"); - let preview = env.to_string(); assert!( - preview.contains("would_refuse") && preview.contains("vendor_lock_entry_unsupported"), - "[scan --dry-run] {env:#}" + env["events"].as_array().is_some_and(|evs| { + evs.iter().any(|e| { + e["action"] == "skipped" && e["errorCode"] == "vendor_lock_entry_unsupported" + }) + }), + "[scan --dry-run] the preview names the refusal: {env:#}" ); assert_intact("scan --mode vendored --dry-run"); } diff --git a/crates/socket-patch-cli/tests/in_process_vendor_pnpm_parent_child.rs b/crates/socket-patch-cli/tests/in_process_vendor_pnpm_parent_child.rs index 5b1f342c8..6f7f3de17 100644 --- a/crates/socket-patch-cli/tests/in_process_vendor_pnpm_parent_child.rs +++ b/crates/socket-patch-cli/tests/in_process_vendor_pnpm_parent_child.rs @@ -444,7 +444,7 @@ async fn hosted_takeover_migrates_parent_and_child() { assert!(!text.contains(code), "{code}: {env:#}"); } assert_clean_unwind(&env); - assert_eq!(env["redirect"]["redirected"], 2, "{env:#}"); + assert_eq!(hosted_pin_count(&env), 2, "{env:#}"); let lock = read_lock(root); assert!( !lock.contains(".socket/vendor/"), @@ -455,3 +455,18 @@ async fn hosted_takeover_migrates_parent_and_child() { } assert!(vendored_uuids(root).is_empty()); } + +/// How many hosted pins the run wrote (`applied`) or, on a dry run, would +/// write (`verified`): the `details.mode: "hosted"` events (v5.0's +/// `redirect.redirected`). +fn hosted_pin_count(envelope: &serde_json::Value) -> usize { + envelope["events"] + .as_array() + .expect("events array") + .iter() + .filter(|e| { + e["details"]["mode"] == "hosted" + && (e["action"] == "applied" || e["action"] == "verified") + }) + .count() +} diff --git a/crates/socket-patch-cli/tests/in_process_vendor_pnpm_takeover.rs b/crates/socket-patch-cli/tests/in_process_vendor_pnpm_takeover.rs index 3b656f4ae..d8b9c172a 100644 --- a/crates/socket-patch-cli/tests/in_process_vendor_pnpm_takeover.rs +++ b/crates/socket-patch-cli/tests/in_process_vendor_pnpm_takeover.rs @@ -277,14 +277,10 @@ fn run_mode(cwd: &Path, api: &str, command: &str, mode: &str, extra: &[&str]) -> run_json(cwd, api, &args) } -/// The vendor events: top-level for `vendor`, under `vendor` for the -/// `scan`/`get` envelopes that embed the vendor step. +/// The vendor events: `vendor`'s, and (v5.0) the `scan`/`get` envelopes' +/// own, which the vendor step's events join. fn events(envelope: &Value) -> Vec { - envelope["events"] - .as_array() - .or_else(|| envelope["vendor"]["events"].as_array()) - .cloned() - .unwrap_or_default() + envelope["events"].as_array().cloned().unwrap_or_default() } fn has_event_code(envelope: &Value, code: &str) -> bool { @@ -357,12 +353,12 @@ fn assert_refused(env: &Value, exit: i32, code: &str) { ); } -/// The `scan` / `get --mode vendored --dry-run` preview row for `PURL`. +/// The `scan` / `get --mode vendored --dry-run` preview event for `PURL` +/// (v5.0: `verified` to vendor, `skipped` + the refusal code to refuse). fn preview_row(envelope: &Value) -> Value { - envelope["vendor"]["patches"] - .as_array() - .and_then(|rows| rows.iter().find(|r| r["purl"] == PURL)) - .cloned() + events(envelope) + .into_iter() + .find(|r| r["purl"] == PURL && r["details"]["mode"] == "vendored") .unwrap_or_else(|| panic!("the dry run must preview {PURL}: {envelope:#}")) } @@ -377,15 +373,12 @@ async fn refused_takeover_keeps_hosted_pin(shape: Shape, command: &str, code: &s // (status and exit code unchanged) instead of promising the takeover. let (exit, env) = run_mode(root, &server.uri(), command, "vendored", &["--dry-run"]); assert_still_hosted(root, &hosted, &env); - assert_eq!( - exit, 0, - "a would_refuse preview does not fail the run: {env:#}" - ); + assert_eq!(exit, 0, "a refusal preview does not fail the run: {env:#}"); let row = preview_row(&env); - assert_eq!(row["action"], "would_refuse", "{env:#}"); + assert_eq!(row["action"], "skipped", "{env:#}"); assert_eq!(row["errorCode"], code, "{env:#}"); assert!( - row["error"].as_str().is_some_and(|e| !e.is_empty()), + row["reason"].as_str().is_some_and(|e| !e.is_empty()), "the preview carries the backend's detail: {env:#}" ); @@ -513,7 +506,7 @@ async fn scan_vendored_over_hosted_pnpm_plain_dep_still_takes_over() { // over-refused by the preview's takeover gates. let (exit, env) = run_mode(root, &server.uri(), "scan", "vendored", &["--dry-run"]); assert_eq!(exit, 0, "{env:#}"); - assert_eq!(preview_row(&env)["action"], "would_vendor", "{env:#}"); + assert_eq!(preview_row(&env)["action"], "verified", "{env:#}"); let (exit, env) = run_mode(root, &server.uri(), "scan", "vendored", &[]); assert_eq!(exit, 0, "the plain takeover must succeed: {env:#}"); @@ -545,7 +538,7 @@ async fn scan_vendored_over_hosted_pnpm_workspace_exact_pin_takes_over() { let (exit, env) = run_mode(root, &server.uri(), "scan", "vendored", &["--dry-run"]); assert_eq!(exit, 0, "{env:#}"); assert_still_hosted(root, &hosted, &env); - assert_eq!(preview_row(&env)["action"], "would_vendor", "{env:#}"); + assert_eq!(preview_row(&env)["action"], "verified", "{env:#}"); let (exit, env) = run_mode(root, &server.uri(), "scan", "vendored", &[]); assert_eq!(exit, 0, "the exact-pin takeover must succeed: {env:#}"); @@ -608,7 +601,7 @@ async fn two_document_lock_takes_over_both_ways() { has_event_code(&env, "redirect_takeover_reverted_vendored"), "{env:#}" ); - assert_eq!(env["redirect"]["redirected"], 1, "{env:#}"); + assert_eq!(hosted_pin_count(&env), 1, "{env:#}"); let lock = read_lock(); assert_eq!(env_doc(&lock), ENV_DOC, "{lock}"); assert!(lock.contains(HOSTED_URL), "{lock}"); @@ -661,3 +654,18 @@ async fn vendor_dry_run_over_hosted_pnpm_catalog_dep_previews_the_refusal() { assert_refused(&env, exit, "vendor_lock_entry_unsupported"); assert_still_hosted(root, &hosted, &env); } + +/// How many hosted pins the run wrote (`applied`) or, on a dry run, would +/// write (`verified`): the `details.mode: "hosted"` events (v5.0's +/// `redirect.redirected`). +fn hosted_pin_count(envelope: &serde_json::Value) -> usize { + envelope["events"] + .as_array() + .expect("events array") + .iter() + .filter(|e| { + e["details"]["mode"] == "hosted" + && (e["action"] == "applied" || e["action"] == "verified") + }) + .count() +} diff --git a/crates/socket-patch-cli/tests/json_error_shape.rs b/crates/socket-patch-cli/tests/json_error_shape.rs index 07492ab44..c893323e4 100644 --- a/crates/socket-patch-cli/tests/json_error_shape.rs +++ b/crates/socket-patch-cli/tests/json_error_shape.rs @@ -1,8 +1,8 @@ //! #704: every `--json` failure carries its top-level `error` as a //! `{code, message}` object, with no top-level `errorCode`, on every //! command. Self-enforced usage errors (exit 2) print that coded error on -//! stdout: a full envelope for the envelope commands, `{status, error}` for -//! `scan`, `get` and `rollback`. None of these cases reaches the network. +//! stdout as a full envelope on every command (v5.0: `scan`, `get` and +//! `rollback` included). None of these cases reaches the network. use std::path::Path; @@ -52,19 +52,15 @@ fn assert_error_object(stdout: &str, code: &str) -> serde_json::Value { v } -/// A legacy-shape usage error is exactly `{status, error}`. -fn assert_legacy_usage(args: &[&str], env: &[(&str, &str)], code: &str) { - let tmp = tempfile::tempdir().unwrap(); - let (exit, stdout, stderr) = run(tmp.path(), args, env); - assert_eq!(exit, 2, "{args:?}: stdout={stdout} stderr={stderr}"); - let v = assert_error_object(&stdout, code); - assert_eq!(v.as_object().unwrap().len(), 2, "{args:?}: {v}"); -} - /// An envelope-command usage error is a full envelope. fn assert_envelope_usage(args: &[&str], command: &str, code: &str) { + assert_envelope_usage_env(args, &[], command, code); +} + +/// [`assert_envelope_usage`] with extra environment. +fn assert_envelope_usage_env(args: &[&str], env: &[(&str, &str)], command: &str, code: &str) { let tmp = tempfile::tempdir().unwrap(); - let (exit, stdout, stderr) = run(tmp.path(), args, &[]); + let (exit, stdout, stderr) = run(tmp.path(), args, env); assert_eq!(exit, 2, "{args:?}: stdout={stdout} stderr={stderr}"); let v = assert_error_object(&stdout, code); assert_eq!(v["command"], command, "{v}"); @@ -73,65 +69,71 @@ fn assert_envelope_usage(args: &[&str], command: &str, code: &str) { #[test] fn scan_usage_errors_print_the_coded_error() { - assert_legacy_usage( + assert_envelope_usage( &["scan", "--mode", "hosted", "--global", "--json"], - &[], + "scan", "global_scope_unsupported", ); - assert_legacy_usage( + assert_envelope_usage( &["scan", "--mode", "hosted", "missing-dir", "--json"], - &[], + "scan", "path_not_directory", ); - assert_legacy_usage( + assert_envelope_usage( &["scan", "--mode", "hosted", "nothing-*", "--json"], - &[], + "scan", "path_glob_no_match", ); - assert_legacy_usage( + assert_envelope_usage( &["scan", "--mode", "agent", "x[", "--json"], - &[], + "scan", "path_glob_invalid", ); - assert_legacy_usage( + assert_envelope_usage_env( &["scan", "--mode", "agent", "--json"], &[("SOCKET_MAX_NEW_PATCHES", "lots")], + "scan", "invalid_env", ); - assert_legacy_usage( + assert_envelope_usage_env( &["scan", "--mode", "agent", "--json"], &[("SOCKET_MIN_SEVERITY", "extreme")], + "scan", "invalid_env", ); } #[test] fn get_usage_errors_print_the_coded_error() { - assert_legacy_usage( + assert_envelope_usage( &["get", "lodash", "--id", "--cve", "--json"], - &[], + "get", "invalid_args", ); - assert_legacy_usage( + assert_envelope_usage( &["get", "lodash", "--id", "--json"], - &[], + "get", "identifier_invalid", ); - assert_legacy_usage( + assert_envelope_usage( &["get", "lodash", "--save-only", "--mode", "hosted", "--json"], - &[], + "get", "invalid_args", ); - assert_legacy_usage( + assert_envelope_usage( &["get", "lodash", "--mode", "hosted", "--global", "--json"], - &[], + "get", "global_scope_unsupported", ); } #[test] fn rollback_usage_error_prints_the_coded_error() { - assert_legacy_usage(&["rollback", "x[", "--json"], &[], "path_glob_invalid"); + assert_envelope_usage( + &["rollback", "x[", "--json"], + "rollback", + "path_glob_invalid", + ); // JSON carries the verbatim message; only stderr is capitalized. let tmp = tempfile::tempdir().unwrap(); let (_, stdout, _) = run(tmp.path(), &["rollback", "x[", "--json"], &[]); @@ -196,11 +198,13 @@ fn offline_refusals_carry_a_code() { ); assert_eq!(exit, 1); let v = assert_error_object(&stdout, "offline_unsupported"); - assert_eq!(v["scannedPackages"], 0, "{v}"); + assert_eq!(v["command"], "scan", "{v}"); + assert_eq!(v["events"], serde_json::json!([]), "{v}"); let (exit, stdout, _) = run(tmp.path(), &["get", "lodash", "--offline", "--json"], &[]); assert_eq!(exit, 1); - assert_error_object(&stdout, "offline_unsupported"); + let v = assert_error_object(&stdout, "offline_unsupported"); + assert_eq!(v["command"], "get", "{v}"); } #[test] @@ -209,6 +213,8 @@ fn rollback_on_an_empty_project_is_manifest_not_found() { let (exit, stdout, _) = run(tmp.path(), &["rollback", "--json"], &[]); assert_eq!(exit, 1, "{stdout}"); let v = assert_error_object(&stdout, "manifest_not_found"); + assert_eq!(v["command"], "rollback", "{v}"); + assert_eq!(v["events"], serde_json::json!([]), "{v}"); assert_eq!(v["error"]["message"], "Manifest not found", "{v}"); assert!(v["path"].is_string(), "{v}"); } @@ -226,5 +232,7 @@ fn rollback_unknown_identifier_is_patch_not_found() { ); assert_eq!(exit, 1, "{stdout}"); let v = assert_error_object(&stdout, "patch_not_found"); - assert_eq!(v["results"], serde_json::json!([]), "{v}"); + assert_eq!(v["command"], "rollback", "{v}"); + assert_eq!(v["events"], serde_json::json!([]), "{v}"); + assert_eq!(v["summary"]["failed"], 0, "{v}"); } diff --git a/crates/socket-patch-cli/tests/manifest_load_error_matrix.rs b/crates/socket-patch-cli/tests/manifest_load_error_matrix.rs new file mode 100644 index 000000000..79f48b63f --- /dev/null +++ b/crates/socket-patch-cli/tests/manifest_load_error_matrix.rs @@ -0,0 +1,114 @@ +//! #931: one manifest-load error mapping on every command. A +//! `.socket/manifest.json` that exists but cannot be parsed is +//! `manifest_invalid`; one that cannot be read (here: a directory at that +//! path) is `manifest_unreadable` — under `--json`, on every command that +//! loads the manifest, with the command's own exit code (1; `vex` keeps its +//! hard-error exit 2). Before v5.0 the same corrupt file surfaced as five +//! different codes (`apply_failed`, `repair_failed`, `invalid_manifest`, +//! `manifest_unreadable`, `manifest_invalid`). Nothing here reaches the +//! network. + +use std::path::Path; + +use socket_patch_cli::args::{GLOBAL_ARG_ENV_VARS, LOCAL_ARG_ENV_VARS}; + +#[path = "common/hermetic.rs"] +mod hermetic; + +fn run(cwd: &Path, args: &[&str]) -> (i32, String, String) { + let mut cmd = hermetic::binary_command(); + hermetic::scrub_extra(&mut cmd, &[hermetic::Extra::Venv]); + cmd.args(args).current_dir(cwd); + for var in GLOBAL_ARG_ENV_VARS.iter().chain(LOCAL_ARG_ENV_VARS.iter()) { + cmd.env_remove(var); + } + cmd.env("SOCKET_TELEMETRY_DISABLED", "1"); + // Unroutable: a case that reached the network would fail differently. + cmd.env("SOCKET_API_URL", "http://127.0.0.1:1"); + cmd.env("SOCKET_PATCH_SERVER_URL", "http://127.0.0.1:1"); + cmd.env("SOCKET_API_TOKEN", "fake-token-for-test"); + cmd.env("SOCKET_ORG_SLUG", "test-org"); + let out = cmd.output().expect("run socket-patch"); + ( + out.status.code().unwrap_or(-1), + String::from_utf8_lossy(&out.stdout).to_string(), + String::from_utf8_lossy(&out.stderr).to_string(), + ) +} + +/// `(argv, envelope command, exit code)` — every command that loads the +/// manifest and prints the envelope. +const MATRIX: &[(&[&str], &str, i32)] = &[ + (&["list", "--json"], "list", 1), + ( + &["remove", "pkg:npm/x@1.0.0", "--json", "--yes"], + "remove", + 1, + ), + (&["apply", "--json", "--offline"], "apply", 1), + (&["apply", "--check", "--json"], "apply", 1), + (&["repair", "--json", "--offline"], "repair", 1), + (&["vendor", "--json", "--offline"], "vendor", 1), + (&["vendor", "--check", "--json"], "vendor", 1), + (&["vex", "--json", "--output", "out.json"], "vex", 2), +]; + +/// A project holding `package.json` plus whatever `seed` puts at +/// `.socket/manifest.json`. +fn project(seed: impl Fn(&Path)) -> tempfile::TempDir { + let tmp = tempfile::tempdir().unwrap(); + std::fs::write( + tmp.path().join("package.json"), + r#"{"name":"proj","version":"1.0.0"}"#, + ) + .unwrap(); + let socket = tmp.path().join(".socket"); + std::fs::create_dir_all(&socket).unwrap(); + seed(&socket.join("manifest.json")); + tmp +} + +fn assert_matrix(seed: impl Fn(&Path), code: &str) { + for (argv, command, exit) in MATRIX { + // A fresh project per command: nothing one run leaves behind (an + // apply.lock, a VEX file) can steer the next. + let tmp = project(&seed); + let (got_exit, stdout, stderr) = run(tmp.path(), argv); + let v: serde_json::Value = serde_json::from_str(stdout.trim()).unwrap_or_else(|e| { + panic!("{argv:?}: stdout must be one JSON envelope ({e}): {stdout:?}\nstderr={stderr}") + }); + assert_eq!(v["error"]["code"], code, "{argv:?}: {v}"); + assert_eq!(got_exit, *exit, "{argv:?}: {v}\nstderr={stderr}"); + // A full envelope, not a bare `{status, error}`. + assert_eq!(v["command"], *command, "{argv:?}: {v}"); + assert_eq!(v["status"], "error", "{argv:?}: {v}"); + assert!(v["dryRun"].is_boolean(), "{argv:?}: {v}"); + assert!(v["summary"].is_object(), "{argv:?}: {v}"); + assert_eq!(v["events"], serde_json::json!([]), "{argv:?}: {v}"); + assert!( + v["error"]["message"] + .as_str() + .is_some_and(|m| !m.is_empty()), + "{argv:?}: {v}" + ); + assert!(v.get("errorCode").is_none(), "{argv:?}: {v}"); + } +} + +#[test] +fn unparseable_manifest_is_manifest_invalid_on_every_command() { + assert_matrix( + |path| std::fs::write(path, r#"{"patches": {"#).unwrap(), + "manifest_invalid", + ); +} + +#[test] +fn unreadable_manifest_is_manifest_unreadable_on_every_command() { + // A directory where the file belongs: it exists, so no command takes + // its missing-manifest path, and reading it is an I/O error. + assert_matrix( + |path| std::fs::create_dir_all(path).unwrap(), + "manifest_unreadable", + ); +} diff --git a/crates/socket-patch-cli/tests/mode_migration_bun.rs b/crates/socket-patch-cli/tests/mode_migration_bun.rs index 3dc388ce4..e74d15aef 100644 --- a/crates/socket-patch-cli/tests/mode_migration_bun.rs +++ b/crates/socket-patch-cli/tests/mode_migration_bun.rs @@ -86,6 +86,9 @@ //! equal `bun --version`, so a CI leg cannot pass by running the wrong bun //! or no bun at all. +#[path = "common/rollback_json.rs"] +mod rollback_json; + #[path = "common/mod.rs"] mod common; use common::binary; @@ -1190,7 +1193,7 @@ fn assert_unscoped_rollback_restores_pristine(fx: &Fixture, proj: &Path, tag: &s "rollback envelope ({tag}): {env:#}" ); assert_eq!( - env["hosted"]["failed"], + rollback_json::hosted_failed(&env), json!([]), "rollback ({tag}) must not fail any hosted purl: {env:#}" ); @@ -1258,8 +1261,8 @@ fn take_over_to_hosted(fx: &Fixture, proj: &Path, api: &str, hp: &HostedPatch, t assert_eq!(code, 0, "hosted scan failed ({tag}): {stdout}\n{stderr}"); let env = envelope(&stdout, &stderr); assert_eq!(env["status"], "success", "{env:#}"); - assert_eq!(env["redirect"]["redirected"], 1, "{env:#}"); - let codes = codes_in(&env["redirect"]["warnings"]); + assert_eq!(hosted_pinned(&env), 1, "{env:#}"); + let codes = codes_in(&env["warnings"]); assert!( codes .iter() @@ -1401,7 +1404,9 @@ fn take_over_to_vendored( ); let env = envelope(&stdout, &stderr); assert_eq!(env["status"], "success", "{env:#}"); - env["vendor"].clone() + // v5.0: the vendor engine's events are merged into scan's + // envelope (no nested `vendor`). + env } }; assert_eq!(vendor_env["status"], "success", "{vendor_env:#}"); @@ -1557,7 +1562,7 @@ async fn bun_hosted_then_vendored_takeover_round_trips_to_registry() { let (code, stdout, stderr) = hosted_scan(&proj, &server.uri(), &[]); assert_eq!(code, 0, "hosted scan failed: {stdout}\n{stderr}"); let env = envelope(&stdout, &stderr); - assert_eq!(env["redirect"]["redirected"], 1, "{env:#}"); + assert_eq!(hosted_pinned(&env), 1, "{env:#}"); assert_pure_hosted(&fx, &proj, hp); let fresh = fresh_frozen_install(&fx, &proj, "fresh-hosted"); assert_installed(&fresh, &DEP_A, &fx.a.patched, "hosted fresh install"); @@ -1599,7 +1604,7 @@ async fn bun_hosted_then_vendored_takeover_round_trips_to_registry() { let (code, stdout, stderr) = vendored_scan(&by_scan, &server.uri(), &[]); assert_eq!(code, 0, "vendored re-run failed: {stdout}\n{stderr}"); let rerun = envelope(&stdout, &stderr); - let codes = event_codes(&rerun["vendor"]); + let codes = event_codes(&rerun); assert!( codes.iter().any(|c| c == "already_vendored") && !codes @@ -1676,10 +1681,13 @@ async fn bun_dry_run_previews_match_wet_outcomes() { ); let env = envelope(&stdout, &stderr); assert_eq!(env["status"], "success", "{env:#}"); - assert_eq!(env["vendor"]["dryRun"], true, "{env:#}"); - let preview = env["vendor"]["patches"] + assert_eq!(env["dryRun"], true, "{env:#}"); + let preview = env["events"] .as_array() - .and_then(|p| p.iter().find(|p| p["purl"] == DEP_A.purl)) + .and_then(|p| { + p.iter() + .find(|p| p["purl"] == DEP_A.purl && p["details"]["mode"] == "vendored") + }) .unwrap_or_else(|| { panic!( "expected a vendored preview record for {}: {env:#}", @@ -1687,8 +1695,8 @@ async fn bun_dry_run_previews_match_wet_outcomes() { ) }); assert_eq!( - preview["action"], "would_vendor", - "the vendored preview over a live hosted bun redirect must classify `would_vendor` \ + preview["action"], "verified", + "the vendored preview over a live hosted bun redirect must preview vendoring \ (the wet run takes over and vendors): {env:#}" ); assert!( @@ -1728,8 +1736,8 @@ async fn bun_dry_run_previews_match_wet_outcomes() { "scan --mode hosted --dry-run must succeed: {stdout}\n{stderr}" ); let env = envelope(&stdout, &stderr); - assert_eq!(env["redirect"]["dryRun"], true, "{env:#}"); - let codes = codes_in(&env["redirect"]["warnings"]); + assert_eq!(env["dryRun"], true, "{env:#}"); + let codes = codes_in(&env["warnings"]); assert!( codes.iter().any(|c| c == "redirect_would_revert_vendored"), "the hosted preview must announce the vendored takeover: {codes:?}\n{env:#}" @@ -1790,7 +1798,7 @@ async fn bun_scoped_rollback_and_remove_unwind_one_of_two_hosted_records() { let (code, stdout, stderr) = hosted_scan(&proj, &server.uri(), &[]); assert_eq!(code, 0, "hosted scan failed: {stdout}\n{stderr}"); let env = envelope(&stdout, &stderr); - assert_eq!(env["redirect"]["redirected"], 2, "{env:#}"); + assert_eq!(hosted_pinned(&env), 2, "{env:#}"); let lock = read(&proj, "bun.lock"); for hp in [a, b] { assert_eq!( @@ -1817,9 +1825,12 @@ async fn bun_scoped_rollback_and_remove_unwind_one_of_two_hosted_records() { assert_eq!(code, 0, "scoped rollback must succeed: {stdout}\n{stderr}"); let env = envelope(&stdout, &stderr); assert_eq!(env["status"], "success", "{env:#}"); - assert_eq!(env["hosted"]["reverted"], json!([DEP_A.purl]), "{env:#}"); - assert_eq!(env["hosted"]["failed"], json!([]), "{env:#}"); - assert_eq!(env["hosted"]["unsupported"], json!([]), "{env:#}"); + assert_eq!( + rollback_json::hosted_reverted(&env), + json!([DEP_A.purl]), + "{env:#}" + ); + assert_eq!(rollback_json::hosted_failed(&env), json!([]), "{env:#}"); assert_only_a_unwound(&fx, &by_rollback, b, "scoped-rollback"); // Then the unscoped rollback restores the remaining pin ⇒ pristine. assert_unscoped_rollback_restores_pristine(&fx, &by_rollback, "after-scoped-rollback"); @@ -1905,14 +1916,29 @@ async fn bun_rollback_from_each_mixed_state_restores_pristine() { let env = envelope(&stdout, &stderr); assert_eq!(env["status"], "success", "{env:#}"); assert_eq!( - env["vendoredReverted"], + rollback_json::vendored_reverted(&env), json!([DEP_A.purl]), "rollback must unwire the vendored purl: {env:#}" ); - assert_eq!(env["vendoredFailed"], json!([]), "{env:#}"); + assert_eq!(rollback_json::vendored_failed(&env), json!([]), "{env:#}"); assert_pristine_unwound(&fx, &two, "rollback (mixed-2)"); let fresh = fresh_frozen_install(&fx, &two, "fresh-rolled-back-mixed-2"); assert_installed(&fresh, &DEP_A, &fx.a.orig, "after rollback (mixed-2)"); assert_installed(&fresh, &DEP_B, &fx.b.orig, "after rollback (mixed-2)"); eprintln!("ROLLBACK OK (mixed-2, bun {})", fx.bun_raw); } + +/// How many hosted pins a `scan --mode hosted --json` run wrote (or would +/// write, on a dry run): its `applied` / `verified` events with +/// `details.mode: "hosted"`. +fn hosted_pinned(env: &serde_json::Value) -> u64 { + env["events"] + .as_array() + .into_iter() + .flatten() + .filter(|e| { + e["details"]["mode"] == "hosted" + && (e["action"] == "applied" || e["action"] == "verified") + }) + .count() as u64 +} diff --git a/crates/socket-patch-cli/tests/mode_migration_cargo.rs b/crates/socket-patch-cli/tests/mode_migration_cargo.rs index 46152b450..7904a7ae8 100644 --- a/crates/socket-patch-cli/tests/mode_migration_cargo.rs +++ b/crates/socket-patch-cli/tests/mode_migration_cargo.rs @@ -716,7 +716,7 @@ async fn vendored_then_hosted_takeover_leaves_pure_hosted() { let (code, stdout, stderr) = run_socket(&proj, &hosted_args, &cargo_home); assert_eq!(code, 0, "hosted scan failed: {stdout}\n{stderr}"); let envelope: serde_json::Value = serde_json::from_str(&stdout).expect("json envelope"); - assert_eq!(envelope["redirect"]["redirected"], 1, "{stdout}"); + assert_eq!(hosted_pinned(&envelope), 1, "{stdout}"); // The takeover is surfaced, and it really reverted the vendored state. assert!( stdout.contains("redirect_takeover_reverted_vendored"), @@ -890,7 +890,7 @@ async fn lockless_vendor_then_first_build_then_hosted_takeover() { !stdout.contains("redirect_cargo_lock_pkg_not_found"), "the reverted lock names the crate by its version: {stdout}" ); - assert_eq!(envelope["redirect"]["redirected"], 1, "{stdout}"); + assert_eq!(hosted_pinned(&envelope), 1, "{stdout}"); assert_lock_version(&proj, &version, "lockless vendor -> hosted"); let lock_block = package_block(&read(&proj, "Cargo.lock"), DEP).unwrap_or_default(); assert!( @@ -1321,6 +1321,21 @@ async fn vendor_over_unrestorable_hosted_pin_is_refused() { assert!(!vendor_ledger_claims(&proj, &purl)); } +/// How many hosted pins a `scan --mode hosted --json` run wrote (or would +/// write, on a dry run): its `applied` / `verified` events with +/// `details.mode: "hosted"`. +fn hosted_pinned(env: &serde_json::Value) -> u64 { + env["events"] + .as_array() + .into_iter() + .flatten() + .filter(|e| { + e["details"]["mode"] == "hosted" + && (e["action"] == "applied" || e["action"] == "verified") + }) + .count() as u64 +} + // ── #1020: a takeover the hosted rewriter refuses keeps the vendored patch ── // Vendored mode accepts every Cargo.toml spelling of the dependency (it never // edits the dependency line); the hosted cargo rewriter refuses some @@ -1405,7 +1420,7 @@ async fn takeover_over_unrewritable_spelling_keeps_vendored() { let (dry_code, dry_out, stderr) = run_socket(&proj, &dry, &cargo_home); let dry_env: serde_json::Value = serde_json::from_str(&dry_out) .unwrap_or_else(|e| panic!("{tag}: dry-run json ({e}): {dry_out}\n{stderr}")); - assert_eq!(dry_env["redirect"]["redirected"], 0, "{tag}: {dry_out}"); + assert_eq!(hosted_pinned(&dry_env), 0, "{tag}: {dry_out}"); assert!( !dry_out.contains("redirect_would_revert_vendored"), "{tag}: the dry run must not preview a takeover the wet run refuses: {dry_out}" @@ -1422,7 +1437,7 @@ async fn takeover_over_unrewritable_spelling_keeps_vendored() { .unwrap_or_else(|e| panic!("{tag}: wet json ({e}): {stdout}\n{stderr}")); assert_eq!(code, dry_code, "{tag}: dry run and wet run agree: {stdout}"); assert_eq!(env["status"], dry_env["status"], "{tag}: {stdout}"); - assert_eq!(env["redirect"]["redirected"], 0, "{tag}: {stdout}"); + assert_eq!(hosted_pinned(&env), 0, "{tag}: {stdout}"); assert!( !stdout.contains("redirect_takeover_unpatched") && !stdout.contains("redirect_takeover_reverted_vendored"), diff --git a/crates/socket-patch-cli/tests/mode_migration_npm.rs b/crates/socket-patch-cli/tests/mode_migration_npm.rs index 83cf9d7e0..c8091f68e 100644 --- a/crates/socket-patch-cli/tests/mode_migration_npm.rs +++ b/crates/socket-patch-cli/tests/mode_migration_npm.rs @@ -1045,7 +1045,7 @@ async fn classic_vendored_then_hosted_takeover_leaves_pure_hosted() { let (code, stdout, stderr) = run_hosted_scan(&proj, &server.uri()); assert_eq!(code, 0, "hosted scan failed: {stdout}\n{stderr}"); let envelope: serde_json::Value = serde_json::from_str(&stdout).expect("json envelope"); - assert_eq!(envelope["redirect"]["redirected"], 1, "{stdout}"); + assert_eq!(hosted_pinned(&envelope), 1, "{stdout}"); assert!( stdout.contains("redirect_takeover_reverted_vendored"), "takeover warning missing: {stdout}" @@ -1382,7 +1382,7 @@ async fn berry_vendored_then_hosted_takeover_leaves_pure_hosted() { let (code, stdout, stderr) = run_hosted_scan(&proj, &server.uri()); assert_eq!(code, 0, "hosted scan failed: {stdout}\n{stderr}"); let envelope: serde_json::Value = serde_json::from_str(&stdout).expect("json envelope"); - assert_eq!(envelope["redirect"]["redirected"], 1, "{stdout}"); + assert_eq!(hosted_pinned(&envelope), 1, "{stdout}"); assert!( stdout.contains("redirect_takeover_reverted_vendored"), "takeover warning missing: {stdout}" @@ -1480,3 +1480,18 @@ async fn berry_vendored_then_hosted_takeover_leaves_pure_hosted() { }; yarn_berry_common::off_runtime(|| yarn_berry_common::run_manifestless_vex_matrix(&flow)); } + +/// How many hosted pins a `scan --mode hosted --json` run wrote (or would +/// write, on a dry run): its `applied` / `verified` events with +/// `details.mode: "hosted"`. +fn hosted_pinned(env: &serde_json::Value) -> u64 { + env["events"] + .as_array() + .into_iter() + .flatten() + .filter(|e| { + e["details"]["mode"] == "hosted" + && (e["action"] == "applied" || e["action"] == "verified") + }) + .count() as u64 +} diff --git a/crates/socket-patch-cli/tests/mode_migration_pypi.rs b/crates/socket-patch-cli/tests/mode_migration_pypi.rs index eedadd23c..448ba51af 100644 --- a/crates/socket-patch-cli/tests/mode_migration_pypi.rs +++ b/crates/socket-patch-cli/tests/mode_migration_pypi.rs @@ -104,6 +104,38 @@ fn run_cli(root: &Path, args: &[&str], extra: &[(&str, &str)]) -> (i32, Value) { (code, env) } +/// How many hosted pins a `scan --mode hosted --json` run wrote (or would +/// write, on a dry run): its `applied` / `verified` hosted events. +fn hosted_pinned(env: &Value) -> u64 { + env["events"] + .as_array() + .into_iter() + .flatten() + .filter(|e| { + e["details"]["mode"] == "hosted" + && (e["action"] == "applied" || e["action"] == "verified") + }) + .count() as u64 +} + +/// A hosted run's skips as `{purl, uuid, reason, detail}` rows: its +/// `skipped` hosted events (`reason` = the `errorCode`, `detail` = the +/// event's `reason`). +fn hosted_skipped(env: &Value) -> Vec { + env["events"] + .as_array() + .into_iter() + .flatten() + .filter(|e| e["details"]["mode"] == "hosted" && e["action"] == "skipped") + .map(|e| { + serde_json::json!({ + "purl": e["purl"], "uuid": e["uuid"], + "reason": e["errorCode"], "detail": e["reason"], + }) + }) + .collect() +} + /// [`run_cli`] without `--json`: `(exit code, stdout, stderr)`. fn run_raw(root: &Path, args: &[&str], extra: &[(&str, &str)]) -> (i32, String, String) { let venv = root.join("../empty-venv"); @@ -257,7 +289,7 @@ async fn assert_vendored_to_hosted(root: &Path, files: &[&str]) { let hosted_url = mount_hosted_api(&server, true).await; let (code, env) = hosted_scan(root, &server); assert_eq!(code, 0, "hosted scan over the vendored project: {env:#}"); - assert_eq!(env["redirect"]["redirected"], 1, "{env:#}"); + assert_eq!(hosted_pinned(&env), 1, "{env:#}"); assert!( env.to_string() .contains("redirect_takeover_reverted_vendored"), @@ -425,7 +457,7 @@ async fn assert_all_hosted_requirements_unwind(pristine: &str) { std::fs::write(root.join("requirements.txt"), pristine).unwrap(); let (code, env) = hosted_scan(&root, &server); assert_eq!(code, 0, "hosted scan: {env:#}"); - assert_eq!(env["redirect"]["redirected"], 1, "{env:#}"); + assert_eq!(hosted_pinned(&env), 1, "{env:#}"); let wired = std::fs::read_to_string(root.join("requirements.txt")).unwrap(); assert!(wired.contains(&hosted_url), "hosted first:\n{wired}"); @@ -566,7 +598,7 @@ async fn pipenv_takeover_beside_an_unreached_include_is_never_stranded() { !env.to_string().contains("redirect_takeover_unpatched"), "{env:#}" ); - assert_eq!(env["redirect"]["redirected"], 1, "{env:#}"); + assert_eq!(hosted_pinned(&env), 1, "{env:#}"); let lock = std::fs::read_to_string(root.join("Pipfile.lock")).unwrap(); assert!( lock.contains(&hosted_url), @@ -574,15 +606,15 @@ async fn pipenv_takeover_beside_an_unreached_include_is_never_stranded() { ); assert!(!lock.contains(".socket/vendor/"), "{lock}"); assert_eq!( - (dry_code, &dry_env["redirect"]["redirected"]), - (code, &env["redirect"]["redirected"]), + (dry_code, hosted_pinned(&dry_env)), + (code, hosted_pinned(&env)), "the dry run predicts the wet run: {dry_env:#}" ); } /// #567 without a takeover: the Pipfile.lock pin the hosted rewriter would /// land is contested by an `-r` include it does not reach, so the patch is -/// left out — reported in `redirect.skipped[]` as `redirect_unattributable` +/// left out — reported as a hosted `skipped` event with `errorCode` `redirect_unattributable` /// with discovery's finding — nothing is written and the exit code is 0. #[tokio::test] async fn pipenv_redirect_beside_an_unreached_include_is_skipped_unattributable() { @@ -600,8 +632,8 @@ async fn pipenv_redirect_beside_an_unreached_include_is_skipped_unattributable() code, 0, "an unattributable pin is a skip, not a failure: {env:#}" ); - assert_eq!(env["redirect"]["redirected"], 0, "{env:#}"); - let skipped = env["redirect"]["skipped"].as_array().expect("skipped[]"); + assert_eq!(hosted_pinned(&env), 0, "{env:#}"); + let skipped = hosted_skipped(&env); assert_eq!(skipped.len(), 1, "{env:#}"); assert_eq!(skipped[0]["purl"], PURL, "{env:#}"); assert_eq!(skipped[0]["reason"], "redirect_unattributable", "{env:#}"); @@ -1236,7 +1268,7 @@ async fn uv_takeover_without_wheel_metadata_keeps_the_package_vendored() { let (code, env) = run_cli(&root, &args, &[]); let ctx = format!("dry_run={dry_run}: {env:#}"); assert_eq!(code, 0, "a retracted takeover is not a failure: {ctx}"); - assert_eq!(env["redirect"]["redirected"], 0, "{ctx}"); + assert_eq!(hosted_pinned(&env), 0, "{ctx}"); let text = env.to_string(); assert!(!text.contains("redirect_takeover_unpatched"), "{ctx}"); assert!( @@ -1246,9 +1278,7 @@ async fn uv_takeover_without_wheel_metadata_keeps_the_package_vendored() { ); assert!(text.contains("redirect_takeover_kept_vendored"), "{ctx}"); assert!( - env["redirect"]["skipped"] - .as_array() - .is_some_and(|s| s.iter().any(|s| s["uuid"] == UUID)), + hosted_skipped(&env).iter().any(|s| s["uuid"] == UUID), "the purl is skipped with its cause: {ctx}" ); assert_eq!( @@ -1417,7 +1447,7 @@ async fn drifted_vendored_line_refuses_takeover() { "no takeover is announced over drifted wiring: {env:#}" ); assert!(text.contains("redirect_vendored_revert_failed"), "{env:#}"); - assert_eq!(env["redirect"]["redirected"], 0, "{env:#}"); + assert_eq!(hosted_pinned(&env), 0, "{env:#}"); assert_eq!( code, 0, "a refused takeover keeps the package vendored: {env:#}" @@ -1460,7 +1490,7 @@ async fn dry_run_predicts_drifted_takeover_refusal() { "no takeover is previewed over drifted wiring: {env:#}" ); assert!(text.contains("redirect_vendored_revert_failed"), "{env:#}"); - assert_eq!(env["redirect"]["redirected"], 0, "{env:#}"); + assert_eq!(hosted_pinned(&env), 0, "{env:#}"); assert_eq!( std::fs::read_to_string(&reqs).unwrap(), drifted, @@ -1714,7 +1744,7 @@ async fn assert_takeover_refused_serving( "the package is never stranded: {env:#}" ); assert!(text.contains(code_name), "the refusal is named: {env:#}"); - assert_eq!(env["redirect"]["redirected"], 0, "{env:#}"); + assert_eq!(hosted_pinned(&env), 0, "{env:#}"); assert_eq!( code, 0, "a refused takeover keeps the package vendored: {env:#}" @@ -1800,7 +1830,7 @@ async fn dry_run_previews_root_pin_takeover() { env.to_string().contains("redirect_would_revert_vendored"), "{env:#}" ); - assert_eq!(env["redirect"]["redirected"], 1, "{env:#}"); + assert_eq!(hosted_pinned(&env), 1, "{env:#}"); } /// uv.lock resolving `six` 1.17.0, either as a direct `six>=1.15` or @@ -2316,7 +2346,7 @@ async fn pypi_unwinds_name_the_reinstall_a_plain_sync_skips() { hosted_stage(&root); let (code, env) = hosted_scan(&root, &server); assert_eq!(code, 0, "{tool}: hosted scan: {env:#}"); - assert_eq!(env["redirect"]["redirected"], 1, "{tool}: {env:#}"); + assert_eq!(hosted_pinned(&env), 1, "{tool}: {env:#}"); let mut args = unwind.clone(); args.extend(["--patch-server-url", uri.as_str()]); let (code, env) = run_cli(&root, &args, &env_vars); @@ -2430,7 +2460,7 @@ async fn hatch_locked_env_pylock_wires_pyproject() { std::fs::write(root.join("pylock.toml"), &pylock).unwrap(); let (code, env) = hosted_scan(&root, &server); assert_eq!(code, 0, "hosted: {env:#}"); - assert_eq!(env["redirect"]["redirected"], 1, "{env:#}"); + assert_eq!(hosted_pinned(&env), 1, "{env:#}"); let pyproject = std::fs::read_to_string(root.join("pyproject.toml")).unwrap(); assert!( pyproject.contains(&format!("six @ {hosted_url}")), @@ -2504,7 +2534,7 @@ async fn lock_only_pep440_equivalent_pin_is_patched() { .unwrap(); let (code, env) = hosted_scan(&root, &server); assert_eq!(code, 0, "{pin}: {env:#}"); - assert_eq!(env["redirect"]["redirected"], 1, "{pin}: {env:#}"); + assert_eq!(hosted_pinned(&env), 1, "{pin}: {env:#}"); assert_eq!(env["packages"][0]["purl"], PURL, "{pin}: {env:#}"); assert_eq!(env["packages"][0]["notInstalled"], true, "{pin}: {env:#}"); let requirements = std::fs::read_to_string(root.join("requirements.txt")).unwrap(); @@ -2723,8 +2753,8 @@ async fn pipenv_residual_export_takeover_refusal_names_the_export() { mount_hosted_api(&server, true).await; let (code, env) = hosted_scan(&root, &server); assert_eq!(code, 0, "{env:#}"); - assert_eq!(env["redirect"]["redirected"], 0, "{env:#}"); - let detail = env["redirect"]["warnings"] + assert_eq!(hosted_pinned(&env), 0, "{env:#}"); + let detail = env["warnings"] .as_array() .unwrap() .iter() @@ -2755,7 +2785,7 @@ async fn pipenv_residual_export_takeover_refusal_names_the_export() { .unwrap(); let (code, env) = hosted_scan(&root, &server); assert_eq!(code, 0, "{env:#}"); - assert_eq!(env["redirect"]["redirected"], 1, "{env:#}"); + assert_eq!(hosted_pinned(&env), 1, "{env:#}"); assert!( !root.join(format!(".socket/vendor/pypi/{UUID}")).exists(), "the takeover reclaims the vendored wheel: {env:#}" diff --git a/crates/socket-patch-cli/tests/mode_migration_vlt.rs b/crates/socket-patch-cli/tests/mode_migration_vlt.rs index ae010a2e4..aa9f27694 100644 --- a/crates/socket-patch-cli/tests/mode_migration_vlt.rs +++ b/crates/socket-patch-cli/tests/mode_migration_vlt.rs @@ -7,6 +7,9 @@ //! checkout's locked install. Each leg is `vlt_pinned_matrix_migration_` //! and prints one `VLT-LEG` line. +#[path = "common/rollback_json.rs"] +mod rollback_json; + use std::collections::BTreeMap; use std::path::Path; @@ -362,12 +365,15 @@ async fn vlt_pinned_matrix_migration_dry_run_parity() { let out = vendored_scan(&fx, &fx.proj, &["--dry-run"]); assert_eq!(out.code, 0, "{out}"); let doc = out.json(); - let preview = doc["vendor"]["patches"] + let preview = doc["events"] .as_array() - .and_then(|p| p.iter().find(|p| p["purl"] == fx.t().purl())) + .and_then(|p| { + p.iter() + .find(|p| p["purl"] == fx.t().purl() && p["details"]["mode"] == "vendored") + }) .cloned() .unwrap_or_else(|| panic!("a preview for {}: {doc:#}", fx.t().purl())); - assert_eq!(preview["action"], "would_vendor", "{doc:#}"); + assert_eq!(preview["action"], "verified", "{doc:#}"); assert!(!out.stdout.contains("would_refuse"), "{out}"); assert_eq!( project_bytes(&fx.proj), @@ -424,7 +430,7 @@ async fn vlt_pinned_matrix_migration_scoped_unwind_one_of_two() { let out = rollback_all(&fx, &fx.proj, &[&a.purl()]); assert_eq!(out.code, 0, "{out}"); assert_eq!( - out.json()["hosted"]["reverted"], + rollback_json::hosted_reverted(&out.json()), serde_json::json!([a.purl()]), "only a is restored: {out}" ); @@ -790,7 +796,7 @@ async fn vlt_pinned_matrix_migration_upgrade_hosted() { let relocked = lock_bytes(&fx.proj); remove_tree(&fx.proj); let doc = fx.scan(&["--vex", "out.vex.json", "--vex-product", PRODUCT]); - assert_eq!(doc["redirect"]["redirected"], 1, "{doc:#}"); + assert_eq!(hosted_pinned(&doc), 1, "{doc:#}"); assert_pinned(&fx.proj, &fx.svc, fx.t()); assert!( fx.vex_attested(fx.t()), @@ -849,3 +855,18 @@ async fn vlt_pinned_matrix_migration_upgrade_vendored() { assert!(!fx.proj.join(".socket/vendor/npm").exists(), "{out}"); fx.leg.ran(); } + +/// How many hosted pins a `scan --mode hosted --json` run wrote (or would +/// write, on a dry run): its `applied` / `verified` events with +/// `details.mode: "hosted"`. +fn hosted_pinned(env: &serde_json::Value) -> u64 { + env["events"] + .as_array() + .into_iter() + .flatten() + .filter(|e| { + e["details"]["mode"] == "hosted" + && (e["action"] == "applied" || e["action"] == "verified") + }) + .count() as u64 +} diff --git a/crates/socket-patch-cli/tests/policy_pypi_names.rs b/crates/socket-patch-cli/tests/policy_pypi_names.rs index fef55ebde..2d8b59b85 100644 --- a/crates/socket-patch-cli/tests/policy_pypi_names.rs +++ b/crates/socket-patch-cli/tests/policy_pypi_names.rs @@ -27,6 +27,21 @@ const UUID: &str = "aaaaaaaa-0000-4000-8000-000000000910"; const GRANT: &str = "11111111-1111-4111-8111-111111111910"; const REQUIREMENTS: &str = "typing_extensions==4.12.2\n"; +/// How many hosted pins a run wrote (dry run: would write): its +/// `applied` / `verified` events with `details.mode: "hosted"` (v5.0's +/// `redirect.redirected`). +fn hosted_pinned(doc: &Value) -> u64 { + doc["events"] + .as_array() + .into_iter() + .flatten() + .filter(|e| { + e["details"]["mode"] == "hosted" + && (e["action"] == "applied" || e["action"] == "verified") + }) + .count() as u64 +} + fn hosted_wheel() -> Vec { let mut zip = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); let opts = zip::write::SimpleFileOptions::default(); @@ -253,7 +268,7 @@ async fn hosted_packages_allowlist_matches_pep503_spelling() { let (code, env) = scan(&root, &server, &["--mode", "hosted"]); assert_eq!(code, 0, "{env:#}"); assert!(filtered_reasons(&env).is_empty(), "{env:#}"); - assert_eq!(env["redirect"]["redirected"], 1, "{env:#}"); + assert_eq!(hosted_pinned(&env), 1, "{env:#}"); assert!( requirements(&root).contains(UUID), "{}", @@ -272,7 +287,7 @@ async fn hosted_scan_package_flag_matches_pep503_spelling() { &["--mode", "hosted", "--package", "typing_extensions"], ); assert_eq!(code, 0, "{env:#}"); - assert_eq!(env["redirect"]["redirected"], 1, "{env:#}"); + assert_eq!(hosted_pinned(&env), 1, "{env:#}"); assert!( requirements(&root).contains(UUID), "{}", diff --git a/crates/socket-patch-cli/tests/remove/main.rs b/crates/socket-patch-cli/tests/remove/main.rs index ff6be7e97..d7690e981 100644 --- a/crates/socket-patch-cli/tests/remove/main.rs +++ b/crates/socket-patch-cli/tests/remove/main.rs @@ -6,6 +6,8 @@ mod common; #[path = "../common/pty_io.rs"] mod pty_io; +#[path = "../common/rollback_json.rs"] +mod rollback_json; #[path = "../vlt_hosted_common/mod.rs"] mod vlt_hosted_common; #[path = "../vlt_hosted_common/vendored.rs"] diff --git a/crates/socket-patch-cli/tests/remove/remove_duality_invariants.rs b/crates/socket-patch-cli/tests/remove/remove_duality_invariants.rs index 11cdc19fe..dee98279b 100644 --- a/crates/socket-patch-cli/tests/remove/remove_duality_invariants.rs +++ b/crates/socket-patch-cli/tests/remove/remove_duality_invariants.rs @@ -183,7 +183,8 @@ fn scoped_removal_preserves_other_patches_for_offline_rollback() { /// no per-purl `removed` event fires. /// /// ACTUAL event shape pinned here: for a pure agent-mode patch the wet run -/// emits ONLY the purl-less artifact carrier (`details.rolledBack: 1`) — the +/// emits ONLY one `rolledBack` event for the restored copy (no GC carrier, +/// since GC is skipped) — the /// `vendor_state_preserved` Skipped reason exists only for vendored entries /// (pinned by the next test). `--offline` proves the restore came from the /// staged before-blob, not the network. @@ -243,25 +244,27 @@ fn preserve_state_restores_but_keeps_entry() { "afterHash blob must be kept (GC skipped under --preserve-state)" ); - // Envelope events: no per-purl removal, and the artifact carrier reports - // the rollback that DID happen. + // Envelope events: no per-purl removal, one `rolledBack` event for the + // rollback that DID happen, and no GC carrier (GC is skipped). assert!( removed_event_purls(&v).is_empty(), "no per-purl removed event may fire under --preserve-state; envelope={v}" ); let events = v["events"].as_array().expect("events array"); - let carrier = events + let rolled: Vec<_> = events .iter() - .find(|e| e["action"] == "removed" && e["purl"].is_null()) - .unwrap_or_else(|| panic!("expected the artifact carrier event: {events:?}")); - assert_eq!( - carrier["details"]["rolledBack"], 1, - "the carrier must report the one rolled-back package; carrier={carrier}" - ); - assert_eq!( - carrier["details"]["blobsRemoved"], 0, - "no blobs may be swept under --preserve-state; carrier={carrier}" + .filter(|e| e["action"] == "rolledBack") + .collect(); + assert_eq!(rolled.len(), 1, "one restored copy; envelope={v}"); + assert_eq!(rolled[0]["purl"], PRESERVE_PURL); + assert_eq!(v["summary"]["rolledBack"], 1, "envelope={v}"); + assert!( + !events + .iter() + .any(|e| e["action"] == "removed" && e["purl"].is_null()), + "no blobs may be swept under --preserve-state; envelope={v}" ); + assert!(v.get("gc").is_none(), "GC skipped; envelope={v}"); } // --------------------------------------------------------------------------- @@ -524,8 +527,15 @@ fn default_remove_sweeps_archives_too() { .find(|e| e["action"] == "removed" && e["purl"].is_null()) .unwrap_or_else(|| panic!("expected the artifact carrier event: {events:?}")); assert_eq!( - carrier["details"]["archivesRemoved"], 4, - "two diff + two package archives swept; carrier={carrier}" + v["gc"]["removedDiffArchives"].as_u64().unwrap() + + v["gc"]["removedPackageArchives"].as_u64().unwrap(), + 4, + "two diff + two package archives swept; envelope={v}" + ); + assert_eq!( + carrier["details"]["count"], + v["gc"]["removedBlobs"].as_u64().unwrap() + 4, + "the carrier counts every swept artifact; carrier={carrier}" ); // The keep-rule really is manifest-anchored: B's entry survives. @@ -695,8 +705,9 @@ fn hosted_only_remove_without_manifest_restores_upstream() { assert!( events.iter().any(|e| e["action"] == "removed" && e["purl"] == NPM_PURL - && e["errorCode"] == "hosted_reverted"), - "removed/hosted_reverted event expected; envelope={v}" + && e["errorCode"] == "hosted_reverted" + && e["details"]["mode"] == "hosted"), + "removed/hosted_reverted event (details.mode hosted) expected; envelope={v}" ); assert_eq!( std::fs::read_to_string(&lock_path).unwrap(), @@ -757,8 +768,17 @@ fn hosted_remove_with_manifest_entry_restores_upstream() { assert!( events.iter().any(|e| e["action"] == "removed" && e["errorCode"] == "hosted_reverted" - && e["purl"] == NPM_PURL), - "expected a removed/hosted_reverted event: {events:?}" + && e["purl"] == NPM_PURL + && e["details"]["mode"] == "hosted"), + "expected a removed/hosted_reverted event (details.mode hosted): {events:?}" + ); + // The manifest entry's own removal is agent-mode: no mode. + assert!( + events + .iter() + .filter(|e| e["action"] == "removed" && e["errorCode"].is_null()) + .all(|e| e["details"].get("mode").is_none()), + "{events:?}" ); assert!( events diff --git a/crates/socket-patch-cli/tests/remove/remove_invariants.rs b/crates/socket-patch-cli/tests/remove/remove_invariants.rs index cc774c433..185f5ce17 100644 --- a/crates/socket-patch-cli/tests/remove/remove_invariants.rs +++ b/crates/socket-patch-cli/tests/remove/remove_invariants.rs @@ -395,10 +395,10 @@ fn remove_without_skip_rollback_fails_closed_and_keeps_manifest() { // Blob-sweep artifact event must not inflate the removed count // --------------------------------------------------------------------------- -/// When `remove` sweeps an orphaned blob (or rolls files back) it appends a -/// purl-less, artifact-level `Removed` event carrying `details.blobsRemoved` / -/// `details.rolledBack`. That carrier is metadata — NOT a removed manifest -/// entry — so it must never bump `summary.removed`. +/// When `remove` sweeps an orphaned blob it appends a purl-less, +/// artifact-level `Removed` event carrying `details.count` (artifacts swept), +/// the same carrier `repair` prints. That carrier is metadata — NOT a +/// removed manifest entry — so it must never bump `summary.removed`. /// /// The `run_remove` helper passes `--skip-rollback` against a manifest whose afterHash /// blobs aren't present on disk, so the cleanup phase sweeps nothing and the @@ -408,7 +408,7 @@ fn remove_without_skip_rollback_fails_closed_and_keeps_manifest() { /// /// The contract: exactly ONE manifest entry was deleted, so `summary.removed` /// must be 1 — matching the single per-purl `removed` event — even though the -/// event stream also carries the artifact carrier reporting `blobsRemoved: 1`. +/// event stream also carries the artifact carrier reporting `count: 1`. /// A regression that routes the carrier through the summary-bumping `record` /// path would report `removed: 2` and flip this test red. #[test] @@ -450,9 +450,20 @@ fn remove_blob_sweep_does_not_inflate_removed_count() { .find(|e| e["action"] == "removed" && e["purl"].is_null()) .expect("artifact-level Removed carrier event must be present"); assert_eq!( - carrier["details"]["blobsRemoved"], 1, + carrier["details"]["count"], 1, "exactly A's orphaned afterHash blob should be swept; carrier={carrier}" ); + assert_eq!(v["gc"]["removedBlobs"], 1, "envelope={v}"); + assert!( + carrier["details"]["checked"].is_u64(), + "repair's carrier shape: `count` + `checked`; carrier={carrier}" + ); + for legacy in ["blobsRemoved", "archivesRemoved", "rolledBack"] { + assert!( + carrier["details"].get(legacy).is_none(), + "`{legacy}` left the carrier (per-kind totals are `gc`); carrier={carrier}" + ); + } // B's afterHash blob is still referenced, so it must survive on disk; // A's must be gone. @@ -613,7 +624,7 @@ fn rollback_by_uuid_reverts_vendoring_when_ledger_generation_is_older() { "the vendored artifact must be deleted; envelope={v}" ); assert_eq!( - v["vendoredReverted"], + crate::rollback_json::vendored_reverted(&v), serde_json::json!([VENDORED_PURL]), "envelope={v}" ); @@ -737,7 +748,7 @@ fn remove_dry_run_keeps_manifest_and_emits_verified_previews() { /// The blob sweep runs in preview mode on `--dry-run`: the artifact-level /// carrier event reports how many blobs WOULD be swept (as `Verified`, -/// with `details.blobsRemoved`), but the blob files stay on disk. +/// with `details.count`), but the blob files stay on disk. #[test] fn remove_dry_run_previews_blob_sweep_without_deleting() { let tmp = tempfile::tempdir().unwrap(); @@ -762,10 +773,10 @@ fn remove_dry_run_previews_blob_sweep_without_deleting() { let events = v["events"].as_array().expect("events array"); let carrier = events .iter() - .find(|e| e["action"] == "verified" && e["details"]["blobsRemoved"].is_number()) + .find(|e| e["action"] == "verified" && e["purl"].is_null()) .unwrap_or_else(|| panic!("expected a Verified blob-sweep carrier event: {events:?}")); assert_eq!( - carrier["details"]["blobsRemoved"], 1, + carrier["details"]["count"], 1, "the preview must count A's now-unreferenced blob" ); diff --git a/crates/socket-patch-cli/tests/repair/covgap_commands_repair.rs b/crates/socket-patch-cli/tests/repair/covgap_commands_repair.rs index 86ee2de21..4231a736f 100644 --- a/crates/socket-patch-cli/tests/repair/covgap_commands_repair.rs +++ b/crates/socket-patch-cli/tests/repair/covgap_commands_repair.rs @@ -181,7 +181,7 @@ fn repair_manifest_not_found_human_mode_prints_to_stderr() { ); } -/// Loud twin of `repair_with_invalid_manifest_emits_repair_failed_envelope`: +/// Loud twin of `repair_with_invalid_manifest_emits_manifest_invalid_envelope`: /// a `repair_inner` failure (unparseable manifest) prints "Error: {e}" to /// STDERR in human mode and exits 1, with nothing on stdout. #[test] diff --git a/crates/socket-patch-cli/tests/repair/covgap_commands_repair_vendor.rs b/crates/socket-patch-cli/tests/repair/covgap_commands_repair_vendor.rs index 568f271d7..f27016f5b 100644 --- a/crates/socket-patch-cli/tests/repair/covgap_commands_repair_vendor.rs +++ b/crates/socket-patch-cli/tests/repair/covgap_commands_repair_vendor.rs @@ -749,6 +749,13 @@ async fn repair_recovers_record_by_uuid_without_manifest_then_fails_offline() { .any(|e| e["action"] == "rebuilt" && e["purl"] == PURL), "envelope={v}" ); + // Vendored-phase events say so, as in every envelope. + assert!( + events_of(&v) + .iter() + .all(|e| e["details"]["mode"] == "vendored"), + "envelope={v}" + ); assert_eq!( std::fs::read(&tgz).unwrap(), tgz_bytes, diff --git a/crates/socket-patch-cli/tests/repair/repair_invariants.rs b/crates/socket-patch-cli/tests/repair/repair_invariants.rs index 082beab9a..eb1679690 100644 --- a/crates/socket-patch-cli/tests/repair/repair_invariants.rs +++ b/crates/socket-patch-cli/tests/repair/repair_invariants.rs @@ -257,7 +257,7 @@ fn repair_redirect_only_project_human_mode_prints_note() { } #[test] -fn repair_with_invalid_manifest_emits_repair_failed_envelope() { +fn repair_with_invalid_manifest_emits_manifest_invalid_envelope() { let tmp = tempfile::tempdir().expect("tempdir"); let socket = tmp.path().join(".socket"); std::fs::create_dir_all(&socket).unwrap(); @@ -268,14 +268,15 @@ fn repair_with_invalid_manifest_emits_repair_failed_envelope() { let v: serde_json::Value = serde_json::from_str(&stdout).expect("envelope JSON"); assert_eq!(v["command"], "repair"); assert_eq!(v["status"], "error"); - // A malformed manifest must surface as a deterministic `repair_failed` - // envelope whose message names the manifest-parse failure. (A bare + // A malformed manifest must surface as a deterministic `manifest_invalid` + // envelope (the shared manifest-load mapping, #931 — not the generic + // `repair_failed`) whose message names the manifest-parse failure. (A bare // `manifest_not_found` here would mean the invalid file was silently // ignored — exactly the regression this test guards against.) let code_str = v["error"]["code"].as_str().expect("error.code"); assert_eq!( - code_str, "repair_failed", - "invalid manifest must report repair_failed, got {code_str}" + code_str, "manifest_invalid", + "invalid manifest must report manifest_invalid, got {code_str}" ); let msg = v["error"]["message"].as_str().expect("error.message"); assert!( @@ -742,7 +743,7 @@ fn repair_refuses_and_keeps_lock_when_live_holder() { /// The lock-file cleanup runs on every completion path, not as a success /// reward: a repair that fails past the lock (here: an unparseable -/// manifest → `repair_failed`) still drops its guard and the file with it. +/// manifest → `manifest_invalid`) still drops its guard and the file with it. #[test] fn repair_deletes_lock_file_even_when_repair_fails() { let tmp = tempfile::tempdir().expect("tempdir"); @@ -752,9 +753,12 @@ fn repair_deletes_lock_file_even_when_repair_fails() { std::fs::write(socket.join("apply.lock"), b"leftover").expect("stage stale lock"); let (code, stdout) = run_repair(tmp.path(), &[]); - assert_eq!(code, 1, "expected repair_failed exit 1; stdout=\n{stdout}"); + assert_eq!( + code, 1, + "expected manifest_invalid exit 1; stdout=\n{stdout}" + ); let v: serde_json::Value = serde_json::from_str(&stdout).expect("envelope JSON"); - assert_eq!(v["error"]["code"], "repair_failed"); + assert_eq!(v["error"]["code"], "manifest_invalid"); assert!( !socket.join("apply.lock").exists(), "the lock-file cleanup must run on the failure path too" diff --git a/crates/socket-patch-cli/tests/rollback/main.rs b/crates/socket-patch-cli/tests/rollback/main.rs index 981cf6e02..e297b6a26 100644 --- a/crates/socket-patch-cli/tests/rollback/main.rs +++ b/crates/socket-patch-cli/tests/rollback/main.rs @@ -4,6 +4,8 @@ #[path = "../common/mod.rs"] mod common; +#[path = "../common/rollback_json.rs"] +mod rollback_json; #[path = "../vlt_hosted_common/mod.rs"] mod vlt_hosted_common; #[path = "../vlt_hosted_common/vendored.rs"] diff --git a/crates/socket-patch-cli/tests/rollback/rollback_duality_invariants.rs b/crates/socket-patch-cli/tests/rollback/rollback_duality_invariants.rs index f5d2b1529..835be053e 100644 --- a/crates/socket-patch-cli/tests/rollback/rollback_duality_invariants.rs +++ b/crates/socket-patch-cli/tests/rollback/rollback_duality_invariants.rs @@ -206,18 +206,19 @@ fn default_rollback_removes_entry_and_sweeps_blobs() { "default rollback must succeed; stdout=\n{stdout}\nstderr=\n{stderr}" ); let v: serde_json::Value = serde_json::from_str(&stdout).expect("valid JSON"); + crate::rollback_json::assert_rollback_envelope(&v); assert_eq!(v["status"], "success", "stdout=\n{stdout}"); - assert_eq!(v["rolledBack"], 1); - assert_eq!(v["failed"], 0); + assert_eq!(v["summary"]["rolledBack"], 1); + assert_eq!(v["summary"]["failed"], 0); + assert_eq!(v["summary"]["removed"], 1, "the manifest entry left"); assert_eq!(v["dryRun"], false); // Envelope: the entry left the manifest and the GC actually swept. assert_eq!( - v["manifest"]["removedEntries"], + crate::rollback_json::manifest_removed(&v), serde_json::json!([fx.purl]), "stdout=\n{stdout}" ); - assert_eq!(v["manifest"]["preserved"], false); assert_eq!( v["gc"]["removedBlobs"], 2, "both the before and after blob are orphaned by the removal; stdout=\n{stdout}" @@ -290,21 +291,23 @@ fn preserve_state_keeps_everything() { ); let v: serde_json::Value = serde_json::from_str(&stdout).expect("valid JSON"); assert_eq!(v["status"], "success", "stdout=\n{stdout}"); - assert_eq!(v["rolledBack"], 1, "the file restore still happens"); assert_eq!( - v["manifest"]["preserved"], true, - "envelope must flag the preserve; stdout=\n{stdout}" + v["summary"]["rolledBack"], 1, + "the file restore still happens" ); assert_eq!( - v["manifest"]["removedEntries"], + crate::rollback_json::manifest_removed(&v), serde_json::json!([]), "nothing leaves the manifest under --preserve-state; stdout=\n{stdout}" ); - assert_eq!( - v["gc"], - serde_json::json!({ "skipped": true }), + assert!( + v.get("gc").is_none(), "GC is skipped wholesale, not run-with-zero-removals; stdout=\n{stdout}" ); + assert!( + !crate::rollback_json::warning_codes(&v).contains(&"gc_skipped".to_string()), + "--preserve-state never requested the GC; stdout=\n{stdout}" + ); // The system IS restored... let restored = std::fs::read(fx.pkg_dir.join("index.js")).expect("read restored file"); @@ -399,7 +402,7 @@ fn eco_scoped_run_pins_other_ecosystems_revert_data() { let v: serde_json::Value = serde_json::from_str(&stdout).expect("valid JSON"); assert_eq!(v["status"], "success", "stdout=\n{stdout}"); assert_eq!( - v["manifest"]["removedEntries"], + crate::rollback_json::manifest_removed(&v), serde_json::json!([npm_purl]), "only the in-scope npm entry is removed; stdout=\n{stdout}" ); @@ -466,18 +469,18 @@ fn not_installed_entry_is_removed_with_pinned_blobs() { ); let v: serde_json::Value = serde_json::from_str(&stdout).expect("valid JSON"); assert_eq!(v["status"], "success", "stdout=\n{stdout}"); - assert_eq!(v["failed"], 0); + assert_eq!(v["summary"]["failed"], 0); - // The entry surfaces as the skipped marker, never a failure. - let results = v["results"].as_array().expect("results array"); - assert_eq!(results.len(), 1, "stdout=\n{stdout}"); - assert_eq!(results[0]["purl"], purl); - assert_eq!(results[0]["skipped"], "package_not_installed"); - assert!(results[0]["path"].is_null()); + // The entry surfaces as a `package_not_installed` skip, never a failure. + assert_eq!( + crate::rollback_json::not_installed(&v), + serde_json::json!([purl]), + "stdout=\n{stdout}" + ); // Removed from the manifest... assert_eq!( - v["manifest"]["removedEntries"], + crate::rollback_json::manifest_removed(&v), serde_json::json!([purl]), "stdout=\n{stdout}" ); @@ -643,13 +646,13 @@ fn path_scoped_rollback_selects_by_installed_path() { "the envelope echoes the pattern verbatim; stdout=\n{stdout}" ); assert_eq!( - v["manifest"]["removedEntries"], + crate::rollback_json::manifest_removed(&v), serde_json::json!([app_purl]), "only the in-scope entry is removed; stdout=\n{stdout}" ); assert_eq!( - v["warnings"], - serde_json::json!([]), + crate::rollback_json::warning_codes(&v), + Vec::::new(), "the restored copy is inside the pattern — no out_of_scope warning; \ stdout=\n{stdout}" ); @@ -728,18 +731,18 @@ fn dry_run_mutates_nothing() { let v: serde_json::Value = serde_json::from_str(&stdout).expect("valid JSON"); assert_eq!(v["status"], "success", "stdout=\n{stdout}"); assert_eq!(v["dryRun"], true); - assert_eq!(v["rolledBack"], 0, "a dry run mutates nothing"); - assert_eq!(v["failed"], 0); + assert_eq!(v["summary"]["rolledBack"], 0, "a dry run mutates nothing"); + assert_eq!(v["summary"]["failed"], 0); // The preview REPORTS the full plan: would-be manifest removal and // would-be GC counts... assert_eq!( - v["manifest"]["removedEntries"], + crate::rollback_json::manifest_removed(&v), serde_json::json!([fx.purl]), "dry-run previews the would-be removal; stdout=\n{stdout}" ); assert!( - v["gc"].get("skipped").is_none(), + v["gc"].is_object(), "dry-run GC is a preview, not a skip; stdout=\n{stdout}" ); assert_eq!(v["gc"]["removedBlobs"], 2, "stdout=\n{stdout}"); @@ -855,7 +858,7 @@ fn uuid_and_purl_targets_still_work() { let v: serde_json::Value = serde_json::from_str(&stdout).expect("valid JSON"); assert_eq!(v["status"], "success", "stdout=\n{stdout}"); assert_eq!( - v["manifest"]["removedEntries"], + crate::rollback_json::manifest_removed(&v), serde_json::json!(["pkg:npm/dual-a@1.0.0"]), "exactly the uuid's entry is removed; stdout=\n{stdout}" ); @@ -894,7 +897,7 @@ fn uuid_and_purl_targets_still_work() { let v: serde_json::Value = serde_json::from_str(&stdout).expect("valid JSON"); assert_eq!(v["status"], "success", "stdout=\n{stdout}"); assert_eq!( - v["manifest"]["removedEntries"], + crate::rollback_json::manifest_removed(&v), serde_json::json!(["pkg:npm/dual-b@1.0.0"]), "exactly the purl's entry is removed; stdout=\n{stdout}" ); diff --git a/crates/socket-patch-cli/tests/rollback/rollback_invariants.rs b/crates/socket-patch-cli/tests/rollback/rollback_invariants.rs index b041f2fe2..8170f3121 100644 --- a/crates/socket-patch-cli/tests/rollback/rollback_invariants.rs +++ b/crates/socket-patch-cli/tests/rollback/rollback_invariants.rs @@ -4,6 +4,7 @@ //! installed packages. Nothing reaches a real registry; network cases hit an //! unroutable localhost port. +use crate::common::envelope::{codes_in, events}; use crate::common::{binary, git_sha256}; use std::path::{Path, PathBuf}; @@ -54,6 +55,14 @@ const MANIFEST_JSON: &str = r#"{ } }"#; +/// The `action` event for `purl`; panics with the envelope when absent. +fn event_for<'a>(v: &'a serde_json::Value, action: &str, purl: &str) -> &'a serde_json::Value { + events(v) + .iter() + .find(|e| e["action"] == action && e["purl"] == purl) + .unwrap_or_else(|| panic!("no `{action}` event for {purl} in:\n{v:#}")) +} + fn make_socket_dir(root: &Path) -> PathBuf { let socket = root.join(".socket"); std::fs::create_dir_all(&socket).expect("create .socket"); @@ -104,7 +113,11 @@ fn rollback_with_no_manifest_emits_error() { let (code, stdout) = run(tmp.path(), &["--json", "--offline"]); assert_eq!(code, 1, "no manifest must exit 1; stdout=\n{stdout}"); let v: serde_json::Value = serde_json::from_str(&stdout).expect("valid JSON"); + assert_eq!(v["command"], "rollback"); assert_eq!(v["status"], "error"); + assert_eq!(v["error"]["code"], "manifest_not_found"); + assert_eq!(v["events"], serde_json::json!([]), "a full error envelope"); + assert_eq!(v["summary"]["failed"], 0); // Pin the *specific* error so a regression that exits 1 for some other // reason (e.g. ambient env steering it elsewhere) can't pass. let err = v["error"]["message"] @@ -126,7 +139,10 @@ fn rollback_unknown_identifier_emits_error() { ); assert_eq!(code, 1, "unknown identifier must exit 1; stdout=\n{stdout}"); let v: serde_json::Value = serde_json::from_str(&stdout).expect("valid JSON"); + assert_eq!(v["command"], "rollback"); assert_eq!(v["status"], "error"); + assert_eq!(v["error"]["code"], "patch_not_found"); + assert_eq!(v["events"], serde_json::json!([]), "a full error envelope"); let err = v["error"]["message"] .as_str() .expect("error message string"); @@ -149,7 +165,7 @@ fn rollback_offline_with_missing_before_blob_partial_failure() { // anything — and the JSON envelope must SAY so. The bail fires before // the rollback loop produces any per-package results, so the failures // are synthesized — `--json` mutes the stderr explanation, so an - // envelope claiming `failed: 0` with empty `results[]` would leave + // envelope claiming `summary.failed: 0` with no events would leave // machine consumers zero diagnostic. (The package is installed because // an entry with no installed package never enters the blob plan — see // `rollback_only_not_installed_entry_is_never_blob_gated`.) @@ -165,35 +181,38 @@ fn rollback_offline_with_missing_before_blob_partial_failure() { // Nothing could be rolled back: a total failure (#1066). assert_eq!(v["status"], "error"); assert_eq!(v["error"]["code"], "rollback_failed"); - assert_eq!(v["rolledBack"], 0); - assert_eq!(v["alreadyOriginal"], 0); + assert_eq!(v["summary"]["rolledBack"], 0); + assert_eq!(v["summary"]["skipped"], 0); assert_eq!(v["dryRun"], false, "not a dry-run"); // The gated package is counted as failed — same per-package counter - // semantics as a mid-run failure. A `failed: 0` partial_failure is - // self-contradictory. + // semantics as a mid-run failure. assert_eq!( - v["failed"], 1, + v["summary"]["failed"], 1, "the blob-gated package must be counted as failed; stdout=\n{stdout}" ); - let results = v["results"].as_array().expect("results array"); + // Nothing was restored, so the GC was skipped (the retry's revert data + // must survive) and says so. + assert!(v.get("gc").is_none(), "stdout=\n{stdout}"); + assert!(codes_in(&v["warnings"]).contains(&"gc_skipped".to_string())); + let evs = events(&v); assert_eq!( - results.len(), + evs.len(), 1, - "the bail must synthesize one failed result per gated package; stdout=\n{stdout}" + "the bail must synthesize one failed event per gated package; stdout=\n{stdout}" ); - let entry = &results[0]; + let entry = &evs[0]; + assert_eq!(entry["action"], "failed"); + assert_eq!(entry["errorCode"], "missing_blob"); assert_eq!(entry["purl"], "pkg:npm/__rollback_test__@1.0.0"); - assert_eq!(entry["success"], false); assert!( - !entry["path"].as_str().expect("path string").is_empty(), + !entry["details"]["path"] + .as_str() + .expect("path string") + .is_empty(), "a blob-gated package is installed, so its path must be reported; stdout=\n{stdout}" ); - assert_eq!( - entry["filesRolledBack"] - .as_array() - .expect("filesRolledBack array") - .len(), - 0, + assert!( + entry.get("files").is_none(), "nothing was restored on the bail" ); // The error names the remedy; the per-file record names the blob. @@ -202,7 +221,7 @@ fn rollback_offline_with_missing_before_blob_partial_failure() { err.contains("Re-run without --offline") && !err.contains("repair"), "error must carry the re-run remedy (repair cannot fetch originals, #893); got: {err}" ); - let verified = entry["filesVerified"] + let verified = entry["details"]["filesVerified"] .as_array() .expect("filesVerified array"); let file = verified @@ -210,8 +229,8 @@ fn rollback_offline_with_missing_before_blob_partial_failure() { .find(|f| f["file"] == "package/index.js") .unwrap_or_else(|| panic!("gated file must appear in filesVerified; stdout=\n{stdout}")); assert_eq!( - file["status"], "missing_blob", - "the engine's missing_blob vocabulary; stdout=\n{stdout}" + file["status"], "missingBlob", + "the engine's missing_blob vocabulary, camelCased; stdout=\n{stdout}" ); assert_eq!( file["targetHash"], MISSING_BEFORE_HASH, @@ -298,25 +317,25 @@ fn rollback_undownloadable_blob_envelope_names_blob_and_remedy() { // The only patch failed: a total failure (#1066). assert_eq!(v["status"], "error", "stdout=\n{stdout}"); assert_eq!(v["error"]["code"], "rollback_failed", "stdout=\n{stdout}"); - assert_eq!(v["failed"], 1, "stdout=\n{stdout}"); - let results = v["results"].as_array().expect("results array"); - assert_eq!(results.len(), 1, "stdout=\n{stdout}"); - let entry = &results[0]; + assert_eq!(v["summary"]["failed"], 1, "stdout=\n{stdout}"); + let evs = events(&v); + assert_eq!(evs.len(), 1, "stdout=\n{stdout}"); + let entry = &evs[0]; + assert_eq!(entry["action"], "failed"); assert_eq!(entry["purl"], "pkg:npm/__rollback_test__@1.0.0"); - assert_eq!(entry["success"], false); let err = entry["error"].as_str().expect("error message string"); assert!( err.contains("patch API is reachable") && !err.contains("repair"), "error must carry the re-run remedy; got: {err}" ); - let verified = entry["filesVerified"] + let verified = entry["details"]["filesVerified"] .as_array() .expect("filesVerified array"); let file = verified .iter() .find(|f| f["file"] == "package/index.js") .unwrap_or_else(|| panic!("gated file must appear in filesVerified; stdout=\n{stdout}")); - assert_eq!(file["status"], "missing_blob"); + assert_eq!(file["status"], "missingBlob"); assert_eq!(file["targetHash"], MISSING_BEFORE_HASH); let msg = file["message"].as_str().expect("message string"); assert!( @@ -364,11 +383,9 @@ fn rollback_undownloadable_blob_envelope_names_blob_and_remedy() { /// `success`. Apply's job is "make the tree patched", so its all-unmatched /// run is a `partialFailure` — the job was NOT done; rollback's job is /// "make the tree unpatched", and a not-installed package already -/// satisfies that end state. Each such entry is surfaced as one skipped -/// marker appended to `results[]` — `path` null, `skipped: -/// "package_not_installed"`, no `success`/`error` keys — NEVER as a failed -/// result: `failed` stays 0 and no result ever carries `path: ""`. There -/// is no top-level `notInstalled` key. +/// satisfies that end state. Each such entry is surfaced as one `skipped` +/// event with `errorCode: "package_not_installed"` — NEVER as a failed +/// event: `summary.failed` stays 0. fn assert_only_not_installed_envelope(code: i32, stdout: &str) { assert_eq!( code, 0, @@ -376,43 +393,43 @@ fn assert_only_not_installed_envelope(code: i32, stdout: &str) { see the asymmetry contract above); stdout=\n{stdout}" ); let v: serde_json::Value = serde_json::from_str(stdout).expect("valid JSON"); + assert_eq!(v["command"], "rollback"); assert_eq!(v["status"], "success", "stdout=\n{stdout}"); - assert_eq!(v["rolledBack"], 0); - assert_eq!(v["alreadyOriginal"], 0); + assert_eq!(v["summary"]["rolledBack"], 0); assert_eq!( - v["failed"], 0, + v["summary"]["failed"], 0, "nothing was attempted, so nothing failed — a not-installed entry \ is a skip, not a failure; stdout=\n{stdout}" ); - assert!( - v.get("notInstalled").is_none(), - "the top-level notInstalled key was dropped in favor of per-entry \ - skipped markers in results[]; stdout=\n{stdout}" - ); - let results = v["results"].as_array().expect("results array"); + assert_eq!(v["summary"]["skipped"], 1, "stdout=\n{stdout}"); + let skipped: Vec<&serde_json::Value> = events(&v) + .iter() + .filter(|e| e["action"] == "skipped") + .collect(); assert_eq!( - results.len(), + skipped.len(), 1, - "exactly one skipped marker for the one not-installed entry; \ + "exactly one skipped event for the one not-installed entry; \ stdout=\n{stdout}" ); - let marker = &results[0]; - assert_eq!(marker["purl"], "pkg:npm/__rollback_test__@1.0.0"); - assert!( - marker["path"].is_null(), - "no installed tree to name — path must be null, never \"\"; \ - stdout=\n{stdout}" + // The entry still leaves the manifest (the tree is already unpatched). + assert_eq!( + event_for(&v, "removed", "pkg:npm/__rollback_test__@1.0.0")["details"]["manifest"], + true ); + let marker = skipped[0]; + assert_eq!(marker["action"], "skipped"); + assert_eq!(marker["purl"], "pkg:npm/__rollback_test__@1.0.0"); assert_eq!( - marker["skipped"], "package_not_installed", + marker["errorCode"], "package_not_installed", "stdout=\n{stdout}" ); assert!( - marker.get("success").is_none() && marker.get("error").is_none(), - "a skipped marker is not a result record; stdout=\n{stdout}" + marker.get("details").is_none(), + "no installed tree to name; stdout=\n{stdout}" ); assert!( - !stdout.contains("missing_blob") && !stdout.contains("Before blob not found"), + !stdout.contains("missingBlob") && !stdout.contains("Before blob not found"), "a not-installed entry must never surface a blob problem; stdout=\n{stdout}" ); } @@ -484,8 +501,8 @@ fn rollback_not_installed_entry_triggers_no_blob_download() { /// Mixed manifest: one entry INSTALLED with its before-blob missing (must /// still fail with the pinned missing-blob abort envelope), one entry NOT -/// installed (must surface as a skipped marker in `results[]`, never as a -/// failed result, and never with `path: ""`). The failure comes solely +/// installed (must surface as a `skipped` `package_not_installed` event, +/// never as a failed one). The failure comes solely /// from the installed package; the not-installed entry rides along as a /// skip. #[test] @@ -543,24 +560,28 @@ fn rollback_mixed_installed_gated_and_not_installed_entries() { "the installed package's missing blob must fail the run; stdout=\n{stdout}" ); let v: serde_json::Value = serde_json::from_str(&stdout).expect("valid JSON"); - assert_eq!(v["status"], "partial_failure"); + assert_eq!(v["status"], "partialFailure"); assert_eq!( - v["failed"], 1, + v["summary"]["failed"], 1, "only the installed, blob-gated package counts as failed; stdout=\n{stdout}" ); - let results = v["results"].as_array().expect("results array"); + assert_eq!(v["summary"]["skipped"], 1, "the ghost; stdout=\n{stdout}"); + let evs = events(&v); assert_eq!( - results.len(), + evs.len(), 2, - "one failed result for the installed package plus one skipped \ - marker for the ghost — never a synthesized ghost failure; \ + "one failed event for the installed package plus one skipped \ + event for the ghost — never a synthesized ghost failure; \ stdout=\n{stdout}" ); - let entry = &results[0]; + let entry = &evs[0]; + assert_eq!(entry["action"], "failed"); assert_eq!(entry["purl"], "pkg:npm/installed-target@1.0.0"); - assert_eq!(entry["success"], false); assert!( - !entry["path"].as_str().expect("path string").is_empty(), + !entry["details"]["path"] + .as_str() + .expect("path string") + .is_empty(), "the gated package is installed — path must be reported; stdout=\n{stdout}" ); // The pinned missing-blob abort envelope survives for the installed @@ -570,48 +591,28 @@ fn rollback_mixed_installed_gated_and_not_installed_entries() { err.contains("Cannot roll back: ") && err.contains("Re-run without --offline"), "pinned abort error shape; got: {err}" ); - let verified = entry["filesVerified"] + let verified = entry["details"]["filesVerified"] .as_array() .expect("filesVerified array"); assert!( verified .iter() - .any(|f| f["status"] == "missing_blob" && f["targetHash"] == MISSING_BEFORE_HASH), + .any(|f| f["status"] == "missingBlob" && f["targetHash"] == MISSING_BEFORE_HASH), "the missing blob must be named with the engine's vocabulary; stdout=\n{stdout}" ); - // The ghost entry is a skipped marker appended after the real results - // — not a failure — and its (equally missing) before-blob must appear - // nowhere in the failure output. - let marker = &results[1]; + // The ghost entry is a skipped event after the real results — not a + // failure — and its (equally missing) before-blob must appear nowhere + // in the failure output. + let marker = &evs[1]; + assert_eq!(marker["action"], "skipped"); assert_eq!( marker["purl"], "pkg:npm/__ghost__@2.0.0", "stdout=\n{stdout}" ); - assert!( - marker["path"].is_null(), - "no installed tree to name — path must be null, never \"\"; \ - stdout=\n{stdout}" - ); assert_eq!( - marker["skipped"], "package_not_installed", + marker["errorCode"], "package_not_installed", "stdout=\n{stdout}" ); - assert!( - marker.get("success").is_none() && marker.get("error").is_none(), - "a skipped marker is not a result record; stdout=\n{stdout}" - ); - assert!( - v.get("notInstalled").is_none(), - "the top-level notInstalled key was dropped in favor of per-entry \ - skipped markers; stdout=\n{stdout}" - ); - assert!( - results - .iter() - .filter(|r| r.get("skipped").is_none()) - .all(|r| !r["path"].as_str().unwrap_or("").is_empty()), - "no result record may carry an empty path; stdout=\n{stdout}" - ); assert!( !stdout.contains("2222222222222222222222222222222222222222222222222222222222222222"), "the not-installed entry's blob hash must never surface as a \ @@ -636,51 +637,51 @@ fn rollback_json_shape_has_documented_keys() { let v: serde_json::Value = serde_json::from_str(&stdout).expect("valid JSON"); let keys: std::collections::BTreeSet<&str> = v.as_object().unwrap().keys().map(|k| k.as_str()).collect(); - // These keys are documented in CLI_CONTRACT.md as the rollback shape - // (not yet migrated to the unified envelope). Pin them so a future - // migration trips this test instead of breaking wrappers silently. - // The v5.0 duality rework added the always-present additive keys from - // `vendored` onward (vendoredReverted/vendoredPreserved/vendoredKept, - // hosted, manifest, gc, paths). + // v5.0 (MAJOR): rollback prints the unified envelope. Its own payload + // is `hosted.editedFiles` and `paths`; every outcome is an event. for key in [ - "status", + "command", "status", "dryRun", "events", "summary", "hosted", "paths", + ] { + assert!(keys.contains(key), "rollback JSON missing key: {key}"); + } + for gone in [ "rolledBack", "alreadyOriginal", "failed", - "dryRun", - "warnings", "results", "vendored", "vendoredReverted", "vendoredPreserved", "vendoredKept", "vendoredFailed", - "hosted", "manifest", - "gc", - "paths", ] { - assert!(keys.contains(key), "rollback JSON missing key: {key}"); + assert!(!keys.contains(gone), "legacy key {gone} survived: {v:#}"); } - // The hosted/manifest sub-objects carry their documented keys. - assert!(v["hosted"]["reverted"].is_array()); - assert!(v["hosted"]["failed"].is_array()); - assert!(v["hosted"]["unsupported"].is_array()); + assert_eq!(v["command"], "rollback"); assert!(v["hosted"]["editedFiles"].is_number()); - assert!(v["manifest"]["removedEntries"].is_array()); - assert!(v["manifest"]["preserved"].is_boolean()); - // Not-installed entries surface as per-entry `skipped` markers inside - // `results[]` — there is deliberately NO top-level `notInstalled` key. - assert!( - v.get("notInstalled").is_none(), - "notInstalled was dropped in favor of skipped markers in results[]" - ); - // `warnings` is documented as ALWAYS present (empty array when nothing - // fired) so consumers can index `.warnings[]` without null-checking. - assert!( - v["warnings"].is_array(), - "warnings must be an array (present even when empty)" - ); + // `warnings` is the shared envelope key: omitted when nothing fired. + assert!(v.get("warnings").is_none_or(|w| w.is_array())); + // summary == the event counts. + for action in [ + "discovered", + "downloaded", + "applied", + "updated", + "skipped", + "failed", + "removed", + "verified", + "rebuilt", + "rolledBack", + ] { + let n = events(&v).iter().filter(|e| e["action"] == action).count(); + assert_eq!(v["summary"][action], n, "summary.{action}: {v:#}"); + } + // The not-installed entry left the manifest: a `removed` event. + let removed = event_for(&v, "removed", "pkg:npm/__rollback_test__@1.0.0"); + assert_eq!(removed["details"]["manifest"], true); + assert!(v["gc"].is_object(), "the GC ran: {v:#}"); } // --------------------------------------------------------------------------- @@ -757,27 +758,26 @@ fn rollback_restores_file_to_before_content() { ); let v: serde_json::Value = serde_json::from_str(stdout.trim()).expect("valid JSON"); assert_eq!(v["status"], "success"); - assert_eq!(v["rolledBack"], 1); + assert_eq!(v["summary"]["rolledBack"], 1); assert_eq!( - v["failed"], 0, + v["summary"]["failed"], 0, "no file should fail to roll back; stdout={stdout}" ); - assert_eq!(v["alreadyOriginal"], 0, "file was patched, not original"); + assert_eq!(v["summary"]["skipped"], 0, "file was patched, not original"); assert_eq!(v["dryRun"], false, "live rollback, not dry-run"); - // The single result must name our package and actually list the restored file. - let results = v["results"].as_array().expect("results array"); - let entry = results - .iter() - .find(|r| r["purl"] == "pkg:npm/rollback-target@1.0.0") - .unwrap_or_else(|| panic!("missing result entry; stdout={stdout}")); - assert_eq!(entry["success"], true); - let rolled = entry["filesRolledBack"] - .as_array() - .expect("filesRolledBack array"); + // The event must name our package and actually list the restored file. + let entry = event_for(&v, "rolledBack", "pkg:npm/rollback-target@1.0.0"); + assert_eq!(entry["uuid"], "11111111-1111-4111-8111-111111111111"); + let rolled = entry["files"].as_array().expect("files array"); assert!( - rolled.iter().any(|f| f == "package/index.js"), + rolled.iter().any(|f| f["path"] == "package/index.js"), "index.js must be listed as rolled back; stdout={stdout}" ); + // ...and the entry left the manifest. + assert_eq!( + event_for(&v, "removed", "pkg:npm/rollback-target@1.0.0")["details"]["manifest"], + true + ); // The file in node_modules should now contain the BEFORE bytes... let restored = std::fs::read(pkg_dir.join("index.js")).unwrap(); @@ -853,43 +853,24 @@ fn rollback_already_original_skips_work() { assert_eq!(code, 0, "rollback must succeed; stdout={stdout}"); let v: serde_json::Value = serde_json::from_str(stdout.trim()).expect("valid JSON"); assert_eq!(v["status"], "success", "stdout={stdout}"); - assert_eq!(v["alreadyOriginal"], 1); - assert_eq!(v["rolledBack"], 0); + assert_eq!(v["summary"]["skipped"], 1); + assert_eq!(v["summary"]["rolledBack"], 0); assert_eq!( - v["failed"], 0, + v["summary"]["failed"], 0, "no-op must not record a failure; stdout={stdout}" ); assert_eq!(v["dryRun"], false); - // The package must actually be discovered and reported as already-original, - // not merely produce a vacuous zero-work success (which would also satisfy - // rolledBack==0 / alreadyOriginal would then be 0, but pin the entry too). - let results = v["results"].as_array().expect("results array"); - let entry = results - .iter() - .find(|r| r["purl"] == "pkg:npm/already-orig@1.0.0") - .unwrap_or_else(|| panic!("missing result entry; stdout={stdout}")); - assert_eq!(entry["success"], true); - // Nothing was rewritten, so filesRolledBack must be empty... + // The package must actually be discovered and reported as already + // original, not merely produce a vacuous zero-work success. + let entry = event_for(&v, "skipped", "pkg:npm/already-orig@1.0.0"); assert_eq!( - entry["filesRolledBack"] - .as_array() - .expect("filesRolledBack array") - .len(), - 0, - "already-original package must roll back zero files; stdout={stdout}" + entry["errorCode"], "already_original", + "file must verify as already original; stdout={stdout}" ); - // ...and the file must be verified as already at its original state. - let verified = entry["filesVerified"] - .as_array() - .expect("filesVerified array"); - let file = verified - .iter() - .find(|f| f["file"] == "package/index.js") - .expect("index.js must appear in filesVerified"); - assert_eq!( - file["status"], "already_original", - "file must verify as already_original; stdout={stdout}" + assert!( + entry.get("files").is_none(), + "already-original package must roll back zero files; stdout={stdout}" ); // File unchanged, and still hashes to the manifest beforeHash (independent @@ -967,35 +948,42 @@ fn rollback_dry_run_does_not_modify_file() { let v: serde_json::Value = serde_json::from_str(stdout.trim()).expect("valid JSON"); assert_eq!(v["status"], "success", "dry-run status; stdout={stdout}"); assert_eq!(v["dryRun"], true, "dry-run must set dryRun=true"); - // Nothing is actually written in a dry run. - assert_eq!(v["rolledBack"], 0, "dry-run must not roll anything back"); - assert_eq!(v["failed"], 0, "dry-run must not record failures"); - let results = v["results"].as_array().expect("results array"); - let entry = results - .iter() - .find(|r| r["purl"] == "pkg:npm/dry-target@1.0.0") - .unwrap_or_else(|| panic!("dry-run must discover the installed package; stdout={stdout}")); + // Nothing is actually written in a dry run: previews are `verified`. assert_eq!( - entry["success"], true, - "discovered package entry must be success" + v["summary"]["rolledBack"], 0, + "dry-run must not roll anything back" ); - let verified = entry["filesVerified"] - .as_array() - .expect("filesVerified array"); - let file = verified - .iter() - .find(|f| f["file"] == "package/index.js") - .expect("index.js must appear in filesVerified"); - // "ready" means the engine confirmed it COULD restore this file (current - // hash matches the patched AFTER state, before blob available) — i.e. it - // genuinely walked the rollback path, just stopping short of writing. assert_eq!( - file["status"], "ready", - "dry-run must report the file as ready-to-roll-back; stdout={stdout}" + v["summary"]["removed"], 0, + "dry-run must not remove anything" ); assert_eq!( - file["targetHash"], before_hash, - "dry-run must target the BEFORE hash" + v["summary"]["failed"], 0, + "dry-run must not record failures" + ); + // The would-be restore lists the file the engine confirmed it COULD + // restore (current hash matches the patched AFTER state, before blob + // available) — it genuinely walked the rollback path, just stopping + // short of writing. + let entry = events(&v) + .iter() + .find(|e| { + e["action"] == "verified" + && e["purl"] == "pkg:npm/dry-target@1.0.0" + && e["details"].get("manifest").is_none() + }) + .unwrap_or_else(|| panic!("dry-run must discover the installed package; stdout={stdout}")); + let files = entry["files"].as_array().expect("files array"); + assert!( + files.iter().any(|f| f["path"] == "package/index.js"), + "dry-run must report the file as ready-to-roll-back; stdout={stdout}" + ); + // The would-be manifest removal is previewed too. + assert!( + events(&v).iter().any(|e| e["action"] == "verified" + && e["purl"] == "pkg:npm/dry-target@1.0.0" + && e["details"]["manifest"] == true), + "stdout={stdout}" ); // Dry-run must NOT modify the file. @@ -1044,18 +1032,15 @@ fn rollback_honors_manifest_path_override() { "all-not-installed succeeds quietly; stdout={stdout}; stderr={}", String::from_utf8_lossy(&out.stderr) ); - assert_eq!(v["rolledBack"], 0); - assert_eq!(v["failed"], 0, "not-installed is a skip, not a failure"); - assert_eq!(v["alreadyOriginal"], 0); - let results = v["results"].as_array().expect("results array"); + assert_eq!(v["summary"]["rolledBack"], 0); assert_eq!( - results.len(), - 1, - "the override manifest's entry must be the one reported; stdout={stdout}" + v["summary"]["failed"], 0, + "not-installed is a skip, not a failure" ); assert_eq!( - results[0]["skipped"], "package_not_installed", - "stdout={stdout}" + event_for(&v, "skipped", "pkg:npm/__rollback_test__@1.0.0")["errorCode"], + "package_not_installed", + "the override manifest's entry must be the one reported; stdout={stdout}" ); } diff --git a/crates/socket-patch-cli/tests/rollback/rollback_multicopy_blob_gate.rs b/crates/socket-patch-cli/tests/rollback/rollback_multicopy_blob_gate.rs index b60d8fa61..319fc6b66 100644 --- a/crates/socket-patch-cli/tests/rollback/rollback_multicopy_blob_gate.rs +++ b/crates/socket-patch-cli/tests/rollback/rollback_multicopy_blob_gate.rs @@ -259,7 +259,7 @@ fn rollback_downloads_blob_needed_only_by_nested_duplicate_copy() { let v: serde_json::Value = serde_json::from_str(stdout.trim()) .unwrap_or_else(|e| panic!("rollback must emit JSON: {e}; stdout={stdout}")); assert_eq!(v["status"], "success", "envelope={v}"); - assert_eq!(v["failed"], 0, "envelope={v}"); + assert_eq!(v["summary"]["failed"], 0, "envelope={v}"); } /// Anti-overshoot guard: when EVERY copy is already original, the absent @@ -289,7 +289,7 @@ fn rollback_offline_succeeds_when_every_copy_already_original() { let v: serde_json::Value = serde_json::from_str(stdout.trim()) .unwrap_or_else(|e| panic!("rollback must emit JSON: {e}; stdout={stdout}")); assert_eq!(v["status"], "success", "envelope={v}"); - assert_eq!(v["failed"], 0, "envelope={v}"); + assert_eq!(v["summary"]["failed"], 0, "envelope={v}"); assert_eq!(std::fs::read(&index_a).unwrap(), original); assert_eq!(std::fs::read(&index_b).unwrap(), original); } diff --git a/crates/socket-patch-cli/tests/scan/covgap_commands_scan_vendor_flow.rs b/crates/socket-patch-cli/tests/scan/covgap_commands_scan_vendor_flow.rs index 23c19572c..b57471277 100644 --- a/crates/socket-patch-cli/tests/scan/covgap_commands_scan_vendor_flow.rs +++ b/crates/socket-patch-cli/tests/scan/covgap_commands_scan_vendor_flow.rs @@ -266,10 +266,10 @@ fn seed_stale_manifest(root: &Path) { } /// Shared assertions for the vendor-step error fold: exit 1, a JSON -/// envelope with `status: "error"`, the given `error.code` and a `download` -/// sub-object (proof the run got PAST the download phase and died inside -/// the vendor step). Whether a `vendor` sub-object rides along depends on -/// WHERE the step died — see [`assert_no_vendor_envelope`] (lock failures) +/// envelope with `status: "error"`, the given `error.code` and a vendored +/// `downloaded` event (proof the run got PAST the download phase and died +/// inside the vendor step). Whether vendor-engine events ride along depends +/// on WHERE the step died — see [`assert_no_vendor_envelope`] (lock failures) /// and [`assert_demoted_empty_vendor_envelope`] (staging failures). fn assert_vendor_step_error( code: i32, @@ -283,7 +283,11 @@ fn assert_vendor_step_error( assert_eq!(v["status"], "error", "envelope={v}"); assert_eq!(v["error"]["code"], expect_code, "envelope={v}"); assert!( - v["download"].is_object(), + v["events"] + .as_array() + .unwrap() + .iter() + .any(|e| e["action"] == "downloaded" && e["details"]["mode"] == "vendored"), "the run must reach the vendor step (download phase completed); envelope={v}" ); v @@ -295,7 +299,15 @@ fn assert_vendor_step_error( fn assert_no_vendor_envelope(v: &serde_json::Value) { assert!( !v.as_object().unwrap().contains_key("vendor"), - "a pre-lock failure has no vendor envelope to carry; envelope={v}" + "no nested vendor envelope (v5.0); envelope={v}" + ); + assert!( + v["events"] + .as_array() + .unwrap() + .iter() + .all(|e| e["action"] == "downloaded" || e["action"] == "failed"), + "a pre-lock failure carries no vendor-engine event; envelope={v}" ); } @@ -317,15 +329,17 @@ async fn scan_vendor_dry_run_reports_already_vendored() { let (code, stdout, stderr) = run_scan_vendor(tmp.path(), &mock.uri(), &["--dry-run"]); 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["vendor"]["dryRun"], true, "envelope={v}"); - let patches = v["vendor"]["patches"].as_array().expect("vendor preview"); + assert_eq!(v["dryRun"], true, "envelope={v}"); + let patches = v["events"].as_array().expect("vendor preview events"); assert_eq!(patches.len(), 1, "envelope={v}"); assert_eq!(patches[0]["purl"], PURL, "envelope={v}"); - assert_eq!(patches[0]["action"], "already_vendored", "envelope={v}"); + assert_eq!(patches[0]["action"], "skipped", "envelope={v}"); + assert_eq!(patches[0]["errorCode"], "already_vendored", "envelope={v}"); + assert_eq!(patches[0]["details"]["mode"], "vendored", "envelope={v}"); assert_eq!(patches[0]["uuid"], UUID, "envelope={v}"); assert!( !patches[0].as_object().unwrap().contains_key("oldUuid"), - "oldUuid marks would_revendor only; envelope={v}" + "oldUuid marks a re-vendor only; envelope={v}" ); // Non-mutation: no manifest written, lock untouched, no view fetch. @@ -363,32 +377,34 @@ async fn scan_vendor_dry_run_prune_previews_gc_without_mutating() { assert_eq!(code, 0, "stdout={stdout}; stderr={stderr}"); let v: serde_json::Value = serde_json::from_str(stdout.trim()).expect("valid JSON"); - // The vendor dry-run preview ran (empty discovery ⇒ empty preview). - assert_eq!(v["vendor"]["dryRun"], true, "envelope={v}"); - assert_eq!( - v["vendor"]["patches"], - serde_json::json!([]), + // The vendor dry-run preview ran (empty discovery ⇒ no vendored + // preview events). + assert_eq!(v["dryRun"], true, "envelope={v}"); + assert!( + v["events"] + .as_array() + .unwrap() + .iter() + .all(|e| e["details"]["mode"] != "vendored"), "envelope={v}" ); - // The GC preview: the stale entry is PRUNABLE (preview vocabulary), - // not "pruned" (the mutating pass's vocabulary). - let gc = v["gc"] - .as_object() - .unwrap_or_else(|| panic!("--prune must emit a gc sub-object; envelope={v}")); + // The GC preview: the stale entry is a `verified` (would-remove) + // manifest event, never `removed`. assert_eq!( - gc["prunedManifestEntries"], - serde_json::json!([STALE_PURL]), + v["events"], + serde_json::json!([{ + "action": "verified", "purl": STALE_PURL, "details": {"manifest": true}, + }]), "envelope={v}" ); + let gc = v["gc"] + .as_object() + .unwrap_or_else(|| panic!("--prune must emit a gc object; envelope={v}")); assert!( gc.contains_key("bytesFreed") && gc.contains_key("removedBlobs"), "dry+prune uses the one gc shape; gc={gc:?}" ); - assert!( - !gc.contains_key("keptVendoredEntries") && !gc.contains_key("failedVendoredEntries"), - "dry+prune must not claim the wet-only vendored checks; gc={gc:?}" - ); // Nothing mutated: the stale entry survives byte-for-byte. assert_eq!( @@ -415,10 +431,9 @@ async fn scan_vendor_dry_run_prune_previews_gc_without_mutating() { run_scan_vendor(tmp.path(), &mock.uri(), &["--dry-run", "--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!(v["vendor"]["dryRun"], true, "envelope={v}"); + assert_eq!(v["dryRun"], true, "envelope={v}"); assert_eq!( - v["gc"]["prunedManifestEntries"], - serde_json::json!([STALE_PURL]), + v["events"][0]["purl"], STALE_PURL, "the lock-free preview lists under a held lock: {v}" ); assert!( @@ -447,7 +462,7 @@ async fn scan_vendor_lock_held_reports_json_error() { v["error"]["message"], "another socket-patch process is operating in this directory", "the contention message is contract; envelope={v}" ); - assert_eq!(v["download"]["downloaded"], 1, "envelope={v}"); + assert_eq!(v["summary"]["downloaded"], 1, "envelope={v}"); assert!(!tmp.path().join(".socket/vendor").exists()); } @@ -494,9 +509,9 @@ async fn scan_vendor_corrupt_manifest_is_reported_and_stepped_around() { 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["status"], "success", "envelope={v}"); - assert_eq!(v["vendor"]["summary"]["applied"], 1, "envelope={v}"); + assert_eq!(v["summary"]["applied"], 1, "envelope={v}"); assert!( - v["vendor"]["warnings"] + v["warnings"] .as_array() .is_some_and(|ws| ws.iter().any(|w| { w["code"] == "vendor_manifest_migration_failed" @@ -574,7 +589,8 @@ async fn scan_vendor_dry_run_reports_already_vendored_for_vlt() { &[], ); assert_eq!(code, 0, "{env:#}\n{stderr}"); - let rec = &env["vendor"]["patches"][0]; - assert_eq!(rec["action"], "already_vendored", "{env:#}"); + let rec = &env["events"][0]; + assert_eq!(rec["action"], "skipped", "{env:#}"); + assert_eq!(rec["errorCode"], "already_vendored", "{env:#}"); assert_eq!(hosted::read(root, "vlt-lock.json"), lock); } diff --git a/crates/socket-patch-cli/tests/scan/covgap_ecosystem_dispatch.rs b/crates/socket-patch-cli/tests/scan/covgap_ecosystem_dispatch.rs index 24379786a..78dcbbfd0 100644 --- a/crates/socket-patch-cli/tests/scan/covgap_ecosystem_dispatch.rs +++ b/crates/socket-patch-cli/tests/scan/covgap_ecosystem_dispatch.rs @@ -20,7 +20,7 @@ //! crawler at a materialized JSR tree — `DenoCrawler::get_jsr_cache_paths` //! returns the prefix verbatim). The rollback must discover the package //! through `find_packages_for_rollback`'s deno branch, restore the file's -//! ORIGINAL bytes on disk, and report `rolledBack == 1` for the exact PURL. +//! ORIGINAL bytes on disk, and report `summary.rolledBack == 1` for the exact PURL. use crate::common::binary; @@ -260,19 +260,23 @@ fn rollback_dispatch_branch_deno() { "rollback --ecosystems=deno: expected success; env={env}" ); assert_eq!( - env["rolledBack"].as_u64(), + env["summary"]["rolledBack"].as_u64(), Some(1), "rollback --ecosystems=deno: must roll back exactly the one staged jsr package; env={env}" ); - assert_eq!(env["failed"].as_u64(), Some(0), "env={env}"); + assert_eq!(env["summary"]["failed"].as_u64(), Some(0), "env={env}"); assert_eq!( - env["alreadyOriginal"].as_u64(), + env["summary"]["skipped"].as_u64(), Some(0), "rollback --ecosystems=deno: package was patched, not already-original; env={env}" ); - let results = env["results"] + let results: Vec<&Value> = env["events"] .as_array() - .unwrap_or_else(|| panic!("rollback --ecosystems=deno: results missing; env={env}")); + .unwrap_or_else(|| panic!("rollback --ecosystems=deno: events missing; env={env}")) + .iter() + // The restore events, not the manifest-removal ones. + .filter(|e| e["details"]["manifest"] != true) + .collect(); assert_eq!( results.len(), 1, @@ -284,9 +288,9 @@ fn rollback_dispatch_branch_deno() { Value::from(purl), "rollback --ecosystems=deno: rolled-back PURL mismatch; env={env}" ); - assert_eq!(results[0]["success"], true, "env={env}"); + assert_eq!(results[0]["action"], "rolledBack", "env={env}"); assert!( - results[0]["filesRolledBack"] + results[0]["files"] .as_array() .is_some_and(|a| !a.is_empty()), "rollback --ecosystems=deno: must list at least one rolled-back file; env={env}" diff --git a/crates/socket-patch-cli/tests/scan/hosted_symlinked_files.rs b/crates/socket-patch-cli/tests/scan/hosted_symlinked_files.rs index 6bc79e5b5..8e30f0459 100644 --- a/crates/socket-patch-cli/tests/scan/hosted_symlinked_files.rs +++ b/crates/socket-patch-cli/tests/scan/hosted_symlinked_files.rs @@ -468,7 +468,11 @@ async fn hosted_rewrites_the_same_lock_once_it_is_a_regular_file() { let (code, doc, stderr) = scan_hosted_json(&root, &server.uri()); assert_eq!(code, 0, "{doc:#}\n{stderr}"); - assert_eq!(doc["redirect"]["redirected"], 1, "{doc:#}"); + assert_eq!( + crate::common::envelope::hosted_pins(&doc).len(), + 1, + "{doc:#}" + ); assert!(std::fs::read_to_string(root.join("package-lock.json")) .unwrap() .contains(NPM_HOSTED_URL)); @@ -604,7 +608,11 @@ async fn hosted_scan_returns_with_fifo_vlt_lock() { }; assert_eq!(code, 0, "{stdout}\n{stderr}"); let doc: Value = serde_json::from_str(&stdout).unwrap(); - assert_eq!(doc["redirect"]["redirected"], 0, "{doc:#}"); + assert_eq!( + crate::common::envelope::hosted_pins(&doc).len(), + 0, + "{doc:#}" + ); assert_eq!(vlt::artifact_requests(&server).await, 0); let meta = std::fs::symlink_metadata(&fifo).unwrap(); assert!(std::os::unix::fs::FileTypeExt::is_fifo(&meta.file_type())); diff --git a/crates/socket-patch-cli/tests/scan/hosted_wheel_metadata_order.rs b/crates/socket-patch-cli/tests/scan/hosted_wheel_metadata_order.rs index d62680ff6..dcf408cfb 100644 --- a/crates/socket-patch-cli/tests/scan/hosted_wheel_metadata_order.rs +++ b/crates/socket-patch-cli/tests/scan/hosted_wheel_metadata_order.rs @@ -35,6 +35,24 @@ const PKGS: [(&str, &str, &str); 4] = [ ("ddd-pkg", "4.0.0", "44444444-4444-4444-8444-444444444444"), ]; +/// The hosted `skipped` events of a scan envelope as v4's +/// `redirect.skipped[]` rows: `{purl, uuid, reason: , detail: +/// }` (v5.0 records them as `details.mode: "hosted"` events). +fn hosted_skipped(doc: &serde_json::Value) -> Vec { + doc["events"] + .as_array() + .into_iter() + .flatten() + .filter(|e| e["details"]["mode"] == "hosted" && e["action"] == "skipped") + .map(|e| { + serde_json::json!({ + "purl": e["purl"], "uuid": e["uuid"], + "reason": e["errorCode"], "detail": e["reason"], + }) + }) + .collect() +} + fn purl(name: &str, version: &str) -> String { format!("pkg:pypi/{name}@{version}") } @@ -370,9 +388,7 @@ async fn wheel_metadata_failures_fold_in_dep_order() { .unwrap_or_else(|e| panic!("JSON envelope ({e}):\n{stdout}\n{stderr}")); assert_eq!(code, 0, "{doc:#}\n{stderr}"); - let skipped: Vec<(String, String)> = doc["redirect"]["skipped"] - .as_array() - .unwrap_or_else(|| panic!("redirect.skipped: {doc:#}")) + let skipped: Vec<(String, String)> = hosted_skipped(&doc) .iter() .filter(|s| s["reason"] == "python_metadata_unavailable") .map(|s| { @@ -390,7 +406,7 @@ async fn wheel_metadata_failures_fold_in_dep_order() { ], "metadata failures must be reported in dep order: {doc:#}" ); - for s in doc["redirect"]["skipped"].as_array().unwrap() { + for s in &hosted_skipped(&doc) { if s["reason"] == "python_metadata_unavailable" { let detail = s["detail"].as_str().unwrap(); // The detail quotes the URL through the shared redactor (this @@ -400,7 +416,11 @@ async fn wheel_metadata_failures_fold_in_dep_order() { } } // The two good wheels still redirect; the refused two stay upstream. - assert_eq!(doc["redirect"]["redirected"], 2, "{doc:#}"); + assert_eq!( + crate::common::envelope::hosted_pins(&doc).len(), + 2, + "{doc:#}" + ); assert_eq!( std::fs::read(root.join("uv.lock")).unwrap(), lock_before, @@ -492,19 +512,15 @@ async fn rate_limited_wheel_host_matches_the_serial_outcome() { assert_eq!(code, 0, "{doc:#}\n{stderr}"); let log = log.lock().unwrap().clone(); assert_eq!( - doc["redirect"]["redirected"], + crate::common::envelope::hosted_pins(&doc).len(), PKGS.len(), "every wheel redirects, as in the serial loop: {doc:#}\nwheel requests: {log:?}" ); - let metadata_skips: Vec<&Value> = doc["redirect"]["skipped"] - .as_array() - .map(|skipped| { - skipped - .iter() - .filter(|s| s["reason"] == "python_metadata_unavailable") - .collect() - }) - .unwrap_or_default(); + let all_skipped = hosted_skipped(&doc); + let metadata_skips: Vec<&Value> = all_skipped + .iter() + .filter(|s| s["reason"] == "python_metadata_unavailable") + .collect(); assert!( metadata_skips.is_empty(), "no wheel may be skipped: {metadata_skips:?}\nwheel requests: {log:?}" @@ -617,9 +633,7 @@ async fn a_deferred_wheel_attempt_does_not_buy_a_second_retry_budget() { "the flapping wheel gets exactly the serial loop's budget, all of \ it spent before the host's first 200: {log:?}" ); - let skipped: Vec = doc["redirect"]["skipped"] - .as_array() - .unwrap_or_else(|| panic!("redirect.skipped: {doc:#}")) + let skipped: Vec = hosted_skipped(&doc) .iter() .filter(|s| s["reason"] == "python_metadata_unavailable") .map(|s| s["purl"].as_str().unwrap().to_string()) @@ -630,7 +644,7 @@ async fn a_deferred_wheel_attempt_does_not_buy_a_second_retry_budget() { "the exhausted wheel is skipped, as in the serial loop: {doc:#}" ); assert_eq!( - doc["redirect"]["redirected"], + crate::common::envelope::hosted_pins(&doc).len(), PKGS.len() - 1, "the other three still redirect: {doc:#}" ); diff --git a/crates/socket-patch-cli/tests/scan/hosted_yarn_berry_manifest.rs b/crates/socket-patch-cli/tests/scan/hosted_yarn_berry_manifest.rs index b4736c7e4..f828d2608 100644 --- a/crates/socket-patch-cli/tests/scan/hosted_yarn_berry_manifest.rs +++ b/crates/socket-patch-cli/tests/scan/hosted_yarn_berry_manifest.rs @@ -21,6 +21,24 @@ const PURL: &str = "pkg:npm/uuid@9.0.1"; const UUID: &str = "71717171-7171-4171-8171-717171717171"; const TOKEN: &str = "33333333-3333-4333-8333-333333333333"; +/// The hosted `skipped` events of a scan envelope as v4's +/// `redirect.skipped[]` rows: `{purl, uuid, reason: , detail: +/// }` (v5.0 records them as `details.mode: "hosted"` events). +fn hosted_skipped(doc: &serde_json::Value) -> Vec { + doc["events"] + .as_array() + .into_iter() + .flatten() + .filter(|e| e["details"]["mode"] == "hosted" && e["action"] == "skipped") + .map(|e| { + serde_json::json!({ + "purl": e["purl"], "uuid": e["uuid"], + "reason": e["errorCode"], "detail": e["reason"], + }) + }) + .collect() +} + fn tgz(entries: &[(&str, &[u8])]) -> Vec { let mut builder = tar::Builder::new(flate2::write::GzEncoder::new( Vec::new(), @@ -204,7 +222,11 @@ async fn issue_718_hosted_pin_takes_bin_from_the_served_tarball() { let (code, doc, stderr) = scan(&root, &server.uri()); assert_eq!(code, 0, "{doc:#}\n{stderr}"); - assert_eq!(doc["redirect"]["redirected"], 1, "{doc:#}"); + assert_eq!( + crate::common::envelope::hosted_pins(&doc).len(), + 1, + "{doc:#}" + ); let lock = std::fs::read_to_string(root.join("yarn.lock")).unwrap(); assert!( lock.contains(&format!( @@ -225,10 +247,9 @@ async fn unfetchable_served_manifest_skips_the_patch() { write_berry_project(&root); let lock_before = std::fs::read(root.join("yarn.lock")).unwrap(); - let (_, doc, stderr) = scan(&root, &server.uri()); - let skipped: Vec<&Value> = doc["redirect"]["skipped"] - .as_array() - .unwrap_or_else(|| panic!("redirect.skipped: {doc:#}\n{stderr}")) + let (_, doc, _stderr) = scan(&root, &server.uri()); + let all_skipped = hosted_skipped(&doc); + let skipped: Vec<&Value> = all_skipped .iter() .filter(|s| s["reason"] == "npm_manifest_unavailable") .collect(); @@ -240,7 +261,11 @@ async fn unfetchable_served_manifest_skips_the_patch() { !detail.contains(TOKEN) && detail.contains(&format!("//{UUID}/")), "the grant token is redacted: {detail}" ); - assert_eq!(doc["redirect"]["redirected"], 0, "{doc:#}"); + assert_eq!( + crate::common::envelope::hosted_pins(&doc).len(), + 0, + "{doc:#}" + ); assert_eq!( std::fs::read(root.join("yarn.lock")).unwrap(), lock_before, diff --git a/crates/socket-patch-cli/tests/scan/hosted_yarn_classic_sha1.rs b/crates/socket-patch-cli/tests/scan/hosted_yarn_classic_sha1.rs index 36d7b8bca..2e4c469e6 100644 --- a/crates/socket-patch-cli/tests/scan/hosted_yarn_classic_sha1.rs +++ b/crates/socket-patch-cli/tests/scan/hosted_yarn_classic_sha1.rs @@ -200,7 +200,7 @@ async fn issue_558_sha512_only_grant_pins_the_served_tarballs_sha1() { let (code, doc, stderr) = scan(&root, &server.uri()); assert_eq!(code, 0, "{doc:#}\n{stderr}"); - assert_eq!(doc["redirect"]["redirected"], 1, "{doc:#}"); + assert_eq!(common::envelope::hosted_pins(&doc).len(), 1, "{doc:#}"); let lock = std::fs::read_to_string(root.join("yarn.lock")).unwrap(); assert!( lock.contains(&format!( @@ -250,22 +250,23 @@ async fn issue_558_unfetchable_tarball_skips_the_patch() { } fn assert_skipped_untouched(root: &Path, doc: &Value, stderr: &str) -> String { - let skipped: Vec<&Value> = doc["redirect"]["skipped"] - .as_array() - .unwrap_or_else(|| panic!("redirect.skipped: {doc:#}\n{stderr}")) - .iter() - .filter(|s| s["reason"] == "npm_tarball_unavailable") + // v5.0: a hosted skip is a `skipped` event, its code the `errorCode` + // and its detail the `reason`. + let skipped: Vec<&Value> = common::envelope::mode_events(doc, "hosted") + .into_iter() + .filter(|s| s["action"] == "skipped" && s["errorCode"] == "npm_tarball_unavailable") .collect(); + assert!(!skipped.is_empty(), "hosted skip: {doc:#}\n{stderr}"); assert_eq!(skipped.len(), 1, "{doc:#}"); assert_eq!(skipped[0]["purl"], PURL, "{doc:#}"); // The served URL's grant token authorizes the org's download: never // shown, whatever the detail says. - let detail = skipped[0]["detail"].as_str().unwrap(); + let detail = skipped[0]["reason"].as_str().unwrap(); assert!( !detail.contains(TOKEN), "the grant token is redacted: {detail}" ); - assert_eq!(doc["redirect"]["redirected"], 0, "{doc:#}"); + assert!(common::envelope::hosted_pins(&doc).is_empty(), "{doc:#}"); assert_eq!( std::fs::read_to_string(root.join("yarn.lock")).unwrap(), LOCK, @@ -297,8 +298,8 @@ async fn issue_591_served_dependency_the_lock_does_not_lock_is_refused() { let (code, doc, stderr) = scan(&root, &server.uri()); assert_eq!(code, 0, "{doc:#}\n{stderr}"); - assert_eq!(doc["redirect"]["redirected"], 0, "{doc:#}"); - let warning = doc["redirect"]["warnings"] + assert!(common::envelope::hosted_pins(&doc).is_empty(), "{doc:#}"); + let warning = doc["warnings"] .as_array() .unwrap() .iter() diff --git a/crates/socket-patch-cli/tests/scan/scan_ecosystems_scope_e2e.rs b/crates/socket-patch-cli/tests/scan/scan_ecosystems_scope_e2e.rs index be0b26e92..853dd2833 100644 --- a/crates/socket-patch-cli/tests/scan/scan_ecosystems_scope_e2e.rs +++ b/crates/socket-patch-cli/tests/scan/scan_ecosystems_scope_e2e.rs @@ -252,9 +252,16 @@ async fn gc_scan_crawls_the_unselected_ecosystems() { &["-e", "npm", "--sync", "--dry-run"][..], ] { let (v, _) = scan(tmp.path(), extra).await; + let pruned: Vec<&serde_json::Value> = v["events"] + .as_array() + .unwrap() + .iter() + .filter(|e| e["details"]["manifest"] == true) + .map(|e| &e["purl"]) + .collect(); assert_eq!( - v["gc"]["prunedManifestEntries"], - serde_json::json!(["pkg:npm/orphan-npm@9.9.9"]), + pruned, + [&serde_json::json!("pkg:npm/orphan-npm@9.9.9")], "{extra:?}: the installed crate must not read as uninstalled: {v}" ); } diff --git a/crates/socket-patch-cli/tests/scan/scan_invariants.rs b/crates/socket-patch-cli/tests/scan/scan_invariants.rs index e644a9afe..fba8330c7 100644 --- a/crates/socket-patch-cli/tests/scan/scan_invariants.rs +++ b/crates/socket-patch-cli/tests/scan/scan_invariants.rs @@ -144,8 +144,8 @@ async fn scan_with_no_installed_packages_reports_zero() { let v: serde_json::Value = serde_json::from_str(stdout.trim()).expect("valid JSON"); assert_eq!(v["status"], "success"); assert_eq!(v["scannedPackages"], 0); - assert_eq!(v["packagesWithPatches"], 0); - assert_eq!(v["totalPatches"], 0); + assert_eq!(v["packages"], serde_json::json!([])); + crate::common::envelope::assert_envelope_invariants(&v, "scan"); // A project with no installed dependencies crawls zero packages, so // scan must never query the batch API. The zeroed counters above are @@ -199,10 +199,11 @@ async fn scan_reports_available_patch_for_installed_package() { ); let v: serde_json::Value = serde_json::from_str(stdout.trim()).expect("valid JSON"); assert_eq!(v["status"], "success"); - assert_eq!(v["packagesWithPatches"], 1); - assert_eq!(v["totalPatches"], 1); - assert_eq!(v["freePatches"], 1); - assert_eq!(v["paidPatches"], 0); + crate::common::envelope::assert_envelope_invariants(&v, "scan"); + // v5.0: the tier counts derive from `packages[].patches[].tier`. + assert_eq!(v["packages"].as_array().unwrap().len(), 1); + assert_eq!(v["packages"][0]["patches"].as_array().unwrap().len(), 1); + assert_eq!(v["packages"][0]["patches"][0]["tier"], "free"); // The packages array carries per-package patch metadata. let packages = v["packages"].as_array().expect("packages array"); @@ -511,7 +512,7 @@ async fn scan_with_no_manifest_emits_empty_updates() { Some(0), "updates should be empty when no manifest exists; got: {v}" ); - assert_eq!(v["packagesWithPatches"], 1); + assert_eq!(v["packages"].as_array().unwrap().len(), 1); let reqs = recorded(&mock).await; assert_single_batch_carries_purl(&reqs, purl); @@ -617,17 +618,15 @@ async fn scan_apply_dry_run_with_empty_manifest_emits_added_action() { ); let v: serde_json::Value = serde_json::from_str(stdout.trim()).expect("valid JSON"); assert_eq!(v["status"], "success"); - let apply = v["apply"] - .as_object() - .expect("apply object present in --mode agent mode"); - assert_eq!(apply["dryRun"], true); - assert_eq!(apply["found"], 1); - assert_eq!(apply["added"], 1); - assert_eq!(apply["updated"], 0); - assert_eq!(apply["skipped"], 0); - let patches = apply["patches"].as_array().expect("patches array"); + crate::common::envelope::assert_envelope_invariants(&v, "scan"); + assert_eq!(v["dryRun"], true); + assert_eq!(v["summary"]["verified"], 1); + assert_eq!(v["summary"]["skipped"], 0); + let patches = v["events"].as_array().expect("events array"); assert_eq!(patches.len(), 1); - assert_eq!(patches[0]["action"], "added"); + // A would-be new record: `verified`, no `oldUuid`. + assert_eq!(patches[0]["action"], "verified"); + assert!(patches[0].get("oldUuid").is_none(), "{v}"); assert_eq!(patches[0]["uuid"], new_uuid); assert_eq!(patches[0]["purl"], purl); @@ -727,12 +726,11 @@ async fn scan_apply_dry_run_with_existing_uuid_emits_skipped_action() { ); assert_eq!(code, 0); let v: serde_json::Value = serde_json::from_str(stdout.trim()).expect("valid JSON"); - let apply = &v["apply"]; - assert_eq!(apply["skipped"], 1); - assert_eq!(apply["added"], 0); - assert_eq!(apply["updated"], 0); - let patches = apply["patches"].as_array().unwrap(); + assert_eq!(v["summary"]["skipped"], 1); + assert_eq!(v["summary"]["verified"], 0); + let patches = v["events"].as_array().unwrap(); assert_eq!(patches[0]["action"], "skipped"); + assert_eq!(patches[0]["errorCode"], "already_in_manifest"); let reqs = recorded(&mock).await; assert_single_batch_carries_purl(&reqs, purl); @@ -820,12 +818,11 @@ async fn scan_apply_dry_run_with_different_uuid_emits_updated_action() { ); assert_eq!(code, 0); let v: serde_json::Value = serde_json::from_str(stdout.trim()).expect("valid JSON"); - let apply = &v["apply"]; - assert_eq!(apply["updated"], 1); - assert_eq!(apply["added"], 0); - assert_eq!(apply["skipped"], 0); - let patches = apply["patches"].as_array().unwrap(); - assert_eq!(patches[0]["action"], "updated"); + assert_eq!(v["summary"]["verified"], 1); + assert_eq!(v["summary"]["skipped"], 0); + let patches = v["events"].as_array().unwrap(); + // A would-be replacement: `verified` naming the uuid it replaces. + assert_eq!(patches[0]["action"], "verified"); assert_eq!(patches[0]["oldUuid"], old_uuid); assert_eq!(patches[0]["uuid"], new_uuid); @@ -888,12 +885,16 @@ async fn scan_prune_dry_run_reports_prunable_manifest_entries() { let gc = v["gc"] .as_object() .unwrap_or_else(|| panic!("--prune must emit gc field; full envelope was: {v}")); - // Dry-run reports the would-be removals under the one `gc` shape. - let prunable = gc["prunedManifestEntries"] + assert!(gc.contains_key("bytesFreed"), "{v}"); + // Dry-run reports the would-be removals as `verified` manifest events. + let prunable: Vec<&serde_json::Value> = v["events"] .as_array() - .expect("prunedManifestEntries present in dry-run gc"); - assert_eq!(prunable.len(), 1); - assert_eq!(prunable[0], "pkg:npm/uninstalled@1.0.0"); + .unwrap() + .iter() + .filter(|e| e["action"] == "verified" && e["details"]["manifest"] == true) + .collect(); + assert_eq!(prunable.len(), 1, "{v}"); + assert_eq!(prunable[0]["purl"], "pkg:npm/uninstalled@1.0.0"); // Manifest must not have been mutated. let body = std::fs::read_to_string(socket.join("manifest.json")).unwrap(); @@ -951,11 +952,15 @@ async fn scan_prune_removes_stale_manifest_entries() { let (code, stdout, _) = run_scan(tmp.path(), &mock.uri(), &["--prune", "--yes"]); assert_eq!(code, 0); let v: serde_json::Value = serde_json::from_str(stdout.trim()).expect("valid JSON"); - let gc = &v["gc"]; - let pruned = gc["prunedManifestEntries"] + assert!(v["gc"].is_object(), "{v}"); + let pruned: Vec<&serde_json::Value> = v["events"] .as_array() - .expect("prunedManifestEntries present in apply-mode gc"); - assert_eq!(pruned.len(), 1); + .unwrap() + .iter() + .filter(|e| e["action"] == "removed" && e["details"]["manifest"] == true) + .collect(); + assert_eq!(pruned.len(), 1, "{v}"); + assert_eq!(v["summary"]["removed"], 1, "{v}"); let body = std::fs::read_to_string(socket.join("manifest.json")).unwrap(); let manifest: serde_json::Value = serde_json::from_str(&body).unwrap(); @@ -1461,10 +1466,8 @@ async fn scan_agent_over_vendored_purl_surfaces_run_level_warning() { "additive warning must NOT change status; envelope={v}" ); - // The per-patch skip record is unchanged (contract-pinned elsewhere)… - let patches = v["apply"]["patches"] - .as_array() - .expect("apply.patches array"); + // The per-patch skip event (contract-pinned elsewhere)… + let patches = v["events"].as_array().expect("events array"); assert_eq!(patches.len(), 1, "envelope={v}"); assert_eq!(patches[0]["errorCode"], "vendored", "envelope={v}"); @@ -2028,8 +2031,12 @@ async fn vendored_mode_envelopes_omit_redirect_state() { assert_eq!(code, 0, "stdout={stdout}; stderr={stderr}"); let v: serde_json::Value = serde_json::from_str(stdout.trim()).expect("valid JSON"); assert!( - v["vendor"].is_object(), - "vendored dry-run envelope carries its vendor block; envelope={v}" + v["events"] + .as_array() + .unwrap() + .iter() + .any(|e| e["details"]["mode"] == "vendored"), + "vendored dry-run envelope carries its vendored preview events; envelope={v}" ); assert!( v.get("redirectState").is_none(), @@ -2162,7 +2169,7 @@ async fn scan_agent_over_vlt_vendored_purl_surfaces_run_level_warning() { &[], ); assert_eq!(code, 0, "{v:#}\n{stderr}"); - let patches = v["apply"]["patches"].as_array().expect("apply.patches"); + let patches = v["events"].as_array().expect("events"); assert_eq!(patches[0]["errorCode"], "vendored", "{v:#}"); let w = find_warning(&v, "vendored_ownership_retained").unwrap_or_else(|| panic!("{v:#}")); assert!(w["detail"].as_str().unwrap().contains(hosted::PURL), "{w}"); diff --git a/crates/socket-patch-cli/tests/scan/scan_ordered_concurrency_e2e.rs b/crates/socket-patch-cli/tests/scan/scan_ordered_concurrency_e2e.rs index 454ad4396..a09c80c17 100644 --- a/crates/socket-patch-cli/tests/scan/scan_ordered_concurrency_e2e.rs +++ b/crates/socket-patch-cli/tests/scan/scan_ordered_concurrency_e2e.rs @@ -878,7 +878,7 @@ async fn hosted_record_fetch_failures_keep_order_and_lock_bytes() { let (stdout, lock) = &outcomes[0]; let v: serde_json::Value = serde_json::from_str(stdout).unwrap(); - let warnings: Vec<&str> = v["redirect"]["warnings"] + let warnings: Vec<&str> = v["warnings"] .as_array() .unwrap() .iter() diff --git a/crates/socket-patch-cli/tests/scan/scan_paths_e2e.rs b/crates/socket-patch-cli/tests/scan/scan_paths_e2e.rs index 322060998..5e2a5db18 100644 --- a/crates/socket-patch-cli/tests/scan/scan_paths_e2e.rs +++ b/crates/socket-patch-cli/tests/scan/scan_paths_e2e.rs @@ -343,9 +343,16 @@ async fn paths_never_narrow_the_prune_universe() { // Envelope gc block agrees with the on-disk outcome. let v = parse_envelope(&stdout); + let pruned: Vec<&serde_json::Value> = v["events"] + .as_array() + .unwrap() + .iter() + .filter(|e| e["action"] == "removed" && e["details"]["manifest"] == true) + .map(|e| &e["purl"]) + .collect(); assert_eq!( - v["gc"]["prunedManifestEntries"], - serde_json::json!(["pkg:npm/gone@9.9.9"]), + pruned, + [&serde_json::json!("pkg:npm/gone@9.9.9")], "gc must report exactly the orphan as pruned; got {v}" ); assert_eq!( @@ -688,8 +695,8 @@ async fn paths_with_hosted_or_vendored_mode_name_project_directories() { assert_usage_error(&stdout, "path_glob_invalid", "invalid path pattern"); } -/// A `--json` usage error: exactly `{status: "error", error: {code, -/// message}}` on stdout, the message containing `needle`. +/// A `--json` usage error: the full envelope (v5.0) with `status: +/// "error"` and `error: {code, message}`, the message containing `needle`. fn assert_usage_error(stdout: &str, code: &str, needle: &str) { let v = parse_envelope(stdout); assert_eq!(v["status"], "error", "{v}"); @@ -701,7 +708,8 @@ fn assert_usage_error(stdout: &str, code: &str, needle: &str) { "{v}" ); assert!(v.get("errorCode").is_none(), "{v}"); - assert_eq!(v.as_object().unwrap().len(), 2, "{v}"); + crate::common::envelope::assert_envelope_invariants(&v, "scan"); + assert_eq!(v["events"], serde_json::json!([]), "{v}"); } // --------------------------------------------------------------------------- diff --git a/crates/socket-patch-cli/tests/scan/scan_sync_e2e.rs b/crates/socket-patch-cli/tests/scan/scan_sync_e2e.rs index 2580f1e3c..0a1d2bad4 100644 --- a/crates/socket-patch-cli/tests/scan/scan_sync_e2e.rs +++ b/crates/socket-patch-cli/tests/scan/scan_sync_e2e.rs @@ -146,44 +146,20 @@ async fn scan_sync_against_clean_project_adds_and_applies_patch() { "scan --sync against a clean project must fully succeed; envelope={v}" ); - // The apply sub-object MUST be present and report exactly one patch - // discovered, downloaded, and applied with no failures. Guarding this - // behind `if let Some(..)` (as before) let a missing apply object pass. - let apply = v["apply"] - .as_object() - .unwrap_or_else(|| panic!("scan --sync must emit an apply sub-object; envelope={v}")); - assert_eq!(apply["found"], 1, "apply.found; apply={apply:?}"); - assert_eq!(apply["applied"], 1, "apply.applied; apply={apply:?}"); - assert_eq!(apply["failed"], 0, "apply.failed; apply={apply:?}"); - // A fresh add against an empty manifest MUST download the blob exactly once - // and classify it as new (not skipped/updated). Without these a regression - // that double-counts, re-uses a stale cache, or mislabels the action stays - // green on `applied == 1` alone. - assert_eq!( - apply["downloaded"], 1, - "the new patch must be downloaded; apply={apply:?}" - ); - assert_eq!( - apply["skipped"], 0, - "nothing to skip on a fresh add; apply={apply:?}" - ); + // Exactly one patch downloaded (a new record: not skipped / updated) + // and applied, with no failures — the events and `summary` agree. + crate::common::envelope::assert_envelope_invariants(&v, "scan"); assert_eq!( - apply["updated"], 0, - "no manifest entry existed to update; apply={apply:?}" - ); - let patches = apply["patches"].as_array().expect("apply.patches array"); - assert_eq!( - patches.len(), - 1, - "exactly one patch record; apply={apply:?}" - ); - assert_eq!(patches[0]["purl"], purl); - assert_eq!(patches[0]["uuid"], UUID); - assert_eq!( - patches[0]["action"], "added", - "patch must be newly added; record={:?}", - patches[0] + crate::common::envelope::event_triples(&v), + vec![ + (purl.to_string(), "downloaded".to_string(), String::new()), + (purl.to_string(), "applied".to_string(), String::new()), + ], + "envelope={v}" ); + assert_eq!(v["events"][0]["uuid"], UUID); + assert_eq!(v["summary"]["failed"], 0, "envelope={v}"); + assert_eq!(v["summary"]["updated"], 0, "envelope={v}"); // The manifest must exist AND record this exact patch/uuid. let manifest_path = tmp.path().join(".socket/manifest.json"); @@ -379,19 +355,14 @@ async fn scan_apply_with_existing_blob_uses_local_cache() { // installed copy is still pristine, so the nested apply must reconcile it // from the cached blob (#454: a recorded-but-unapplied patch used to be // left unpatched with exit 0). - let apply = v["apply"] - .as_object() - .unwrap_or_else(|| panic!("scan --mode agent must emit an apply sub-object; envelope={v}")); - assert_eq!(apply["found"], 1, "apply.found; apply={apply:?}"); - assert_eq!( - apply["skipped"], 1, - "patch must be skipped; apply={apply:?}" - ); + crate::common::envelope::assert_envelope_invariants(&v, "scan"); + let apply = &v["summary"]; + assert_eq!(apply["skipped"], 1, "patch must be skipped; envelope={v}"); assert_eq!( apply["applied"], 1, - "the recorded patch is applied to the pristine install; apply={apply:?}" + "the recorded patch is applied to the pristine install; envelope={v}" ); - assert_eq!(apply["failed"], 0, "apply.failed; apply={apply:?}"); + assert_eq!(apply["failed"], 0, "envelope={v}"); // The defining claim of this test ("skip the blob download / use the cached // one"): a known UUID with a cached blob must NOT trigger a blob download // and must NOT update the manifest. The original test asserted neither, so @@ -405,14 +376,16 @@ async fn scan_apply_with_existing_blob_uses_local_cache() { apply["updated"], 0, "a skipped patch must not update the manifest; apply={apply:?}" ); - let patches = apply["patches"].as_array().expect("apply.patches array"); - assert_eq!(patches.len(), 1, "apply={apply:?}"); + let patches = v["events"].as_array().expect("events array"); + assert_eq!(patches.len(), 2, "skipped, then applied: {v}"); assert_eq!(patches[0]["uuid"], UUID); assert_eq!( patches[0]["action"], "skipped", "cached/known UUID must yield action=skipped; record={:?}", patches[0] ); + assert_eq!(patches[0]["errorCode"], "already_in_manifest"); + assert_eq!(patches[1]["action"], "applied"); // The skipped record is still applied: index.js now holds the cached // blob's ("after") content, with no blob download (asserted above). @@ -493,18 +466,12 @@ async fn scan_apply_with_no_patches_emits_empty_apply_object() { assert_eq!(code, 0, "stdout={stdout}; stderr={stderr}"); let v: serde_json::Value = serde_json::from_str(stdout.trim()).unwrap(); assert_eq!(v["status"], "success", "envelope={v}"); - let apply = v["apply"].as_object().unwrap(); - assert_eq!(apply["found"], 0, "apply={apply:?}"); - assert_eq!(apply["applied"], 0, "apply={apply:?}"); - assert_eq!(apply["skipped"], 0, "apply={apply:?}"); - assert_eq!(apply["failed"], 0, "apply={apply:?}"); - assert_eq!(apply["downloaded"], 0, "apply={apply:?}"); - // No patches discovered => the patches list must be empty, not just absent. - assert_eq!( - apply["patches"].as_array().expect("patches array").len(), - 0, - "apply.patches must be empty; apply={apply:?}" - ); + crate::common::envelope::assert_envelope_invariants(&v, "scan"); + // No patches discovered => no events at all, and a zero summary. + assert_eq!(v["events"], serde_json::json!([]), "envelope={v}"); + for key in ["applied", "skipped", "failed", "downloaded"] { + assert_eq!(v["summary"][key], 0, "{key}: envelope={v}"); + } // Discovery (batch) must have actually been queried. let reqs = mock.received_requests().await.expect("recorded requests"); @@ -639,17 +606,16 @@ async fn scan_apply_skips_vendored_purl_without_downloading() { let v: serde_json::Value = serde_json::from_str(stdout.trim()).expect("valid JSON"); assert_eq!(v["status"], "success", "envelope={v}"); - let apply = v["apply"].as_object().expect("apply sub-object"); - assert_eq!(apply["found"], 1, "apply={apply:?}"); - assert_eq!(apply["skipped"], 1, "apply={apply:?}"); + let apply = &v["summary"]; + assert_eq!(apply["skipped"], 1, "envelope={v}"); assert_eq!( apply["downloaded"], 0, - "vendored purl must not download; apply={apply:?}" + "vendored purl must not download; envelope={v}" ); - assert_eq!(apply["applied"], 0, "apply={apply:?}"); - assert_eq!(apply["failed"], 0, "apply={apply:?}"); - let patches = apply["patches"].as_array().expect("patches array"); - assert_eq!(patches.len(), 1, "apply={apply:?}"); + assert_eq!(apply["applied"], 0, "envelope={v}"); + assert_eq!(apply["failed"], 0, "envelope={v}"); + let patches = v["events"].as_array().expect("events array"); + assert_eq!(patches.len(), 1, "envelope={v}"); assert_eq!(patches[0]["purl"], purl); assert_eq!(patches[0]["action"], "skipped", "record={:?}", patches[0]); assert_eq!( diff --git a/crates/socket-patch-cli/tests/scan/scan_vendor_step_error_e2e.rs b/crates/socket-patch-cli/tests/scan/scan_vendor_step_error_e2e.rs index 0c604326c..a3969d7a0 100644 --- a/crates/socket-patch-cli/tests/scan/scan_vendor_step_error_e2e.rs +++ b/crates/socket-patch-cli/tests/scan/scan_vendor_step_error_e2e.rs @@ -210,11 +210,18 @@ async fn scan_vendor_download_error_preserves_the_vendor_envelope() { ); let v: serde_json::Value = serde_json::from_str(stdout.trim()) .unwrap_or_else(|e| panic!("stdout must be one JSON object ({e}); stdout={stdout}")); - assert_eq!(v["status"], "partial_failure", "{v}"); - assert_eq!(v["download"]["downloaded"], 1, "{v}"); - assert_eq!(v["download"]["detached"], true, "{v}"); - assert_eq!(v["vendor"]["status"], "partialFailure", "{v}"); - let events = v["vendor"]["events"].as_array().unwrap(); + assert_eq!(v["status"], "partialFailure", "{v}"); + crate::common::envelope::assert_envelope_invariants(&v, "scan"); + assert_eq!(v["summary"]["downloaded"], 1, "{v}"); + // The download event, then the vendor engine's (all `details.mode: + // "vendored"`; no nested vendor envelope). + let all = v["events"].as_array().unwrap(); + assert!( + all.iter().all(|e| e["details"]["mode"] == "vendored"), + "{v}" + ); + assert_eq!(all[0]["action"], "downloaded", "{v}"); + let events: Vec<&serde_json::Value> = all[1..].iter().collect(); assert_eq!(events.len(), 1, "{v}"); assert_eq!(events[0]["purl"], PURL, "{v}"); assert_eq!(events[0]["errorCode"], "apply_failed", "{v}"); diff --git a/crates/socket-patch-cli/tests/scan_api_retry_e2e.rs b/crates/socket-patch-cli/tests/scan_api_retry_e2e.rs index 2ef7df2eb..340c9ac83 100644 --- a/crates/socket-patch-cli/tests/scan_api_retry_e2e.rs +++ b/crates/socket-patch-cli/tests/scan_api_retry_e2e.rs @@ -306,7 +306,7 @@ async fn exhausted_batches_surface_as_json_warnings() { assert_eq!(code, 0, "{stdout}\n{stderr}"); let v = json(&stdout); assert_eq!(v["status"], "success"); - assert_eq!(v["packagesWithPatches"], 4, "{v:#}"); + assert_eq!(v["packages"].as_array().unwrap().len(), 4, "{v:#}"); let w = warnings(&v, "api_batch_failed"); assert_eq!(w.len(), 2, "{v:#}"); // Chunk order is crawl order (readdir), so match either batch number. @@ -396,7 +396,7 @@ async fn max_retries_env_zero_disables_retry() { ); assert_eq!(code, 0, "{stdout}\n{stderr}"); let v = json(&stdout); - assert_eq!(v["packagesWithPatches"], 5); + assert_eq!(v["packages"].as_array().unwrap().len(), 5); let w = warnings(&v, "api_batch_failed"); assert_eq!(w.len(), 1, "{v:#}"); assert!( diff --git a/crates/socket-patch-cli/tests/scan_get_mode_from_project_state.rs b/crates/socket-patch-cli/tests/scan_get_mode_from_project_state.rs index fdd85a544..95b097e8f 100644 --- a/crates/socket-patch-cli/tests/scan_get_mode_from_project_state.rs +++ b/crates/socket-patch-cli/tests/scan_get_mode_from_project_state.rs @@ -362,12 +362,14 @@ async fn bare_scan_keeps_an_agent_project_out_of_hosted_mode() { // fails (exit 1); what matters is which mode ran. let (code, env) = run_cmd(root, &server.uri(), "scan", None, &[]); assert_ne!(code, 2, "the mode is not ambiguous: {env:#}"); + // v5.0: agent events carry no `details.mode`; hosted ones say "hosted". + let events = env["events"].as_array().cloned().unwrap_or_default(); assert!( - env.get("apply").is_some(), + events.iter().any(|e| e["details"]["mode"].is_null()), "the agent apply step ran: {env:#}" ); assert!( - env.get("redirect").is_none(), + env.get("redirect").is_none() && !events.iter().any(|e| e["details"]["mode"] == "hosted"), "no hosted step may run: {env:#}" ); assert_eq!( diff --git a/crates/socket-patch-cli/tests/scan_rollout_e2e.rs b/crates/socket-patch-cli/tests/scan_rollout_e2e.rs index 63adbe95b..173a32307 100644 --- a/crates/socket-patch-cli/tests/scan_rollout_e2e.rs +++ b/crates/socket-patch-cli/tests/scan_rollout_e2e.rs @@ -39,6 +39,45 @@ const ORDER: [&str; 9] = [ "roll-e", "roll-b", "roll-g", "roll-c", "roll-d", "roll-h", "roll-a", "roll-i", "roll-f", ]; +/// The hosted events (`details.mode: "hosted"`) of a scan envelope. +fn hosted_events(v: &Value) -> Vec<&Value> { + v["events"] + .as_array() + .unwrap() + .iter() + .filter(|e| e["details"]["mode"] == "hosted") + .collect() +} + +/// A hosted run's decisions, tense-neutral so a dry run (`verified` pins, +/// "would be written" warning details) compares equal to the wet run it +/// predicts (`applied` pins): each hosted event as `(action, purl, uuid, +/// errorCode)`, the rewritten files and the warning codes. +fn hosted_decisions(v: &Value) -> Value { + let events: Vec = hosted_events(v) + .into_iter() + .map(|e| { + let action = if e["action"] == "verified" { + json!("applied") + } else { + e["action"].clone() + }; + json!([action, e["purl"], e["uuid"], e["errorCode"]]) + }) + .collect(); + let codes: Vec = v["warnings"] + .as_array() + .into_iter() + .flatten() + .map(|w| w["code"].clone()) + .collect(); + json!({ + "events": events, + "rewrittenFiles": v["redirect"]["rewrittenFiles"], + "warnings": codes, + }) +} + fn uuid(name: &str) -> String { let n = PACKAGES.iter().position(|(p, _)| *p == name).unwrap() + 1; format!("{n:08x}-1111-4111-8111-{n:012x}") @@ -341,26 +380,7 @@ async fn hosted_cap_rolls_nine_packages_forward_three_per_run() { let v = run_json(tmp.path(), &mock, &args); // The dry run before each wet run predicts it exactly. // (Warning details switch tense: "would be written" / "was written".) - let decisions = |block: &Value| -> Value { - let mut block = block.clone(); - let obj = block.as_object_mut().unwrap(); - obj.remove("dryRun"); - // `patches[]` rows switch tense too: `would_pin` / `pinned`. - for row in obj["patches"].as_array_mut().unwrap() { - if row["action"] == "would_pin" { - row["action"] = json!("pinned"); - } - } - let codes: Vec = obj["warnings"] - .as_array() - .unwrap() - .iter() - .map(|w| w["code"].clone()) - .collect(); - obj.insert("warnings".into(), Value::Array(codes)); - block - }; - let (predicted, actual) = (decisions(&dry["redirect"]), decisions(&v["redirect"])); + let (predicted, actual) = (hosted_decisions(&dry), hosted_decisions(&v)); assert_eq!( dry["rollout"], v["rollout"], @@ -406,15 +426,14 @@ async fn hosted_cap_rolls_nine_packages_forward_three_per_run() { .map(|d| d["rank"].as_u64().unwrap()) .collect(); assert_eq!(ranks, (4..4 + ranks.len() as u64).collect::>()); - let skipped: Vec<&str> = v["redirect"]["skipped"] - .as_array() - .unwrap() - .iter() - .map(|s| s["reason"].as_str().unwrap()) + let skipped: Vec<&str> = hosted_events(&v) + .into_iter() + .filter(|e| e["action"] == "skipped") + .map(|e| e["errorCode"].as_str().unwrap()) .collect(); assert!(skipped.iter().all(|r| *r == "rollout_deferred")); assert_eq!(skipped.len() as u64, 6 - done); - assert_eq!(v["redirect"]["redirected"], 3 * (run_no as u64 + 1)); + assert_eq!(v["summary"]["applied"], 3 * (run_no as u64 + 1)); previous_lock = std::fs::read(tmp.path().join("package-lock.json")).unwrap(); let mut next_dry = args.to_vec(); next_dry.push("--dry-run"); @@ -490,11 +509,15 @@ async fn hosted_ineligible_top_ranked_patches_hold_no_slot() { v["rollout"]["deferred"][0]["rank"], 3, "ranks count eligible rows only" ); - let reasons: Vec<(&str, &str)> = v["redirect"]["skipped"] - .as_array() - .unwrap() - .iter() - .map(|s| (s["purl"].as_str().unwrap(), s["reason"].as_str().unwrap())) + let reasons: Vec<(&str, &str)> = hosted_events(&v) + .into_iter() + .filter(|e| e["action"] == "skipped") + .map(|e| { + ( + e["purl"].as_str().unwrap(), + e["errorCode"].as_str().unwrap(), + ) + }) .collect(); assert!( reasons.contains(&("pkg:npm/roll-e@1.0.0", "withdrawn")), @@ -574,10 +597,11 @@ async fn agent_cap_rolls_forward_and_upgrades_ignore_the_cap() { let v = run_json(tmp.path(), &mock, &args); if run_no == 1 { assert_eq!(dry["rollout"], v["rollout"], "dry run == wet run"); - let added: Vec<&str> = dry["apply"]["patches"] + let added: Vec<&str> = dry["events"] .as_array() .unwrap() .iter() + .filter(|e| e["action"] == "verified") .map(|p| p["purl"].as_str().unwrap()) .collect(); assert_eq!(added.len(), 3, "{dry}"); @@ -704,7 +728,7 @@ async fn vendored_cap_rolls_forward_three_per_run() { let v = run_json(tmp.path(), &mock, &args); if run_no == 1 { assert_eq!(dry["rollout"], v["rollout"], "dry run == wet run"); - assert_eq!(dry["vendor"]["patches"].as_array().unwrap().len(), 3); + assert_eq!(dry["summary"]["verified"], 3, "{dry}"); } assert_eq!(vendored(tmp.path()), names(&ORDER[..3 * run_no]), "{v}"); } @@ -1255,8 +1279,8 @@ async fn vendored_upgrade_without_a_served_artifact_keeps_the_vendored_patch() { assert_eq!(exit, 0, "{status}: stdout={stdout}\nstderr={stderr}"); let v: Value = serde_json::from_str(stdout.trim()).unwrap(); assert_eq!(v["status"], "success", "{status}: {v:#}"); - assert_eq!(v["vendor"]["summary"]["failed"], 0, "{status}: {v:#}"); - let vendor = v["vendor"].to_string(); + assert_eq!(v["summary"]["failed"], 0, "{status}: {v:#}"); + let vendor = v["events"].to_string(); assert!( vendor.contains(code) && vendor.contains(newer), "{status}: the upgrade is a `{code}` skip: {v:#}" diff --git a/crates/socket-patch-cli/tests/scan_vendor_e2e.rs b/crates/socket-patch-cli/tests/scan_vendor_e2e.rs index ad74f7c48..a903a1e7e 100644 --- a/crates/socket-patch-cli/tests/scan_vendor_e2e.rs +++ b/crates/socket-patch-cli/tests/scan_vendor_e2e.rs @@ -15,6 +15,39 @@ use sha2::{Digest, Sha256}; use wiremock::matchers::{method, path}; use wiremock::{Mock, MockServer, ResponseTemplate}; +/// The envelope's events (v5.0: the download phase and the vendor engine +/// record into the one scan envelope, every event `details.mode: +/// "vendored"`). +fn evs(v: &serde_json::Value) -> &Vec { + v["events"] + .as_array() + .unwrap_or_else(|| panic!("no events array: {v}")) +} + +/// The purls of the GC events with `errorCode == code` (`vendor_reverted`, +/// `vendor_revert_kept`, …), as JSON for one-line asserts. +fn gc_purls(v: &serde_json::Value, code: &str) -> serde_json::Value { + evs(v) + .iter() + .filter(|e| e["errorCode"] == code) + .map(|e| e["purl"].clone()) + .collect() +} + +/// The purls of the pruned manifest entries (`details.manifest: true`). +fn pruned_purls(v: &serde_json::Value) -> serde_json::Value { + evs(v) + .iter() + .filter(|e| e["details"]["manifest"] == true) + .map(|e| e["purl"].clone()) + .collect() +} + +/// The events with `action`. +fn with_action<'a>(v: &'a serde_json::Value, action: &str) -> Vec<&'a serde_json::Value> { + evs(v).iter().filter(|e| e["action"] == action).collect() +} + #[path = "npm_e2e_common/manifestless.rs"] mod npm_e2e_common; #[path = "vex_e2e_common/mod.rs"] @@ -247,11 +280,11 @@ async fn scan_vendor_end_to_end_is_manifest_free() { assert_eq!(v["status"], "success", "envelope={v}"); // Download phase: the record fetched in memory, nothing written. - let dl = v["download"].as_object().expect("download sub-object"); - assert_eq!(dl["downloaded"], 1, "download={dl:?}"); - assert_eq!(dl["failed"], 0, "download={dl:?}"); - assert_eq!(dl["detached"], true, "download={dl:?}"); - assert_eq!(dl["patches"][0]["action"], "downloaded", "download={dl:?}"); + assert_eq!(v["command"], "scan", "envelope={v}"); + assert_eq!(v["summary"]["downloaded"], 1, "envelope={v}"); + assert_eq!(v["summary"]["failed"], 0, "envelope={v}"); + let dl = with_action(&v, "downloaded"); + assert_eq!(dl[0]["details"]["mode"], "vendored", "envelope={v}"); assert!( !tmp.path().join(".socket/manifest.json").exists(), "vendored mode never writes a manifest" @@ -264,11 +297,10 @@ async fn scan_vendor_end_to_end_is_manifest_free() { "the view is fetched exactly once" ); - // Vendor phase: a full vendor Envelope with one applied event. - let venv = v["vendor"].as_object().expect("vendor sub-object"); - assert_eq!(venv["command"], "vendor", "vendor={venv:?}"); - assert_eq!(venv["status"], "success", "vendor={venv:?}"); - assert_eq!(venv["summary"]["applied"], 1, "vendor={venv:?}"); + // Vendor phase: merged into the same envelope, one applied event. + assert!(v.get("vendor").is_none(), "no nested vendor envelope: {v}"); + assert_eq!(v["summary"]["applied"], 1, "envelope={v}"); + assert_eq!(with_action(&v, "applied")[0]["details"]["mode"], "vendored"); // Disk: tarball at the contract path, ledger entry DETACHED with the // embedded record (the verification source), lock rewired to consume @@ -316,9 +348,9 @@ async fn scan_vendor_end_to_end_is_manifest_free() { assert_eq!(code, 0, "stdout={stdout}; stderr={stderr}"); let v2: serde_json::Value = serde_json::from_str(stdout.trim()).unwrap(); assert_eq!(v2["status"], "success", "envelope={v2}"); - assert_eq!(v2["download"]["skipped"], 1, "envelope={v2}"); - assert_eq!(v2["vendor"]["summary"]["applied"], 0, "envelope={v2}"); - let events = v2["vendor"]["events"].as_array().expect("events"); + assert!(with_action(&v2, "downloaded").is_empty(), "reused: {v2}"); + assert_eq!(v2["summary"]["applied"], 0, "envelope={v2}"); + let events = evs(&v2); assert!( events .iter() @@ -414,9 +446,8 @@ async fn scan_vendor_with_empty_discovery_is_a_no_op() { assert_eq!(code, 0, "json={json}; stdout={stdout}; stderr={stderr}"); if json { let v: serde_json::Value = serde_json::from_str(stdout.trim()).expect("valid JSON"); - assert_eq!(v["download"]["found"], 0, "{v}"); - assert_eq!(v["download"]["detached"], true, "{v}"); - assert_eq!(v["vendor"]["summary"]["applied"], 0, "{v}"); + assert_eq!(v["events"], serde_json::json!([]), "{v}"); + assert_eq!(v["summary"]["applied"], 0, "{v}"); } assert!( !tmp.path().join(".socket/vendor").exists(), @@ -438,7 +469,7 @@ async fn scan_vendor_with_empty_discovery_is_a_no_op() { /// NON-detached ledger entry at the same uuid): the next vendored run /// migrates it — the ledger entry gains `detached: true` plus the embedded /// record, the manifest record moves out (an emptied manifest stays as -/// `{"patches":{}}`), the run says so in `vendor.warnings[]` — and the run +/// `{"patches":{}}`), the run says so in `warnings[]` — and the run /// after that is a fetch-free `skipped` re-run with nothing left to /// migrate. #[tokio::test] @@ -466,9 +497,9 @@ async fn scan_vendor_migrates_legacy_manifest_mode_project() { let v: serde_json::Value = serde_json::from_str(stdout.trim()).expect("valid JSON"); assert_eq!(v["status"], "success", "{v}"); // A legacy entry carries no record, so the view is fetched once more… - assert_eq!(v["download"]["downloaded"], 1, "{v}"); + assert_eq!(v["summary"]["downloaded"], 1, "{v}"); // …and the engine finds artifact + wiring already in sync. - let events = v["vendor"]["events"].as_array().expect("events"); + let events = evs(&v); assert!( events .iter() @@ -476,7 +507,7 @@ async fn scan_vendor_migrates_legacy_manifest_mode_project() { "{v}" ); assert!( - v["vendor"]["warnings"] + v["warnings"] .as_array() .is_some_and(|ws| ws.iter().any(|w| { w["code"] == "vendor_manifest_record_migrated" @@ -509,9 +540,9 @@ async fn scan_vendor_migrates_legacy_manifest_mode_project() { let (code, stdout, stderr) = run_scan_vendor(tmp.path(), &mock.uri(), &[]); assert_eq!(code, 0, "stdout={stdout}; stderr={stderr}"); let v2: serde_json::Value = serde_json::from_str(stdout.trim()).unwrap(); - assert_eq!(v2["download"]["skipped"], 1, "{v2}"); + assert!(with_action(&v2, "downloaded").is_empty(), "{v2}"); assert!( - v2["vendor"].get("warnings").is_none(), + v2.get("warnings").is_none(), "nothing left to migrate: {v2}" ); let after_reqs = mock.received_requests().await.unwrap(); @@ -536,8 +567,7 @@ async fn scan_vendor_writes_no_manifest() { 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["status"], "success", "envelope={v}"); - assert_eq!(v["download"]["detached"], true, "envelope={v}"); - assert_eq!(v["vendor"]["summary"]["applied"], 1, "envelope={v}"); + assert_eq!(v["summary"]["applied"], 1, "envelope={v}"); // Embedded VEX works manifest-less: the detached entry's embedded // record is the attestation source. @@ -592,8 +622,8 @@ async fn scan_vendor_writes_no_manifest() { let (code, stdout, _) = run_scan_vendor(tmp.path(), &mock.uri(), &[]); assert_eq!(code, 0, "stdout={stdout}"); let v2: serde_json::Value = serde_json::from_str(stdout.trim()).unwrap(); - assert_eq!(v2["download"]["skipped"], 1, "envelope={v2}"); - assert_eq!(v2["download"]["downloaded"], 0, "envelope={v2}"); + assert_eq!(v2["summary"]["downloaded"], 0, "envelope={v2}"); + assert!(with_action(&v2, "failed").is_empty(), "envelope={v2}"); let after_reqs = mock.received_requests().await.unwrap(); assert!( !after_reqs[before_reqs..] @@ -610,7 +640,7 @@ async fn scan_vendor_writes_no_manifest() { #[tokio::test] async fn scan_vendor_dry_run_previews_without_touching_disk() { // Pre-vendored at UUID; discovery now offers NEW_UUID. The dry run - // must classify it as would_revendor (oldUuid = UUID) and write + // must preview it as a re-vendor (`verified`, oldUuid = UUID) and write // nothing — no view fetch, no lock edit, no vendor tree change. let mock = MockServer::start().await; mount_patch_api(&mock, NEW_UUID).await; @@ -640,12 +670,14 @@ async fn scan_vendor_dry_run_previews_without_touching_disk() { let (code, stdout, stderr) = run_scan_vendor(tmp.path(), &mock.uri(), &["--dry-run"]); assert_eq!(code, 0, "stdout={stdout}; stderr={stderr}"); let v: serde_json::Value = serde_json::from_str(stdout.trim()).expect("valid JSON"); - let patches = v["vendor"]["patches"].as_array().expect("vendor preview"); + let patches = evs(&v); assert_eq!(patches.len(), 1, "envelope={v}"); assert_eq!(patches[0]["purl"], PURL); - assert_eq!(patches[0]["action"], "would_revendor", "envelope={v}"); + assert_eq!(patches[0]["action"], "verified", "envelope={v}"); + assert_eq!(patches[0]["details"]["mode"], "vendored", "envelope={v}"); assert_eq!(patches[0]["oldUuid"], UUID, "envelope={v}"); assert_eq!(patches[0]["uuid"], NEW_UUID, "envelope={v}"); + assert_eq!(v["dryRun"], true, "envelope={v}"); assert!( !tmp.path().join(".socket/manifest.json").exists(), @@ -991,18 +1023,18 @@ async fn scan_vendor_resolves_percent_encoded_scoped_purl() { "vendored mode never writes a manifest" ); assert_eq!( - v["gc"]["prunedManifestEntries"], + pruned_purls(&v), serde_json::json!([]), "nothing looks prunable: {v}" ); assert_eq!( - v["gc"]["revertedVendoredEntries"], + gc_purls(&v, "vendor_reverted"), serde_json::json!([]), "the just-vendored entry is lock-visible and must not be reverted: {v}" ); // Vendored: artifact under the DECODED scope dir, lock rewired. - assert_eq!(v["vendor"]["summary"]["applied"], 1, "envelope={v}"); + assert_eq!(v["summary"]["applied"], 1, "envelope={v}"); let tgz = tmp.path().join(format!( ".socket/vendor/npm/{UUID}/@scope/left-pad-1.3.0.tgz" )); @@ -1106,12 +1138,12 @@ async fn scan_prune_reverts_unused_vendored_entry() { // 1. Vanished lock entry: reverted in one run. let v = run_prune(); assert_eq!( - v["gc"]["revertedVendoredEntries"], + gc_purls(&v, "vendor_reverted"), serde_json::json!([PURL]), "gc must report the reverted entry: {v}" ); assert_eq!( - v["gc"]["keptVendoredEntries"], + gc_purls(&v, "vendor_revert_kept"), serde_json::json!([]), "nothing resolves through the artifact, so nothing is kept: {v}" ); @@ -1119,8 +1151,8 @@ async fn scan_prune_reverts_unused_vendored_entry() { // routine for a prune of an uninstalled dependency, so it is not a // gc warning. assert!( - v["gc"].get("warnings").is_none(), - "a routine prune adds no gc warning: {v}" + v.get("warnings").is_none(), + "a routine prune adds no warning: {v}" ); // Ledger empty (an emptied state file is removed outright), artifact @@ -1151,7 +1183,7 @@ async fn scan_prune_reverts_unused_vendored_entry() { // 2. Nothing left to reclaim. let v = run_prune(); assert_eq!( - v["gc"]["revertedVendoredEntries"], + gc_purls(&v, "vendor_reverted"), serde_json::json!([]), "{v}" ); @@ -1229,14 +1261,24 @@ async fn scan_prune_reverts_vendored_entry_after_version_upgrade() { let stdout = String::from_utf8_lossy(&out.stdout).into_owned(); assert_eq!(out.status.code(), Some(0), "stdout={stdout}"); let v: serde_json::Value = serde_json::from_str(stdout.trim()).expect("valid JSON"); + // v5.0: the GC's reverts are `vendor_reverted` events, its drift keeps + // `vendor_revert_kept` events. + let purls_with = |code: &str| -> Vec { + v["events"] + .as_array() + .into_iter() + .flatten() + .filter(|e| e["errorCode"] == code) + .map(|e| e["purl"].clone()) + .collect() + }; assert_eq!( - v["gc"]["revertedVendoredEntries"], - serde_json::json!([PURL]), + purls_with("vendor_reverted"), + vec![serde_json::json!(PURL)], "gc must revert the upgraded-away entry: {v}" ); - assert_eq!( - v["gc"]["keptVendoredEntries"], - serde_json::json!([]), + assert!( + purls_with("vendor_revert_kept").is_empty(), "nothing resolves through the artifact, so nothing is kept: {v}" ); assert!( @@ -1388,12 +1430,12 @@ async fn scan_vendor_prune_reconciles_unwired_entry_on_an_empty_crawl() { // entry away (nothing resolves through the artifact) it reverts it // (#665; see `scan_prune_reverts_unused_vendored_entry`). assert_eq!( - v["gc"]["revertedVendoredEntries"], + gc_purls(&v, "vendor_reverted"), serde_json::json!([PURL]), "envelope={v}" ); assert_eq!( - v["gc"]["keptVendoredEntries"], + gc_purls(&v, "vendor_revert_kept"), serde_json::json!([]), "envelope={v}" ); @@ -1876,7 +1918,7 @@ async fn scan_vendor_works_on_a_completely_fresh_clone() { 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["lockfileOnlyPackages"], 1, "{v}"); - assert_eq!(v["vendor"]["summary"]["applied"], 1, "{v}"); + assert_eq!(v["summary"]["applied"], 1, "{v}"); assert!(tmp .path() .join(format!(".socket/vendor/npm/{UUID}/left-pad-1.3.0.tgz")) @@ -1888,7 +1930,7 @@ async fn scan_vendor_works_on_a_completely_fresh_clone() { 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 events = v["vendor"]["events"].as_array().unwrap(); + let events = evs(&v); assert!( events.iter().any(|e| e["errorCode"] == "already_vendored"), "{v}" @@ -2132,7 +2174,7 @@ async fn scan_apply_skips_lockfile_only_without_error() { let v: serde_json::Value = serde_json::from_str(stdout.trim()).expect("valid JSON"); assert_eq!(code, 0, "lockfile-only must not flip the exit code: {v}"); assert_eq!(v["status"], "success", "{v}"); - let patches = v["apply"]["patches"].as_array().unwrap(); + let patches = evs(&v); assert!( patches .iter() @@ -2194,21 +2236,17 @@ async fn scan_vendored_bun_v1_workspace_refuses_in_download_phase() { let (code, stdout, stderr) = run_scan_vendor(tmp.path(), &mock.uri(), &[]); assert_eq!(code, 1, "stdout={stdout}; stderr={stderr}"); let v: serde_json::Value = serde_json::from_str(stdout.trim()).expect("valid JSON"); - assert_eq!(v["status"], "partial_failure", "envelope={v}"); - let dl = &v["download"]; - assert_eq!(dl["found"], 1, "envelope={v}"); - assert_eq!(dl["downloaded"], 0, "envelope={v}"); - assert_eq!(dl["failed"], 1, "envelope={v}"); - assert_eq!(dl["patches"][0]["purl"], PURL, "envelope={v}"); - assert_eq!(dl["patches"][0]["action"], "failed", "envelope={v}"); - assert_eq!(dl["patches"][0]["errorCode"], BUN_WS_CODE, "envelope={v}"); + assert_eq!(v["status"], "partialFailure", "envelope={v}"); + assert_eq!(v["summary"]["downloaded"], 0, "envelope={v}"); + assert_eq!(v["summary"]["failed"], 1, "envelope={v}"); + let refused = with_action(&v, "failed"); + assert_eq!(refused[0]["purl"], PURL, "envelope={v}"); + assert_eq!(refused[0]["errorCode"], BUN_WS_CODE, "envelope={v}"); assert!( - dl["patches"][0]["error"] - .as_str() - .is_some_and(|d| !d.is_empty()), - "the refused record carries the engine's detail: {v}" + refused[0]["error"].as_str().is_some_and(|d| !d.is_empty()), + "the refused event carries the engine's detail: {v}" ); - assert_eq!(v["vendor"]["summary"]["applied"], 0, "envelope={v}"); + assert_eq!(v["summary"]["applied"], 0, "envelope={v}"); let reqs = mock.received_requests().await.unwrap(); assert!( @@ -2328,10 +2366,10 @@ async fn scan_vendored_vlt_transitive_refuses_in_download_phase() { let (code, stdout, stderr) = run_scan_vendor(tmp.path(), &mock.uri(), &[]); assert_eq!(code, 1, "stdout={stdout}; stderr={stderr}"); let v: serde_json::Value = serde_json::from_str(stdout.trim()).expect("valid JSON"); - let dl = &v["download"]; - assert_eq!(dl["failed"], 1, "envelope={v}"); + assert_eq!(v["summary"]["failed"], 1, "envelope={v}"); assert_eq!( - dl["patches"][0]["errorCode"], VLT_TRANSITIVE_CODE, + with_action(&v, "failed")[0]["errorCode"], + VLT_TRANSITIVE_CODE, "envelope={v}" ); assert_eq!( @@ -2348,10 +2386,11 @@ async fn scan_vendored_vlt_transitive_refuses_in_download_phase() { let (code, stdout, stderr) = run_scan_vendor(tmp.path(), &mock.uri(), &["--dry-run"]); assert_eq!(code, 0, "stdout={stdout}; stderr={stderr}"); let v: serde_json::Value = serde_json::from_str(stdout.trim()).expect("valid JSON"); - let text = v.to_string(); assert!( - text.contains("would_refuse") && text.contains(VLT_TRANSITIVE_CODE), - "envelope={v}" + evs(&v) + .iter() + .any(|e| e["action"] == "skipped" && e["errorCode"] == VLT_TRANSITIVE_CODE), + "the preview names the refusal: {v}" ); assert!(!tmp.path().join(".socket").exists()); } @@ -2927,11 +2966,14 @@ snapshots: let (_code, stdout, stderr) = run_scan_vendor(tmp.path(), &mock.uri(), &[]); let v: serde_json::Value = serde_json::from_str(stdout.trim()) .unwrap_or_else(|e| panic!("valid JSON: {e}\nstdout={stdout}\nstderr={stderr}")); - let events = v["vendor"]["events"].as_array().expect("vendor events"); + // The vendor engine's events (the download phase's are `downloaded`). + let events = evs(&v); let event_for = |purl: &str| { events .iter() - .find(|e| e["purl"] == purl && e["action"] != "skipped") + .find(|e| { + e["purl"] == purl && e["action"] != "skipped" && e["action"] != "downloaded" + }) .unwrap_or_else(|| panic!("no vendor event for {purl}: {v}")) }; assert_eq!(event_for(COMPOSER[0].0)["action"], "applied", "{v}"); @@ -2979,8 +3021,14 @@ snapshots: let (_code, stdout, stderr) = run_scan_vendor(tmp.path(), &mock.uri(), &[]); let v: serde_json::Value = serde_json::from_str(stdout.trim()) .unwrap_or_else(|e| panic!("valid JSON: {e}\nstdout={stdout}\nstderr={stderr}")); - let events = v["vendor"]["events"].as_array().expect("vendor events"); - let event_for = |name: &str| events.iter().find(|e| e["purl"] == purl(name)); + // The vendor engine's events (the download phase's are `downloaded` + // / its refusals `failed`, checked below). + let events = evs(&v); + let event_for = |name: &str| { + events + .iter() + .find(|e| e["purl"] == purl(name) && e["action"] != "downloaded") + }; assert_eq!( event_for("pkg-a").expect("pkg-a event")["action"], "applied", @@ -2991,17 +3039,17 @@ snapshots: "applied", "{v}" ); - assert!( - event_for("pkg-b").is_none(), - "refused before the vendor step: {v}" - ); - let refused = v["download"]["patches"] - .as_array() - .and_then(|p| p.iter().find(|r| r["purl"] == purl("pkg-b"))) - .unwrap_or_else(|| panic!("no download record for pkg-b: {v}")); + // Refused before the vendor step: its one event is the download + // phase's `failed`. + let b_events: Vec<_> = events + .iter() + .filter(|e| e["purl"] == purl("pkg-b")) + .collect(); + assert_eq!(b_events.len(), 1, "refused before the vendor step: {v}"); + let refused = b_events[0]; assert_eq!(refused["action"], "failed", "{v}"); assert_eq!(refused["errorCode"], "vendor_lock_entry_unsupported", "{v}"); - assert_eq!(v["download"]["failed"], 1, "{v}"); + assert_eq!(v["summary"]["failed"], 1, "{v}"); let mut granted = granted_uuids(&mock).await; granted.sort(); @@ -3129,19 +3177,49 @@ snapshots: assert!(registry.is_empty(), "no pristine fetch: {registry:?}"); } - fn record_for<'a>(records: &'a serde_json::Value, purl: &str) -> &'a serde_json::Value { - records + /// The download phase's event for `purl`: its first event, a + /// `downloaded` or (refused / failed fetch) `failed` one — the download + /// phase records before the vendor engine in the one envelope. + fn download_event<'a>(v: &'a serde_json::Value, purl: &str) -> Option<&'a serde_json::Value> { + v["events"] .as_array() - .and_then(|p| p.iter().find(|r| r["purl"] == purl)) - .unwrap_or_else(|| panic!("no record for {purl}: {records}")) + .expect("events") + .iter() + .find(|e| e["purl"] == purl) + .filter(|e| e["action"] == "downloaded" || e["action"] == "failed") + } + + fn record_for<'a>(v: &'a serde_json::Value, purl: &str) -> &'a serde_json::Value { + download_event(v, purl).unwrap_or_else(|| panic!("no download event for {purl}: {v}")) } + /// `(downloaded, failed)` counted over the download phase's events. + fn download_counts(v: &serde_json::Value) -> (usize, usize) { + let purls: std::collections::BTreeSet<&str> = v["events"] + .as_array() + .expect("events") + .iter() + .filter_map(|e| e["purl"].as_str()) + .collect(); + let records: Vec<&serde_json::Value> = + purls.iter().filter_map(|p| download_event(v, p)).collect(); + ( + records + .iter() + .filter(|e| e["action"] == "downloaded") + .count(), + records.iter().filter(|e| e["action"] == "failed").count(), + ) + } + + /// The vendor engine's events for `purl` (its download event left out). fn events_for<'a>(v: &'a serde_json::Value, purl: &str) -> Vec<(&'a str, &'a str)> { - v["vendor"]["events"] + let skip = download_event(v, purl).map(|e| e as *const serde_json::Value); + v["events"] .as_array() - .expect("vendor events") + .expect("events") .iter() - .filter(|e| e["purl"] == purl) + .filter(|e| e["purl"] == purl && Some(*e as *const serde_json::Value) != skip) .map(|e| { ( e["action"].as_str().unwrap_or_default(), @@ -3190,7 +3268,7 @@ snapshots: &api_argv(&uri, &["scan", "--mode", "vendored"]), &[("SOCKET_NPM_REGISTRY", registry.as_str())], ); - let dl = &v["download"]["patches"]; + let dl = &v; let b = record_for(dl, "pkg:npm/pkg-b@1.0.0"); assert_eq!( (&b["action"], &b["errorCode"]), @@ -3214,11 +3292,7 @@ snapshots: "downloaded", "not refused early: {v}" ); - assert_eq!( - (&v["download"]["downloaded"], &v["download"]["failed"]), - (&serde_json::json!(2), &serde_json::json!(2)), - "{v}" - ); + assert_eq!(download_counts(&v), (2, 2), "{v}"); assert_eq!( events_for(&v, "pkg:npm/pkg-z@1.0.0"), vec![("failed", "vendor_lock_entry_not_found")], @@ -3257,11 +3331,11 @@ snapshots: &[("SOCKET_NPM_REGISTRY", registry.as_str())], ); assert_eq!( - record_for(&v["patches"], "pkg:npm/pkg-z@1.0.0")["action"], + record_for(&v, "pkg:npm/pkg-z@1.0.0")["action"], "downloaded", "{v}" ); - assert_eq!(v["failed"], 0, "{v}"); + assert_eq!(download_counts(&v).1, 0, "{v}"); assert_eq!( events_for(&v, "pkg:npm/pkg-z@1.0.0"), vec![("failed", "vendor_lock_entry_not_found")], @@ -3275,7 +3349,7 @@ snapshots: &api_argv(&uri, &["get", "pkg:npm/pkg-b@1.0.0", "--mode", "vendored"]), &[("SOCKET_NPM_REGISTRY", registry.as_str())], ); - let b = record_for(&v["patches"], "pkg:npm/pkg-b@1.0.0"); + let b = record_for(&v, "pkg:npm/pkg-b@1.0.0"); assert_eq!( (&b["action"], &b["errorCode"]), ( @@ -3357,7 +3431,7 @@ snapshots: &api_argv(&uri, &["scan", "--mode", "vendored"]), &env, ); - let dl = &v["download"]["patches"]; + let dl = &v; let installed = record_for(dl, CARGO_SCOPE[0].0); assert_eq!( (&installed["action"], &installed["errorCode"]), @@ -3390,7 +3464,7 @@ snapshots: &env, ); assert_eq!( - record_for(&v["patches"], CARGO_SCOPE[1].0)["action"], + record_for(&v, CARGO_SCOPE[1].0)["action"], "downloaded", "{v}" ); @@ -3404,7 +3478,7 @@ snapshots: &api_argv(&uri, &["get", CARGO_SCOPE[0].0, "--mode", "vendored"]), &env, ); - let installed = record_for(&v["patches"], CARGO_SCOPE[0].0); + let installed = record_for(&v, CARGO_SCOPE[0].0); assert_eq!(installed["errorCode"], "locked_version_mismatch", "{v}"); assert_eq!( viewed_uuids(&mock).await, diff --git a/crates/socket-patch-cli/tests/scan_vendor_requirements_unwired.rs b/crates/socket-patch-cli/tests/scan_vendor_requirements_unwired.rs index ad9fe9148..f4552aaf5 100644 --- a/crates/socket-patch-cli/tests/scan_vendor_requirements_unwired.rs +++ b/crates/socket-patch-cli/tests/scan_vendor_requirements_unwired.rs @@ -169,7 +169,7 @@ async fn assert_unwired_and_pruned(edited: &str) { let (code, v) = run_scan_vendored(root, &mock.uri(), &["--prune"]); assert_eq!(code, 0, "edited={edited:?}: {v}"); assert_eq!( - v["gc"]["revertedVendoredEntries"], + reverted_purls(&v), serde_json::json!([PURL]), "edited={edited:?}: {v}" ); @@ -222,6 +222,18 @@ async fn wired_vendored_pin_stays_discoverable() { ); } +/// The purls the prune GC reverted: its `vendor_reverted` events (v5.0's +/// `gc.revertedVendoredEntries`). +fn reverted_purls(envelope: &serde_json::Value) -> serde_json::Value { + envelope["events"] + .as_array() + .expect("events array") + .iter() + .filter(|e| e["errorCode"] == "vendor_reverted") + .map(|e| e["purl"].clone()) + .collect() +} + /// #1127: the human `--prune` run reverts an unwired entry too, when the /// crawl found packages but none of them has a patch. Its early "No patches /// available" exit used to skip the GC in vendored mode, while `--json` 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 8e155b408..3e983f1a4 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 @@ -20,8 +20,8 @@ //! //! Every refusal test pins the whole observable contract: exit code; the //! exact envelope shape (uuid path: `status:"error"` with `error{code, -//! message}` and a `failed` record carrying `errorCode` AND `error`; scan -//! and purl paths: `partial_failure` with the same record); ZERO +//! message}` and a `failed` event carrying `errorCode` AND `error`; scan +//! and purl paths: `partialFailure` with the same event); ZERO //! `/patches/view/` fetches for the refused patch (request-log oracle); a //! byte-identical `bun.lock`; no `.socket/vendor/`; and — where a legacy //! manifest existed — that manifest surviving byte-for-byte (vendored mode @@ -435,12 +435,30 @@ fn manifest_value(root: &Path) -> Option { Some(serde_json::from_str(&body).unwrap_or_else(|e| panic!("manifest not JSON: {e}\n{body}"))) } -/// The refused `failed` record every entry point must emit for [`PURL`]. +/// The envelope's events with `action` (any leg). +fn events_with<'a>(v: &'a serde_json::Value, action: &str) -> Vec<&'a serde_json::Value> { + v["events"] + .as_array() + .unwrap_or_else(|| panic!("no events array: {v}")) + .iter() + .filter(|e| e["action"] == action) + .collect() +} + +/// The ONE `failed` event of a refused run (the download-phase refusal). +fn refused_event(v: &serde_json::Value) -> &serde_json::Value { + let failed = events_with(v, "failed"); + assert_eq!(failed.len(), 1, "exactly one failed event: {v}"); + failed[0] +} + +/// The refused `failed` event every entry point must emit for [`PURL`]. fn assert_refused_record(record: &serde_json::Value, code: &str, ctx: &serde_json::Value) { assert_eq!(record["purl"], PURL, "{ctx}"); assert_eq!(record["uuid"], UUID, "{ctx}"); assert_eq!(record["action"], "failed", "{ctx}"); assert_eq!(record["errorCode"], code, "{ctx}"); + assert_eq!(record["details"]["mode"], "vendored", "{ctx}"); assert!( record["error"].as_str().is_some_and(|d| !d.is_empty()), "a refused record must carry the engine's detail text: {ctx}" @@ -474,7 +492,7 @@ fn assert_refusal_left_tree_alone(root: &Path, lock_before: &[u8]) { // --------------------------------------------------------------------------- /// Drive `scan --mode vendored --json` on `shape` and pin the refusal -/// contract for `code`: exit 1, `partial_failure`, the download-phase +/// contract for `code`: exit 1, `partialFailure`, the download-phase /// record, zero downloads, zero view fetches, engine untouched. async fn assert_scan_refuses(shape: LockShape, code: &str) { let mock = MockServer::start().await; @@ -490,17 +508,12 @@ async fn assert_scan_refuses(shape: LockShape, code: &str) { let (exit, stdout, stderr) = scan_vendored(tmp.path(), &mock.uri(), &["--json"]); assert_eq!(exit, 1, "{shape:?}: stdout={stdout}\nstderr={stderr}"); let v = parse_single_json_doc(&stdout); - assert_eq!(v["status"], "partial_failure", "{shape:?}: {v}"); - let dl = &v["download"]; - assert_eq!(dl["found"], 1, "{v}"); - assert_eq!(dl["downloaded"], 0, "{v}"); - assert_eq!(dl["skipped"], 0, "{v}"); - assert_eq!(dl["failed"], 1, "{v}"); - assert_refused_record(&dl["patches"][0], code, &v); - assert_eq!( - v["vendor"]["summary"]["applied"], 0, - "nothing may be vendored: {v}" - ); + assert_eq!(v["status"], "partialFailure", "{shape:?}: {v}"); + assert_eq!(v["summary"]["downloaded"], 0, "{v}"); + assert_eq!(v["summary"]["skipped"], 0, "{v}"); + assert_eq!(v["summary"]["failed"], 1, "{v}"); + assert_refused_record(refused_event(&v), code, &v); + assert_eq!(v["summary"]["applied"], 0, "nothing may be vendored: {v}"); assert_eq!( view_requests_for(&mock, UUID).await, 0, @@ -551,7 +564,7 @@ async fn scan_vendored_refusal_preserves_seeded_manifest_record() { let (exit, stdout, stderr) = scan_vendored(tmp.path(), &mock.uri(), &["--json"]); assert_eq!(exit, 1, "stdout={stdout}\nstderr={stderr}"); let v = parse_single_json_doc(&stdout); - assert_refused_record(&v["download"]["patches"][0], WS_CODE, &v); + assert_refused_record(refused_event(&v), WS_CODE, &v); assert_eq!(view_requests_for(&mock, UUID).await, 0); assert_eq!( view_requests_for(&mock, OTHER_UUID).await, @@ -584,15 +597,13 @@ async fn scan_vendored_refuses_v1_workspace_before_fetch() { let (exit, stdout, stderr) = scan_vendored(tmp.path(), &mock.uri(), &["--json"]); assert_eq!(exit, 1, "stdout={stdout}\nstderr={stderr}"); let v = parse_single_json_doc(&stdout); - assert_eq!(v["status"], "partial_failure", "{v}"); - let dl = &v["download"]; - assert_eq!(dl["detached"], true, "{v}"); + assert_eq!(v["status"], "partialFailure", "{v}"); assert_eq!( - dl["downloaded"], 0, + v["summary"]["downloaded"], 0, "detached must refuse BEFORE fetching: {v}" ); - assert_eq!(dl["failed"], 1, "{v}"); - assert_refused_record(&dl["patches"][0], WS_CODE, &v); + assert_eq!(v["summary"]["failed"], 1, "{v}"); + assert_refused_record(refused_event(&v), WS_CODE, &v); assert_eq!(view_requests_for(&mock, UUID).await, 0); assert!( !tmp.path().join(".socket/manifest.json").exists(), @@ -613,12 +624,12 @@ async fn scan_vendored_refuses_bun_lockb_before_fetch() { let (exit, stdout, stderr) = scan_vendored(tmp.path(), &mock.uri(), &["--json"]); assert_eq!(exit, 1, "stdout={stdout}\nstderr={stderr}"); let v = parse_single_json_doc(&stdout); - assert_eq!(v["download"]["downloaded"], 0, "{v}"); - assert_refused_record(&v["download"]["patches"][0], LOCKB_CODE, &v); + assert_eq!(v["summary"]["downloaded"], 0, "{v}"); + assert_refused_record(refused_event(&v), LOCKB_CODE, &v); assert!( - !v["vendor"]["events"] + !v["events"] .as_array() - .unwrap_or(&vec![]) + .unwrap() .iter() .any(|e| e["errorCode"] == "package_not_installed"), "the refusal must not degrade to package_not_installed: {v}" @@ -634,7 +645,7 @@ async fn scan_vendored_refuses_bun_lockb_before_fetch() { /// `get --mode vendored --json` on a refused Bun project exits 1 /// with EXACTLY this envelope (contract: uuid-path pre-record refusal): -/// `status:"error"`, `error{code,message}`, counts, and a `failed` record +/// `status:"error"`, `error{code,message}`, and one `failed` event /// carrying both `errorCode` and `error`. The uuid lookup itself is the /// only network traffic (one view fetch — that IS the identifier /// resolution), and NOTHING is written: no `.socket/` at all. @@ -655,19 +666,23 @@ async fn get_uuid_vendored_refusal_envelope_is_exact_and_writes_nothing() { .to_string(); assert!(!detail.is_empty(), "{v}"); let expected = serde_json::json!({ + "command": "get", "status": "error", - "found": 1, - "downloaded": 0, - "skipped": 0, - "failed": 1, - "error": { "code": WS_CODE, "message": detail }, - "patches": [{ + "dryRun": false, + "events": [{ + "action": "failed", "purl": PURL, "uuid": UUID, - "action": "failed", "errorCode": WS_CODE, "error": detail, + "details": { "mode": "vendored" }, }], + "summary": { + "discovered": 0, "downloaded": 0, "applied": 0, "updated": 0, + "skipped": 0, "failed": 1, "removed": 0, "verified": 0, + "rebuilt": 0, "rolledBack": 0, "bytesFreed": 0, + }, + "error": { "code": WS_CODE, "message": detail }, }); assert_eq!( v, @@ -714,10 +729,9 @@ async fn get_uuid_vendored_refusal_human_names_code_on_stderr() { // get --mode vendored: the search-path refusal // --------------------------------------------------------------------------- -/// The search path shares `scan`'s download phase: `partial_failure`, the -/// same `failed` record (with `errorCode` + `error`), zero fetches, an -/// empty vendor envelope, `applied` dropped — and, vendored mode being -/// manifest-free, no manifest. +/// The search path shares `scan`'s download phase: `partialFailure`, the +/// same `failed` event (with `errorCode` + `error`), zero fetches, nothing +/// applied — and, vendored mode being manifest-free, no manifest. #[tokio::test] async fn get_purl_vendored_refuses_v1_workspace_before_fetch() { let mock = MockServer::start().await; @@ -729,17 +743,13 @@ async fn get_purl_vendored_refuses_v1_workspace_before_fetch() { let (exit, stdout, stderr) = get_vendored(tmp.path(), &mock.uri(), PURL, &["--json"]); assert_eq!(exit, 1, "stdout={stdout}\nstderr={stderr}"); let v = parse_single_json_doc(&stdout); - assert_eq!(v["status"], "partial_failure", "{v}"); - assert_eq!(v["found"], 1, "{v}"); - assert_eq!(v["downloaded"], 0, "{v}"); - assert_eq!(v["skipped"], 0, "{v}"); - assert_eq!(v["failed"], 1, "{v}"); - assert!( - v.get("applied").is_none(), - "vendored mode drops `applied`: {v}" - ); - assert_refused_record(&v["patches"][0], WS_CODE, &v); - assert_eq!(v["vendor"]["summary"]["applied"], 0, "{v}"); + assert_eq!(v["command"], "get", "{v}"); + assert_eq!(v["status"], "partialFailure", "{v}"); + assert_eq!(v["summary"]["downloaded"], 0, "{v}"); + assert_eq!(v["summary"]["skipped"], 0, "{v}"); + assert_eq!(v["summary"]["failed"], 1, "{v}"); + assert_refused_record(refused_event(&v), WS_CODE, &v); + assert_eq!(v["summary"]["applied"], 0, "{v}"); assert_eq!(view_requests_for(&mock, UUID).await, 0); assert_refusal_left_tree_alone(tmp.path(), &lock_before); assert_eq!( @@ -797,14 +807,14 @@ async fn silent_refusals_stay_visible_on_stderr_with_empty_stdout() { } // --------------------------------------------------------------------------- -// --dry-run: the preview names the refusal (additive `would_refuse`) +// --dry-run: the preview names the refusal (a `skipped` event + code) // --------------------------------------------------------------------------- /// The vendored dry-run preview is a ledger classification by contract /// (exit 0, `status:"success"`, nothing written); on a Bun project the -/// wet run is known to refuse, its npm records become the additive -/// `would_refuse` (+`errorCode`/`error`) instead of advertising -/// `would_vendor`. All three entry points; nothing touched on disk. +/// wet run is known to refuse, its npm patch is a `skipped` event with the +/// refusal code (+ the detail as `reason`) instead of a `verified` +/// would-vendor. All three entry points; nothing touched on disk. #[tokio::test] async fn dry_run_previews_report_would_refuse_on_refused_bun_project() { let mock = MockServer::start().await; @@ -834,17 +844,22 @@ async fn dry_run_previews_report_would_refuse_on_refused_bun_project() { ); let v = parse_single_json_doc(&stdout); assert_eq!(v["status"], "success", "{label}: {v}"); - let preview = &v["vendor"]; - assert_eq!(preview["dryRun"], true, "{label}: {v}"); - let rec = &preview["patches"][0]; - assert_eq!(rec["purl"], PURL, "{label}: {v}"); + assert_eq!(v["dryRun"], true, "{label}: {v}"); + let rec = v["events"] + .as_array() + .unwrap() + .iter() + .find(|e| e["purl"] == PURL) + .unwrap_or_else(|| panic!("{label}: no preview event: {v}")); assert_eq!(rec["uuid"], UUID, "{label}: {v}"); - assert_eq!(rec["action"], "would_refuse", "{label}: {v}"); + assert_eq!(rec["action"], "skipped", "{label}: {v}"); assert_eq!(rec["errorCode"], WS_CODE, "{label}: {v}"); + assert_eq!(rec["details"]["mode"], "vendored", "{label}: {v}"); assert!( - rec["error"].as_str().is_some_and(|d| !d.is_empty()), + rec["reason"].as_str().is_some_and(|d| !d.is_empty()), "{label}: {v}" ); + assert_eq!(v["summary"]["verified"], 0, "{label}: {v}"); assert!( !tmp.path().join(".socket").exists(), "{label}: a dry run writes nothing" @@ -902,8 +917,9 @@ async fn get_save_only_agent_bypasses_bun_preflight() { assert_eq!(exit, 0, "stdout={stdout}\nstderr={stderr}"); let v = parse_single_json_doc(&stdout); assert_eq!(v["status"], "success", "{v}"); - assert_eq!(v["patches"][0]["action"], "added", "{v}"); - assert!(v["patches"][0].get("errorCode").is_none(), "{v}"); + let dl = events_with(&v, "downloaded"); + assert_eq!(dl.len(), 1, "{v}"); + assert!(dl[0].get("errorCode").is_none(), "{v}"); assert_eq!(view_requests_for(&mock, UUID).await, 1); let manifest = manifest_value(tmp.path()).expect("manifest written"); assert_eq!(manifest["patches"][PURL]["uuid"], UUID, "{manifest}"); @@ -937,10 +953,10 @@ async fn scan_vendored_v2_workspace_lock_vendors() { assert_eq!(exit, 0, "stdout={stdout}\nstderr={stderr}"); let v = parse_single_json_doc(&stdout); assert_eq!(v["status"], "success", "{v}"); - assert_eq!(v["download"]["downloaded"], 1, "{v}"); - assert_eq!(v["download"]["detached"], true, "{v}"); - assert_eq!(v["download"]["patches"][0]["action"], "downloaded", "{v}"); - assert_eq!(v["vendor"]["summary"]["applied"], 1, "{v}"); + assert_eq!(v["summary"]["downloaded"], 1, "{v}"); + let dl = events_with(&v, "downloaded"); + assert_eq!(dl[0]["details"]["mode"], "vendored", "{v}"); + assert_eq!(v["summary"]["applied"], 1, "{v}"); assert_eq!( manifest_value(tmp.path()), None, @@ -990,8 +1006,8 @@ async fn get_uuid_vendored_v0_direct_lock_vendors_and_rollback_restores_bytes() assert_eq!(v["status"], "success", "{v}"); // Vendored mode is manifest-free for `get` too: the record is fetched // in memory (`downloaded`), never recorded in a manifest. - assert_eq!(v["patches"][0]["action"], "downloaded", "{v}"); - assert_eq!(v["vendor"]["summary"]["applied"], 1, "{v}"); + assert_eq!(events_with(&v, "downloaded").len(), 1, "{v}"); + assert_eq!(v["summary"]["applied"], 1, "{v}"); assert_eq!(manifest_value(tmp.path()), None, "{v}"); let tgz_rel = format!(".socket/vendor/npm/{UUID}/left-pad-1.3.0.tgz"); let lock = String::from_utf8(lock_bytes(tmp.path())).unwrap(); @@ -1074,21 +1090,16 @@ async fn preserved_ledger_does_not_bypass_bun_refusal_after_rollback() { let (exit, stdout, stderr) = scan_vendored(root, &mock.uri(), &["--json", "--dry-run"]); assert_eq!(exit, 0, "{stdout}\n{stderr}"); let preview = parse_single_json_doc(&stdout); - assert_eq!( - preview["vendor"]["patches"][0]["action"], "would_refuse", - "{preview}" - ); - assert_eq!( - preview["vendor"]["patches"][0]["errorCode"], WS_CODE, - "{preview}" - ); + let rec = &preview["events"][0]; + assert_eq!(rec["action"], "skipped", "{preview}"); + assert_eq!(rec["errorCode"], WS_CODE, "{preview}"); let views_before = view_requests_for(&mock, UUID).await; let (exit, stdout, stderr) = get_vendored(root, &mock.uri(), UUID, &["--json"]); assert_eq!(exit, 1, "{stdout}\n{stderr}"); let env = parse_single_json_doc(&stdout); assert_eq!(env["status"], "error", "{env}"); - assert_eq!(env["downloaded"], 0, "{env}"); + assert_eq!(env["summary"]["downloaded"], 0, "{env}"); assert_eq!(env["error"]["code"], WS_CODE, "{env}"); assert_eq!( view_requests_for(&mock, UUID).await, @@ -1105,21 +1116,21 @@ async fn preserved_ledger_does_not_bypass_bun_refusal_after_rollback() { // The WET scan/search path: the preserved ledger still names this exact // uuid (detached, record embedded — the idempotency skip's shape), but - // the refusal must win over the skip: `download.patches[0]` is `failed` + // the refusal must win over the skip: the one event is `failed` // with the workspace code, nothing is fetched, nothing changes on disk. // (Contract: "UUID equality in the ledger alone never exempts a purl".) let views_before = view_requests_for(&mock, UUID).await; let (exit, stdout, stderr) = scan_vendored(root, &mock.uri(), &["--json"]); assert_eq!(exit, 1, "{stdout}\n{stderr}"); let v = parse_single_json_doc(&stdout); - assert_eq!(v["status"], "partial_failure", "{v}"); - assert_eq!(v["download"]["downloaded"], 0, "{v}"); + assert_eq!(v["status"], "partialFailure", "{v}"); + assert_eq!(v["summary"]["downloaded"], 0, "{v}"); assert_eq!( - v["download"]["skipped"], 0, + v["summary"]["skipped"], 0, "a preserved uuid is not a skip: {v}" ); - assert_eq!(v["download"]["failed"], 1, "{v}"); - assert_refused_record(&v["download"]["patches"][0], WS_CODE, &v); + assert_eq!(v["summary"]["failed"], 1, "{v}"); + assert_refused_record(refused_event(&v), WS_CODE, &v); assert_eq!( view_requests_for(&mock, UUID).await, views_before, @@ -1143,9 +1154,9 @@ async fn preserved_ledger_does_not_bypass_bun_refusal_after_rollback() { } /// The download phase must NOT refuse a purl the ledger already wires at -/// the selected uuid: the re-run classifies it `skipped` (the ledger's -/// embedded record is reused) exactly as on a non-Bun project, instead of -/// `failed`. Pinned independently of the vendor step (see the next test) so +/// the selected uuid: the re-run reuses the ledger's embedded record (no +/// download event, no fetch) exactly as on a non-Bun project, instead of +/// a `failed` event. Pinned independently of the vendor step (see the next test) so /// a regression in the CLI half fails on its own. #[tokio::test] async fn already_vendored_v1_workspace_rerun_download_phase_is_skipped_not_refused() { @@ -1156,13 +1167,11 @@ async fn already_vendored_v1_workspace_rerun_download_phase_is_skipped_not_refus let (exit, stdout, stderr) = scan_vendored(tmp.path(), &mock.uri(), &["--json"]); let v = parse_single_json_doc(&stdout); - let rec = &v["download"]["patches"][0]; - assert_eq!( - rec["action"], "skipped", + assert!( + events_with(&v, "failed").is_empty() && events_with(&v, "downloaded").is_empty(), "an in-sync vendored purl must not be refused by the preflight (exit {exit}): {v}\n{stderr}" ); - assert!(rec.get("errorCode").is_none(), "{v}"); - assert_eq!(v["download"]["failed"], 0, "{v}"); + assert_eq!(v["summary"]["failed"], 0, "{v}"); assert_eq!( lock_bytes(tmp.path()), lock_before, @@ -1186,8 +1195,11 @@ async fn already_vendored_v1_workspace_rerun_is_already_vendored_exit_zero() { assert_eq!(exit, 0, "stdout={stdout}\nstderr={stderr}"); let v = parse_single_json_doc(&stdout); assert_eq!(v["status"], "success", "{v}"); - assert_eq!(v["download"]["patches"][0]["action"], "skipped", "{v}"); - let events = v["vendor"]["events"].as_array().unwrap(); + assert!( + events_with(&v, "downloaded").is_empty(), + "reused, not fetched: {v}" + ); + let events = v["events"].as_array().unwrap(); assert!( events.iter().any(|e| e["purl"] == PURL && e["action"] == "skipped" @@ -1221,18 +1233,19 @@ async fn superseding_uuid_on_already_vendored_v1_workspace_is_revendored_not_ref assert_eq!(exit, 0, "stdout={stdout}\nstderr={stderr}"); let v = parse_single_json_doc(&stdout); assert_eq!(v["status"], "success", "{v}"); - assert_eq!(v["patches"][0]["action"], "downloaded", "{v}"); + let dl = events_with(&v, "downloaded"); + assert_eq!(dl.len(), 1, "{v}"); assert_eq!( - v["patches"][0]["oldUuid"], UUID, - "the superseded ledger uuid must ride the downloaded record: {v}" + dl[0]["details"]["oldUuid"], UUID, + "the superseded ledger uuid must ride the downloaded event: {v}" ); - assert!(v["patches"][0].get("errorCode").is_none(), "{v}"); + assert!(dl[0].get("errorCode").is_none(), "{v}"); assert!( !tmp.path().join(".socket/manifest.json").exists(), "vendored mode never writes a manifest" ); - assert_eq!(v["vendor"]["summary"]["applied"], 1, "{v}"); - assert_eq!(v["vendor"]["summary"]["failed"], 0, "{v}"); + assert_eq!(v["summary"]["applied"], 1, "{v}"); + assert_eq!(v["summary"]["failed"], 0, "{v}"); assert!( !stdout.contains(WS_CODE), "no arm may raise the workspace refusal for an already-vendored purl: {v}" @@ -1289,13 +1302,19 @@ async fn wiped_ledger_on_already_vendored_v1_workspace_is_not_refused_at_preflig let (exit, stdout, stderr) = scan_vendored(tmp.path(), &mock.uri(), &["--json"]); let v = parse_single_json_doc(&stdout); - let rec = &v["download"]["patches"][0]; + let dl = events_with(&v, "downloaded"); assert_eq!( - rec["action"], "downloaded", + dl.len(), + 1, "an in-sync purl must not be refused for a lost ledger (exit {exit}): {v}\n{stderr}" ); - assert!(rec.get("errorCode").is_none(), "{v}"); - assert_eq!(v["download"]["failed"], 0, "{v}"); + assert!(dl[0].get("errorCode").is_none(), "{v}"); + assert!( + !events_with(&v, "failed") + .iter() + .any(|e| e["errorCode"] == WS_CODE), + "{v}" + ); assert!( !stdout.contains(WS_CODE), "no arm may raise the workspace refusal: {v}" @@ -1342,7 +1361,7 @@ async fn corrupt_vendor_ledger_on_refused_bun_lock_reports_vendor_state_unreadab .is_some_and(|m| m.contains("state.json")), "the detail names the ledger file: {v}" ); - assert_eq!(v["patches"][0]["errorCode"], LEDGER_CODE, "{v}"); + assert_eq!(refused_event(&v)["errorCode"], LEDGER_CODE, "{v}"); assert!( !stdout.contains(WS_CODE), "the Bun lock remedy must not shadow the ledger corruption: {v}" @@ -1357,23 +1376,22 @@ async fn corrupt_vendor_ledger_on_refused_bun_lock_reports_vendor_state_unreadab "stderr must carry the ledger code:\n{stderr}" ); - // Dry-run preview: `would_refuse` with the ledger code. + // Dry-run preview: a `skipped` event with the ledger code. let (exit, stdout, stderr) = get_vendored(tmp.path(), &mock.uri(), UUID, &["--dry-run", "--json"]); assert_eq!(exit, 0, "stdout={stdout}\nstderr={stderr}"); let v = parse_single_json_doc(&stdout); - let rec = &v["vendor"]["patches"][0]; - assert_eq!(rec["action"], "would_refuse", "{v}"); + let rec = &v["events"][0]; + assert_eq!(rec["action"], "skipped", "{v}"); assert_eq!(rec["errorCode"], LEDGER_CODE, "{v}"); // Detached download phase: the same code before any fetch. let (exit, stdout, stderr) = scan_vendored(tmp.path(), &mock.uri(), &["--json"]); assert_eq!(exit, 1, "stdout={stdout}\nstderr={stderr}"); let v = parse_single_json_doc(&stdout); - let rec = &v["download"]["patches"][0]; - assert_eq!(rec["action"], "failed", "{v}"); + let rec = refused_event(&v); assert_eq!(rec["errorCode"], LEDGER_CODE, "{v}"); - assert_eq!(v["download"]["downloaded"], 0, "{v}"); + assert_eq!(v["summary"]["downloaded"], 0, "{v}"); assert_eq!( lock_bytes(tmp.path()), @@ -1453,16 +1471,14 @@ async fn digestless_vendored_tuple_rerun_is_already_vendored_and_heals_the_diges assert_eq!(exit, 0, "stdout={stdout}\nstderr={stderr}"); let v = parse_single_json_doc(&stdout); assert_eq!(v["status"], "success", "{v}"); - assert_eq!( - v["download"]["patches"][0]["action"], "skipped", - "the ledger still wires the purl: {v}" - ); - assert_eq!(v["download"]["failed"], 0, "{v}"); - let vendor = &v["vendor"]; - assert_eq!(vendor["summary"]["applied"], 0, "{v}"); - assert_eq!(vendor["summary"]["skipped"], 1, "{v}"); - assert_eq!(vendor["summary"]["failed"], 0, "{v}"); - let events = vendor["events"].as_array().unwrap(); + assert!( + events_with(&v, "downloaded").is_empty(), + "the ledger still wires the purl (reused, not fetched): {v}" + ); + assert_eq!(v["summary"]["applied"], 0, "{v}"); + assert_eq!(v["summary"]["skipped"], 1, "{v}"); + assert_eq!(v["summary"]["failed"], 0, "{v}"); + let events = v["events"].as_array().unwrap(); assert!( events.iter().any(|e| e["purl"] == PURL && e["action"] == "skipped" @@ -1569,10 +1585,10 @@ async fn scan_vendored_from_workspace_member_cwd_fetches_then_engine_refuses_loc scan_vendored(tmp.path(), &mock.uri(), &["--json", "--cwd", &member_str]); assert_eq!(exit, 1, "stdout={stdout}\nstderr={stderr}"); let v = parse_single_json_doc(&stdout); - assert_eq!(v["status"], "partial_failure", "{v}"); - assert_eq!(v["download"]["downloaded"], 1, "{v}"); - assert_eq!(v["download"]["patches"][0]["action"], "downloaded", "{v}"); - let events = v["vendor"]["events"].as_array().unwrap(); + assert_eq!(v["status"], "partialFailure", "{v}"); + assert_eq!(v["summary"]["downloaded"], 1, "{v}"); + assert_eq!(events_with(&v, "downloaded").len(), 1, "{v}"); + let events = v["events"].as_array().unwrap(); assert!( events .iter() @@ -1683,7 +1699,7 @@ async fn fifo_bun_lock_is_refused_without_blocking() { let (exit, stdout, stderr) = run_with_deadline(tmp.path(), &argv, Duration::from_secs(20)); assert_eq!(exit, 1, "scan: stdout={stdout}\nstderr={stderr}"); let v = parse_single_json_doc(&stdout); - assert_refused_record(&v["download"]["patches"][0], MISSING_CODE, &v); + assert_refused_record(refused_event(&v), MISSING_CODE, &v); assert_eq!(view_requests_for(&mock, UUID).await, 0); // get : the FIFO is the only Bun artefact the preflight reads. diff --git a/crates/socket-patch-cli/tests/vendor/main.rs b/crates/socket-patch-cli/tests/vendor/main.rs index ddfa3588f..468c27472 100644 --- a/crates/socket-patch-cli/tests/vendor/main.rs +++ b/crates/socket-patch-cli/tests/vendor/main.rs @@ -2,6 +2,9 @@ //! //! One test binary per command: each module was its own binary. +#[path = "../common/rollback_json.rs"] +mod rollback_json; + #[path = "../vex_e2e_common/bun.rs"] mod bun_vex; #[path = "../common/mod.rs"] diff --git a/crates/socket-patch-cli/tests/vendor/redirect_npm_allow_remote.rs b/crates/socket-patch-cli/tests/vendor/redirect_npm_allow_remote.rs index 96ce71ac7..7dd013aa0 100644 --- a/crates/socket-patch-cli/tests/vendor/redirect_npm_allow_remote.rs +++ b/crates/socket-patch-cli/tests/vendor/redirect_npm_allow_remote.rs @@ -239,7 +239,7 @@ fn scan_hosted_env( } fn allow_remote_warning(doc: &Value) -> Option<&str> { - doc["redirect"]["warnings"] + doc["warnings"] .as_array() .into_iter() .flatten() @@ -303,7 +303,7 @@ async fn package_lock_redirect_writes_npmrc_warns_and_rollback_removes_it() { let (code, doc, _) = scan_hosted(tmp.path(), &server.uri(), &["--json"]); assert_eq!(code, 0, "{doc:#}"); - assert_eq!(doc["redirect"]["redirected"], 1, "{doc:#}"); + assert_eq!(doc["summary"]["applied"], 1, "{doc:#}"); let detail = allow_remote_warning(&doc).unwrap_or_else(|| panic!("no {CODE}: {doc:#}")); for needle in [ "patch.test", @@ -414,7 +414,11 @@ async fn existing_npmrc_gets_one_line_and_rollback_keeps_user_edits() { let (code, doc) = rollback(tmp.path(), &server, &[]); assert_eq!(code, 0, "{doc:#}"); - assert_eq!(doc["hosted"]["reverted"], json!([PURL]), "{doc:#}"); + assert_eq!( + crate::rollback_json::hosted_reverted(&doc), + json!([PURL]), + "{doc:#}" + ); assert_eq!( std::fs::read_to_string(tmp.path().join(".npmrc")).unwrap(), live, @@ -528,7 +532,7 @@ async fn opt_out_dry_run_and_unredirected_projects() { let (code, stdout, stderr) = run_isolated(tmp.path(), &args, &env); assert_eq!(code, 0, "{stdout}\n{stderr}"); let doc: Value = serde_json::from_str(&stdout).unwrap(); - assert_eq!(doc["redirect"]["redirected"], 1, "{doc:#}"); + assert_eq!(doc["summary"]["applied"], 1, "{doc:#}"); let detail = allow_remote_warning(&doc).unwrap_or_else(|| panic!("no {CODE}: {doc:#}")); assert!( detail.contains("Commit `allow-remote=all` in the project .npmrc") @@ -589,7 +593,7 @@ async fn symlinked_npmrc_is_left_alone() { std::os::unix::fs::symlink("shared.npmrc", tmp.path().join(".npmrc")).unwrap(); let (code, doc, _) = scan_hosted(tmp.path(), &server.uri(), &["--json"]); assert_eq!(code, 0, "{doc:#}"); - assert_eq!(doc["redirect"]["redirected"], 1, "{doc:#}"); + assert_eq!(doc["summary"]["applied"], 1, "{doc:#}"); let detail = allow_remote_warning(&doc).unwrap_or_else(|| panic!("no {CODE}: {doc:#}")); assert!(detail.contains("symbolic link") && detail.contains("npm ci --allow-remote=all")); assert_eq!( @@ -703,7 +707,7 @@ async fn outer_npm_config_layers_are_respected() { ], ); assert_eq!(code, 0, "{doc:#}"); - assert_eq!(doc["redirect"]["redirected"], 1, "{doc:#}"); + assert_eq!(doc["summary"]["applied"], 1, "{doc:#}"); let detail = allow_remote_warning(&doc).unwrap_or_else(|| panic!("no {CODE}: {doc:#}")); assert!( detail.contains("The user npm config") @@ -793,7 +797,7 @@ async fn outer_npm_config_layers_are_respected() { } fn replace_host_warning(doc: &Value) -> Option<&str> { - doc["redirect"]["warnings"] + doc["warnings"] .as_array() .into_iter() .flatten() diff --git a/crates/socket-patch-cli/tests/vendor_eject_bun_lockb.rs b/crates/socket-patch-cli/tests/vendor_eject_bun_lockb.rs index 27bad0a4f..18be305fc 100644 --- a/crates/socket-patch-cli/tests/vendor_eject_bun_lockb.rs +++ b/crates/socket-patch-cli/tests/vendor_eject_bun_lockb.rs @@ -21,6 +21,9 @@ //! registry (`SOCKET_NPM_REGISTRY`) and the patch-server origin //! (`SOCKET_PATCH_SERVER_URL`) all point at a wiremock. +#[path = "common/rollback_json.rs"] +mod rollback_json; + #[path = "prebuilt_common/mod.rs"] mod prebuilt_common; @@ -416,10 +419,8 @@ async fn rollback_and_offline_vendor_refuse_with_the_checkout_remedy() { let (code, env) = p.run_json(&["rollback", "--yes"]); assert_eq!(code, 1, "{env:#}"); - let failed = env["hosted"]["failed"] - .as_array() - .cloned() - .unwrap_or_default(); + let failed_view = rollback_json::hosted_failed(&env); + let failed = failed_view.as_array().cloned().unwrap_or_default(); assert!( failed.iter().any(|f| f["purl"] == PURL && f["error"] diff --git a/crates/socket-patch-cli/tests/vendor_partial_staging_e2e.rs b/crates/socket-patch-cli/tests/vendor_partial_staging_e2e.rs index f2b8a3ab0..cd61bc305 100644 --- a/crates/socket-patch-cli/tests/vendor_partial_staging_e2e.rs +++ b/crates/socket-patch-cli/tests/vendor_partial_staging_e2e.rs @@ -383,7 +383,7 @@ async fn scan_vendor_uses_contentless_views_and_downloaded_archives() { std::fs::remove_file(root.join(".socket/manifest.json")).unwrap(); let (code, env, stderr) = scan_vendored_cli(root, &server.uri()); assert_eq!(code, 0, "{env:#}\n{stderr}"); - assert_eq!(env["vendor"]["summary"]["applied"], 2); + assert_eq!(env["summary"]["applied"], 2); assert!(!root.join(".socket/manifest.json").exists()); assert!(!root.join(".socket/blobs").exists()); } diff --git a/crates/socket-patch-cli/tests/vex_e2e_common/uv.rs b/crates/socket-patch-cli/tests/vex_e2e_common/uv.rs index 8a767ed97..c941f0d6d 100644 --- a/crates/socket-patch-cli/tests/vex_e2e_common/uv.rs +++ b/crates/socket-patch-cli/tests/vex_e2e_common/uv.rs @@ -1315,7 +1315,7 @@ pub fn run_lane(suite: &str, uv: &Uv, mode: Mode, lane: Lane) { ); let env = envelope(&out, &report.what("scan --mode hosted")); assert!( - env["redirect"]["redirected"].as_u64().unwrap_or(0) >= 1, + env["summary"]["applied"].as_u64().unwrap_or(0) >= 1, "{}: nothing redirected: {env:#}", report.what("scan --mode hosted") ); @@ -1723,8 +1723,17 @@ pub fn run_lane(suite: &str, uv: &Uv, mode: Mode, lane: Lane) { |f: &str| String::from_utf8_lossy(&std::fs::read(proj.join(f)).unwrap()).contains(uuid); match out.status.code() { Some(0) => { + // v5.0 rollback envelope: restored pins are `rolledBack` + // events with `details.mode: "hosted"`. + let reverted: Vec<&Value> = env["events"] + .as_array() + .into_iter() + .flatten() + .filter(|e| e["action"] == "rolledBack" && e["details"]["mode"] == "hosted") + .map(|e| &e["purl"]) + .collect(); assert_eq!( - env["hosted"]["reverted"], + json!(reverted), json!([built.purl]), "{}:\n{}", report.what("revert"), @@ -1753,8 +1762,12 @@ pub fn run_lane(suite: &str, uv: &Uv, mode: Mode, lane: Lane) { dump(&out) ), _ => { - let error = env["hosted"]["failed"][0]["error"] - .as_str() + let error = env["events"] + .as_array() + .into_iter() + .flatten() + .find(|e| e["action"] == "failed" && e["details"]["mode"] == "hosted") + .and_then(|e| e["error"].as_str()) .unwrap_or_default() .to_string(); assert!( diff --git a/crates/socket-patch-cli/tests/vex_pdm_hatch_common/mod.rs b/crates/socket-patch-cli/tests/vex_pdm_hatch_common/mod.rs index 8f1c0e2b2..f39e29e85 100644 --- a/crates/socket-patch-cli/tests/vex_pdm_hatch_common/mod.rs +++ b/crates/socket-patch-cli/tests/vex_pdm_hatch_common/mod.rs @@ -1234,7 +1234,12 @@ pub fn embedded_rescan_of_a_manifest_less_checkout( match mode { Mode::Vendored => { assert_eq!(code, Some(1), "{what}: {env}\n{stderr}"); - let refusal = env["vendor"]["events"][0]["errorCode"].as_str(); + // The vendor engine's refusal: the first `failed` event (the + // download phase records the fetched patch as `downloaded`). + let refusal = env["events"] + .as_array() + .and_then(|e| e.iter().find(|e| e["action"] == "failed")) + .and_then(|e| e["errorCode"].as_str()); assert!( refusal == Some("vendor_ledger_entry_missing") || (expect_refusal.is_some() && refusal == expect_refusal), diff --git a/crates/socket-patch-cli/tests/vex_pipenv_pip_common/mod.rs b/crates/socket-patch-cli/tests/vex_pipenv_pip_common/mod.rs index fc8029810..4d4010952 100644 --- a/crates/socket-patch-cli/tests/vex_pipenv_pip_common/mod.rs +++ b/crates/socket-patch-cli/tests/vex_pipenv_pip_common/mod.rs @@ -1287,8 +1287,11 @@ pub fn embedded_rescan_of_a_manifest_less_checkout(flavors: &[Flavor]) { ); if mode == Mode::Vendored { assert_eq!(code, Some(1), "{what}: {env}\n{stderr}"); - assert_eq!( - env["vendor"]["events"][0]["errorCode"], "vendor_ledger_entry_missing", + assert!( + env["events"] + .as_array() + .is_some_and(|e| e.iter().any(|e| e["action"] == "failed" + && e["errorCode"] == "vendor_ledger_entry_missing")), "{env}" ); assert!(!vex_out.exists(), "failed scan cannot emit VEX"); diff --git a/crates/socket-patch-cli/tests/vlt_e2e_common/fixture.rs b/crates/socket-patch-cli/tests/vlt_e2e_common/fixture.rs index 48e79a883..0865d061d 100644 --- a/crates/socket-patch-cli/tests/vlt_e2e_common/fixture.rs +++ b/crates/socket-patch-cli/tests/vlt_e2e_common/fixture.rs @@ -375,7 +375,7 @@ pub fn vex_doc_attests(path: &Path, t: &PatchTarget) -> bool { /// The `reason` of the envelope event carrying `code`. pub fn event_reason(doc: &Value, code: &str) -> String { - [&doc["events"], &doc["vendor"]["events"]] + [&doc["events"]] .into_iter() .filter_map(|e| e.as_array()) .flatten() 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 0cb67ae6c..0784c4ea5 100644 --- a/crates/socket-patch-cli/tests/vlt_e2e_common/mod.rs +++ b/crates/socket-patch-cli/tests/vlt_e2e_common/mod.rs @@ -1894,7 +1894,7 @@ pub fn rollback_upstream( pub fn redirect_warnings(doc: &Value) -> Vec<(String, String)> { let mut out = Vec::new(); - for w in [&doc["redirect"]["warnings"], &doc["warnings"]] { + for w in [&doc["warnings"]] { for w in w.as_array().into_iter().flatten() { if let Some(code) = w["code"].as_str() { out.push(( diff --git a/crates/socket-patch-cli/tests/vlt_hosted_common/mod.rs b/crates/socket-patch-cli/tests/vlt_hosted_common/mod.rs index d4bae0464..9ae436209 100644 --- a/crates/socket-patch-cli/tests/vlt_hosted_common/mod.rs +++ b/crates/socket-patch-cli/tests/vlt_hosted_common/mod.rs @@ -494,8 +494,10 @@ pub fn scan_hosted( (code, doc) } +/// The scan envelope's top-level `warnings[]` codes (v5.0: the hosted +/// engine's warnings are no longer nested under `redirect`). pub fn warning_codes(doc: &Value) -> Vec { - doc["redirect"]["warnings"] + doc["warnings"] .as_array() .map(|arr| { arr.iter() @@ -506,7 +508,7 @@ pub fn warning_codes(doc: &Value) -> Vec { } pub fn warning_detail(doc: &Value, code: &str) -> String { - doc["redirect"]["warnings"] + doc["warnings"] .as_array() .into_iter() .flatten() @@ -516,10 +518,18 @@ pub fn warning_detail(doc: &Value, code: &str) -> String { .to_string() } +/// How many hosted pins the run wrote (would write, on a dry run): its +/// `applied` / `verified` events tagged `details.mode: "hosted"`. pub fn redirected(doc: &Value) -> u64 { - doc["redirect"]["redirected"] - .as_u64() + doc["events"] + .as_array() .unwrap_or_else(|| panic!("{doc:#}")) + .iter() + .filter(|e| { + e["details"]["mode"] == "hosted" + && (e["action"] == "applied" || e["action"] == "verified") + }) + .count() as u64 } /// Whether the VEX document at `path` attests [`PURL`]. diff --git a/crates/socket-patch-core/src/patch/sidecars/types.rs b/crates/socket-patch-core/src/patch/sidecars/types.rs index 6b2a4fa53..db67292b1 100644 --- a/crates/socket-patch-core/src/patch/sidecars/types.rs +++ b/crates/socket-patch-core/src/patch/sidecars/types.rs @@ -9,8 +9,10 @@ //! Every struct/enum derives `serde::Serialize` with stable JSON //! key conventions: //! * structs serialize with `#[serde(rename_all = "camelCase")]`; -//! * enums serialize as `#[serde(rename_all = "snake_case")]` -//! strings. +//! * value enums (`SidecarFileAction`, `SidecarSeverity`) serialize +//! camelCase, like the envelope's own enums (`action`, `status`); +//! * the advisory `code` is a routing tag and serializes snake_case, +//! like every other `code` / `errorCode` in the envelope. //! //! Downstream consumers (CI bots, dashboards, jq pipelines, //! telemetry) can rely on the field set and tag spelling — see the @@ -55,15 +57,16 @@ pub struct SidecarFile { pub action: SidecarFileAction, } -/// What the fixup did with a sidecar file. Stable snake_case JSON -/// tag — consumers branch on this without parsing free-form text. +/// What the fixup did with a sidecar file. Stable camelCase JSON +/// tag (the envelope's enum convention) — consumers branch on this +/// without parsing free-form text. /// /// Variants are added only when an ecosystem actually produces them /// (rather than reserved up front). Adding a variant is a /// non-breaking change to the JSON contract; renaming or removing /// one is breaking. #[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] +#[serde(rename_all = "camelCase")] pub enum SidecarFileAction { Rewritten, Deleted, @@ -127,7 +130,7 @@ pub enum SidecarAdvisoryCode { /// itself failing — informational consequences of the apply use /// `Info` or `Warning`. #[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] +#[serde(rename_all = "camelCase")] pub enum SidecarSeverity { Info, Warning, @@ -184,7 +187,7 @@ mod tests { } #[test] - fn file_action_tags_are_snake_case() { + fn file_action_tags_are_camel_case() { let cases = [ (SidecarFileAction::Rewritten, "rewritten"), (SidecarFileAction::Deleted, "deleted"), @@ -236,7 +239,7 @@ mod tests { } #[test] - fn severity_tags_are_snake_case() { + fn severity_tags_are_camel_case() { assert_eq!( serde_json::to_value(SidecarSeverity::Info).unwrap(), serde_json::Value::String("info".to_string()) diff --git a/scripts/backtest-bun.py b/scripts/backtest-bun.py index 8487c7b05..5bed70fd9 100644 --- a/scripts/backtest-bun.py +++ b/scripts/backtest-bun.py @@ -34,8 +34,8 @@ vendored-then-hosted vendored, then the hosted takeover already-vendored-workspace wire a plain project (the cell's mode), add a workspace member, `bun install`, re-run the same - mode (a clean no-op: already_vendored / redirected - 1), then `repair` rebuilds a deleted artifact + mode (a clean no-op: already_vendored / one hosted + `applied` event), then `repair` rebuilds a deleted artifact preexisting-manifest a foreign .socket/manifest.json record must survive a refused vendored run @@ -604,56 +604,58 @@ def parse_envelope(output): return json.loads(output[output.index('{'):]) +def mode_events(envelope, mode, *actions): + """The v5 envelope's events of one leg (`details.mode` hosted / vendored), + narrowed to `actions` when given.""" + return [e for e in envelope.get('events') or [] + if (e.get('details') or {}).get('mode') == mode + and (not actions or e.get('action') in actions)] + + def envelope_codes(envelope): """(codes about the minimist patch or carrying no purl, codes about other - purls) — every channel the CLI reports on: redirect.warnings, top-level - warnings, vendor.events, download.patches, patches, error.""" + purls) — every channel the v5 envelope reports on: top-level warnings + (`{code, detail}`), every event's `errorCode`, and `error.code`.""" mine, others = [], [] def take(purl, code): if code: (mine if purl in (None, PURL) else others).append(code) - for w in envelope.get('redirect', {}).get('warnings', []): - take(None, w.get('code')) - for w in envelope.get('warnings', []): + for w in envelope.get('warnings') or []: take(None, w.get('code') if isinstance(w, dict) else w) - for e in envelope.get('vendor', {}).get('events', []): + for e in envelope.get('events') or []: take(e.get('purl'), e.get('errorCode')) - for p in envelope.get('download', {}).get('patches', []): - take(p.get('purl'), p.get('errorCode')) - for p in envelope.get('patches', []): - take(p.get('purl'), p.get('errorCode')) if isinstance(envelope.get('error'), dict): take(None, envelope['error'].get('code')) return mine, others def applied_count(envelope, mode): - if mode == 'hosted': - return envelope.get('redirect', {}).get('redirected', 0) - return envelope.get('vendor', {}).get('summary', {}).get('applied', 0) + """Pins (hosted) / vendorings (vendored) the run wrote: the leg's + `applied` events (`verified` on a dry run). A hosted re-run over an + already-pinned lock still reports `applied` for the confirmed pin.""" + return len(mode_events(envelope, mode, 'applied', 'verified')) def downloaded_count(envelope): - return envelope.get('download', {}).get('downloaded', envelope.get('downloaded', 0)) + return (envelope.get('summary') or {}).get('downloaded', 0) def rerun_clean(code, envelope, mode): - """The documented no-op re-run: hosted re-confirms the wiring (redirected 1, - nothing rewritten, no warnings beyond advisories); vendored skips exactly - one already_vendored purl with nothing failed.""" + """The documented no-op re-run: hosted re-confirms the wiring (one hosted + `applied` event, no warnings beyond advisories); vendored skips exactly + one already_vendored purl with nothing applied or failed.""" if code != 0 or envelope.get('status') != 'success': return False codes, _ = envelope_codes(envelope) if mode == 'hosted': - return (envelope.get('redirect', {}).get('redirected') == 1 + return (applied_count(envelope, mode) == 1 and not set(codes) - INFORMATIONAL) - vendor = envelope.get('vendor', {}) - summary, events = vendor.get('summary', {}), vendor.get('events', []) - return (summary.get('applied') == 0 and summary.get('skipped') == 1 - and summary.get('failed') == 0 - and sum(e.get('errorCode') == 'already_vendored' for e in events) == 1 - and not any(e.get('action') == 'failed' for e in events) + events = mode_events(envelope, mode) + return (applied_count(envelope, mode) == 0 + and sum(e.get('action') == 'skipped' and e.get('errorCode') == 'already_vendored' + for e in events) == 1 + and not any(e.get('action') == 'failed' for e in envelope.get('events') or []) and not set(codes) - INFORMATIONAL - {'already_vendored'}) @@ -1060,7 +1062,7 @@ def install(binary, label, flags=(), cache=None): checks['unchanged'] = all((project / n).read_bytes() == b for n, b in original.items()) checks['unchangedLockPresence'] = all((project / name).exists() == (name in original) for name in ['bun.lock', 'bun.lockb']) - # Hosted refusals exit 0 with redirected 0 (documented posture); + # Hosted refusals exit 0 with no hosted pin (documented posture); # vendored / get refusals exit non-zero and never fetch. checks['exitCodeContract'] = code == 0 if expected['exit'] == 'zero' else code != 0 if expected['exit'] == 'nonzero': @@ -1333,7 +1335,7 @@ def vex(label, *extra, via='vex'): # bun.lockb is binary: the v5 upstream restore refuses it # with the version-control remedy and writes nothing; the # cell then applies that remedy. - failed = (rolled.get('hosted') or {}).get('failed') or [] + failed = mode_events(rolled, 'hosted', 'failed') checks['rollbackLockbRefused'] = ( code == 1 and any(f.get('purl') == PURL and 'git checkout -- bun.lockb' in (f.get('error') or '') diff --git a/scripts/backtest-pdm.py b/scripts/backtest-pdm.py index 35035c090..e7f4db789 100644 --- a/scripts/backtest-pdm.py +++ b/scripts/backtest-pdm.py @@ -256,6 +256,50 @@ def wanted(version, shape, mode): return True +# ---------------------------------------------------- v5 `--json` envelope +# Every command prints ONE envelope (CLI_CONTRACT.md "`scan` and `get` JSON +# (v5.0)"): `status` (camelCase), `dryRun`, `events[]` (each `{action, purl, +# uuid, errorCode, reason, error, details}`; hosted / vendored events carry +# `details.mode`, agent events none), `summary`, top-level `warnings[{code, +# detail}]` and `error{code, message}`. No `redirect.redirected`, +# `vendor.summary`, `apply.*` or legacy rollback arrays any more. +def leg_events(mode, envelope): + """The events of one leg: `details.mode` hosted / vendored; every other + mode (agent, agent-oot) reads the mode-less agent events.""" + leg = mode if mode in ("hosted", "vendored") else None + return [e for e in (envelope or {}).get("events") or [] if (e.get("details") or {}).get("mode") == leg] + + +def applied_count(mode, envelope, action=None): + """Patches the leg wired: its `applied` events (a hosted re-run still + emits `applied` for the pins it re-confirms), or on a `--dry-run` the + `verified` previews. `action` counts another action instead.""" + action = action or ("verified" if (envelope or {}).get("dryRun") else "applied") + return sum(1 for e in leg_events(mode, envelope) if e.get("action") == action) + + +def envelope_warnings(envelope): + """Every coded record of the run as `{code, detail, action, purl}`: the + top-level `error`, each event carrying an `errorCode` (refusals, skips, + failures, vendor advisories) and each run-level warning.""" + env = envelope or {} + rows = [] + if isinstance(env.get("error"), dict) and env["error"].get("code"): + rows.append({"code": env["error"]["code"], "detail": env["error"].get("message") or ""}) + for e in env.get("events") or []: + if e.get("errorCode"): + rows.append({"code": e["errorCode"], "detail": e.get("reason") or e.get("error") or "", "action": e.get("action"), "purl": e.get("purl")}) + for w in env.get("warnings") or []: + if isinstance(w, dict) and w.get("code"): + rows.append({"code": w["code"], "detail": w.get("detail") or ""}) + return rows + + +def envelope_codes(envelope): + """The sorted distinct codes of `envelope_warnings`.""" + return sorted({w["code"] for w in envelope_warnings(envelope)}) + + def save(path, data): Path(path).write_text(json.dumps(data, indent=2, sort_keys=True) + "\n", encoding="utf-8") @@ -870,20 +914,6 @@ def generate_original(version, shape): def cli_cmd(project, *rest): return [cli, *rest, "--cwd", project, "--json", "--yes", "--no-telemetry"] - def applied_count(mode, envelope): - if mode == "hosted": - return envelope.get("redirect", {}).get("redirected", 0) - if mode == "vendored": - return envelope.get("vendor", {}).get("summary", {}).get("applied", 0) - return envelope.get("apply", {}).get("applied", 0) - - def refusal_codes(mode, envelope): - if mode == "hosted": - return sorted({w.get("code") for w in envelope.get("redirect", {}).get("warnings", []) if w.get("code")}) - if mode == "vendored": - return sorted({e.get("errorCode") for e in envelope.get("vendor", {}).get("events", []) if e.get("errorCode")}) - return sorted({p.get("errorCode") for p in envelope.get("apply", {}).get("patches", []) if p.get("errorCode")}) - def hosted_uuid(text): """The patch uuid of the first patch.socket.dev URL in `text` (the LAST uuid-shaped path segment: an earlier one may be a grant token).""" @@ -1171,7 +1201,7 @@ def uninstall(log): if mode == "hosted" and not pep582: r0 = Run(cli_cmd(project, "scan", "--mode", "hosted"), project, cenv, case / "scan-lockonly.log", timeout=600, retry=True) e0 = r0.json_or_empty() - info["lockOnlyHosted"] = {"exit": r0.rc, "redirected": applied_count("hosted", e0), "codes": refusal_codes("hosted", e0), "lockfileOnlyPackages": e0.get("lockfileOnlyPackages"), "scannedPackages": e0.get("scannedPackages"), "crawledUrllib3": sorted(p["purl"] for p in e0.get("packages", []) if "urllib3" in p.get("purl", ""))} + info["lockOnlyHosted"] = {"exit": r0.rc, "redirected": applied_count("hosted", e0), "codes": envelope_codes(e0), "lockfileOnlyPackages": e0.get("lockfileOnlyPackages"), "scannedPackages": e0.get("scannedPackages"), "crawledUrllib3": sorted(p["purl"] for p in e0.get("packages", []) if "urllib3" in p.get("purl", ""))} shutil.rmtree(project / ".socket", ignore_errors=True) (project / lockname).write_bytes(pristine_lock) @@ -1207,7 +1237,7 @@ def uninstall(log): applied = applied_count(mode, envelope) info["applied"] = applied info["scannedPackages"] = envelope.get("scannedPackages") - codes = refusal_codes(mode, envelope) + codes = envelope_codes(envelope) info["codes"] = codes crawled = sorted(p.get("purl", "") for p in envelope.get("packages", [])) info["crawledWithPatches"] = crawled @@ -1292,7 +1322,10 @@ def uninstall(log): return finish("REFUSED-EXPECTED" if all(checks.values()) else "FAIL") if mode == "agent": - found = envelope.get("apply", {}).get("found", 0) + # Legacy `apply.found`: the distinct purls the agent leg touched + # (downloaded / updated / skipped / applied / failed) for this + # one-package selection. + found = len({e.get("purl") for e in leg_events("agent", envelope) if e.get("purl")}) info["found"] = found if not check("appliedExactlyOne", applied == 1, {"applied": applied, "found": found, "codes": codes, "status": envelope.get("status")}, r): return finish("FAIL") @@ -1329,7 +1362,7 @@ def uninstall(log): check("rollbackClearsManifest", ledger_cleared(project, "agent"), None, rb) check("rollbackKeepsPyproject", (project / "pyproject.toml").read_bytes() == pristine_pyproject) check("rollbackKeepsLock", (project / lockname).read_bytes() == lock_now) - info["rollbackEnvelope"] = {k: erb.get(k) for k in ("status", "rolledBack", "failed") if k in erb} + info["rollbackEnvelope"] = {k: erb.get(k) for k in ("status", "summary", "error", "warnings") if k in erb} return finish("PASS" if all(checks.values()) else "FAIL") # ------------------------------------------------ hosted / vendored @@ -1432,11 +1465,12 @@ def uninstall(log): rs = Run(cli_cmd(project, "scan", *scan_mode), project, cenv, case / "rescan-after-relock.log", timeout=900, retry=True) ers = rs.json_or_empty() rescanned = (project / lockname).read_bytes() - info["rescanAfterRelock"] = {"exit": rs.rc, "applied": applied_count(mode, ers), "codes": refusal_codes(mode, ers), "patchInLock": marker in rescanned} + info["rescanAfterRelock"] = {"exit": rs.rc, "applied": applied_count(mode, ers), "codes": envelope_codes(ers), "patchInLock": marker in rescanned} rb1 = Run(cli_cmd(project, "rollback"), project, cenv, case / "rollback-after-relock.log", timeout=900) erb1 = rb1.json_or_empty() shutil.copyfile(project / lockname, case / "rollback-after-relock.lock") - failures = (erb1.get("hosted") or {}).get("failed") or erb1.get("vendoredFailed") or [] + # v5 rollback: every leg failure is a `failed` event with an errorCode. + failures = [{"purl": e.get("purl"), "errorCode": e.get("errorCode"), "error": e.get("error")} for e in leg_events(mode, erb1) if e.get("action") == "failed"] rollback_note = {"exit": rb1.rc, "status": erb1.get("status"), "failed": failures[:3], "lockEqualsRelocked": (project / lockname).read_bytes() == relocked} if target_kept: check("rescanAfterRelockApplies", rs.ok() and marker in rescanned, info["rescanAfterRelock"], rs) @@ -1483,9 +1517,9 @@ def uninstall(log): check("rollbackClearsLedger", ledger_cleared(project, mode), None, rb) if mode == "vendored": check("rollbackRemovesVendoredWheel", not (project / ".socket/vendor/pypi" / (uuid or "x")).exists(), None, rb) - info["rollbackEnvelope"] = {k: erb.get(k) for k in ("status", "rolledBack", "failed", "vendoredReverted") if k in erb} + info["rollbackEnvelope"] = {k: erb.get(k) for k in ("status", "summary", "error", "warnings") if k in erb} if erb.get("hosted"): - info["rollbackEnvelope"]["hosted"] = {k: erb["hosted"].get(k) for k in ("reverted", "failed", "unsupported", "editedFiles")} + info["rollbackEnvelope"]["hosted"] = {"editedFiles": erb["hosted"].get("editedFiles")} return finish("PASS" if all(checks.values()) else "FAIL") # --------------------------------------------------------- execution diff --git a/scripts/backtest-pipenv.py b/scripts/backtest-pipenv.py index ab29df08b..1b46f5aa2 100755 --- a/scripts/backtest-pipenv.py +++ b/scripts/backtest-pipenv.py @@ -148,6 +148,50 @@ def is_legacy(version): return major_of(version) < 2018 +# ---------------------------------------------------- v5 `--json` envelope +# Every command prints ONE envelope (CLI_CONTRACT.md "`scan` and `get` JSON +# (v5.0)"): `status` (camelCase), `dryRun`, `events[]` (each `{action, purl, +# uuid, errorCode, reason, error, details}`; hosted / vendored events carry +# `details.mode`, agent events none), `summary`, top-level `warnings[{code, +# detail}]` and `error{code, message}`. No `redirect.redirected`, +# `vendor.summary`, `apply.*` or legacy rollback arrays any more. +def leg_events(mode, envelope): + """The events of one leg: `details.mode` hosted / vendored; every other + mode (agent, agent-oot) reads the mode-less agent events.""" + leg = mode if mode in ("hosted", "vendored") else None + return [e for e in (envelope or {}).get("events") or [] if (e.get("details") or {}).get("mode") == leg] + + +def applied_count(mode, envelope, action=None): + """Patches the leg wired: its `applied` events (a hosted re-run still + emits `applied` for the pins it re-confirms), or on a `--dry-run` the + `verified` previews. `action` counts another action instead.""" + action = action or ("verified" if (envelope or {}).get("dryRun") else "applied") + return sum(1 for e in leg_events(mode, envelope) if e.get("action") == action) + + +def envelope_warnings(envelope): + """Every coded record of the run as `{code, detail, action, purl}`: the + top-level `error`, each event carrying an `errorCode` (refusals, skips, + failures, vendor advisories) and each run-level warning.""" + env = envelope or {} + rows = [] + if isinstance(env.get("error"), dict) and env["error"].get("code"): + rows.append({"code": env["error"]["code"], "detail": env["error"].get("message") or ""}) + for e in env.get("events") or []: + if e.get("errorCode"): + rows.append({"code": e["errorCode"], "detail": e.get("reason") or e.get("error") or "", "action": e.get("action"), "purl": e.get("purl")}) + for w in env.get("warnings") or []: + if isinstance(w, dict) and w.get("code"): + rows.append({"code": w["code"], "detail": w.get("detail") or ""}) + return rows + + +def envelope_codes(envelope): + """The sorted distinct codes of `envelope_warnings`.""" + return sorted({w["code"] for w in envelope_warnings(envelope)}) + + def save(path, data): path.write_text(json.dumps(data, indent=2, sort_keys=True) + "\n") @@ -660,32 +704,6 @@ def cli_invocation(case, project, invocation): return link, ["--cwd", str(link)] raise ValueError(invocation) - def applied_count(mode, envelope): - if mode == "hosted": - return envelope.get("redirect", {}).get("redirected", 0) - if mode == "vendored": - return envelope.get("vendor", {}).get("summary", {}).get("applied", 0) - return envelope.get("apply", {}).get("applied", 0) - - def planned_count(mode, envelope): - """What a --dry-run envelope says WOULD happen (no summary is written).""" - if mode == "hosted": - return envelope.get("redirect", {}).get("redirected", 0) - if mode == "vendored": - v = envelope.get("vendor", {}) - if v.get("dryRun"): - return sum(1 for p in v.get("patches", []) if p.get("action") == "would_vendor") - return v.get("summary", {}).get("applied", 0) - a = envelope.get("apply", {}) - return a.get("added", 0) + a.get("updated", 0) if a.get("dryRun") else a.get("applied", 0) - - def envelope_warnings(mode, envelope): - if mode == "hosted": - return envelope.get("redirect", {}).get("warnings", []) - if mode == "vendored": - return envelope.get("vendor", {}).get("events", []) - return envelope.get("apply", {}).get("patches", []) - def record_hashes(project, mode): if mode == "hosted": # v5 hosted mode keeps no ledger: the lock pin names the uuid. @@ -842,7 +860,9 @@ def cli_run(penv_, *rest, log): e1 = r1.json_or_empty() pkgs = e1.get("packages") or [] u3 = [p for p in pkgs if "urllib3" in (p.get("purl") or "")] - found = e1.get("apply", {}).get("found", 0) + # Legacy `apply.found`: distinct purls among the agent events of + # this dry run (the `verified` previews of the urllib3 selection). + found = len({e.get("purl") for e in leg_events("agent", e1) if e.get("purl")}) # The envelope names packages but not where they live. A crawler that # found the out-of-tree venv scans exactly its distributions; the # project-marker fallback scans `python3`-on-PATH instead (here the @@ -920,7 +940,7 @@ def cli_run(penv_, *rest, log): envelope = r.json() save(case / "cli-output.json", envelope) applied = applied_count("agent", envelope) - check("appliedExactlyOne", applied == 1, {"applied": applied, "status": envelope.get("status"), "patches": envelope_warnings("agent", envelope)[:4]}) + check("appliedExactlyOne", applied == 1, {"applied": applied, "status": envelope.get("status"), "events": [{k: e.get(k) for k in ("action", "purl", "errorCode")} for e in leg_events("agent", envelope)][:4]}) if not checks["appliedExactlyOne"]: row["passed"] = False return row @@ -951,7 +971,7 @@ def cli_run(penv_, *rest, log): mf = project / ".socket/manifest.json" check("rollbackClearsManifest", not mf.exists() or json.loads(mf.read_text()).get("patches") in ({}, None)) check("rollbackKeepsLock", (project / "Pipfile.lock").read_bytes() == pristine_lock and (project / "Pipfile").read_bytes() == pristine_pipfile) - info["rollbackEnvelope"] = {k: erb.get(k) for k in ("status", "rolledBack", "failed", "hosted", "vendoredReverted", "manifest") if k in erb} + info["rollbackEnvelope"] = {k: erb.get(k) for k in ("status", "summary", "hosted", "error", "warnings") if k in erb} row["supported"] = True row["passed"] = all(checks.values()) return row @@ -963,7 +983,7 @@ def cli_run(penv_, *rest, log): make_venv(version, tool, venv, project, case / "venv-empty.log") r0 = cli_run(penv, "scan", "--mode", mode, log="scan-lockonly.log") e0 = r0.json_or_empty() - codes0 = sorted({(w.get("code") or w.get("errorCode")) for w in envelope_warnings(mode, e0) if (w.get("code") or w.get("errorCode"))}) + codes0 = envelope_codes(e0) info["lockOnly"] = {"exit": r0.rc, "applied": applied_count(mode, e0), "lockfileOnlyPackages": e0.get("lockfileOnlyPackages"), "codes": codes0} check("lockOnlyApplies", applied_count(mode, e0) == 1, info["lockOnly"]) if applied_count(mode, e0) == 1: @@ -974,7 +994,7 @@ def cli_run(penv_, *rest, log): lock1 = (project / "Pipfile.lock").read_bytes() r1 = cli_run(penv, "scan", "--mode", mode, log="scan-lockonly-rescan.log") e1 = r1.json_or_empty() - codes1 = sorted({(w.get("code") or w.get("errorCode")) for w in envelope_warnings(mode, e1) if (w.get("code") or w.get("errorCode"))}) + codes1 = envelope_codes(e1) ok1 = r1.ok() and e1.get("status") == "success" and (project / "Pipfile.lock").read_bytes() == lock1 and "package_not_installed" not in codes1 check("lockOnlyRescanGreen", ok1, {"exit": r1.rc, "status": e1.get("status"), "codes": codes1}) shutil.rmtree(project / ".socket", ignore_errors=True) @@ -989,7 +1009,7 @@ def cli_run(penv_, *rest, log): # --dry-run first: must report the same count and leave everything untouched. rd = cli_run(penv, "scan", "--mode", mode, "--dry-run", log="scan-dryrun.log") ed = rd.json_or_empty() - dry_applied = planned_count(mode, ed) + dry_applied = applied_count(mode, ed) dry_clean = (project / "Pipfile.lock").read_bytes() == pristine_lock and (project / "Pipfile").read_bytes() == pristine_pipfile and not (project / ".socket").exists() info["dryRun"] = {"exit": rd.rc, "applied": dry_applied, "untouched": dry_clean} @@ -1002,12 +1022,12 @@ def cli_run(penv_, *rest, log): save(case / "cli-output.json", envelope) applied = applied_count(mode, envelope) info["applied"] = applied - warnings = envelope_warnings(mode, envelope) + warnings = envelope_warnings(envelope) info["warnings"] = warnings[:8] lock_after = (project / "Pipfile.lock").read_bytes() check("pipfileUnchanged", (project / "Pipfile").read_bytes() == pristine_pipfile) # Hosted --dry-run computes the rewrite; vendored --dry-run is a - # ledger-only preview (`would_vendor`) that runs no backend guard, so + # ledger-only preview (vendored `verified` events) that runs no backend guard, so # its parity is recorded, not required. check("dryRunParity", dry_applied == applied and dry_clean, info["dryRun"]) @@ -1023,7 +1043,7 @@ def cli_run(penv_, *rest, log): reason, code = refusal row["supported"] = False row["expected"] = f"refused: {reason} ({code})" - codes = sorted({(w.get("code") or w.get("errorCode")) for w in warnings if (w.get("code") or w.get("errorCode"))}) + codes = envelope_codes(envelope) check("refusedWithCode", applied == 0 and code in codes, {"applied": applied, "codes": codes, "exit": r.rc}) check("lockUnchanged", lock_after == pristine_lock) check("noLedger", not (project / ".socket/vendor/redirect-state.json").exists() and not (project / ".socket/vendor/state.json").exists()) @@ -1040,9 +1060,9 @@ def cli_run(penv_, *rest, log): # The scan ran against a venv holding the UPSTREAM release: Pipenv will # not reinstall it, so the CLI must say so (positive-evidence probe). stale_code = "redirect_pypi_stale_install" if mode == "hosted" else "pypi_pipenv_stale_install" - stale = [w for w in warnings if (w.get("code") or w.get("errorCode")) == stale_code] - stale_text = (stale[0].get("detail") or stale[0].get("reason") or "") if stale else "" - check("staleInstallWarned", bool(stale) and "pipenv run pip uninstall" in stale_text, {"codes": sorted({(w.get("code") or w.get("errorCode")) for w in warnings if (w.get("code") or w.get("errorCode"))}), "detail": stale_text[:300] or None}) + stale = [w for w in warnings if w["code"] == stale_code] + stale_text = stale[0]["detail"] if stale else "" + check("staleInstallWarned", bool(stale) and "pipenv run pip uninstall" in stale_text, {"codes": envelope_codes(envelope), "detail": stale_text[:300] or None}) check("lockRewritten", lock_after != pristine_lock) if shape == "crlf": check("crlfPreserved", b"\n" not in lock_after.replace(b"\r\n", b"")) @@ -1188,10 +1208,10 @@ def cli_run(penv_, *rest, log): lock_ok = post == relocked if mode == "hosted" and not hybrid: # No pin, no ledger, no manifest: nothing to roll back. - retired = rrb.rc == 1 and (erb2.get("error") or {}).get("message") == "Manifest not found" + retired = rrb.rc == 1 and (erb2.get("error") or {}).get("code") == "manifest_not_found" else: retired = rrb.ok() - check("rollbackAfterRelockRetires", retired and cleared and lock_ok, {"exit": rrb.rc, "cleared": cleared, "hybridRelock": hybrid, "lockKeptRelocked": post == relocked, "lockRestoredOriginal": post == pristine_lock, "referenceLeft": marker in post, "envelope": {k: erb2.get(k) for k in ("status", "hosted", "vendoredReverted", "failed") if k in erb2}, "tail": rrb.tail(400) if not rrb.ok() else None}) + check("rollbackAfterRelockRetires", retired and cleared and lock_ok, {"exit": rrb.rc, "cleared": cleared, "hybridRelock": hybrid, "lockKeptRelocked": post == relocked, "lockRestoredOriginal": post == pristine_lock, "referenceLeft": marker in post, "envelope": {"status": erb2.get("status"), "error": erb2.get("error"), "summary": erb2.get("summary"), "codes": envelope_codes(erb2)}, "tail": rrb.tail(400) if not rrb.ok() else None}) (project / "Pipfile.lock").write_bytes(lock_after) (project / "Pipfile").write_bytes(pristine_pipfile) @@ -1204,7 +1224,9 @@ def cli_run(penv_, *rest, log): check("rollbackKeepsPipfile", (project / "Pipfile").read_bytes() == pristine_pipfile) if mode == "hosted": check("rollbackNoRedirectLedger", not (project / ".socket/vendor/redirect-state.json").exists()) - check("rollbackRestoredUpstream", PURL_BASE in ((erb.get("hosted") or {}).get("reverted") or []), erb.get("hosted")) + # v5: a hosted restore is a `rolledBack` event with details.mode hosted. + reverted = [e.get("purl") or "" for e in leg_events("hosted", erb) if e.get("action") == "rolledBack"] + check("rollbackRestoredUpstream", any(p == PURL_BASE or p.startswith(PURL_BASE + "?") for p in reverted), {"reverted": reverted, "hosted": erb.get("hosted")}) if mode == "vendored": check("rollbackRemovesVendoredWheel", not (project / ".socket/vendor/pypi" / (uuid or "x")).exists()) state = project / ".socket/vendor/state.json" @@ -1212,7 +1234,7 @@ def cli_run(penv_, *rest, log): # Hosted and vendored runs are manifest-free (v5.0): nothing may have # been written to `.socket/manifest.json` at any point. check("noManifestWritten", not (project / ".socket/manifest.json").exists()) - info["rollbackEnvelope"] = {k: erb.get(k) for k in ("status", "rolledBack", "failed", "hosted", "vendoredReverted", "manifest") if k in erb} + info["rollbackEnvelope"] = {k: erb.get(k) for k in ("status", "summary", "hosted", "error", "warnings") if k in erb} # Measured boundaries, recorded rather than required: Pipenv never # reinstalls a present release (warmInstallReplacesUpstream — the CLI # warns instead, see staleInstallWarned) and the vendored --dry-run diff --git a/scripts/backtest-poetry.py b/scripts/backtest-poetry.py index da1a66da0..11086381f 100755 --- a/scripts/backtest-poetry.py +++ b/scripts/backtest-poetry.py @@ -98,6 +98,50 @@ def vtuple(v): return tuple(int(x) for x in v.split(".")) +# ---------------------------------------------------- v5 `--json` envelope +# Every command prints ONE envelope (CLI_CONTRACT.md "`scan` and `get` JSON +# (v5.0)"): `status` (camelCase), `dryRun`, `events[]` (each `{action, purl, +# uuid, errorCode, reason, error, details}`; hosted / vendored events carry +# `details.mode`, agent events none), `summary`, top-level `warnings[{code, +# detail}]` and `error{code, message}`. No `redirect.redirected`, +# `vendor.summary`, `apply.*` or legacy rollback arrays any more. +def leg_events(mode, envelope): + """The events of one leg: `details.mode` hosted / vendored; every other + mode (agent, agent-oot) reads the mode-less agent events.""" + leg = mode if mode in ("hosted", "vendored") else None + return [e for e in (envelope or {}).get("events") or [] if (e.get("details") or {}).get("mode") == leg] + + +def applied_count(mode, envelope, action=None): + """Patches the leg wired: its `applied` events (a hosted re-run still + emits `applied` for the pins it re-confirms), or on a `--dry-run` the + `verified` previews. `action` counts another action instead.""" + action = action or ("verified" if (envelope or {}).get("dryRun") else "applied") + return sum(1 for e in leg_events(mode, envelope) if e.get("action") == action) + + +def envelope_warnings(envelope): + """Every coded record of the run as `{code, detail, action, purl}`: the + top-level `error`, each event carrying an `errorCode` (refusals, skips, + failures, vendor advisories) and each run-level warning.""" + env = envelope or {} + rows = [] + if isinstance(env.get("error"), dict) and env["error"].get("code"): + rows.append({"code": env["error"]["code"], "detail": env["error"].get("message") or ""}) + for e in env.get("events") or []: + if e.get("errorCode"): + rows.append({"code": e["errorCode"], "detail": e.get("reason") or e.get("error") or "", "action": e.get("action"), "purl": e.get("purl")}) + for w in env.get("warnings") or []: + if isinstance(w, dict) and w.get("code"): + rows.append({"code": w["code"], "detail": w.get("detail") or ""}) + return rows + + +def envelope_codes(envelope): + """The sorted distinct codes of `envelope_warnings`.""" + return sorted({w["code"] for w in envelope_warnings(envelope)}) + + def save(path, data): path.write_text(json.dumps(data, indent=2, sort_keys=True) + "\n") @@ -553,13 +597,6 @@ def poetry_install_cmd(version, poetry): def cli_cmd(project, *rest): return [cli, *rest, "--cwd", project, "--json", "--yes", "--no-telemetry"] - def applied_count(mode, envelope): - if mode == "hosted": - return envelope.get("redirect", {}).get("redirected", 0) - if mode == "vendored": - return envelope.get("vendor", {}).get("summary", {}).get("applied", 0) - return envelope.get("apply", {}).get("applied", 0) - def lock_check(version, poetry, project, penv, log): """Poetry's own lock consistency check, whichever spelling exists.""" v = vtuple(version) @@ -650,10 +687,14 @@ def check(name, value, note=None, operation=None): e1 = r1.json_or_empty() paths = [p for p in (e1.get("paths") or [])] pkgs = e1.get("packages") or [] + # Legacy `apply.found`: distinct purls among the agent events of + # this dry run (the `verified` previews of the selection). + agent_events = leg_events("agent", e1) + found = len({ev.get("purl") for ev in agent_events if ev.get("purl")}) info["bareScan"] = { "exit": r1.rc, "scannedPackages": e1.get("scannedPackages"), - "packagesWithPatches": e1.get("packagesWithPatches"), + "packagesWithPatches": len(pkgs), "paths": paths[:10], "urllib3Found": any("urllib3" in (p.get("purl") or "") for p in pkgs), "packageDirs": [pth for p in pkgs for pth in (p.get("paths") or [])][:10], @@ -665,10 +706,10 @@ def check(name, value, note=None, operation=None): sees = any( "urllib3" in (p.get("purl") or "") and not p.get("notInstalled") for p in pkgs - ) and e1.get("apply", {}).get("found", 0) >= 1 and not any( - ev.get("errorCode") == "package_not_installed" for ev in e1.get("apply", {}).get("patches", []) + ) and found >= 1 and not any( + ev.get("errorCode") == "package_not_installed" for ev in agent_events ) - check("bareScanSeesPoetryVenv", sees, {"scannedPackages": e1.get("scannedPackages"), "found": e1.get("apply", {}).get("found")}, operation=r1) + check("bareScanSeesPoetryVenv", sees, {"scannedPackages": e1.get("scannedPackages"), "found": found}, operation=r1) # 2. apply for real: BARE when the crawler found the venv (the fixed # CLI), else via `poetry run` (Poetry exports VIRTUAL_ENV). bare = bool(sees) @@ -713,11 +754,10 @@ def check(name, value, note=None, operation=None): # Fresh-clone scenario first: nothing installed, lock only. r0 = Run(cli_cmd(project, "scan", "--mode", "vendored"), project, env, case / "scan-lockonly.log") e0 = r0.json_or_empty() - events = e0.get("vendor", {}).get("events", []) info["lockOnlyVendor"] = { "exit": r0.rc, "applied": applied_count("vendored", e0), - "codes": sorted({ev.get("errorCode") for ev in events if ev.get("errorCode")}), + "codes": envelope_codes(e0), } check("lockOnlyVendorApplies", applied_count("vendored", e0) == 1, info["lockOnlyVendor"]) # reset any partial state @@ -740,7 +780,7 @@ def check(name, value, note=None, operation=None): save(case / "cli-output.json", envelope) applied = applied_count(mode, envelope) info["applied"] = applied - warnings = envelope.get("redirect", {}).get("warnings", []) if mode == "hosted" else envelope.get("vendor", {}).get("events", []) + warnings = envelope_warnings(envelope) info["warnings"] = warnings[:8] lock_after = (project / "poetry.lock").read_bytes() check("pyprojectUnchanged", (project / "pyproject.toml").read_bytes() == pristine_pyproject) @@ -880,7 +920,9 @@ def check(name, value, note=None, operation=None): check("rollbackKeepsPyproject", (project / "pyproject.toml").read_bytes() == pristine_pyproject) if mode == "hosted": check("rollbackNoRedirectLedger", not (project / ".socket/vendor/redirect-state.json").exists(), operation=rb) - check("rollbackRestoredUpstream", PURL_BASE in ((erb.get("hosted") or {}).get("reverted") or []), erb.get("hosted"), operation=rb) + # v5: a hosted restore is a `rolledBack` event with details.mode hosted. + reverted = [e.get("purl") or "" for e in leg_events("hosted", erb) if e.get("action") == "rolledBack"] + check("rollbackRestoredUpstream", any(p == PURL_BASE or p.startswith(PURL_BASE + "?") for p in reverted), {"reverted": reverted, "hosted": erb.get("hosted")}, operation=rb) if mode == "vendored": check("rollbackRemovesVendoredWheel", not (project / ".socket/vendor/pypi" / (uuid or "x")).exists(), operation=rb) if mode == "agent": @@ -893,7 +935,7 @@ def check(name, value, note=None, operation=None): # Hosted and vendored runs are manifest-free (v5.0): nothing may # have been written to `.socket/manifest.json` at any point. check("noManifestWritten", not mf.exists()) - info["rollbackEnvelope"] = {k: erb.get(k) for k in ("status", "rolledBack", "failed", "hosted", "vendoredReverted", "manifest") if k in erb} + info["rollbackEnvelope"] = {k: erb.get(k) for k in ("status", "summary", "hosted", "error", "warnings") if k in erb} row["passed"] = not failed_required_checks(row) return row diff --git a/scripts/backtest-uv.py b/scripts/backtest-uv.py index 308c2f7d2..ca86365a7 100644 --- a/scripts/backtest-uv.py +++ b/scripts/backtest-uv.py @@ -1005,6 +1005,41 @@ def variant_matrix(version): } +def scan_observation(envelope, command): + """The observation fields of one `scan --json` run, read from the v5 + envelope by the run's `--mode`. Hosted: `rewrittenFiles` (kept on + `redirect`), `redirected` (the hosted `applied` events — `verified` on a + dry run) and `warnings` (the run-level `warnings[].code`, plus + `error.code` when the run refused). Vendored: `vendorSummary` (the + vendored `applied` / `failed` event counts) and `vendorErrors` (the + vendored `failed` events).""" + mode = command[command.index('--mode') + 1] if '--mode' in command else None + events = [ + e + for e in envelope.get('events') or [] + if (e.get('details') or {}).get('mode') == mode + ] + if mode == 'hosted': + codes = [w.get('code') for w in envelope.get('warnings') or []] + if (envelope.get('error') or {}).get('code'): + codes.append(envelope['error']['code']) + return { + 'rewrittenFiles': (envelope.get('redirect') or {}).get('rewrittenFiles', []), + 'redirected': sum(e.get('action') in ('applied', 'verified') for e in events), + 'warnings': codes, + } + if mode == 'vendored': + failed = [e for e in events if e.get('action') == 'failed'] + return { + 'vendorSummary': { + 'applied': sum(e.get('action') == 'applied' for e in events), + 'failed': len(failed), + }, + 'vendorErrors': failed, + } + return {} + + def variant_status(observations, name, mode): """Collapse one fixture/mode's observations into the doc-table verdict. @@ -1115,25 +1150,7 @@ def write_summary(): row['installedResponseSha256'] == patched_response ) if key.endswith('socket-patch'): - payload = json.loads(row['stdout']) - redirect = payload.get('redirect') - vendor = payload.get('vendor') - if redirect: - item['rewrittenFiles'] = redirect.get('rewrittenFiles', []) - item['redirected'] = redirect.get('redirected', 0) - item['warnings'] = [ - warning['code'] for warning in redirect.get('warnings', []) - ] - if vendor: - item['vendorSummary'] = { - key: vendor.get('summary', {}).get(key) - for key in ['applied', 'failed'] - } - item['vendorErrors'] = [ - event - for event in vendor.get('events', []) - if event.get('action') == 'failed' - ] + item.update(scan_observation(json.loads(row['stdout']), command)) elif item['exitCode']: item['diagnostic'] = row['stderr'].replace(str(ROOT), '')[ :1000 diff --git a/scripts/backtest-vlt.py b/scripts/backtest-vlt.py index fb8533fa2..5c9df50ac 100644 --- a/scripts/backtest-vlt.py +++ b/scripts/backtest-vlt.py @@ -906,35 +906,49 @@ def parse_envelope(output): return None -def envelope_codes(value): - """Every `code` / `errorCode` / skipped `reason` code anywhere in it.""" - codes = [] +def envelope_entries(envelope): + """`(code, detail)` for every coded entry of a v5 envelope: `error` + (`{code, message}`), every event with an `errorCode` (its `reason`, or its + `error` for a `failed` event, as the detail), every top-level + `warnings[]` `{code, detail}` and, on a `--vex` run, `vex.warnings`.""" + if not isinstance(envelope, dict): + return [] + entries = [] + error = envelope.get('error') + if isinstance(error, dict) and error.get('code'): + entries.append((error['code'], error.get('message'))) + for event in envelope.get('events') or []: + if event.get('errorCode'): + entries.append((event['errorCode'], event.get('reason') or event.get('error'))) + for warning in [*(envelope.get('warnings') or []), + *((envelope.get('vex') or {}).get('warnings') or [])]: + if isinstance(warning, dict) and warning.get('code'): + entries.append((warning['code'], warning.get('detail'))) + return entries + + +def all_codes(value): + """Every `code` / `errorCode` string anywhere in a JSON document, depth + first (the Rust tests' `all_codes`): for a PUBLISHED release's output, + which may predate the v5 envelope.""" if isinstance(value, dict): - for key, item in value.items(): - if key in ('code', 'errorCode') and isinstance(item, str): - codes.append(item) - elif key == 'reason' and isinstance(item, str) and re.fullmatch(r'[a-z0-9_]+', item): - codes.append(item) - else: - codes.extend(envelope_codes(item)) - elif isinstance(value, list): - for item in value: - codes.extend(envelope_codes(item)) - return codes + return [c for k, v in value.items() + for c in ([v] if k in ('code', 'errorCode') and isinstance(v, str) else []) + + all_codes(v)] + if isinstance(value, list): + return [c for item in value for c in all_codes(item)] + return [] -def envelope_details(value, code): - found = [] - if isinstance(value, dict): - if code in (value.get('code'), value.get('errorCode')) and isinstance( - value.get('detail'), str): - found.append(value['detail']) - for item in value.values(): - found.extend(envelope_details(item, code)) - elif isinstance(value, list): - for item in value: - found.extend(envelope_details(item, code)) - return found +def envelope_codes(envelope): + """Every code the envelope reports (see `envelope_entries`).""" + return [code for code, _ in envelope_entries(envelope)] + + +def envelope_details(envelope, code): + """The detail strings of every `code` entry (see `envelope_entries`).""" + return [detail for c, detail in envelope_entries(envelope) + if c == code and isinstance(detail, str)] def vex_statements(doc, purl): @@ -1592,7 +1606,7 @@ def downgrade(args, ctx, vlt): code, output, _ = run([published, *command, '--json', '--yes', '--no-telemetry', '--cwd', project], project, ctx['cli_env'], log) row['publishedExitCode'] = code - row['publishedCodes'] = sorted(set(envelope_codes(parse_envelope(output)))) + row['publishedCodes'] = sorted(set(all_codes(parse_envelope(output)))) now = snapshot(project) untouched = now == written reverted = {n: b for n, b in now.items() if not n.startswith('.socket/')} == { diff --git a/scripts/tests/test_backtest_harnesses.py b/scripts/tests/test_backtest_harnesses.py index 3ec7557aa..39e94625e 100644 --- a/scripts/tests/test_backtest_harnesses.py +++ b/scripts/tests/test_backtest_harnesses.py @@ -43,8 +43,10 @@ def run_case(_job): calls.append(True) row = dict(passed=len(calls) > 1, checks={'repeatStableLock': len(calls) > 1}) if len(calls) == 1: - row['repeat'] = {'vendor': {'events': [{'reason': - 'Network error: error sending request for url (https://patch.socket.dev/example)'}]}} + row['repeat'] = {'status': 'partialFailure', 'events': [{ + 'action': 'failed', 'purl': bun.PURL, 'errorCode': 'download_failed', + 'details': {'mode': 'vendored'}, 'error': + 'Network error: error sending request for url (https://patch.socket.dev/example)'}]} bun.save(case / 'result.json', row) return row @@ -145,8 +147,9 @@ def test_reinstall_advisories_are_informational(self): for code in ('vendor_bun_reinstall_required', 'redirect_bun_reinstall_required'): self.assertIn(code, bun.INFORMATIONAL) self.assertIn(code, bun.BUN_REINSTALL_ADVISORIES) - envelope = {'status': 'success', 'redirect': {'redirected': 1, 'warnings': [ - {'code': 'redirect_bun_reinstall_required'}]}} + envelope = {'status': 'success', + 'events': [{'action': 'applied', 'purl': bun.PURL, 'details': {'mode': 'hosted'}}], + 'warnings': [{'code': 'redirect_bun_reinstall_required', 'detail': 'bun install --force'}]} self.assertTrue(bun.rerun_clean(0, envelope, 'hosted')) def test_misclassification_codes_stay_refusals(self): @@ -468,8 +471,8 @@ def run_case(_job): def test_zero_exit_cli_transport_warning_is_retried_from_a_fresh_case(self): # The CLI exits 0 and reports the exhausted patch API fetch in its # envelope; `Run`'s exit-code retry never sees it. - envelope = json.dumps({"status": "partial_failure", "warnings": [{"code": "patch_details_failed", - "message": "API request failed with status 503: service unavailable"}]}) + envelope = json.dumps({"status": "partialFailure", "events": [], "warnings": [{"code": "patch_details_failed", + "detail": "API request failed with status 503: service unavailable"}]}) blip = self.operation(0, envelope) with tempfile.TemporaryDirectory() as temp: root = Path(temp) @@ -540,6 +543,67 @@ def test_failure_details_list_only_failed_checks_with_notes(self): self.assertEqual(pdm.failure_details(row), [' appliedExactlyOne: {"applied": 0}']) +class PythonHarnessEnvelopeTests(unittest.TestCase): + """pdm / pipenv / poetry read the v5 envelope through the same small + accessors: per-leg `applied` (or dry-run `verified`) event counts and the + codes of `error`, every event `errorCode` and every `warnings[]` entry.""" + + PURL = "pkg:pypi/urllib3@1.26.18" + + def event(self, action, mode=None, code=None, reason=None): + e = {"action": action, "purl": self.PURL, "uuid": "u"} + if mode: + e["details"] = {"mode": mode} + if code: + e["errorCode"] = code + if reason: + e["reason"] = reason + return e + + def test_applied_count_reads_one_leg(self): + env = {"status": "success", "dryRun": False, "events": [ + self.event("applied", "hosted"), self.event("applied", "vendored"), + self.event("downloaded"), self.event("applied"), + self.event("skipped", "hosted", "redirect_unconfirmed")]} + for script in (pdm, pipenv, poetry): + with self.subTest(script=script.__name__): + self.assertEqual([script.applied_count(m, env) for m in ("hosted", "vendored", "agent")], [1, 1, 1]) + self.assertEqual(script.applied_count("agent-oot", env), 1) + + def test_dry_run_counts_verified_previews(self): + env = {"status": "success", "dryRun": True, "events": [ + self.event("verified", "hosted"), self.event("verified"), self.event("verified")]} + for script in (pdm, pipenv, poetry): + with self.subTest(script=script.__name__): + self.assertEqual((script.applied_count("hosted", env), script.applied_count("vendored", env), + script.applied_count("agent", env)), (1, 0, 2)) + + def test_codes_read_error_events_and_warnings(self): + env = {"status": "error", "error": {"code": "pypi_pdm_lock_unsupported", "message": "lock_version 3.0"}, + "events": [self.event("skipped", "vendored", "vendor_artifact_reused", "re-wired"), + self.event("failed", None, "package_not_installed")], + "warnings": [{"code": "redirect_pypi_stale_install", "detail": "pipenv run pip uninstall urllib3"}]} + for script in (pdm, pipenv, poetry): + with self.subTest(script=script.__name__): + self.assertEqual(script.envelope_codes(env), [ + "package_not_installed", "pypi_pdm_lock_unsupported", + "redirect_pypi_stale_install", "vendor_artifact_reused"]) + details = {w["code"]: w["detail"] for w in script.envelope_warnings(env)} + self.assertEqual(details["redirect_pypi_stale_install"], "pipenv run pip uninstall urllib3") + self.assertEqual(details["vendor_artifact_reused"], "re-wired") + self.assertEqual(details["pypi_pdm_lock_unsupported"], "lock_version 3.0") + self.assertEqual(script.envelope_codes({}), []) + + def test_rollback_restores_are_leg_events(self): + env = {"command": "rollback", "status": "partialFailure", "events": [ + self.event("rolledBack", "hosted"), self.event("failed", "vendored", "vendor_revert_kept")]} + for script in (pdm, pipenv, poetry): + with self.subTest(script=script.__name__): + self.assertEqual([e["action"] for e in script.leg_events("hosted", env)], ["rolledBack"]) + self.assertEqual([e["errorCode"] for e in script.leg_events("vendored", env)], ["vendor_revert_kept"]) + self.assertEqual(script.leg_events("agent", env), []) + + class PipenvShimTests(unittest.TestCase): def test_parallel_first_use(self): # Force every worker to reach symlink creation before any can create @@ -1102,8 +1166,8 @@ def test_a_successful_runs_preflight_reasons_reach_the_row(self): row = dict(cell=cell.name, expectedVerdict='patched', passed=False, checks={}, safeRefusal=True) detail = 'vlt would fail to verify https://h/a.tgz: http 503; nothing was written for x' - envelope = {'status': 'success', 'redirect': {'warnings': [ - {'code': 'redirect_vlt_artifact_unverifiable', 'detail': detail}]}} + envelope = {'status': 'success', 'events': [], 'warnings': [ + {'code': 'redirect_vlt_artifact_unverifiable', 'detail': detail}]} with patch.object(vlt, 'run', return_value=(0, json.dumps(envelope), '')): cell.patch_run('hosted') with patch('sys.stdout'): @@ -1156,8 +1220,8 @@ def row(self, encoding='gzip', status=200, expected='patched'): codes=[], checks={'serveEncodingIdentity': False}) def envelope(self, detail=DETAIL): - return {'redirect': {'warnings': [{'code': 'redirect_vlt_artifact_unverifiable', - 'detail': detail}]}} + return {'status': 'success', 'events': [], + 'warnings': [{'code': 'redirect_vlt_artifact_unverifiable', 'detail': detail}]} def test_a_clean_refusal_of_an_encoded_artifact_is_blocked(self): row = self.row() @@ -1196,8 +1260,9 @@ def test_the_refusal_must_name_the_encoding_and_write_nothing(self): for envelope in (self.envelope('vlt would fail to verify x: sha512 mismatch; ' 'nothing was written'), self.envelope('content-encoding gzip'), - {'redirect': {'warnings': [{'code': 'redirect_vlt_lock_unsupported', - 'detail': self.DETAIL}]}}): + {'status': 'success', 'events': [], + 'warnings': [{'code': 'redirect_vlt_lock_unsupported', + 'detail': self.DETAIL}]}): with self.subTest(envelope=envelope): row = self.row() self.assertFalse(self.cell.blocked_refusal(row, [envelope], self.reference)) @@ -1231,11 +1296,14 @@ def test_grant_selection_fails_closed(self): vlt.select_tarball(broken) def test_envelope_codes_read_every_channel(self): - envelope = {'redirect': {'warnings': [{'code': 'a'}], - 'skipped': [{'reason': 'redirect_vlt_artifact_unverifiable'}]}, - 'vendor': {'events': [{'errorCode': 'b', 'reason': 'prose with spaces'}]}} + envelope = {'status': 'error', 'error': {'code': 'c', 'message': 'm'}, + 'warnings': [{'code': 'a', 'detail': ''}], + 'events': [{'action': 'skipped', 'errorCode': 'redirect_vlt_artifact_unverifiable', + 'details': {'mode': 'hosted'}}, + {'action': 'skipped', 'errorCode': 'b', 'reason': 'prose with spaces', + 'details': {'mode': 'vendored'}}]} self.assertEqual(sorted(vlt.envelope_codes(envelope)), - ['a', 'b', 'redirect_vlt_artifact_unverifiable']) + ['a', 'b', 'c', 'redirect_vlt_artifact_unverifiable']) if __name__ == "__main__": diff --git a/scripts/tests/test_poetry_retry.py b/scripts/tests/test_poetry_retry.py index aa8408c54..9c63e34b7 100644 --- a/scripts/tests/test_poetry_retry.py +++ b/scripts/tests/test_poetry_retry.py @@ -77,6 +77,10 @@ def oracle(python, names, project, log): ns["oracle"] = oracle + def hosted_event(action): + # v5 envelope: a hosted pin / restore is an event tagged details.mode hosted. + return {"action": action, "purl": ns["PURL_BASE"], "details": {"mode": "hosted"}} + class FakeRun(poetry.Run): def __init__(self, cmd, cwd, env, log, timeout=None): self.cmd = list(map(str, cmd)) @@ -90,31 +94,32 @@ def __init__(self, cmd, cwd, env, log, timeout=None): (venv / "bin/python").touch() self.out = str(venv) elif log.name == "scan-bare-dryrun.log": - self.out = json.dumps({"packages": [{"purl": ns["PURL_BASE"]}], "apply": {"found": 1}}) + self.out = json.dumps({"status": "success", "dryRun": True, "packages": [{"purl": ns["PURL_BASE"]}], + "events": [{"action": "verified", "purl": ns["PURL_BASE"]}]}) if first and transport_site == "oot_prior_functional_failure": self.out = "{}" elif log.name == "scan-apply.log": if first: - self.out = json.dumps({"error": TRANSPORT}) + self.out = json.dumps({"status": "error", "events": [], "error": {"code": "patch_fetch_failed", "message": TRANSPORT}}) else: manifest = project / ".socket/manifest.json" manifest.parent.mkdir(parents=True, exist_ok=True) manifest.write_text('{"patches": {"fixture": {}}}') - self.out = json.dumps({"apply": {"applied": 1}}) + self.out = json.dumps({"status": "success", "events": [{"action": "applied", "purl": ns["PURL_BASE"]}]}) elif log.name == "scan.log": if first and transport_site == "required_scan_json_transport": - self.out = json.dumps({"status": "error", "error": {"message": TRANSPORT}}) + self.out = json.dumps({"status": "error", "events": [], "error": {"code": "patch_fetch_failed", "message": TRANSPORT}}) else: (project / "poetry.lock").write_bytes(patched) - self.out = json.dumps({"status": "success", "redirect": {"redirected": 1, "warnings": []}}) + self.out = json.dumps({"status": "success", "events": [hosted_event("applied")]}) elif log.name == "rescan.log": - self.out = json.dumps({"status": "success", "redirect": {"redirected": 0, "warnings": []}}) + self.out = json.dumps({"status": "success", "events": [hosted_event("applied")]}) if first and churn: (project / "poetry.lock").write_bytes(patched + b"# unwanted churn\n") if first and transport_site == "rescan_churn_transport": self.rc, self.err = 1, TRANSPORT if first and transport_site == "rescan_churn_json_transport": - self.out = json.dumps({"error": TRANSPORT}) + self.out = json.dumps({"status": "error", "events": [], "error": {"code": "patch_fetch_failed", "message": TRANSPORT}}) elif log.name == "install-warm.log": (project / "poetry.lock").write_bytes(patched) if first and transport_site == "successful_install_warning": @@ -133,11 +138,13 @@ def __init__(self, cmd, cwd, env, log, timeout=None): if first and transport_site == "required_rollback_transport": self.rc, self.err = 1, "API request failed with status 503: unavailable" elif first and transport_site == "required_rollback_json_transport": - self.out = json.dumps({"error": "API request failed with status 503: unavailable"}) + self.out = json.dumps({"command": "rollback", "status": "error", "events": [], + "error": {"code": "rollback_failed", "message": "API request failed with status 503: unavailable"}}) else: (project / "poetry.lock").write_bytes(pristine) (project / ".socket/manifest.json").unlink(missing_ok=True) - self.out = json.dumps({"hosted": {"reverted": [ns["PURL_BASE"]]}}) + self.out = json.dumps({"command": "rollback", "status": "success", "events": [hosted_event("rolledBack")], + "hosted": {"editedFiles": 1}}) log.write_text(f"$ {' '.join(self.cmd)}\n# exit {self.rc}\n--- stdout\n{self.out}\n--- stderr\n{self.err}") ns["Run"] = FakeRun @@ -209,7 +216,7 @@ def test_zero_exit_cli_errors_are_structured_and_terminal(self): for envelope in ({"error": {"message": TRANSPORT}}, {"warnings": [{"code": "api_batch_failed", "detail": TRANSPORT}]}, {"warnings": [{"code": "patch_details_failed", "detail": "could not fetch details for pkg:pypi/urllib3@1.26.18: Rate limit exceeded (HTTP 429, gave up after 3 retries). Please try again later."}]}, - {"vendor": {"events": [{"action": "skipped", "errorCode": "download_failed", "reason": TRANSPORT}]}}): + {"events": [{"action": "skipped", "errorCode": "download_failed", "reason": TRANSPORT, "details": {"mode": "vendored"}}]}): with self.subTest(envelope=envelope): self.assertTrue(poetry.operation_transport_failure(operation(rc=0, out=json.dumps(envelope), err=""))) for envelope in ({"status": "success", "detail": TRANSPORT},