diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index 2c6b07551..cbf8ecd75 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -79,7 +79,7 @@ Every subcommand accepts the same set of "global" flags via a single shared `Glo `--offline` means the same thing on every command (v3.0): never contact the network, fail loudly when a required local source is missing. On `repair`, `--offline` and `--download-only` are mutually exclusive (exit 2). `scan` and `get` need remote data for their core function (patch discovery / patch fetch), so `--offline` refuses them up front — exit 1 with an error naming the offline gate (JSON: `status: "error"`), before any crawl, client build, or network contact. This covers `scan --mode vendored` too: offline vendored staging is `vendor --offline`'s job. -The `--strict` mismatch policy applies to the in-place apply paths (apply/get/scan --mode agent/hook/go redirect). DEFAULT (v3.4): a file whose on-disk content matches neither the patch's beforeHash nor its afterHash is overwritten with the FULL verified patched content (blob writes are hash-gated to exactly afterHash; the missing blob is downloaded on demand) and surfaced as a `content_mismatch_overwritten` stderr warning + Skipped event (agent-mode `get` / `scan --json`: a `warnings[]` entry, see "Agent-mode mismatch overwrites"). A file the patch adds (empty beforeHash) that already exists with other content is the same case. `--strict` turns that case into a hard error. Rollback of an added file deletes it; the content it replaced is not kept. `--force` overrides `--strict` and additionally skips missing files. Vendor staging is unaffected (it always auto-overwrites into its private stage). +The `--strict` mismatch policy applies to the in-place apply paths (apply/get/scan --mode agent/hook/go redirect). DEFAULT (v3.4): a file whose on-disk content matches neither the patch's beforeHash nor its afterHash is overwritten with the FULL verified patched content (blob writes are hash-gated to exactly afterHash; the missing blob is downloaded on demand) and surfaced as a `content_mismatch_overwritten` stderr warning + Skipped event (agent-mode `get` / `scan --json`: a `warnings[]` entry, see "Agent-mode mismatch overwrites"). A file the patch adds (empty beforeHash) that already exists with other content is the same case. `--strict` turns that case into a hard error. Rollback of an added file deletes it; the content it replaced is not kept. `--force` overrides `--strict` and additionally skips missing files. Vendoring is unaffected: it commits the patch server's verified artifact and never reads the installed bytes. ## Per-subcommand arguments @@ -89,7 +89,7 @@ Beyond the globals above, each subcommand defines a small set of local arguments |---|---|---|---| | `apply` | `--force` / `-f` | — | Bypass beforeHash check | | `apply` | `--check` | — | Read-only audit that every in-scope (`--ecosystems`) manifest patch is in place, for CI / GitHub-App auditing (v5.0: previously Go-only, which passed on any unpatched non-Go tree). Local Go patches: the committed `.socket/go-patches/` copies and `go.mod` `replace` directives match the manifest (`go_redirect_drift`). Every other patch: each installed copy hashes to the record's `afterHash` — the `vex` verifier over the `vex` copy lookup; the copies are narrowed by `apply`'s own rules: a release variant (a qualified purl such as `?artifact_id=` / `?platform=`) is judged only on the copies holding its distribution, matched against every variant of its base as `apply` matches them, and an installed copy that holds none of them is drift of the base purl (`no_matching_variant`, the copy `apply` fails with "no matching variant found"; a Gradle / Ivy cache dir is exempt, as in `apply`); for gem, once a bundle-store copy exists the `gem env` fallback-home copies are not judged (`apply` treats them as best-effort). Drift is a `failed` event per patch with `errorCode` `not_applied` (still unpatched), `hash_mismatch` (neither the original nor the patched bytes), `file_not_found` or `no_matching_variant`, status `partialFailure`, exit 1, and the human `Error: Patches are OUT OF SYNC:` report (printed even under `--silent`), which ends with the remedy ``Run `socket-patch apply ` to regenerate them.``: `` repeats the check's own `--cwd`, `--manifest-path`, `-g` / `--global-prefix` and `--ecosystems` (each shell-quoted when needed), so running it verbatim heals the tree that was checked (v5.0, #1219). In sync is exit 0: `Patches are in sync (N checked).` and, under `--json`, a `skipped` event per verified patch (`errorCode: already_patched`). A patch with no installed copy is skipped as `apply` skips it (`package_not_installed`; the human line adds `M not installed, skipped`). Vendor-owned patches are excluded (`vendor --check` audits them). Lock-free, fetch-free, offline-safe; it never writes. An unreadable manifest is drift (`manifest_unreadable`, exit 1) | -| `vendor` | `--force` / `-f` | — | Tolerate missing patch-target files in the stage + bypass the variant probe. A beforeHash mismatch no longer needs it: vendor staging auto-overwrites with the verified patched content (`vendor_content_mismatch_overwritten` warning) | +| `vendor` | `--force` / `-f` | — | Bypass the installed-variant probe for multi-release ecosystems. That is its only effect: vendoring commits the patch server's verified artifact and never reads the installed files' content, so a missing or mismatched installed file vendors the same with or without `--force` (no warning) | | `vendor` | `--revert` | `SOCKET_VENDOR_REVERT` | Undo vendoring: restore recorded original lockfile fragments + remove `.socket/vendor/` artifacts. Works without a manifest. A package vendored over a hosted pin returns to its upstream registry entry, never to hosted (see "Takeover reconciliation") | | `vendor` | `--check` | — | Offline, read-only artifact and wiring audit; exits 1 on drift. Conflicts with `--revert`. | | `vendor` | `--local-repo ` | — | With `--check`, also inspect suffixed Maven jar/POM copies in this cache for conflicts. | @@ -1343,7 +1343,7 @@ Every `--json` invocation emits a single JSON object that follows the **unified | `vendor_yarn_berry_mixed_line_endings` | `failed` | vendor (yarn berry): `yarn.lock` or the root `package.json` mixes CRLF and LF line endings (or holds a bare CR) — no single ending can be kept, and yarn rewrites such a file wholesale on its next install (a mixed lock also fails `--immutable`, YN0028). Refused before any write; `yarn install` normalizes the files. A uniformly CRLF pair is vendored in CRLF. A hosted→vendored takeover (`vendor`, `scan`/`get --mode vendored`) raises this — and the berry `vendor_yarn_berry_cache_unsupported` gates — BEFORE restoring the hosted pin's upstream entry (dry run too), so a refused purl stays hosted. | | `vendor_override_conflict` | `failed` | vendor (pnpm/yarn-berry): a user-authored override/resolution for the package already exists. pnpm exception: a user exact pin equal to the vendored version, under the bare `` or the `@` key, in package.json `pnpm.overrides` or pnpm-workspace.yaml `overrides:` (#854; there a quoted `'1.3.0'` or a trailing `# comment` is the same pin), is taken over in place (its value becomes the vendored `file:` spec under the user's own key on every override surface) and `vendor --revert` restores it byte-for-byte. A range, another version, a `>` selector, several same-name keys, or package.json and pnpm-workspace.yaml pinning under different keys still refuse; the detail names the file that carries the override. | | `vendor_integrity_unverified` | `skipped` (warning) | vendor (pipenv): the lockfile format does not hash-check file entries; the committed wheel bytes are the protection. | -| `vendor_content_mismatch_overwritten` | `skipped` (warning) | vendor: a staged file matched NEITHER beforeHash nor afterHash (patch built against different bytes, or local edits); the stage was overwritten with the verified patched content and the vendor succeeded. | +| `vendor_content_mismatch_overwritten` | — | **No longer emitted** (service-only vendoring): `vendor` commits the patch server's verified artifact without reading the installed bytes, so it has no mismatch to report. Kept so consumers that match on it know it will not appear. | | `vendor_vlt_transitive_unsupported` | `failed` | vendor (vlt): the target has an inbound edge from another package in `vlt-lock.json` (the detail names it); vendored mode rewires only direct dependencies of the root or a workspace member, because vlt silently reverts transitive lock surgery. Remedy: `--mode hosted`. Refused before any download or write, dry runs included (`would_refuse`). | | `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. | diff --git a/crates/socket-patch-cli/src/commands/vendor.rs b/crates/socket-patch-cli/src/commands/vendor.rs index f522c439f..47a49a3f9 100644 --- a/crates/socket-patch-cli/src/commands/vendor.rs +++ b/crates/socket-patch-cli/src/commands/vendor.rs @@ -71,11 +71,11 @@ pub struct VendorArgs { #[command(flatten)] pub common: GlobalArgs, - /// Tolerate missing patch-target files in the staged copy (skip them - /// instead of failing) and bypass the variant probe for multi-release - /// ecosystems. Not needed for a beforeHash mismatch: vendoring always - /// overwrites mismatched content with the verified patched bytes and - /// warns (`vendor_content_mismatch_overwritten`). + /// Bypass the installed-variant probe for multi-release ecosystems + /// (vendor every recorded release variant, not just the one whose + /// bytes match the installed copy). Vendoring never reads the + /// installed files' content: it commits the patch server's verified + /// artifact, so a missing or locally edited file needs no flag. #[arg( short = 'f', long, diff --git a/crates/socket-patch-cli/tests/help_text_hygiene.rs b/crates/socket-patch-cli/tests/help_text_hygiene.rs index 369297994..6ec3cf487 100644 --- a/crates/socket-patch-cli/tests/help_text_hygiene.rs +++ b/crates/socket-patch-cli/tests/help_text_hygiene.rs @@ -285,3 +285,21 @@ fn short_help_lists_about_eight_options_and_long_help_lists_all() { "{long}" ); } + +/// #923: service-only vendoring never reads the installed files, so +/// `vendor --force` only bypasses the installed-variant probe. Its help +/// must not promise a missing-file tolerance or a mismatch warning that +/// no vendored backend implements. +#[test] +fn vendor_force_help_describes_only_the_variant_probe_bypass() { + let help = long_help(&["vendor"]); + let force = help + .split("--force") + .nth(1) + .and_then(|rest| rest.split("\n -").next()) + .expect("vendor --help documents --force"); + assert!(force.contains("variant probe"), "{force}"); + for stale in ["Tolerate missing", "vendor_content_mismatch_overwritten"] { + assert!(!force.contains(stale), "stale {stale:?} in: {force}"); + } +} diff --git a/crates/socket-patch-cli/tests/in_process_vendor.rs b/crates/socket-patch-cli/tests/in_process_vendor.rs index 45bdc69ee..4c1cd2133 100644 --- a/crates/socket-patch-cli/tests/in_process_vendor.rs +++ b/crates/socket-patch-cli/tests/in_process_vendor.rs @@ -2880,9 +2880,10 @@ fn vendor_after_in_place_apply_emits_applied_event() { /// Installed content matching NEITHER hash (a patch built against different /// bytes than the installed artifact — the flatted@3.3.1 case) still vendors: -/// the stage is overwritten with the verified patched content, the run exits -/// 0 with an `applied` event, and the overwrite surfaces as a -/// `vendor_content_mismatch_overwritten` warning event. +/// vendoring commits the server's verified artifact without reading the +/// installed bytes, so the run exits 0 with an `applied` event and the +/// `vendor_prebuilt_downloaded` advisory, and the installed tree is left +/// untouched. #[test] fn mismatched_install_does_not_change_the_server_artifact() { let fx = npm_fixture(); diff --git a/crates/socket-patch-cli/tests/scan_vendor_e2e.rs b/crates/socket-patch-cli/tests/scan_vendor_e2e.rs index a471e4ede..7ee82b67e 100644 --- a/crates/socket-patch-cli/tests/scan_vendor_e2e.rs +++ b/crates/socket-patch-cli/tests/scan_vendor_e2e.rs @@ -1407,8 +1407,8 @@ async fn scan_vendor_prune_reconciles_unwired_entry_on_an_empty_crawl() { /// Interactive (non-JSON) `scan --mode vendored` pre-verifies patch baselines: /// installed content matching NEITHER hash is annotated before vendoring -/// starts, and the run still vendors (auto-force) with the -/// `vendor_content_mismatch_overwritten` warning on stderr. +/// starts, and the run still vendors the server's verified artifact (no +/// mismatch warning: vendoring never reads the installed bytes). #[tokio::test] async fn scan_vendor_annotates_mismatched_baseline_and_vendors_anyway() { let mock = MockServer::start().await; diff --git a/crates/socket-patch-core/src/vendor/npm_lock.rs b/crates/socket-patch-core/src/vendor/npm_lock.rs index 59f11b5c4..9b95d7e0b 100644 --- a/crates/socket-patch-core/src/vendor/npm_lock.rs +++ b/crates/socket-patch-core/src/vendor/npm_lock.rs @@ -2322,32 +2322,34 @@ mod tests { ); } - /// `vendor --force` keeps its missing-file tolerance (strict superset - /// of the auto-force policy). + /// Vendoring ignores the installed copy's content: a missing patch + /// target vendors the same with or without `force` (#923). #[tokio::test] - async fn vendor_force_still_skips_missing_files() { - let fx = fixture().await; - tokio::fs::remove_file(fx.installed().join("index.js")) - .await - .unwrap(); + async fn vendor_ignores_missing_installed_files_with_or_without_force() { + for force in [false, true] { + let fx = fixture().await; + tokio::fs::remove_file(fx.installed().join("index.js")) + .await + .unwrap(); - let blobs = fx.root().join(".socket/blobs"); - let sources = PatchSources::blobs_only(&blobs); - let outcome = crate::vendor::test_support::vendor_npm( - &fx.purl(), - &fx.installed(), - fx.root(), - &fx.record, - &sources, - "2026-06-09T00:00:00Z", - false, - /*force=*/ true, - None, - ) - .await; - let (result, entry, _) = expect_done(outcome); - assert!(result.success, "{:?}", result.error); - assert!(entry.is_some()); + let blobs = fx.root().join(".socket/blobs"); + let sources = PatchSources::blobs_only(&blobs); + let outcome = crate::vendor::test_support::vendor_npm( + &fx.purl(), + &fx.installed(), + fx.root(), + &fx.record, + &sources, + "2026-06-09T00:00:00Z", + false, + force, + None, + ) + .await; + let (result, entry, _) = expect_done(outcome); + assert!(result.success, "force={force}: {:?}", result.error); + assert!(entry.is_some(), "force={force}"); + } } /// A package already patched IN PLACE by `apply` vendors cleanly: the