Repository navigation
Fix vendored Hatch member breaking workspace root (#505) - #1724
Mikola Lysenko (mikolalysenko) merged 8 commits into
Conversation
Assisted-by: Claude Code:claude-opus-5-5
A vendored scan run from a member of a Hatch 1.16+ workspace wrote
{root:uri} references into the member's [project] dependencies and
reported success. Hatch's workspace environment reads a member's
static dependencies raw, so every sync of the workspace root env then
failed with pip's "No connection adapters were found for
'{root:uri}/...'" until someone hand-edited the member.
Detect a workspace environment declared in an ancestor pyproject.toml
or hatch.toml (up to the checkout root) that lists the project, the
way Hatch resolves members (globs, path tables, exclude, features and
self-referencing extras), and refuse the package with
pypi_hatch_unsupported before anything is written, pointing at hosted
or agent mode. Dependencies the workspace does not read raw (the
member's own env deps, unselected extras, dynamic metadata) are still
vendored.
Fixes #505
Assisted-by: Claude Code:claude-opus-5-5
Review follow-ups for the workspace-member refusal:
- look for a workspace root anywhere in the checkout, since Hatch
lets a root list a member beside it through a ../ spec
- read members written as an array of tables and specs using the
{root} and {home} context fields
- stop at the checkout root even when the member is that root
- match fnmatch patterns in linear time, and fold case and
separators on Windows the way Python's fnmatch does
- look a selected feature up by its exact key, as Hatch does
- tell a user whose member was vendored by an earlier run how to
undo it
- share the hatch.toml/pyproject env-table rule with the planner
Assisted-by: Claude Code:claude-opus-5-5
|
BugBot review Generated by Claude Code |
Read workspace candidates through the non-blocking regular-file reader, so a FIFO at a pyproject.toml or hatch.toml path can't hang a vendored scan, and walk ancestors with the shared repository lookup, which stops at the checkout root, never enters the home directory and honours GIT_CEILING_DIRECTORIES. Assisted-by: Claude Code:claude-opus-5-5
|
BugBot review Generated by Claude Code |
Resolve each workspace member spec by listing directories the way Hatch's find_members does, following symlinked directories, and compare what it finds with the scanned project by real path. Roots are looked for above both the spelled and the real path, so a member Hatch reaches through a symlink is refused whichever path the scan was given. Assisted-by: Claude Code:claude-opus-5-5
|
BugBot review Generated by Claude Code |
|
Labeled Ready for review by the burn-down agent.
Generated by Claude Code |
Final review briefWhat it does: A vendored scan run inside a member of a Hatch 1.16+ workspace used to write Risk: low to medium. It only adds a refusal on the vendored Hatch path, so the worst case is a false refusal, not a broken install. The new code is a 665-line module that reimplements Hatch's member matching (fnmatch, Look here:
Verified:
Changes I made: none. Open questions: none blocking. One small nit: Auto-merge is armed, so approving sends it straight to the merge queue. Generated by Claude Code |
Hatch's all_dependencies_complex expands a workspace member's raw
optional dependencies whenever any of the environment's requirement
strings names the member with an extra: the root's own dependencies,
its environments', or another member's (lib[s3]). Those groups reach
pip raw, so a vendored {root:uri} there breaks the workspace just
like a selected feature does. Count those extras as consumed.
Also say in the Hatch notes that a sibling root listing the member
through ../ is found as well.
Assisted-by: Claude Code:claude-opus-5-5
|
BugBot review Generated by Claude Code |
Collect the requirement strings that name the member from what the workspace environment actually installs: the root's dependencies and its environment features' extras, the environment's and its templates' dependencies (from hatch.toml when that replaces the pyproject tables), and the other members' dependencies and selected features. Resolve the extras they name through the same normalized lookup Hatch's resolve_extras uses, so a mixed-case extra name is found and strings Hatch never installs select nothing. Assisted-by: Claude Code:claude-opus-5-5
|
BugBot review Generated by Claude Code |
|
[final reviewer] Disabled auto-merge. This PR was armed only on the strength of the final-review brief, and the head has gained non-merge commits since that brief (review fixes pushed today). I'll write a fresh brief once those pushes settle and CI and Bugbot are green on the new head, then re-arm. The Generated by Claude Code |
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 3 potential issues.
Autofix Details
Bugbot Autofix prepared a fix for the issue found in the latest run.
- ✅ Fixed: Cross-package extras stay unexpanded
- Implemented resolve_extras_across_workspace to expand extras from all packages (root and siblings) before filtering, matching Hatch's resolve_extras behavior that walks every install spec transitively.
Or push these changes by commenting:
@cursor push 90d6630253
Preview (90d6630253)
diff --git a/crates/socket-patch-core/src/utils/hatch_workspace.rs b/crates/socket-patch-core/src/utils/hatch_workspace.rs
--- a/crates/socket-patch-core/src/utils/hatch_workspace.rs
+++ b/crates/socket-patch-core/src/utils/hatch_workspace.rs
@@ -153,6 +153,92 @@
Some((path, features))
}
+/// Resolve extras across the workspace: expand extras from specs that
+/// reference the root or siblings, then filter to specs that reference `own`.
+/// This matches Hatch's `resolve_extras` behavior, which walks every install
+/// spec against every member's and the root's `optional-dependencies`.
+fn resolve_extras_across_workspace(
+ specs: &[String],
+ root_project: Option<&Item>,
+ sibling_projects: &[&Item],
+ own: &str,
+) -> Vec<String> {
+ // Build a map of package name -> optional-dependencies
+ let mut extras_map: BTreeMap<String, BTreeMap<String, Vec<String>>> = BTreeMap::new();
+
+ if let Some(root) = root_project {
+ if let Some(name) = root.get("name").and_then(Item::as_str) {
+ let canonical_name = canonicalize_pypi_name(name);
+ if let Some(groups) = root
+ .get("optional-dependencies")
+ .and_then(Item::as_table_like)
+ {
+ let mut pkg_extras = BTreeMap::new();
+ for (key, group) in groups.iter() {
+ let deps: Vec<String> = strings(Some(group))
+ .into_iter()
+ .map(str::to_owned)
+ .collect();
+ pkg_extras.insert(key.to_owned(), deps.clone());
+ pkg_extras.insert(canonicalize_pypi_name(key), deps);
+ }
+ extras_map.insert(canonical_name, pkg_extras);
+ }
+ }
+ }
+
+ for sibling in sibling_projects {
+ if let Some(name) = sibling.get("name").and_then(Item::as_str) {
+ let canonical_name = canonicalize_pypi_name(name);
+ if let Some(groups) = sibling
+ .get("optional-dependencies")
+ .and_then(Item::as_table_like)
+ {
+ let mut pkg_extras = BTreeMap::new();
+ for (key, group) in groups.iter() {
+ let deps: Vec<String> = strings(Some(group))
+ .into_iter()
+ .map(str::to_owned)
+ .collect();
+ pkg_extras.insert(key.to_owned(), deps.clone());
+ pkg_extras.insert(canonicalize_pypi_name(key), deps);
+ }
+ extras_map.insert(canonical_name, pkg_extras);
+ }
+ }
+ }
+
+ // Expand all specs with extras
+ let mut expanded = Vec::new();
+ let mut queue: VecDeque<String> = specs.iter().cloned().collect();
+ let mut seen = BTreeSet::new();
+
+ while let Some(spec) = queue.pop_front() {
+ if !seen.insert(spec.clone()) {
+ continue;
+ }
+
+ let name = canonicalize_pypi_name(pep508_name(&spec));
+ if name == own {
+ expanded.push(spec.clone());
+ }
+
+ // If this spec has extras and references a package we know about, expand them
+ let spec_extras = extras(&spec);
+ if !spec_extras.is_empty() {
+ if let Some(pkg_extras) = extras_map.get(&name) {
+ for extra in spec_extras {
+ if let Some(deps) = pkg_extras.get(&extra) {
+ queue.extend(deps.iter().cloned());
+ }
+ }
+ }
+ }
+ }
+
+ expanded
+}
+
/// For each environment of the project at `root` whose workspace lists
/// `member`: the environment name and the member's selected features
/// (normalized): those the entry selects, plus every extra of `own` (the
@@ -212,7 +298,8 @@
// templates') dependencies, and what the workspace takes from
// the other members.
let mut specs = environment_specs(envs, env);
- if let Some(root_project) = project.as_ref().and_then(|doc| doc.get("project")) {
+ let root_project_item = project.as_ref().and_then(|doc| doc.get("project"));
+ if let Some(root_project) = root_project_item {
let env_features: Vec<String> = environment_chain(envs, env)
.iter()
.flat_map(|config| strings(config.get("features")))
@@ -220,22 +307,26 @@
.collect();
specs.extend(selected_specs(root_project, &env_features));
}
+ let mut sibling_docs = Vec::new();
for (paths, features) in &listed {
for path in paths
.iter()
.filter(|path| real(path).as_deref() != Some(member))
{
if let Some(sibling) = parse(path, HATCH_FILES[0]) {
- if let Some(sibling) = sibling.get("project") {
- specs.extend(selected_specs(sibling, features));
+ if let Some(sibling_project) = sibling.get("project") {
+ specs.extend(selected_specs(sibling_project, features));
+ sibling_docs.push(sibling);
}
}
}
}
- let referenced = specs
- .into_iter()
- .filter(|spec| canonicalize_pypi_name(pep508_name(spec)) == own)
+ let sibling_projects: Vec<&Item> = sibling_docs
+ .iter()
+ .filter_map(|doc| doc.get("project"))
.collect();
+ let referenced =
+ resolve_extras_across_workspace(&specs, root_project_item, &sibling_projects, &own);
found.push((env.to_string(), features.clone(), referenced));
}
}You can send follow-ups to the cloud agent here.
Which optional dependencies a Hatch workspace environment installs from
a member depends on the whole workspace: the member's features,
<member>[extra] references from the root, environments, their
templates and dependency groups, and extras of other members that
reach it. Each review round found another path the precise model
missed, and a miss writes a {root:uri} that breaks every workspace
sync.
Once a workspace lists the member, count all its static dependencies
and optional dependencies as read raw instead. A refusal still leaves
hosted and agent mode, and the module shrinks to member discovery.
Assisted-by: Claude Code:claude-opus-5-5
|
BugBot review Generated by Claude Code |
There was a problem hiding this comment.
✅ Bugbot reviewed your changes and found no new issues!
Comment @cursor review or bugbot run to trigger another review on this PR
Reviewed by Cursor Bugbot for commit 5a83ffc. Configure here.
|
Burn-down agent: Ready for review at
Generated by Claude Code |
Final review briefWhat it does: Vendored mode now refuses ( Risk: low. The worst case of a miss is what main does today. The new code is about 600 lines that emulate Hatch's Look here
Verified
Changes I made: none. Open questions (none blocking)
Auto-merge is armed, so approving sends this straight to the merge queue. Generated by Claude Code |

LLM Description written by Claude Code:claude-opus-5-5
Fixes #505
Summary
A vendored scan run from a member of a Hatch 1.16+ workspace wrote
six @ {root:uri}/.socket/vendor/...into the member's[project]dependencies and reported success. Every sync of the workspace root env then failed with pip'sNo connection adapters were found for '{root:uri}/...'. That covered the existing root env, every fresh root env and CI. The member is now refused withpypi_hatch_unsupportedbefore anything is written, and the message points at hosted or agent mode.Root cause
Hatch's
Workspace.get_dependenciesreads a member's static[project] dependenciesand the optional dependencies of the member'sfeaturesstraight from itspyproject.toml, with no context formatting.{root:uri}is only expanded when the member's own backend or env formats it. socket-patch already refused the equivalent dependency-groups shape but never checked whether the project was consumed by a workspace.Fix
New
utils::hatch_workspace::raw_consumermirrors Hatch 1.18'sWorkspace.members/find_members, checked against the installed Hatch source:../spec.hatch.tomlenvsreplaces[tool.hatch.envs]. This now shares one helper with the planner (hatch::environments).{root}and{home}context fields are expanded.exclude. On Windows it folds case and separators the waynormcasedoes.dependenciesand everyoptional-dependenciesgroup. Which extras Hatch installs depends on the whole workspace: memberfeatures,<member>[extra]references from the root, envs, templates, dependency groups and cross-member extras chains. Three review rounds each found another path a precise model missed, and a miss writes a breaking{root:uri}. A false refusal still leaves hosted and agent mode.{root:uri}.pypi_hatch::loadandpreflightcall it, so both the real run and--dry-runrefuse. A member's own env dependencies, and projects no workspace lists, are still vendored. When an earlier run already vendored the member, the error also says to undo it withsocket-patch vendor --revert.Test evidence
project.dependenciesvia glob, path, table, array of tables,{root}, sibling../rootutils::hatch_workspace::tests::{members_listed_by_glob_path_or_table, sibling_roots_listing_the_member_through_parent_specs}features(comment 2)optional_dependencies_follow_member_features,vendor::pypi_hatch::tests::member_consumed_by_a_hatch_workspace_is_refusedrequire_no_raw_workspacecall (panics "six must be refused"), green with ithatch.toml(comment 2)hatch_toml_workspaces_replace_pyproject_envs, same pypi_hatch teste2e_vex_build hatch::hatch_workspace_member_vendored_is_refused: vendored scan from the member exits non-zero withpypi_hatch_unsupported, both pyprojects untouched, roothatch env createstill succeedsCommands run locally:
cargo fmt --all -- --check: okcargo clippy --workspace --all-features -- -D warnings: okSOCKET_PATCH_HATCH_E2E_REQUIRED=1 SOCKET_PATCH_HATCH_E2E_VERSION=1.18.1 cargo test -p socket-patch-cli --all-features --test e2e_vex_build -- hatch:: --ignored: 7/7 passcargo test --workspace --all-features --no-fail-fast: everything passes except 11 tests that fail the same way in this sandbox and are unrelated to this PR:pypi_poetry::wire_write_failure_*andcopy_tree::relax_loop_*e2e_bun_lockbcases that needpatches-api.socket.dev, which the proxy blocksCI is the authority for those.
Review
I ran
/code-review highand fixed every confirmed finding in ce0ef59: sibling roots, array-of-tables members,{root}specs, the stop at the checkout root, linear-time fnmatch, Windows normcase, exact feature keys, the revert hint and the shared env-table helper. I didn't change the self-extras finding: Hatch'sresolve_extrasdoes expand self-referencing extras ("Self-referencing extras within each project AND cross-project extras are expanded in a single BFS pass"), so following them is correct. Bugbot then raised 4 findings over two rounds. I fixed three in 40c3024 and c37fc34: FIFO-safe reads, bounding the walk with the shared repo lookup (home dir and `GIT_CEILING_DIRECTORIES`), and matching members reached through symlinks. I answered the Windows-case finding with evidence from Hatch's `find_members`. Bugbot's latest review of c37fc34 found no issues.I also left the second ancestor walk on the supersede path: it's a few small TOML reads per package.
Known limits
{env:..}) isn't resolved and fails open.workspacetables are ignored, a member is still refused. That's conservative, and hosted or agent mode work there.No wrapper (npm/pypi/gem) changes are needed: this is CLI-internal behavior.
https://claude.ai/code/session_01Bh7BpCB27Q7GVBEkuSCuS6
Note
Medium Risk
Changes PyPI Hatch vendoring preflight/load gates and adds filesystem/TOML workspace detection; false positives would block legitimate vendored member scans, but behavior is intentionally conservative with clear escape via hosted/agent mode.
Overview
Vendored Hatch scans from a workspace member no longer write
{root:uri}into the member'spyproject.toml, which would break every workspace-root environment sync (#505).loadandpreflightnow callrequire_no_raw_workspace, which uses newhatch_workspace::raw_consumerto detect when a Hatch 1.16+ workspace environment lists the project and would install a static[project]dependency or optional dependency without expanding{root:uri}. Those runs fail early withpypi_hatch_unsupported(hosted/agent unchanged).raw_consumerwalks ancestor and sibling workspace roots (git checkout, member globs/paths,hatch.tomlvs pyproject env tables, symlinks, excludes) and reuseshatch::environments. Listed members are treated as consuming all optional dependencies conservatively. Unit tests, apypi_hatchrefusal test, an ignored e2e case, anddocs/testing/hatch.mddocument the behavior.Reviewed by Cursor Bugbot for commit 5a83ffc. Configure here.
Generated by Claude Code