Skip to content

Fix vendored member refusal not naming root (#1904) - #1913

Open
Mikola Lysenko (mikolalysenko) wants to merge 3 commits into
mainfrom
agent/fix-vendored-member-lockfile-refusal
Open

Mikola Lysenko (mikolalysenko) wants to merge 3 commits into
mainfrom
agent/fix-vendored-member-lockfile-refusal

Conversation

@mikolalysenko

@mikolalysenko Mikola Lysenko (mikolalysenko) commented Nov 1, 2026 •

Copy link
Copy Markdown
Collaborator

LLM Description written by Claude Code:claude-opus-5-5

Fixes #1904

Summary

When a vendored run (scan --mode vendored, get --mode vendored or vendor) starts in a workspace member that has no lockfile of its own, it refused with "no package-lock.json … run your package manager's install first". Following that advice only updates the workspace root's lock, so the next run refused the same way. The refusal now keeps the vendor_lockfile_missing code (so the JSON contract doesn't change), but it says which lock installs the member and which directory to vendor from:

/repo/packages/a is a workspace member with no lockfile of its own: the workspace root /repo lists it under "workspaces" and installs it from /repo/package-lock.json, so there is no lockfile here for vendoring to rewire; vendor from /repo (the workspace root)

This covers npm, yarn (classic and berry) and Bun package.json workspaces, vlt workspaces, and pnpm workspaces. For a pnpm lockfile-dir project outside a pnpm workspace, the lock's directory holds no project, so the refusal names the setting and points at hosted mode rather than sending the user somewhere else that also doesn't work.

Root cause

Step 4 of the lock-flavor probe (vendor/npm_flavor.rs) always returned the generic detail. It never ran the "the lock lives in another directory" lookup that hosted's redirect_*_lockfile_elsewhere refusal uses in hosted/governing_root.rs.

Change

  • governing_root: the npm part of hosted's refusal() (package.json workspaces, vlt workspaces, the pnpm workspace or lockfile-dir lock, with the nearer root winning) moves into one npm_lock_elsewhere() helper. It returns a LockElsewhere { dir, dir_kind, vendorable_dir, detail }. Hosted builds its refusal from that, and its messages are byte-for-byte unchanged (the existing governing_root tests pass untouched).
  • npm_flavor: detect_vendorable_npm_flavor (forward vendoring; vendor_npm_any and the yarn berry takeover preflight) swaps the generic detail for the governing-root one. preflight_packages and lock_text_refusals keep only the code, so they use a code-only probe and skip the lookup.

Test evidence

Issue Test Without fix With fix
#1904 (npm/yarn/Bun members) vendor::npm_flavor::tests::lockless_workspace_member_refusal_names_the_workspace_root FAILED ok
#1904 (pnpm member) vendor::npm_flavor::tests::lockless_pnpm_member_refusal_names_the_lock_directory FAILED ok
#1904 (CLI, npm member, vendor --json) vendor_failure_paths::lockless_workspace_member_refusal_names_the_workspace_root (--test vendor) FAILED ok
lockfile-dir variant lockfile_dir_project_refusal_names_the_setting_not_a_dead_end (new behavior) ok
a standalone lockless project keeps the install remedy lockless_standalone_project_keeps_the_install_remedy ok ok

To get the red runs I disabled the detail swap locally; with it disabled, both core tests and the CLI test failed.

Commands run locally:

  • cargo fmt --all -- --check: ok
  • cargo clippy --workspace --all-features -- -D warnings: ok
  • cargo test -p socket-patch-core --all-features: 6998 passed, 4 failed. The 4 failures are chmod-based fault-injection tests (copy_tree::relax_loop_must_not_traverse_symlinked_root, vlt_heal::an_unremovable_hidden_lock_keeps_every_store_entry, pypi_poetry::wire_write_failure_…, pypi_requirements::wire_failure_rolls_back_…). They can't fail a write when run as root, and this container runs as uid 0. None of them touch this change. CI runs as non-root.
  • cargo test -p socket-patch-cli --all-features --test vendor -- vendor_failure_paths: 28 passed
  • I didn't run the full cargo test --workspace locally: the container ran out of disk building every test binary. CI covers it.

No wrapper changes: the npm/PyPI/gem wrappers only pass the CLI's output through.

Review findings not fixed (from /code-review high)

  • The read-only probe (detect_npm_lock_flavor, used by vendor --check/--revert, the lock inventory, VEX, crawlers) still has the generic text. Left as is on purpose. Those consumers either keep only the code or don't show this refusal to the user as a remedy. Adding the ancestor walk there would put filesystem reads on every lockless crawl and VEX snapshot, and the VEX snapshot reads are recorded.
  • has_own_npm_family_lock is checked again inside npm_lock_elsewhere and pnpm_lock_elsewhere. Each check is a handful of stats on a refusal path only, and the guard keeps the pub(crate) helper correct for any caller.
  • The standalone-project test assumes no ancestor of TMPDIR is a JS workspace root with a lock. The existing governing_root tests already rely on the same thing.

https://claude.ai/code/session_011f9aeR3rq51BGZkfNJkypH


Note

Medium Risk
Refactors shared workspace-root detection used by both hosted and vendored refusal paths; behavior is heavily tested but touches many npm workspace edge cases.

Overview
Fixes #1904: vendored runs from an npm workspace member with no local lock no longer tell users to “run install first” (which only updates the root lock and loops). The vendor_lockfile_missing code is unchanged; the error text now names which lock governs the member and where to run vendor—e.g. vendor from the workspace root.

governing_root: npm “lock lives elsewhere” logic (package.json / vlt / pnpm workspaces and lockfile-dir, with the same nearer-root rules) is consolidated into npm_lock_elsewhere and a shared LockElsewhere type. Hosted refusals still use the same wording via hosted_refusal().

npm_flavor: forward vendoring (detect_vendorable_npm_flavor) replaces the generic missing-lock detail with lock_elsewhere_detail, which reuses that lookup. For pnpm lockfile-dir setups where the lock directory isn’t a vendorable project, the message points to hosted scan instead of a dead-end “vendor from” hint. preflight_packages / lock_text_refusals use a code-only probe so they don’t pay for the ancestor walk.

Tests cover npm/yarn/Bun/pnpm members, lockfile-dir, standalone lockless projects (install remedy unchanged), and a CLI vendor --offline path.

Reviewed by Cursor Bugbot for commit 8a97c8d. Configure here.


Generated by Claude Code

Assisted-by: Claude Code:claude-opus-5-5
Running a vendored scan, get or vendor from a workspace member with no
lockfile of its own refused with "no lockfile ... run your package
manager's install first". Following that advice only updated the
workspace root's lock, so the next run refused the same way.

The vendored refusal now keeps its vendor_lockfile_missing code but
says which lock installs the member and which directory to vendor
from, the same lookup hosted mode's refusal already used. The lookup
(package.json and vlt workspaces, pnpm workspace or lockfile-dir) moves
out of the hosted refusal into one shared helper; hosted messages are
unchanged.

Fixes #1904

Assisted-by: Claude Code:claude-opus-5-5
Review follow-ups to the workspace-member refusal:

- A pnpm project whose lock lives elsewhere through lockfile-dir was
  told to vendor from the lock's directory, which holds no project.
  It now names the setting and points at hosted mode instead.
- The message showed "." for the default --cwd; it now shows the
  member's own path.
- Callers that keep only the refusal code skip the workspace lookup.
- The run-from wording follows the stray-lock refusal's convention.

Assisted-by: Claude Code:claude-opus-5-5
@mikolalysenko
Mikola Lysenko (mikolalysenko) marked this pull request as ready for review November 1, 2026 04:53
@mikolalysenko

Copy link
Copy Markdown
Collaborator Author

BugBot review


Generated by Claude Code

@mikolalysenko Mikola Lysenko (mikolalysenko) added the Ready for review Agent-verified: mergeable, CI green, Bugbot clean — awaiting human review label Nov 1, 2026
@mikolalysenko

Copy link
Copy Markdown
Collaborator Author

Burn-down agent: labeled Ready for review.

  • Head: 8a97c8ddd6715e7d056951ce06bbd1d17d75dd6d, mergeable.
  • CI: 32/32 ran green (50 skipped by path filters).
  • Bugbot: reviewed this head, no findings; no open review threads.
  • Reviewer focus: governing_root.rs refactor now shared between hosted and vendored refusal paths; new npm-flavor test in vendor_failure_paths.rs.
  • Slack announcement could not be sent this run (send tool unavailable); next run retries.

Generated by Claude Code

@mikolalysenko

Copy link
Copy Markdown
Collaborator Author

Final review brief (8a97c8d)

What it does. Today, a vendored run started in a workspace member that has no lockfile of its own refuses with "run your package manager's install first", and following that advice just loops (#1904). This PR moves hosted's "the lock lives in another directory" lookup into a shared npm_lock_elsewhere() helper. The vendored refusal now uses that helper:

  • For npm, yarn, Bun, vlt and pnpm-workspace members, it names the root to vendor from.
  • For a pnpm lockfile-dir project, it points at hosted mode instead.

The error code stays vendor_lockfile_missing; only the message changes.

Risk: low. The new text only appears on a path that already fails closed (exit 1, nothing written), and the JSON code is unchanged. I traced the hosted refactor by hand:

  • The "stray lock first" order is kept.
  • The pnpm, workspace and vlt messages come out byte-identical.
  • "Nearer root wins, a tie goes to pnpm" is kept.
  • The existing hosted tests pass unchanged.

Look here

Verified

Changes I made: none.

Open questions (not blocking)

  • Should a member or lockfile-dir refusal get its own code (for example vendor_workspace_lockfile_elsewhere), so JSON tooling can tell it apart from a project that has no lock at all? The issue raised this. The PR keeps the existing code for contract stability.
  • For a pnpm member that inherits lockfile-dir from the workspace, the message says "run pnpm install to write a lock here". pnpm would actually write the lock at the workspace root. That is imprecise but not a loop, because the next run gives the correct "vendor from root" refusal. Only the standalone lockfile-dir case is tested.
  • vendorable_dir only checks that pnpm-workspace.yaml exists in the lock's directory. It does not check that the workspace lists this project. That is an edge case.

Auto-merge is armed, so approving sends this straight to the merge queue.


Generated by Claude Code

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

Labels

Ready for review Agent-verified: mergeable, CI green, Bugbot clean — awaiting human review

Projects

None yet

2 participants