Skip to content

Fix four vendored/hosted correctness bugs found while profiling - #268

Draft
Mikola Lysenko (mikolalysenko) wants to merge 12 commits into
mainfrom
fix/profiling-correctness-bugs
Draft

Mikola Lysenko (mikolalysenko) wants to merge 12 commits into
mainfrom
fix/profiling-correctness-bugs

Conversation

@mikolalysenko

Copy link
Copy Markdown
Collaborator

Four correctness bugs recorded while profiling the vendored and hosted scan
paths, plus the adjudication of an adversarial review of the first pass at
them. This is not a performance change — nothing here is from §2.2 or §5
of the perf plan, and no code was restructured for speed.

Base: origin/main @ 1bfe5326. Every fix is its own commit, each one
reproduced on unmodified main (or, for the three regressions the first
pass introduced, A/B'd against main's own behavior) before being written.

What was wrong, and what changed

1. download.patches came out in HashMap order (acae5db3, b8f08b07)

Bug (BASE vendored JSON, §6). filter_to_installed_releases buckets
every release-variant purl (PyPI ?artifact_id=, RubyGems ?platform=,
Maven ?classifier=) into a HashMap keyed by base purl and then drains
it. That drain is the function's OUTPUT order, which is the order the
download loop walks, so download.patches, apply.patches and the
per-patch stderr lines came out in bucket order: two identical runs of the
same project emitted the same records in different orders.

Change. Sort the multi-variant bases before resolving them (stable
warnings) and sort the kept selection by (purl, uuid) before returning —
matching scan's packages and the agent flow's skip_records. The
--all-releases arm gets the same order so both arms share one contract,
and its docstring no longer claims a verbatim pass-through.

Tests. release_narrowing_keeps_a_stable_purl_order (the narrowing
itself) and download_patches_json_is_purl_ordered (the emitted array).

2. A hosted- or vendored-rewired requirements.txt dropped out of the inventory (11de805c, 45425953)

Bug (req-big, §6). The requirements.txt inventory read only exact ==
pins, so every line this CLI had already rewritten vanished from it. The
hosted wet re-run of a 391-pin requirements.txt reported
packagesWithPatches: 1 instead of 12, having "lost" the eleven packages it
had just wired. uv.lock keeps its [[package]] name/version through the
same rewrite; Pipfile.lock's reader already kept a Socket-written reference
as the package it replaces.

Change. Read both shapes we write, through the same
socket_reference_coords reader Pipfile.lock uses:

  • hosted — the PEP 508 direct reference name @ <patch-server url>;
  • vendored — a BARE path line,
    `./.socket/vendor/pypi// --hash=sha256:…

    socket-patch vendor: ==`, whose requirement name lives ONLY

    in that comment tag (utils::requirements::vendor_tag, the reader
    vex::discover::pypi_other already uses).

The first commit covered the hosted half only, while both docstrings claimed
the vendored half too; 45425953 closes it. The recovered entry is
discovery-only (resolved: None, integrity: None), so the PATCHED
artifact it points at can never be fetched as a pristine source. A line
whose reference contradicts its own name, and a user's own file/url/path
reference, still stay out.

Tests. The hosted shape and the vendored shape, each beside a == pin
and a user's own wheel, plus a round trip through each writer's own
formatter — the "the two grammars must agree" guard, once per writer.

3. One contentless patch view killed the whole vendored run (a34bb9d0, 7dbc90a1, ec2c1c7d, 5f598436)

Bug (JS-7, §6). The patch view serves blobContent only for the files a
patch CHANGES; a zero-delta file (beforeHash == afterHash) comes back with
hashes and no content. The in-memory vendor stager counted any such view as
a failed fetch, and a single failed fetch made the WHOLE run bail
no_local_source: exit 1, status: "error", zero events, and every other
package in the manifest left unvendored without a word. Reproduced against
the live API with pkg:npm/tar-fs@2.1.1, patch 8ff3e0c7-… — seven
zero-delta fixture files plus one changed package/index.js.

Change, in three parts.

  • A zero-delta file needs no blob: the pristine copy already carries the
    patched bytes, which is exactly what verify_file_patch answers
    AlreadyPatched for. needs_blob() is now the one rule, shared by
    covered() (what to fetch) and the fetch loop (whether a view came back
    complete), so the two can never disagree. The §6 example package vendors
    now instead of failing on every run.
  • A patch whose content genuinely cannot be obtained is an unsatisfiable
    PACKAGE, like vendor_fetch_failed or redirect_revert_failed: it gets
    its own failed/no_local_source event and the run carries on, in
    vendor, scan/get --mode vendored and repair alike.
  • That event now carries the REAL reason — which file the view served with
    no content, a malformed or undecodable blob, or the fetch error — instead
    of the verbatim run-level string "patch artifacts unavailable (offline or
    download failure)", which named two causes that are both false for a view
    served 200. Every human channel in the stager is gated on !--json, so
    the per-package slot is the only explanation a --json consumer gets. The
    fetch loop also collects every contentless file instead of breaking at the
    first one, since patch.files is a HashMap.

The pre-event bail is deliberately kept for the case it was written for —
NOTHING in the manifest can be staged, so there are no per-package events to
report. See "Deliberately not changed" below.

Tests. a_view_whose_only_contentless_files_are_zero_delta_vendors
(red on unmodified main with status: "error"; asserts the vendored
tarball carries the changed file at its patched bytes AND the zero-delta
file at the bytes it always had), contentless_patch_view_fails_only_its_own_package,
every_patch_unstageable_keeps_the_run_level_error, and a mixed-selection
case for scan --mode vendored so the vendored scan's own
Ok(staging_errors || engine_errors) fold is covered, not just vendor's.

4. --vendor-source build downloaded a gem it could never use (25756641, 66a6e4b9)

Bug (GEM-4, §6). A bundler path source will not load without the
eval-able stub gemspec rubygems writes into <gem home>/specifications/
when the gem is INSTALLED, and a downloaded .gem carries its gemspec only
as YAML in metadata.gz — which is why the vendoring service converts it
and serves a separate gem-stub-gemspec artifact. So a local build can
never vendor a fetched gem. The auto-fetch rung downloaded the .gem anyway
and only then hit the backend's gem_spec_missing: a wasted registry round
trip on every run, ending in a message whose remedy named the mode that does
not work from there.

Change. Refuse before the fetch, with the same gem_spec_missing code
and a detail that names the real remedy (bundle install, or
--vendor-source=auto). Scoped to the purls a DOWNLOAD would actually
happen for, mirroring fetch_pristine_package's own fetchable filter: an
inventory entry whose integrity is not LockIntegrity::None, and no ledger
entry. The backend keeps its own refusal as the backstop for every other
route into it.

25756641 keyed the gate off mere resolvability
(lookup(inv, purl).is_some() || ledger_entry.is_some()) and so fired in
two cases where main never fetched anything and never failed; 66a6e4b9
is the scope correction. Both are measured A/B against origin/main:

fixture, --vendor-source build origin/main 25756641 now
not installed, lock verifies it (bundler ≥ 2.6 CHECKSUMS) 1 registry GET, then failed/gem_spec_missing 0 GETs, failed/gem_spec_missing same as 25756641
not installed, lock does NOT verify it (bundler < 2.6, no CHECKSUMS) 0 GETs, skipped/vendor_fetch_unverifiable + skipped/package_not_installed, exit 1 failed/gem_spec_missing same as main
already vendored, fresh clone (rm -rf vendor/) exit 0, success, skipped/vendor_fetched_missing + skipped/already_vendored exit 1, partialFailure, failed/gem_spec_missing same as main

The second row matters because CLI_CONTRACT documents it ("Entries the lock
cannot verify are NEVER fetched"), there was no download to save, and the
refusal's remedy could not help: the same fixture under --vendor-source auto still yields the skip pair, because the purl never reaches the gem
backend in any mode. The third is the already-vendored fresh-clone case the
ladder exists for — the gem backend's idempotent hot path re-confirms the
wired lock and needs no stub gemspec of its own.

Tests. build_mode_refuses_a_lockfile_only_gem_before_downloading_it,
auto_mode_still_fetches_a_lockfile_only_gem,
a_gem_that_resolves_from_nowhere_still_reports_not_installed, plus the two
scope guards an_unverifiable_lock_entry_keeps_the_documented_skip_pair and
an_already_vendored_gem_re_runs_green_on_a_fresh_clone (both green on
origin/main, red on 25756641).

Docs (ee41402d)

[Unreleased] → Fixed carries one entry per fix, each naming what a JSON
consumer sees change. CLI_CONTRACT's error-code table gains two facts that
were not readable anywhere:

  • no_local_source is reported at TWO levels, and which one arrives depends
    on whether anything else in the manifest staged.
  • gem_spec_missing was never in the table at all. Its row states the
    pre-fetch refusal, its exact scope, what is deliberately unaffected, and
    the vendor_fetched_missing warning a build-mode run no longer emits
    because it no longer downloads.

Deliberately not changed

  • The whole-run no_local_source bail, including for a one-patch
    manifest.
    When NOTHING in the manifest can be staged there are no
    per-package events to report, and five suites pin that shape
    (scan_vendor_step_error_e2e, covgap_commands_scan_vendor_flow ×2,
    covgap_commands_get, coverage_fix_vendor_silent_mute_exit,
    in_process_vendor::offline_missing_source_fails). Making the
    per-package path unconditional would mean rewriting those assertions,
    which is a contract change and not this branch's job. The inconsistency is
    real but it is now documented rather than undecided, in both
    CLI_CONTRACT's error-code table and json_envelope's error_code doc.
    Note that the §6 example (tar-fs@2.1.1) no longer reaches this path at
    all: its view is complete once zero-delta files stop demanding content.
  • repair's candidate partition has no mixed-manifest test. Its
    unavailable arm is exercised only through single-candidate fixtures.
    scan --mode vendored (the other untested caller) is covered here; the
    repair twin is left as a follow-up.
  • Anything from perf-plan-phase3 §2.2 or §5. Note that the gem fix (4)
    is §2.2's X1b behavior for the gem half, which §5.4 holds behind a product
    decision — while GEM-4 is also named in §6, so the mandate is genuinely
    ambiguous. It is a separate commit (plus its separate scope correction) so
    it can be dropped or held for that sign-off without touching the other
    three fixes, and §2.2's stated reason for wanting sign-off — the
    vendor_fetched_missing warning disappearing from JSON — is now recorded
    in CHANGELOG.md and the CLI_CONTRACT error-code table.

Verification

  • cargo clippy --workspace --all-targets -- -D warnings: clean.
  • cargo test --workspace --no-fail-fast: 8773 passed, 0 failed, 0
    ignored
    across 254 suites (macOS, CARGO_INCREMENTAL=0). The 15
    docker_e2e_* suites self-skip without docker on this host and report
    zero tests.
  • rustfmt run on touched files only — never cargo fmt --all, and note that
    rustfmt <crate>/src/vendor/mod.rs reformats that module's WHOLE tree,
    which is how two unrelated files briefly picked up churn that has been
    taken back out.

🤖 Generated with Claude Code

…bucket

`filter_to_installed_releases` buckets every release-variant purl (PyPI
`?artifact_id=`, RubyGems `?platform=`, Maven `?classifier=`) into a
`HashMap` keyed by base purl and then drains it. That drain is the
function's OUTPUT order, which is the order the download loop walks — so
`download.patches` (and `apply.patches`, and the per-patch stderr lines)
came out in `HashMap` bucket order: two identical runs of the same
project emitted the same records in different orders.

Sort the multi-variant bases before resolving them (stable warnings) and
sort the kept selection by purl before returning it, matching how every
sibling collection in the same envelope is ordered (scan's `packages`,
the agent flow's `skip_records`). The `--all-releases` pass-through gets
the same order so both arms of the function share one contract.

Two tests: one on the narrowing itself and one on the emitted
`download.patches` array.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The patch view serves `blobContent` only for the files a patch CHANGES. A
zero-delta file — `beforeHash == afterHash` — comes back with hashes and
no content, which the in-memory vendor stager counts as a failed fetch.
One such patch made the WHOLE run bail `no_local_source`: exit 1,
`status: error`, zero events, and every other package in the manifest
left unvendored without a word.

Live example: `pkg:npm/tar-fs@2.1.1`, patch
`8ff3e0c7-6855-4224-924b-3e1151744ed4` — seven zero-delta fixture files
plus one changed `package/index.js`. A three-package project (tar-fs,
braces, minimist) downloaded all three records and then vendored none.

A package whose patch content cannot be obtained is an unsatisfiable
package like any other (`vendor_fetch_failed`, `redirect_revert_failed`,
the Bun refusals …): it gets its own `failed` event and the run carries
on. `stage_vendor_sources_in_memory` now hands those purls back in
`MemStagedSources::unavailable()`; `vendor`, `scan --vendor` /
`get --mode vendored` and `repair` report them one by one and run the
engine over the rest. The pre-event `no_local_source` bail stays for the
case it was written for — NOTHING in the manifest can be staged, so
there are no events to report — which is the shape every existing test
pins. The stager's own stderr summary is unchanged, so `--silent` still
gets exactly one error line per arm.

Repro on the live API (same project, built binaries): before, exit 1 /
`status: error` / `no_local_source` / 0 events; after, exit 1 /
`partial_failure` with `failed pkg:npm/tar-fs@2.1.1 no_local_source` plus
`applied` braces and minimist, both in the ledger.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…inventory

The hosted rewriter turns `name==X` into the PEP 508 direct reference
`name @ <patch-server url> --hash=sha256:…`. The requirements.txt
inventory only reads exact `==` pins, so every line a hosted run had
already rewritten vanished from it — and the second hosted run over a wet
requirements.txt reported `packagesWithPatches: 1` instead of 12, having
"lost" the eleven packages it had just wired. uv.lock keeps its
`[[package]]` name/version through the same rewrite, and Pipfile.lock's
reader already keeps a Socket-written reference as the package it
replaces (`socket_reference_coords`); requirements.txt now does too,
through that same reader and for both shapes it writes (the hosted url
and the vendored `.socket/vendor/pypi/…` path).

The recovered entry is discovery-only — `resolved: None`,
`integrity: None`, exactly what a `==` pin beside it yields — so the
PATCHED artifact the line points at can never be fetched as a pristine
source, and VEX ledger liveness keeps reading it as the "proves nothing"
entry it reads a hosted uv.lock/Cargo.lock entry as. A user's own
file/url reference is still not ours to resolve and stays out; a
reference whose coordinates contradict the requirement's own name is
skipped fail-closed.

Live repro on the phase-3 `req-big` fixture (391 pins, 11 redirected):
before, run 1 reported 12 packages with patches and run 2 reported 1 with
0 redirected; after, both runs report 12 / 11 with the same single
pre-existing warning and a byte-identical requirements.txt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ing it

A bundler path source will not load without the eval-able stub gemspec
rubygems writes into `<gem home>/specifications/` when the gem is
INSTALLED, and a downloaded `.gem` carries its gemspec only as YAML in
`metadata.gz` — which is precisely why the vendoring service converts it
and serves a separate `gem-stub-gemspec` artifact. So a local build can
never vendor a fetched gem. The auto-fetch rung downloaded the `.gem`
anyway and only then hit the backend's `gem_spec_missing`: a wasted
registry round trip on every `--vendor-source build` run, ending in a
message whose remedy ("use --vendor-source=service") did not name the
mode that actually works from here.

Refuse before the fetch, for gem purls only, only when the run cannot use
the patch service at all (`--vendor-source build`, or no service config),
and only for the purls a fetch would actually be attempted for — the ones
`fetch_pristine_package` resolves from the lockfile or recovers from the
ledger. A gem that resolves from nowhere has nothing to say about
gemspecs and keeps its calm `package_not_installed` skip. The refusal is
the same `gem_spec_missing` code and the same `failed` event, with a
detail that says why a fetched gem is unusable and points at
`bundle install` or `--vendor-source=auto`. `auto` and `service` still
fetch: the service path needs the staged pristine copy, and that is the
mode that CAN vendor this gem. The backend keeps its own refusal as the
backstop for every other route into it (including the security case where
a staging dir must never yield a stub).

Repro: a lockfile-only gem against a mock rubygems host — before, the
`.gem` was downloaded and the run then failed `gem_spec_missing`; after,
the host sees no request at all and the same failure arrives with the
real remedy. Two guard twins pin the scope: `auto` still downloads, and a
gem no lockfile resolves still reports `package_not_installed`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ownload

The pre-fetch `gem_spec_missing` gate keyed off "the lockfile resolves this
purl, or the ledger knows it" — mere RESOLVABILITY. The download it exists
to save needs VERIFIABILITY, and the already-vendored case needs no local
build at all, so the gate fired in two cases where `main` never fetched
anything and never failed:

* A bundler < 2.6 `Gemfile.lock` (no `CHECKSUMS` section — the majority of
  real locks) resolves the gem but records no verifier.
  `registry_fetch::fetch_and_stage` refuses a `LockIntegrity::None` entry
  before any network I/O, so CLI_CONTRACT's documented pair fires instead
  (`vendor_fetch_unverifiable` warning + the calm `package_not_installed`
  skip). The gate replaced that with `failed`/`gem_spec_missing` — for a
  download that never existed, and with a remedy that cannot work: the same
  fixture under `--vendor-source auto` still yields the skip pair, because
  the purl never reaches the gem backend in any mode.

* An already-vendored gem on a fresh clone (committed `.socket/vendor/gem/`
  copy + wired lock, `bundle install` not yet run) has a ledger entry, which
  is exactly the case `fetch_pristine_package`'s ledger-recovery rung exists
  for ("an already-vendored lock-only checkout re-scans green"). The
  recovered fetch feeds the gem backend's idempotent hot path, which
  re-confirms the wired lock and returns `already_vendored` without ever
  needing a stub gemspec. Measured on `main`: exit 0, `status: success`,
  events `[skipped/vendor_fetched_missing, skipped/already_vendored]`. With
  the gate: exit 1, `partialFailure`, `failed`/`gem_spec_missing` — a green
  idempotent re-run turned into a failure, with nothing wrong with the
  project. `scan --vendor` / `get --mode vendored` under
  `--vendor-source build` broke the same way.

Mirror `fetch_pristine_package`'s own `fetchable` filter instead: an
inventory entry whose integrity is not `LockIntegrity::None`, and no ledger
entry. GEM-4's real case — a not-installed gem a bundler >= 2.6 lock CAN
verify — still refuses before the download, unchanged.

Two regression tests, both green on `origin/main` and red on the gate as
written: the unverifiable lock keeps its documented skip pair with zero
registry requests, and a vendor-then-`rm -rf vendor/` re-run stays exit 0
with `already_vendored`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ventory

The hosted half of this fix landed in 11de805; the vendored half did not,
although both docstrings claimed it. The hosted rewriter emits a PEP 508
direct reference (`name @ <patch-server url>`), but the VENDORED
requirements writer emits something else entirely — a bare path line,
`./<rel wheel>[ ; marker] --hash=sha256:<hex>  # socket-patch vendor:
<name>==<ver>` (`vendor::pypi_requirements::vendor_line`) — with no
`name @` at all. `direct_reference` splits on `@` and so never matched it,
and `inventory_requirements_txt` discarded the comment part where the
`socket-patch vendor:` tag lives, so a vendored requirements.txt kept the
exact symptom the hosted arm fixed: its packages drop out of
`lock_inventory`, which is what `scan/discovery.rs::lockfile_supplement`
counts, so a re-scan of an already-vendored lockfile-only checkout
under-reports them. The `.socket/vendor/pypi/` arm of
`socket_reference_coords` was only ever reachable from Pipfile.lock.

Read the vendored shape too: keep the logical line's comment, and when the
code part is a bare path `socket_reference_coords` recognizes, take the
requirement name from the `socket-patch vendor:` tag — the same
`utils::requirements::vendor_tag` reader `vex::discover::pypi_other`
already uses — and cross-check it against the path's own coordinates, the
same fail-closed rule the hosted arm applies to its url. The recovered
entry stays discovery-only (`resolved: None`, `integrity: None`), so the
PATCHED wheel it points at can never be fetched as a pristine source. A
user's own wheel path is still not ours to resolve and stays out.

Two tests, both red before: the vendored shape (with and without an env
marker, beside a `==` pin and a user's own wheel path), and a round trip
through the writer's own `vendor_line` formatter — the twin of the hosted
`the_hosted_rewriters_own_output_reinventories` guard. Both docstrings now
describe the two shapes they actually read.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The JS-7 package is still unvendorable. a34bb9d stopped one contentless
view from killing a whole run, but the run's own example package —
`pkg:npm/tar-fs@2.1.1`, patch `8ff3e0c7-…`, one changed file plus seven
zero-delta fixture files — still fails on every run, and nothing in it
actually needs the bytes the view withholds.

`covered()` demanded the after-blob for EVERY file of a record. A zero-delta
file (`beforeHash == afterHash`) is already at its patched content in the
pristine copy: `verify_file_patch` answers `AlreadyPatched` as soon as the
on-disk hash equals `afterHash` (`patch/apply.rs`), which is exactly why the
view serves such a file with hashes and no `blobContent`. Requiring it made
every patch that carries one permanently unvendorable — measured on
origin/main as the original JS-7 symptom (exit 1, `status: error`,
`no_local_source`, zero events) and on a34bb9d as a per-package
`failed`/`no_local_source` on every run.

`needs_blob()` is now the one rule, used by `covered()` (which decides what
to fetch) and by the fetch loop (which decides whether a view came back
complete), so the two can never disagree about which files a fetch must
bring back. The loop also collects every genuinely contentless file instead
of breaking at the first one: `patch.files` is a `HashMap`, so "the first
file with no content" was bucket order, and a partial view abandoned its
remaining files at random.

Tests: `a_view_whose_only_contentless_files_are_zero_delta_vendors` is the
live JS-7 shape — red on unmodified main (`status: error`) and on a34bb9d,
green now, with the vendored tarball asserted to carry the changed file at
its patched bytes AND the zero-delta file at the bytes it always had.

`contentless_patch_view_fails_only_its_own_package` and
`every_patch_unstageable_keeps_the_run_level_error` (both added by a34bb9d)
encoded the wrong classification: they made their package unstageable with a
zero-delta file, i.e. they asserted that the JS-7 package must fail. Every
assertion in both is unchanged; only the fixture's view changed, so the
package they exercise is now unsatisfiable for a reason that really is
unsatisfiable — the file the patch CHANGES is served with no content, so its
patched bytes exist nowhere.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`drop_unstageable` recorded every dropped purl with one verbatim run-level
string, "patch artifacts unavailable (offline or download failure)". For the
case the per-package report was written for — the view is served fine, 200,
but a file the patch changes carries no `blobContent` — the run is neither
offline nor a download failure, so the one machine-readable explanation
named two causes that are both false.

The stager knew the real one (it formats `[error] <purl>: no blob content
served for <file>`), but every human channel in that block is gated on
`if !common.json`, so a `--json` consumer — depscan, CI — saw only the
misleading detail and never learned which file was contentless. The whole
point of the per-package report is per-package diagnostics, and the
per-package slot was the one place the specific reason was dropped.

Carry the reason out of the fetch loop with its purl and put it in that
package's `failed` event: which file was served without content (and how
many others), which file carried a malformed or undecodable blob, that no
view is served for the uuid at all, or the transport error. `no_local_source`
stays the stable `errorCode`; only the free-text `error` changes. The
`[error]`/summary stderr lines are untouched.

Pinned by an added assertion on the existing e2e: the failed event's `error`
must read "the patch view served no blob content for package/index.js".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
acae5db made `filter_to_installed_releases` sort both arms by (purl, uuid)
but left the contract sentence "With `--all-releases` set this is a verbatim
pass-through" in place. Harmless today — `select_patches` hands this function
one patch per purl, so the uuid tiebreak never decides which record survives
the purl-keyed `records` map in `download_patch_records_preflighted` — but a
future caller would read a sentence that is no longer true. Say what the arm
does now: nothing is narrowed away and no view is fetched, and the output
order is the same one the narrowed arm returns.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`drop_unstageable` has three callers — `vendor`, `scan`/`get --mode
vendored` (`scan::vendor_flow`) and `repair` — and every test that reached
it drove `vendor`. The suites that touch the vendored scan
(`scan_vendor_step_error_e2e`, `covgap_commands_scan_vendor_flow`,
`covgap_commands_get`) all mount single-patch manifests, so they only ever
exercised the preserved whole-run bail: a regression in the vendored scan's
`Ok(staging_errors || engine_errors)` fold — dropping the staging-error bit,
or reporting a stuck package twice — passed the whole suite.

One mixed-selection case for `scan --mode vendored`, manifest-free the way
vendored mode really runs (discovery + the download phase's blob seed, no
`.socket/` on disk): one package whose view serves the file it changes
without content and one it serves complete. The stuck package is reported
exactly once as `failed`/`no_local_source` with the reason naming the file,
the other still vendors, and the envelope is `partialFailure`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Four user- and consumer-visible behavior changes shipped on this branch
with no `[Unreleased]` entry, although the file's own header states the
Release workflow refuses to publish a version that does not appear in it
(`scripts/release-lint.sh`) and every recent merge to main updates it.

CHANGELOG `[Unreleased]` → `Fixed` now carries one entry per fix, each
calling out what a JSON consumer sees change: the vendor envelope's shift
from `status: "error"` + top-level `no_local_source` to `partialFailure` +
per-package `failed` events, the purl ordering of `download.patches` /
`apply.patches`, the requirements.txt inventory recovery, the zero-delta
staging fix, and the gem build-mode refusal — including the
`vendor_fetched_missing` warning that disappears from a build-mode run
that no longer downloads.

CLI_CONTRACT's error-code table gains the two facts a consumer needs and
could not previously read anywhere:

* `no_local_source` is now reported at TWO levels, and which one arrives
  depends on whether anything else in the manifest staged (so a one-patch
  manifest still gets the run-level shape). The table says so explicitly
  rather than leaving the inconsistency undocumented, and `json_envelope`'s
  `error_code` doc points at it.
* `gem_spec_missing` was never in the table at all. Its row states the
  pre-fetch refusal, its exact scope (the lock both resolves AND verifies
  the gem, and no ledger entry already vendors it), what is deliberately
  unaffected (`vendor_fetch_unverifiable`'s documented pair, an
  already-vendored re-run, `auto`/`service`), and the dropped warning.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`plural()` already carries the count ("2 files"), so the multi-file arm of
the staging reason read "(and 2 other 2 files)". Say "(and 2 more files)",
and pin all three arms — none, one, several — with a unit test, since that
string is the only machine-readable explanation a `--json` consumer gets
for a per-package `no_local_source`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant