Problem
scan --json --mode hosted emits TWO incompatible top-level JSON shapes depending on whether discovery found packages:
- 0 packages (gate-off): the zero-discovery early-return in
crates/socket-patch-cli/src/commands/scan/mod.rs prints the CLASSIC scan object ({status, scannedPackages, lockfileOnlyPackages, totalPatches, canAccessPaidPatches, packages, updates, …}) before the hosted branch runs — with no redirect key.
- >=1 package (gate-on):
hosted::run_redirect in crates/socket-patch-cli/src/commands/scan/hosted.rs:531 emitted a BARE {status, redirect:{mode, redirected, rewrittenFiles, skipped, warnings, dryRun}} envelope — NONE of the scan-count keys, and no per-patch enumeration.
A JSON consumer parsing scannedPackages/totalPatches/canAccessPaidPatches/packages breaks the moment >=1 package is discovered. Confirmed against real prod for maven + nuget, reproduces on npm. Vendored mode does not have this flip — it NESTS a vendor block inside the classic scan object.
Fix
Follow the vendored-mode precedent: always emit the classic scan object and NEST the redirect result under a redirect key (additive), for BOTH paths. Human (non-JSON) output is unchanged.
run_redirect now takes the classic scan object as Option<serde_json::Value> (crates/socket-patch-cli/src/commands/scan/hosted.rs:114), built by run. A new build_redirect_json_envelope helper (hosted.rs:88) folds the redirect block into it; emit_json_error (hosted.rs:75) folds error status into it too — so error envelopes keep the same top-level keys instead of flipping schema.
- In
run (mod.rs): the HUMAN hosted path returns early (if hosted && !args.common.json, passing None); the --json hosted path now returns from inside the JSON block, after the classic result (including the packages enumeration + notInstalled flags) is built, passing Some(result) (mod.rs:927).
- The zero-discovery early-return gains a no-op nested
redirect block when hosted (mod.rs:622) so the 0-package envelope is shape-consistent with the >=1-package one.
This also fixes the missing enumeration: the hosted --json output now includes the packages array (which packages / patch UUIDs were selected), same as every other scan.
Kills sweep findings: hosted-scan-json-schema-flips-with-discovery (P2), hosted-scan-json-omits-enumeration (P2).
Test
Hermetic serialization test hosted_json_envelope_nests_redirect_into_classic_scan_object (crates/socket-patch-cli/src/commands/scan/hosted.rs) asserts the hosted --json envelope for the >=1-package case carries the classic scan keys (scannedPackages/packagesWithPatches/totalPatches/freePatches/paidPatches/canAccessPaidPatches/updates) AND the per-package/patch-uuid enumeration AND a nested redirect object preserving every sub-field. It exercises the extracted build_redirect_json_envelope — the exact schema that flipped before. cargo test -p socket-patch-cli --lib commands::scan → 39 passed. cargo build -p socket-patch-cli and cargo clippy -p socket-patch-cli both clean.
Scope
Touches only crates/socket-patch-cli/src/commands/scan/{hosted.rs,mod.rs} (the hosted-mode JSON envelope). run_redirect has a single caller (the hosted branch in run), updated in place. No cross-dependencies on other in-flight fixes.
🤖 Generated with Claude Code
Note
Low Risk
CLI JSON output shape only for hosted scan mode; redirect/lockfile behavior unchanged; human output unchanged.
Overview
Fixes inconsistent scan --json --mode hosted stdout when discovery finds packages: success used to emit only {status, redirect} and dropped classic scan fields (scannedPackages, totalPatches, packages, etc.), while the zero-package path already printed the full scan object.
Hosted JSON now follows vendored mode: run builds the classic scan result first (including packages and lockfile-only flags), then run_redirect nests redirect details under redirect via build_redirect_json_envelope. emit_json_error merges errors into that same object when scan_result is present. Human (non-JSON) hosted runs still call run_redirect early with None.
The empty-scan JSON early return adds a no-op redirect block when hosted so shape matches the ≥1-package path. A unit test locks the nested envelope schema.
Reviewed by Cursor Bugbot for commit 24d479b. Configure here.