Skip to content

Fix NuGet lock discovery for member and named locks (#353, #514) - #1340

Open
Mikola Lysenko (mikolalysenko) wants to merge 15 commits into
mainfrom
agent/v5-nuget-member-locks
Open

Mikola Lysenko (mikolalysenko) wants to merge 15 commits into
mainfrom
agent/v5-nuget-member-locks

Conversation

@mikolalysenko

@mikolalysenko Mikola Lysenko (mikolalysenko) commented Oct 9, 2026 •

Copy link
Copy Markdown
Collaborator

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

Fixes #353
Fixes #514

Summary

Vendored and hosted NuGet now pin every lock that the root nuget.config governs, not just <root>/packages.lock.json:

Root cause

Both writers wire the root nuget.config, which every project below it inherits. But lock discovery was hard-coded to <root>/packages.lock.json: PACKAGES_LOCK in vendor/nuget_feed.rs and files["packages.lock.json"] in the hosted rewrite_nuget. Any other lock kept the upstream contentHash, so every fresh restore failed NU1403. Hosted reported redirected: 1 with no warning. Vendored reported vendor_nuget_no_lockfile ("RestorePackagesWithLockFile is off"), which was false.

Fix

  • formats::nuget::lock::governed_locks (pure) takes the project files (.csproj / .fsproj / .vbproj) and returns each one's lock as NuGet picks it: a literal NuGetLockFilePath, else packages.<Name>.lock.json (spaces become _) when it exists, else packages.lock.json. The root lock is included too. A NuGetLockFilePath it can't evaluate (property or item references, a Condition, an absolute path or one outside the root) is reported as unresolved. vendor::nuget_config::project_files walks the tree. It skips bin, obj, packages, node_modules, hidden dirs and symlinked dirs, and fails closed when it can't see the whole tree.
  • Vendored: pins each lock and records one nuget_lock_entry wiring per lock path. A failure rolls back the locks already written and the config. Revert restores each lock (the recorded path must pass is_safe_multi_segment). The hot path checks every lock. An unevaluable path is refused with vendor_nuget_lock_path_unresolved. The no-lock warning no longer claims the setting is off.
  • Hosted: on disk, the engine walks the tree with the same discovery, reads every lock, and hands the rewriter the answer through a synthetic key (<socket-patch:nuget-locks>: the lock paths, an unevaluable path, or a walk error, like sbt's resolution key). The rewriter rewrites each lock under its own path. It warns and skips the nuget redirect, writing nothing, for an unevaluable path (redirect_nuget_lock_path_unresolved) or an incomplete walk (redirect_nuget_lock_unreadable). Without the key (in memory) only the root lock counts. The project files stay out of the candidate texts: a first version put them there, and the confirmation probe's extra search regressed nuget/rescan by 15% in the scan benchmark. A local perf compare is now +1.2% CPU, within noise.
  • Hosted unwind (remove / rollback) restores every lock the root config governs. VEX reads the same set, so a member-lock-only hosted pin is still attributed.
  • CLI_CONTRACT.md updated (hosted candidate files, vendored table, upstream restore).

Stacked on #1339 (shared lock reader; its commit is included here) and #1288.

Per-issue tests

Commands run

  • cargo test -p socket-patch-core --no-fail-fast (lib + every integration suite): green
  • cargo test -p socket-patch-cli --all-features --test e2e_nuget (21): green
  • cargo test -p socket-patch-cli --all-features --test e2e_nuget_dotnet_build -- --ignored (3, local SDK 8.0.129): green
  • cargo clippy --workspace --all-features -- -D warnings: clean. cargo fmt --check: clean for touched files.

🤖 Generated with Claude Code

Claude (claude) and others added 7 commits October 9, 2026 15:00
Assisted-by: Claude Code:claude-opus-5-5
Vendored NuGet now reads the source keys and finds the
<packageSources>, <packageSourceMapping> and <configuration> anchors
through formats::nuget::parse_config, the reader that hosted,
upstream restore and VEX already use. The private substring scanner
(blank_comments, parse_config_source_keys, attr_value,
self_closing_package_sources, insert_at_line) is deleted.

User impact:
- A close tag written with whitespace (</packageSources >) is now the
  section that gets extended; vendor used to append a second section
  NuGet ignores, so restore failed NU1100/NU1403 (#685).
- An empty <packageSourceMapping /> is expanded in place instead of
  left beside a second mapping section.
- A section opened and closed on one line receives the source inside
  it, not before its open tag.
- Catch-all keys are written XML-encoded, so a key with & or a quote
  keeps its identity.
- Malformed XML or a repeated section is refused with "malformed XML
  or a repeated section; not wired" instead of being spliced at the
  first substring match, as hosted already does.

Output bytes for well-formed configs are unchanged.

Fixes #685
Refs #594

Assisted-by: Claude Code:claude-opus-5-5
Draft placeholder while the fix is written.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Draft placeholder while the fix is written.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Hosted NuGet rewrote every packages.lock.json entry of the patched id,
whatever version it resolved, so a multi-targeting project silently got
the patched version's bytes in another framework (#593). Vendored only
pinned the matching version but still routed every version of the id to
a feed serving one, so the other framework failed NU1102. Both modes now
refuse such a lock (redirect_ / vendor_nuget_lock_other_version) before
writing anything, and only entries at the patched version are re-pinned.

A lock starting with a UTF-8 BOM, which dotnet restores fine, made hosted
skip the redirect with exit 0 and vendored fail apply (#623). The lock is
now read past the BOM, and the BOM and layout are kept on write.

All four lock walkers (vendored pin, hosted redirect, upstream restore,
VEX) now share one reader in formats::nuget::lock.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
vendor / scan --mode vendored and scan --mode hosted wired the root
nuget.config, which every project under the root inherits, but only
re-pinned <root>/packages.lock.json. A member project's own lock
(#353) or a per-project packages.<Project>.lock.json (#514) kept the
upstream contentHash, so every fresh restore failed NU1403 while the
run reported success, and vendored even claimed the lockfile setting
was off.

Both modes now discover the locks the projects under the root restore
into (formats::nuget::lock::governed_locks over a walk of the project
files) and pin all of them: the root lock, member locks, named locks
and a literal NuGetLockFilePath. A NuGetLockFilePath that cannot be
evaluated is refused (vendor_ / redirect_nuget_lock_path_unresolved)
instead of leaving a lock unpinned. Vendored records one wiring entry
per lock path and reverts each; hosted rewrites them under their own
paths; the hosted unwind and VEX read the same set.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@mikolalysenko
Mikola Lysenko (mikolalysenko) marked this pull request as ready for review October 9, 2026 17:56
@mikolalysenko

Copy link
Copy Markdown
Collaborator Author

BugBot review

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes using high effort and found 3 potential issues.

Fix All in Cursor

Bugbot Autofix is ON. A cloud agent has been kicked off to fix the reported issues.

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit 0168763. Configure here.

Err(detail) => {
result.success = false;
result.error = Some(detail);
result.error = Some(format!("{}: {detail}", lock.rel));

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Rebuild leaves member locks half-pinned

Medium Severity

The stale-artifact rebuild path writes each governed lock in turn and returns on the first write or parse failure without putting earlier locks back. The first-run path rolls those writes back via unwind_locks, but this path does not. After a multi-lock rebuild fails, some member locks pin the new nupkg hash and others keep the old one, so a later --locked-mode restore hits NU1403 in the unpinned projects.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 0168763. Configure here.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

[agent] Fixed in d99550f: the rebuild path now tracks the locks it re-pinned and puts them back (unwind_locks) on a later write or parse failure, like the first-run path.

rels.push(rel);
}
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

VEX drops member locks on walk failure

Medium Severity

load_lock takes governed_locks_on_disk only on Ok and never inspects unresolved. A walk error or an unevaluable NuGetLockFilePath silently falls back to the root packages.lock.json. Hosted and vendored writers refuse those cases. For a member-lock-only tree that is the #353 layout, discovery then treats the pin as lockless, so VEX cannot attest it and rollback/remove that key off lock evidence will not see the member hashes.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 0168763. Configure here.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

[agent] Fixed in d99550f: a walk error or an unevaluable NuGetLockFilePath now diagnoses lockfile_unreadable and makes the lock unusable (no ref read off a partial lock set), matching the writers' refusal. Covered in hosted_version_and_pin_come_from_a_member_lock.

};
let Ok(kind) = entry.file_type() else {
continue;
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Project walk fail-opens on unreadable entries

Medium Severity

project_files is documented to fail closed when it cannot see the whole tree, but a non-UTF-8 name, a file_type() error, and a symlink-to-.csproj are skipped with continue. Those projects never enter governed_locks, so the root mapping can still be written while their locks keep the upstream contentHash and restore fails NU1403.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 0168763. Configure here.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

[agent] Fixed in d99550f: a non-UTF-8 project or directory name and a file_type() error now fail the walk closed; a symlinked project file is read through the link (symlinked directories stay unentered). Test: project_walk_follows_project_links_and_skips_output.

The hosted engine put every project file's text into the candidate
files so the rewriter could re-derive the locks; the confirmation
probe then searched that text too, and nuget/rescan regressed 15% in
the scan benchmark. The engine now hands the rewriter the walk's
answer (lock paths, an unevaluable NuGetLockFilePath, or why the tree
could not be listed) through a synthetic key instead.

Review fixes (Bugbot on #1340): the stale-artifact rebuild puts back
the locks it already re-pinned when a later one fails; VEX reports a
tree whose locks it cannot all locate instead of reading the root lock
alone; the project walk fails closed on a project or directory name
that is not UTF-8 and on an unreadable entry, and reads a symlinked
project file.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The hosted engine and VEX discovery asked the project view for its raw
disk root to walk the project files. On a re-scan that ends the read
cache's recording, so the discovery it guards was redone and
nuget/rescan regressed about 14% in the scan benchmark. The walk now
lists, reads and probes through the view (governed_locks_in), which
fingerprints them like its own reads. Local perf compare against main:
nuget/rescan +1.6% (noise), nuget/hosted +3.6%.

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

# Conflicts:
#	crates/socket-patch-core/src/vendor/nuget_feed.rs
@mikolalysenko

Copy link
Copy Markdown
Collaborator Author

[final reviewer] Auto-merge is off. Tanmay Singla (@Tanmay182003), one non-merge commit landed after your approval at d99550f3:

  • 473881ab Walk NuGet projects through the project view (hosted/engine.rs, vendor/nuget_config.rs, vex/discover/nuget.rs, +75/-4)

clippy is red on the current head and 3 review threads are open. Please re-look at that commit once it's green.


Generated by Claude Code

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
"more than {WALK_DIR_BUDGET} directories under the project root"
));
}
let entries = view.list_dir(&rel).await.map_err(|e| {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[agent] This walk goes through ProjectView::list_dir → list_disk_dir (lock_inventory/view.rs:631), which silently skips non-UTF-8 names and stops on a mid-listing read error (while let Ok(Some(..))), where project_files failed closed on both. Scenario: a member project under a non-UTF-8 dir name (or an I/O error while listing): the hosted engine (hosted/engine.rs:645) builds the governed-locks set without that member's packages.lock.json, the redirect wires the root nuget.config source/mapping but leaves that lock on its upstream contentHash → NU1403 on restore while the ledger records the redirect; VEX discovery (vex/discover/nuget.rs:303) has the same gap, and rollback/nuget_feed still use the strict disk walk so the two disagree. Fix: give the view a strict listing that errors on non-UTF-8 names and read errors, and use it here.

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

3 participants