Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions crates/socket-patch-cli/CLI_CONTRACT.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ Beyond the globals above, each subcommand defines a small set of local arguments

`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.

**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`, `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. `--apply` partitions lockfile-only patches out BEFORE download (calm `skipped`/`package_not_installed` records — never an error exit, never a manifest write); `--vendor` passes them through to the vendor engine's auto-fetch. 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.
**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`, `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. `--apply` partitions lockfile-only patches out BEFORE download (calm `skipped`/`package_not_installed` records — never an error exit, never a manifest write); `--vendor` passes them through to the vendor engine's auto-fetch. 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 <pkg>`) or `pnpm_pnp_unsupported` (pnpm's `node-linker=pnp` twin; pnpm remedies), plus a stderr `Warning (<code>): …` 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`.

**Vendor auto-fetch (v3.4)**: `vendor`/`scan --vendor` no longer fail on lockfile-resolved packages with no installed copy. Already-vendored purls stage from their committed artifact (sha256-verified against the vendor ledger; offline-safe). Otherwise the pristine artifact is fetched per the lockfile resolution and verified against the lock's recorded integrity FAIL-CLOSED before any write: npm SRI (or yarn classic's sha1 fragment), yarn berry's cache-zip checksum (rebuilt from the fetched tarball; cacheKey 10c0 only), Cargo.lock sha256 over the .crate, go.sum `h1:` dirhash over the module zip, composer `dist.shasum` (sha1), Gemfile.lock `CHECKSUMS` sha256, uv.lock wheel sha256 (pure `py3-none-any` wheels only). Entries the lock cannot verify are NEVER fetched (`vendor_fetch_unverifiable` warning + the calm `package_not_installed` skip). Registry bases honor `SOCKET_NPM_REGISTRY`, `SOCKET_CRATES_REGISTRY`, `SOCKET_GOPROXY` (else `GOPROXY`); npm/yarn/composer/gem/uv lock-recorded URLs are used verbatim. `--offline` refuses the fetch with the calm skip (the detail names the lockfile resolution). The fetch stages into a private tempdir — the project tree is never touched.

Expand Down Expand Up @@ -1101,7 +1101,7 @@ socket-patch apply --json | jq '
Exit `0` when `status` is `success`, `noManifest`, or `notFound`-with-zero-failed.
Exit `1` when `status` is `partialFailure` (any `events[*].action == "failed"`) or `error`.

`apply` with no manifest at all is a clean exit-0 no-op (`status: "noManifest"`), and an **empty** manifest (zero patches) is a plain `success` exit 0 — this is load-bearing for the install hooks, which run `apply` on every install. Pinned by `tests/in_process_edge_cases.rs` and `tests/cli_dry_run_paths_e2e.rs`.
`apply` with no manifest at all is a clean exit-0 no-op (`status: "noManifest"`), and an **empty** manifest (zero patches) is a plain `success` exit 0 — this is load-bearing for the install hooks, which run `apply` on every install. Pinned by `tests/in_process_edge_cases.rs` and `tests/cli_dry_run_paths_e2e.rs`. **One carve-out**: a yarn-berry Plug'n'Play layout (`.pnp.*` loader at `--cwd`) refuses with the loud `yarn_pnp_unsupported` error (exit 1) even when no manifest exists — `scan` cannot discover PnP packages (they live inside `.yarn/cache/*.zip`, no `node_modules/`) and therefore never writes a manifest, so without the carve-out the documented refusal was unreachable and a PnP project's only signal was the calm noManifest exit. Pinned by `tests/e2e_safety_yarn_pnp.rs`.

## Exit codes

Expand Down
58 changes: 40 additions & 18 deletions crates/socket-patch-cli/src/commands/apply.rs
Original file line number Diff line number Diff line change
Expand Up @@ -589,6 +589,33 @@ pub(crate) fn result_to_event(result: &ApplyResult, dry_run: bool) -> PatchEvent
PatchEvent::new(PatchAction::Applied, purl).with_files(files)
}

/// Print the yarn-PnP refusal (JSON envelope or human stderr) and return
/// apply's refusal exit code. Shared by the pre-manifest gate and the
/// package-manager layout gate below: scan cannot discover PnP packages so
/// it never writes a manifest, which used to leave the calm `noManifest`
/// exit as the ONLY thing a PnP user ever saw — the documented loud
/// `yarn_pnp_unsupported` refusal was unreachable without a manifest.
fn refuse_yarn_pnp(args: &ApplyArgs) -> i32 {
if args.common.json {
let mut env = Envelope::new(Command::Apply);
env.dry_run = args.common.dry_run;
env.mark_error(EnvelopeError::new(
"yarn_pnp_unsupported",
"yarn-berry Plug'n'Play layout is not supported by socket-patch (packages live inside .yarn/cache zips). Use `yarn patch <pkg>` instead.",
));
println!("{}", env.to_pretty_json());
} else {
// Errors print even under --silent ("errors only", never
// "nothing"): exit 1 with no message would be undiagnosable.
eprintln!("Error: yarn-berry Plug'n'Play layout is not supported.");
eprintln!(
" Packages live inside .yarn/cache/*.zip — socket-patch cannot rewrite them in place."
);
eprintln!(" Use `yarn patch <pkg>` instead.");
}
1
}

pub async fn run(args: ApplyArgs) -> i32 {
apply_env_toggles(&args.common);
let (telemetry_client, _) =
Expand All @@ -600,6 +627,18 @@ pub async fn run(args: ApplyArgs) -> i32 {

// Check if manifest exists - exit successfully if no .socket folder is set up
if tokio::fs::metadata(&manifest_path).await.is_err() {
// A yarn-PnP layout refuses loudly even with no manifest: scan
// cannot discover PnP packages (they live inside .yarn/cache zips),
// so it never writes one — without this hoisted check the layout
// gate further down never fired and the ONLY signal a PnP project
// ever produced was this calm exit-0 noManifest, i.e. a silent
// no-op. Same envelope + exit semantics as the with-manifest gate.
if matches!(
detect_npm_pkg_manager(&args.common.cwd),
NpmPkgManager::YarnBerryPnP
) {
return refuse_yarn_pnp(&args);
}
if args.common.json {
let mut env = Envelope::new(Command::Apply);
env.status = Status::NoManifest;
Expand Down Expand Up @@ -642,24 +681,7 @@ pub async fn run(args: ApplyArgs) -> i32 {
// in `apply_file_patch` does the substantive safety work.
match detect_npm_pkg_manager(&args.common.cwd) {
NpmPkgManager::YarnBerryPnP => {
if args.common.json {
let mut env = Envelope::new(Command::Apply);
env.dry_run = args.common.dry_run;
env.mark_error(EnvelopeError::new(
"yarn_pnp_unsupported",
"yarn-berry Plug'n'Play layout is not supported by socket-patch (packages live inside .yarn/cache zips). Use `yarn patch <pkg>` instead.",
));
println!("{}", env.to_pretty_json());
} else {
// Errors print even under --silent ("errors only", never
// "nothing"): exit 1 with no message would be undiagnosable.
eprintln!("Error: yarn-berry Plug'n'Play layout is not supported.");
eprintln!(
" Packages live inside .yarn/cache/*.zip — socket-patch cannot rewrite them in place."
);
eprintln!(" Use `yarn patch <pkg>` instead.");
}
return 1;
return refuse_yarn_pnp(&args);
}
NpmPkgManager::Pnpm => {
if !args.common.json && !args.common.silent {
Expand Down
43 changes: 42 additions & 1 deletion crates/socket-patch-cli/src/commands/scan/discovery.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,46 @@ pub(super) struct LockfileSupplement {
pub(super) packages: Vec<socket_patch_core::crawlers::types::CrawledPackage>,
/// Literal crawler-form purls, for fast membership tests.
pub(super) purls: HashSet<String>,
/// npm layouts the lockfile inventory REFUSED (Plug'n'Play loaders) —
/// packages structurally unreachable, as opposed to nothing-to-inventory.
/// Scan surfaces these as explicit refusal warnings: under yarn PnP the
/// installed-tree crawl is also empty (no `node_modules/`), so without
/// this channel a PnP project scans as a silent success-0 no-op in
/// every mode.
pub(super) unsupported: Vec<socket_patch_core::vendor::lock_inventory::UnsupportedNpmLayout>,
}

/// Map a core npm-layout refusal onto scan's warning channel as
/// `(code, detail)`. The yarn code matches apply's refusal errorCode
/// (`yarn_pnp_unsupported`) so consumers key on ONE name across commands;
/// the pnpm twin gets the parallel spelling. Details are scan-phrased (what
/// was NOT scanned + remedy) rather than the probe's vendor-phrased text.
pub(super) fn unsupported_layout_warnings(sup: &LockfileSupplement) -> Vec<(String, String)> {
sup.unsupported
.iter()
.map(|diag| match diag.code {
"vendor_yarn_berry_unsupported" => (
"yarn_pnp_unsupported".to_string(),
"this project uses yarn Plug'n'Play (a `.pnp.*` loader is present): its npm \
packages live inside `.yarn/cache/*.zip`, not `node_modules/`, so socket-patch \
cannot discover or patch them in ANY mode (agent, hosted, or vendored) — npm \
dependencies were NOT scanned. Use `yarn patch <pkg>` to patch them instead."
.to_string(),
),
"vendor_pnpm_pnp_unsupported" => (
"pnpm_pnp_unsupported".to_string(),
"this project uses pnpm's Plug'n'Play linker (`node-linker=pnp` in .npmrc): \
lockfile discovery is skipped under this layout, so lockfile-only npm \
dependencies were NOT scanned. Switch .npmrc to `node-linker=isolated`, run \
`pnpm install`, and re-run — or use `socket-patch scan --mode hosted`, which \
edits pnpm-lock.yaml in place."
.to_string(),
),
// Forward-compat: a new refusal code surfaces verbatim rather
// than being swallowed back into silence.
other => (other.to_string(), diag.detail.clone()),
})
.collect()
}

/// Inventory the project's lockfile(s) and fabricate crawl entries for
Expand All @@ -45,7 +85,8 @@ pub(super) async fn lockfile_supplement(
if common.global || common.global_prefix.is_some() {
return out;
}
let entries = lock_inventory::inventory_project(&common.cwd).await;
let (entries, unsupported) = lock_inventory::inventory_project_diagnosed(&common.cwd).await;
out.unsupported = unsupported;
if entries.is_empty() {
return out;
}
Expand Down
51 changes: 50 additions & 1 deletion crates/socket-patch-cli/src/commands/scan/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,8 @@ mod vendor_flow;

use self::discovery::{
collect_vuln_ids, detect_updates, lockfile_supplement, merge_redirect_records_for_updates,
preverify_vendor_baselines, severity_order, vendored_ledger_supplement,
preverify_vendor_baselines, severity_order, unsupported_layout_warnings,
vendored_ledger_supplement,
};
use self::gc::{gc_json, print_gc_vendored_line, run_apply_gc};
use self::hosted::run_redirect;
Expand Down Expand Up @@ -957,6 +958,18 @@ pub(super) async fn note_vendor_supersedes_redirect(
});
}

/// 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)| serde_json::json!({ "code": code, "detail": detail }))
.collect(),
)
}

pub async fn run(mut args: ScanArgs) -> i32 {
apply_env_toggles(&args.common);

Expand Down Expand Up @@ -1085,6 +1098,16 @@ pub async fn run(mut args: ScanArgs) -> i32 {
// discovery — counts, API lookup, table, the prune "scanned" set — and
// are flagged "not yet installed" everywhere a user could act on them.
let lockfile_only = lockfile_supplement(&args.common, &all_crawled).await;
// Explicit refusals for npm layouts whose packages are structurally
// unreachable (yarn PnP, pnpm node-linker=pnp). Under yarn PnP the
// crawler leg above is ALSO empty (no `node_modules/`), so without this
// channel every mode used to print a clean success with
// `scannedPackages: 0` — a silent no-op the user read as "protected".
// Surfaced as run-level `warnings[]` in the JSON envelope (omitted when
// empty) and a stderr line on the human path; exit code and `status`
// stay deliberately unchanged (same posture as hosted refusals, which
// exit 0 with `redirected: 0`).
let layout_refusals = unsupported_layout_warnings(&lockfile_only);
if !lockfile_only.packages.is_empty() {
for pkg in &lockfile_only.packages {
if let Some(eco) = Ecosystem::from_purl(&pkg.purl) {
Expand Down Expand Up @@ -1143,6 +1166,11 @@ pub async fn run(mut args: ScanArgs) -> i32 {
if show_progress {
eprintln!();
}
if !args.common.json && !args.common.silent {
for (code, detail) in &layout_refusals {
eprintln!("Warning ({code}): {detail}");
}
}
// Telemetry: empty-scan still counts as a successful scan.
track_patch_scanned(
0,
Expand Down Expand Up @@ -1177,6 +1205,14 @@ pub async fn run(mut args: ScanArgs) -> i32 {
"packages": [],
"updates": [],
});
// PnP layout refusals: additive top-level `warnings` (omitted
// when empty — run-level warnings precedent) so a JSON consumer
// can tell "structurally unscannable project" apart from a
// genuinely-empty one. This is the loud half of the fix for the
// yarn-PnP silent success-0 no-op.
if !layout_refusals.is_empty() {
result["warnings"] = layout_refusal_json(&layout_refusals);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Conflicting install hint after PnP

Low Severity

On an empty human-mode yarn PnP scan, the new yarn_pnp_unsupported warning is followed by No packages found. Run … install first. Running install does not create a crawlable node_modules/ under Plug'n'Play, so that second message conflicts with the refusal's yarn patch remedy and can send users down a dead end.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit cd894fe. Configure here.

// Hosted mode: keep the `--json` envelope schema-consistent with
// the ≥1-package path by including a (no-op) nested `redirect`
// block — nothing was discovered, so nothing is redirected. The
Expand Down Expand Up @@ -1257,6 +1293,12 @@ pub async fn run(mut args: ScanArgs) -> i32 {
lockfile_only.purls.len(),
);
}
// Polyglot PnP repos (e.g. a PnP frontend + a python venv) reach
// this non-empty path: the refusal still prints so the invisible
// npm half is never silently blessed by the other ecosystems' scan.
for (code, detail) in &layout_refusals {
eprintln!("Warning ({code}): {detail}");
}
}

// Query API in batches
Expand Down Expand Up @@ -1498,6 +1540,13 @@ pub async fn run(mut args: ScanArgs) -> i32 {
"newUuid": u.new_uuid,
})).collect::<Vec<_>>(),
});
// PnP layout refusals ride the non-empty envelope too (polyglot
// repos: the OTHER ecosystems' discovery being non-empty must not
// silently bless the structurally-invisible npm half). Additive,
// omitted when empty.
if !layout_refusals.is_empty() {
result["warnings"] = layout_refusal_json(&layout_refusals);
}
// Flag lockfile-only packages so JSON consumers can tell "patch
// available but not installed" from the installed case. Additive
// field; absent means installed. Matching bridges the API's
Expand Down
Loading
Loading