From 5ea908e85c2f09040833a7651ff6ab20c7fc8b2c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 08:28:27 +0000 Subject: [PATCH 1/4] Start fix for #1471 Assisted-by: Claude Code:claude-opus-5-5 From 7922fe350d87351a682ca90ec6aec75c091f272d Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 08:36:21 +0000 Subject: [PATCH 2/4] Wire all hosted Hatch deps in one pass A hosted scan of a Hatch project re-parsed pyproject.toml and hatch.toml, re-checked every declared requirement and rendered both files again for each patched dependency, so scan time grew with the square of the number of patched pins (#1471). utils::hatch::rewrite_all now parses the documents once, looks each requirement up by its canonical name and renders once. Each dependency still gets exactly the outcome a lone rewrite gives it: a check pass records refusals without changing anything, the apply pass rewrites only the deps that passed, a structural refusal fails every dep not already refused, and repeats of one name run in input order. The vendored planner uses the same walk with one dep. Hosted redirect edits now record the requirement strings rewritten rather than a full copy of the file per dependency. Assisted-by: Claude Code:claude-opus-5-5 --- .../src/patch/redirect/mod.rs | 59 +- crates/socket-patch-core/src/utils/hatch.rs | 720 +++++++++++++++--- 2 files changed, 651 insertions(+), 128 deletions(-) diff --git a/crates/socket-patch-core/src/patch/redirect/mod.rs b/crates/socket-patch-core/src/patch/redirect/mod.rs index 65c0d9067..d81379b3f 100644 --- a/crates/socket-patch-core/src/patch/redirect/mod.rs +++ b/crates/socket-patch-core/src/patch/redirect/mod.rs @@ -1056,27 +1056,45 @@ fn rewrite_hatch( .extend(result.confirmed_requirements_uuids.iter().cloned()); return; } - // Overlay only the two documents the hatch planner reads, so the second - // pypi dep sees the first dep's rewritten pyproject — without cloning - // every candidate lockfile in `files` for it. - let mut current: BTreeMap = crate::utils::hatch::HATCH_FILES - .into_iter() - .filter_map(|k| files.get(k).map(|v| (k.to_owned(), v.clone()))) + // Every dep is wired over one parse and one render of the Hatch + // documents (#1471); each step is what rewriting that dep after the + // ones before it would give. + let pypi: Vec<(&DepOverride, Option)> = overrides + .iter() + .filter(|dep| dep.ecosystem == "pypi") + .map(|dep| { + let url = dep + .integrity + .sha256 + .as_ref() + .filter(|hash| { + hash.len() == 64 && hash.bytes().all(|byte| byte.is_ascii_hexdigit()) + }) + .map(|hash| format!("{}#sha256={hash}", dep.artifact_url)); + (dep, url) + }) .collect(); - for dep in overrides.iter().filter(|dep| dep.ecosystem == "pypi") { - let Some(hash) = - dep.integrity.sha256.as_ref().filter(|hash| { - hash.len() == 64 && hash.bytes().all(|byte| byte.is_ascii_hexdigit()) + let deps: Vec = pypi + .iter() + .filter_map(|(dep, url)| { + url.as_deref().map(|url| crate::utils::hatch::HatchDep { + name: &dep.name, + version: &dep.version, + url, }) - else { + }) + .collect(); + let batch = crate::utils::hatch::rewrite_all(files, &deps); + let mut steps = batch.steps.into_iter(); + for (dep, url) in &pypi { + if url.is_none() { result.warnings.push(RewriteWarning { code: "redirect_hatch_missing_sha256".into(), detail: format!("{} has no valid wheel digest", dep.name), }); continue; - }; - let url = format!("{}#sha256={hash}", dep.artifact_url); - match crate::utils::hatch::rewrite(¤t, &dep.name, &dep.version, &url) { + } + match steps.next().expect("one step per hatch dep") { Ok(edits) => { result.confirmed_hatch_uuids.insert(dep.patch_uuid.clone()); if !edits.is_empty() && !hatch_locks.is_empty() { @@ -1093,17 +1111,15 @@ fn rewrite_hatch( ), }); } - for (path, new) in edits { + for edit in edits { result.edits.push(FileEdit { - path: path.clone(), - kind: "redirect_hatch_document".into(), + path: edit.file, + kind: "redirect_hatch_requirement".into(), action: "rewritten".into(), key: Some(format!("{}@{}", dep.name, dep.version)), - original: current.get(&path).cloned().map(Value::String), - new: Some(Value::String(new.clone())), + original: Some(Value::String(edit.original)), + new: Some(Value::String(edit.new)), }); - current.insert(path.clone(), new.clone()); - result.files.insert(path, new); } } Err(detail) => result.warnings.push(RewriteWarning { @@ -1112,6 +1128,7 @@ fn rewrite_hatch( }), } } + result.files.extend(batch.files); } // ── npm package-lock.json / npm-shrinkwrap.json ───────────────────────────── diff --git a/crates/socket-patch-core/src/utils/hatch.rs b/crates/socket-patch-core/src/utils/hatch.rs index b1cfea9e0..78daf5fc7 100644 --- a/crates/socket-patch-core/src/utils/hatch.rs +++ b/crates/socket-patch-core/src/utils/hatch.rs @@ -1,4 +1,4 @@ -use std::collections::BTreeMap; +use std::collections::{BTreeMap, HashMap}; use toml_edit::{DocumentMut, Item, Value}; @@ -7,8 +7,7 @@ use crate::utils::python_lock::preserve_line_endings; use crate::vendor::common::{pep508_name, pyproject_dependency_specs, DeclTable}; /// The two documents the hatch planner reads and rewrites, in the order -/// [`plan`] parses them. The redirect overlay (`redirect/mod.rs`) clones -/// exactly these from the candidate set, so the lists cannot drift. +/// [`plan`] and [`rewrite_all`] parse them. pub const HATCH_FILES: [&str; 2] = ["pyproject.toml", "hatch.toml"]; /// The first character of `root`'s path that Hatch's `{root:uri}` expands @@ -243,11 +242,79 @@ fn replacement(spec: &str, name: &str, version: &str, url: &str) -> Result Result { +/// One dependency of a [`rewrite_all`] batch: the arguments [`rewrite`] +/// takes for a single one. +pub struct HatchDep<'a> { + pub name: &'a str, + pub version: &'a str, + pub url: &'a str, +} + +/// One requirement string a batch rewrote, in `file` (a [`HATCH_FILES`] +/// entry): the declaration before and after. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct HatchEdit { + pub file: String, + pub original: String, + pub new: String, +} + +/// What [`rewrite_all`] made of a batch: the Hatch files it changed (path → +/// new text) and, per dependency in input order, the requirement strings it +/// rewrote or why it refused that dependency. +pub struct HatchBatch { + pub files: BTreeMap, + pub steps: Vec, String>>, +} + +/// One dependency of a [`Round`] and what the walk found for it. +struct Target<'a> { + /// Canonical PyPI name. + name: String, + version: &'a str, + url: &'a str, + error: Option, + matched: usize, + project_matched: usize, + edits: Vec, +} + +/// The dependencies one walk of the Hatch documents rewrites, at most one +/// per canonical name. Requirements of different names never touch each +/// other's strings, so each target's outcome is the one a [`plan`] of it +/// alone would give. The walk runs twice: a check pass (`apply == false`) +/// that counts matches and records errors without changing anything, then +/// an apply pass that rewrites only the targets the check left error-free, +/// so a target refused midway leaves none of its declarations rewritten. +struct Round<'a> { + targets: Vec>, + index: HashMap, + apply: bool, +} + +impl Round<'_> { + /// A structural error stops the walk, as it stops a single [`plan`]: + /// every target that has not already failed fails with it. + fn fail_rest(&mut self, error: &str) { + for target in &mut self.targets { + target.error.get_or_insert_with(|| error.to_owned()); + } + } +} + +/// Rewrite `item`'s declarations of every live target. `Err` is a +/// structural error (it applies to every target); a requirement a target +/// cannot take is recorded on that target and the walk goes on. +fn rewrite_array( + item: &mut Item, + round: &mut Round, + file: &str, + project: bool, +) -> Result, String> { let array = item .as_array_mut() .ok_or("dependency declarations must be arrays")?; - let mut matched = 0; + let mut matched = Vec::new(); for entry in array.iter_mut() { if entry.is_inline_table() { continue; @@ -255,25 +322,42 @@ fn rewrite_array(item: &mut Item, name: &str, version: &str, url: &str) -> Resul let text = entry .as_str() .ok_or("dependency declarations must contain strings")?; - if let Some(rewritten) = replacement(text, name, version, url)? { - matched += 1; - if rewritten != text { - let decoration = entry.decor().clone(); - *entry = Value::from(rewritten); - *entry.decor_mut() = decoration; + let Some(&index) = round + .index + .get(canonicalize_pypi_name(pep508_name(text)).as_str()) + else { + continue; + }; + let target = &mut round.targets[index]; + if target.error.is_some() { + continue; + } + match replacement(text, &target.name, target.version, target.url) { + Err(error) => target.error = Some(error), + Ok(None) => {} + Ok(Some(rewritten)) => { + matched.push(index); + if !round.apply { + target.matched += 1; + target.project_matched += usize::from(project); + } else if rewritten != text { + target.edits.push(HatchEdit { + file: file.to_owned(), + original: text.to_owned(), + new: rewritten.clone(), + }); + let decoration = entry.decor().clone(); + *entry = Value::from(rewritten); + *entry.decor_mut() = decoration; + } } } } Ok(matched) } -fn rewrite_project( - document: &mut DocumentMut, - name: &str, - version: &str, - url: &str, -) -> Result { - let mut matched = 0; +fn rewrite_project(document: &mut DocumentMut, round: &mut Round) -> Result<(), String> { + let file = HATCH_FILES[0]; if let Some(project) = document.get_mut("project") { if project .get("dynamic") @@ -290,7 +374,7 @@ fn rewrite_project( .as_table_like_mut() .and_then(|table| table.get_mut("dependencies")) { - matched += rewrite_array(dependencies, name, version, url)?; + rewrite_array(dependencies, round, file, true)?; } if let Some(groups) = project .as_table_like_mut() @@ -301,7 +385,7 @@ fn rewrite_project( .ok_or("optional dependencies must be a table")? .iter_mut() { - matched += rewrite_array(dependencies, name, version, url)?; + rewrite_array(dependencies, round, file, true)?; } } } @@ -311,26 +395,21 @@ fn rewrite_project( .ok_or("dependency groups must be a table")? .iter_mut() { - let group_matches = rewrite_array(dependencies, name, version, url)?; - if group_matches > 0 && url.starts_with("{root:uri}") { - return Err("Hatch does not expand root placeholders in dependency groups; use environment dependencies or agent mode".into()); + for index in rewrite_array(dependencies, round, file, true)? { + let target = &mut round.targets[index]; + if target.url.starts_with("{root:uri}") { + target.error.get_or_insert_with(|| "Hatch does not expand root placeholders in dependency groups; use environment dependencies or agent mode".into()); + } } - matched += group_matches; } } - Ok(matched) + Ok(()) } -fn rewrite_environments( - hatch: &mut Item, - name: &str, - version: &str, - url: &str, -) -> Result { +fn rewrite_environments(hatch: &mut Item, round: &mut Round, file: &str) -> Result<(), String> { if hatch.get("sources").is_some() || hatch.get("env").is_some() { return Err("Hatch sources and environment plugins require agent mode".into()); } - let mut matched = 0; if let Some(environments) = hatch .as_table_like_mut() .and_then(|table| table.get_mut("envs")) @@ -356,12 +435,230 @@ fn rewrite_environments( .as_table_like_mut() .and_then(|table| table.get_mut(key)) { - matched += rewrite_array(dependencies, name, version, url)?; + rewrite_array(dependencies, round, file, false)?; } } } } - Ok(matched) + Ok(()) +} + +/// One pass over every declaration Hatch installs, in the order a single +/// [`plan`] walks them: pyproject's project tables, then the environments +/// of the merged configuration. +fn walk( + documents: &mut BTreeMap<&'static str, DocumentMut>, + round: &mut Round, +) -> Result<(), String> { + if let Some(project) = documents.get_mut(HATCH_FILES[0]) { + rewrite_project(project, round)?; + } + // Hatch merges external configuration by top-level key, not recursively. + let external_keys = external_keys(documents); + if let Some(document) = documents.get_mut(HATCH_FILES[0]) { + if let Some(hatch) = document.get_mut("tool").and_then(|tool| { + tool.as_table_like_mut() + .and_then(|table| table.get_mut("hatch")) + }) { + let mut effective = hatch.clone(); + if let Some(table) = effective.as_table_like_mut() { + for key in &external_keys { + table.remove(key); + } + } + rewrite_environments(&mut effective, round, HATCH_FILES[0])?; + if round.apply { + if let Some(table) = effective.as_table_like() { + for (key, item) in table.iter() { + hatch[key] = item.clone(); + } + } + } + } + } + if let Some(document) = documents.get_mut(HATCH_FILES[1]) { + let mut hatch = Item::Table(document.as_table().clone()); + rewrite_environments(&mut hatch, round, HATCH_FILES[1])?; + if round.apply { + *document.as_table_mut() = hatch + .as_table() + .ok_or("invalid Hatch configuration")? + .clone(); + } + } + Ok(()) +} + +fn external_keys(documents: &BTreeMap<&'static str, DocumentMut>) -> Vec { + documents + .get(HATCH_FILES[1]) + .map(|d| d.iter().map(|(k, _)| k.to_owned()).collect()) + .unwrap_or_default() +} + +/// Run one [`Round`] over `documents`: check, then apply what passed. +fn run_round(documents: &mut BTreeMap<&'static str, DocumentMut>, round: &mut Round) { + if round + .targets + .iter() + .any(|t| t.url.starts_with("{root:uri}")) + { + if let Err(error) = require_pip_installer_documents(documents.values()) { + for target in &mut round.targets { + if target.url.starts_with("{root:uri}") { + target.error = Some(error.clone()); + } + } + } + } + round.apply = false; + if let Err(error) = walk(documents, round) { + round.fail_rest(&error); + return; + } + for target in &mut round.targets { + if target.error.is_none() && target.matched == 0 { + target.error = Some(format!("{}=={} has no explicit Hatch declaration; transitive-only dependencies require agent mode", target.name, target.version)); + } + } + // A project-table match needs Hatch's direct-reference permission, which + // no requirement rewrite touches: whether it can be granted is judged + // once, on a copy. + let external = external_keys(documents).iter().any(|key| key == "metadata"); + let permission_file = permission_file(external); + let needs_permission = |target: &Target| target.error.is_none() && target.project_matched > 0; + if round.targets.iter().any(needs_permission) { + let granted = documents + .get(permission_file) + .ok_or_else(|| "missing Hatch configuration".to_owned()) + .and_then(|document| enable_permission(&mut document.clone(), external)); + if let Err(error) = granted { + for target in &mut round.targets { + if needs_permission(target) { + target.error = Some(error.clone()); + } + } + } + } + if round.targets.iter().all(|target| target.error.is_some()) { + return; + } + round.apply = true; + // The check pass found every structural error, and the apply pass walks + // the same tables. + if let Err(error) = walk(documents, round) { + round.fail_rest(&error); + return; + } + if round.targets.iter().any(needs_permission) { + if let Some(document) = documents.get_mut(permission_file) { + // Judged grantable above. + let _ = enable_permission(document, external); + } + } +} + +fn permission_file(external: bool) -> &'static str { + if external { + HATCH_FILES[1] + } else { + HATCH_FILES[0] + } +} + +fn parse_documents( + files: &BTreeMap, +) -> Result, String> { + let mut documents = BTreeMap::new(); + for file in HATCH_FILES { + if let Some(text) = files.get(file) { + documents.insert( + file, + text.parse::() + .map_err(|error| format!("{file}: {error}"))?, + ); + } + } + Ok(documents) +} + +fn render( + files: &BTreeMap, + documents: BTreeMap<&'static str, DocumentMut>, +) -> BTreeMap { + documents + .into_iter() + .filter_map(|(name, document)| { + let original = &files[name]; + let new = preserve_line_endings(original, document.to_string()); + (new != *original).then_some((name.to_owned(), new)) + }) + .collect() +} + +/// [`rewrite`] every dependency of `deps` over one parse and one render of +/// the Hatch documents. Each step is what [`rewrite`] would give for that +/// dependency applied after the ones before it: dependencies of one name +/// run in input order, one per round, and distinct names never interact. +pub fn rewrite_all(files: &BTreeMap, deps: &[HatchDep]) -> HatchBatch { + let mut documents = match parse_documents(files) { + Ok(documents) => documents, + Err(error) => { + return HatchBatch { + files: BTreeMap::new(), + steps: deps.iter().map(|_| Err(error.clone())).collect(), + } + } + }; + let names: Vec = deps + .iter() + .map(|dep| canonicalize_pypi_name(dep.name)) + .collect(); + let mut rounds: Vec> = Vec::new(); + let mut seen: HashMap<&str, usize> = HashMap::new(); + for (position, name) in names.iter().enumerate() { + let occurrence = seen.entry(name.as_str()).or_default(); + if rounds.len() <= *occurrence { + rounds.push(Vec::new()); + } + rounds[*occurrence].push(position); + *occurrence += 1; + } + let mut steps: Vec, String>> = + deps.iter().map(|_| Ok(Vec::new())).collect(); + for positions in rounds { + let mut round = Round { + targets: positions + .iter() + .map(|&position| Target { + name: names[position].clone(), + version: deps[position].version, + url: deps[position].url, + error: None, + matched: 0, + project_matched: 0, + edits: Vec::new(), + }) + .collect(), + index: positions + .iter() + .enumerate() + .map(|(index, &position)| (names[position].clone(), index)) + .collect(), + apply: false, + }; + run_round(&mut documents, &mut round); + for (position, target) in positions.into_iter().zip(round.targets) { + steps[position] = match target.error { + Some(error) => Err(error), + None => Ok(target.edits), + }; + } + } + HatchBatch { + files: render(files, documents), + steps, + } } pub struct HatchPermission { @@ -503,71 +800,32 @@ pub fn plan( url: &str, ) -> Result { let name = canonicalize_pypi_name(name); - let mut documents = BTreeMap::new(); - for file in HATCH_FILES { - if let Some(text) = files.get(file) { - documents.insert( - file, - text.parse::() - .map_err(|error| format!("{file}: {error}"))?, - ); - } - } - if url.starts_with("{root:uri}") { - require_pip_installer_documents(documents.values())?; - } - let mut matched = 0; - let mut project_matched = 0; - if let Some(project) = documents.get_mut("pyproject.toml") { - project_matched = rewrite_project(project, &name, version, url)?; - matched += project_matched; - } - // Hatch merges external configuration by top-level key, not recursively. - let external_keys: Vec = documents - .get("hatch.toml") - .map(|d| d.iter().map(|(k, _)| k.to_owned()).collect()) - .unwrap_or_default(); - if let Some(document) = documents.get_mut("pyproject.toml") { - if let Some(hatch) = document.get_mut("tool").and_then(|tool| { - tool.as_table_like_mut() - .and_then(|table| table.get_mut("hatch")) - }) { - let mut effective = hatch.clone(); - if let Some(table) = effective.as_table_like_mut() { - for key in &external_keys { - table.remove(key); - } - } - matched += rewrite_environments(&mut effective, &name, version, url)?; - if let Some(table) = effective.as_table_like() { - for (key, item) in table.iter() { - hatch[key] = item.clone(); - } - } - } + let mut documents = parse_documents(files)?; + let mut round = Round { + targets: vec![Target { + name: name.clone(), + version, + url, + error: None, + matched: 0, + project_matched: 0, + edits: Vec::new(), + }], + index: HashMap::from([(name, 0)]), + apply: false, + }; + run_round(&mut documents, &mut round); + let target = round.targets.pop().ok_or("missing Hatch target")?; + if let Some(error) = target.error { + return Err(error); } - if let Some(document) = documents.get_mut("hatch.toml") { - let mut hatch = Item::Table(document.as_table().clone()); - matched += rewrite_environments(&mut hatch, &name, version, url)?; - *document.as_table_mut() = hatch - .as_table() - .ok_or("invalid Hatch configuration")? - .clone(); - } - if matched == 0 { - return Err(format!("{name}=={version} has no explicit Hatch declaration; transitive-only dependencies require agent mode")); - } - let permission = if project_matched > 0 { - let external = external_keys.iter().any(|key| key == "metadata"); - let file = if external { - "hatch.toml" - } else { - "pyproject.toml" - }; - let document = documents - .get_mut(file) - .ok_or("missing Hatch configuration")?; - enable_permission(document, external)?; + // `run_round` granted the permission in the rewritten documents; the + // vendored ledger also records it alone over the original text. + let permission = if target.project_matched > 0 { + let external = external_keys(&documents) + .iter() + .any(|key| key == "metadata"); + let file = permission_file(external); let original = files[file].clone(); let mut permission_only = original .parse::() @@ -581,15 +839,10 @@ pub fn plan( } else { None }; - let files = documents - .into_iter() - .filter_map(|(name, document)| { - let original = &files[name]; - let new = preserve_line_endings(original, document.to_string()); - (new != *original).then_some((name.to_owned(), new)) - }) - .collect(); - Ok(HatchPlan { files, permission }) + Ok(HatchPlan { + files: render(files, documents), + permission, + }) } #[cfg(test)] @@ -923,4 +1176,257 @@ mod tests { .unwrap_err() .contains("does not expand")); } + + /// Rewrite `deps` one at a time, each over the previous one's output: + /// the per-dependency contract [`rewrite_all`] must keep. + fn sequential( + files: &BTreeMap, + deps: &[HatchDep], + ) -> (BTreeMap, Vec>) { + let mut current = files.clone(); + let steps = deps + .iter() + .map(|dep| { + rewrite(¤t, dep.name, dep.version, dep.url).map(|changes| { + current.extend(changes); + }) + }) + .collect(); + (current, steps) + } + + fn assert_batch_matches_sequential(files: &BTreeMap, deps: &[HatchDep]) { + let (expected, expected_steps) = sequential(files, deps); + let batch = rewrite_all(files, deps); + let mut actual = files.clone(); + actual.extend(batch.files.clone()); + assert_eq!(actual, expected); + let steps: Vec> = batch + .steps + .iter() + .map(|step| step.as_ref().map(|_| ()).map_err(Clone::clone)) + .collect(); + assert_eq!(steps, expected_steps); + // Only the changed documents come back, as with `rewrite`. + for (path, text) in &batch.files { + assert_ne!(files.get(path), Some(text), "{path} is unchanged"); + } + // Every recorded edit changes a requirement string of its file. + for edit in batch.steps.iter().flatten().flatten() { + assert_ne!(edit.original, edit.new); + assert!(files.contains_key(&edit.file), "{edit:?}"); + } + } + + const HOSTED_A: &str = "https://patch.socket.dev/patch/pypi/six/1.16.0/11111111-1111-4111-8111-111111111111/aaaaaaaa-0000-4000-8000-000000000001/six-1.16.0-py2.py3-none-any.whl#sha256=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + const HOSTED_B: &str = "https://patch.socket.dev/patch/pypi/six/1.16.0/22222222-2222-4222-8222-222222222222/aaaaaaaa-0000-4000-8000-000000000004/six-1.16.0-py2.py3-none-any.whl#sha256=bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + + /// #1471: one batch over many deps gives exactly what rewriting them + /// one at a time gives — successes, per-dependency refusals (inexact + /// pin, no declaration), extras and markers, a name repeated with a + /// superseding url (runs after the first, in input order), and a name + /// spelled differently in the declaration. + #[test] + fn batch_rewrite_matches_sequential_rewrites() { + let pyproject = files(concat!( + "[project]\nname = \"app\"\n", + "dependencies = [\n \"six==1.16.0\",\n \"Requests[socks]==2.31.0 ; python_version >= '3.8'\",\n \"idna>=3\",\n]\n", + "[project.optional-dependencies]\nextra = [\"six==1.16.0\", \"Zope.Interface==6.0\"]\n", + "[dependency-groups]\nqa = [\"pytest==8.0.0\"]\n", + "[tool.hatch.envs.default]\ndependencies = [\"urllib3==1.26.18\", \"zope-interface==6.0\"]\n", + "[tool.hatch.envs.lint]\nextra-dependencies = [\"idna==3.4\", \"six==1.16.0\"]\n", + )); + let deps = [ + HatchDep { + name: "six", + version: "1.16.0", + url: HOSTED_A, + }, + HatchDep { + name: "requests", + version: "2.31.0", + url: "https://patch.test/requests.whl#sha256=00", + }, + HatchDep { + name: "idna", + version: "3.4", + url: "https://patch.test/idna.whl", + }, + HatchDep { + name: "urllib3", + version: "1.26.18", + url: "https://patch.test/urllib3.whl", + }, + HatchDep { + name: "absent", + version: "1.0", + url: "https://patch.test/absent.whl", + }, + HatchDep { + name: "zope.interface", + version: "6.0", + url: "https://patch.test/zope.whl", + }, + HatchDep { + name: "pytest", + version: "8.0.0", + url: "https://patch.test/pytest.whl", + }, + HatchDep { + name: "SIX", + version: "1.16.0", + url: HOSTED_B, + }, + ]; + assert_batch_matches_sequential(&pyproject, &deps); + let batch = rewrite_all(&pyproject, &deps); + assert!(batch.steps[2].as_ref().unwrap_err().contains("exact")); + assert!(batch.steps[4].as_ref().unwrap_err().contains("no explicit")); + let wired = &batch.files["pyproject.toml"]; + // The repeated name re-pinned all three six declarations to the + // superseding url; the refused idna kept every declaration. + assert_eq!(wired.matches(HOSTED_B).count(), 3, "{wired}"); + assert!(!wired.contains(HOSTED_A), "{wired}"); + assert!( + wired.contains("\"idna>=3\"") && wired.contains("\"idna==3.4\""), + "{wired}" + ); + assert_eq!(batch.steps[7].as_ref().unwrap().len(), 3); + assert!(batch.steps.iter().filter(|s| s.is_ok()).count() == 6); + // An empty batch changes nothing. + let empty = rewrite_all(&pyproject, &[]); + assert!(empty.files.is_empty() && empty.steps.is_empty()); + } + + /// #1471: hatch.toml's `envs` replaces pyproject's, a refusal found in + /// a later table leaves that dep's earlier declarations untouched, and + /// a structural refusal fails every dep not already refused (each with + /// the message a lone `rewrite` gives it). + #[test] + fn batch_rewrite_follows_merges_and_refusal_order() { + let mut merged = files(concat!( + "[project]\nname = \"app\"\n", + "dependencies = [\"six==1.16.0\", \"attrs==23.1.0\"]\n", + "[tool.hatch.envs.default]\ndependencies = [\"click==8.1.7\"]\n", + )); + merged.insert( + "hatch.toml".into(), + "[envs.default]\ndependencies = [\"click==8.1.7\", \"attrs>=23\"]\n\r\n[envs.docs]\nextra-dependencies = [\"six==1.16.0\"]\r\n".into(), + ); + let deps = [ + HatchDep { + name: "six", + version: "1.16.0", + url: "https://patch.test/six.whl", + }, + HatchDep { + name: "attrs", + version: "23.1.0", + url: "https://patch.test/attrs.whl", + }, + HatchDep { + name: "click", + version: "8.1.7", + url: "https://patch.test/click.whl", + }, + ]; + assert_batch_matches_sequential(&merged, &deps); + let batch = rewrite_all(&merged, &deps); + // attrs is refused by its hatch.toml range, so its exact project pin + // stays as written. + assert!(batch.steps[1].is_err()); + assert!(batch.files["pyproject.toml"].contains("\"attrs==23.1.0\"")); + // pyproject's shadowed environment is not rewritten. + assert!(batch.files["pyproject.toml"].contains("dependencies = [\"click==8.1.7\"]")); + + let structural = files(concat!( + "[project]\nname = \"app\"\n", + "dependencies = [\"six==1.16.0\", \"idna>=3\"]\n", + "[tool.hatch.envs.default]\ndependencies = [\"click==8.1.7\"]\n", + "[tool.hatch.envs.custom]\ntype = \"container\"\n", + )); + let deps = [ + HatchDep { + name: "six", + version: "1.16.0", + url: "https://patch.test/six.whl", + }, + HatchDep { + name: "idna", + version: "3.4", + url: "https://patch.test/idna.whl", + }, + HatchDep { + name: "click", + version: "8.1.7", + url: "https://patch.test/click.whl", + }, + ]; + assert_batch_matches_sequential(&structural, &deps); + let batch = rewrite_all(&structural, &deps); + assert!(batch.files.is_empty()); + assert!(batch.steps[0] + .as_ref() + .unwrap_err() + .contains("custom environments")); + assert!(batch.steps[1].as_ref().unwrap_err().contains("exact")); + + let mut broken = structural.clone(); + broken.insert("hatch.toml".into(), "[envs\n".into()); + assert_batch_matches_sequential(&broken, &deps); + assert!(rewrite_all(&broken, &deps) + .steps + .iter() + .all(|step| step.as_ref().unwrap_err().starts_with("hatch.toml:"))); + } + + /// #1471: vendored `{root:uri}` deps keep their pip-installer and + /// dependency-group refusals inside a batch, beside hosted deps that + /// still wire. + #[test] + fn batch_rewrite_keeps_root_placeholder_refusals() { + let inputs = files(concat!( + "[dependency-groups]\nqa = [\"urllib3==1.26.18\"]\n", + "[tool.hatch.envs.default]\ndependencies = [\"six==1.16.0\"]\n", + )); + let deps = [ + HatchDep { + name: "urllib3", + version: "1.26.18", + url: "{root:uri}/.socket/vendor/u.whl", + }, + HatchDep { + name: "six", + version: "1.16.0", + url: "https://patch.test/six.whl", + }, + ]; + assert_batch_matches_sequential(&inputs, &deps); + assert!(rewrite_all(&inputs, &deps).steps[0] + .as_ref() + .unwrap_err() + .contains("does not expand")); + let mut uv = inputs.clone(); + uv.insert( + "hatch.toml".into(), + "[envs.default]\ninstaller = \"uv\"\ndependencies = [\"six==1.16.0\"]\n".into(), + ); + let deps = [ + HatchDep { + name: "six", + version: "1.16.0", + url: "{root:uri}/.socket/vendor/six.whl", + }, + HatchDep { + name: "urllib3", + version: "1.26.18", + url: "https://patch.test/u.whl", + }, + ]; + assert_batch_matches_sequential(&uv, &deps); + assert!(rewrite_all(&uv, &deps).steps[0] + .as_ref() + .unwrap_err() + .contains("pip installer")); + } } From b6cef3018429d3a61ce18d17cfe8d4570bd997c9 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 09:37:01 +0000 Subject: [PATCH 3/4] Record the Hatch permission in batch edits When the only change a hosted Hatch re-run makes is restoring the allow-direct-references permission the user removed, the file was rewritten but no edit named it. The batch now records that change on the first dependency that needs it, as a redirect_hatch_permission edit, and the tests pin the exact output instead of only comparing against the shared single-dep path. Assisted-by: Claude Code:claude-opus-5-5 --- .../src/patch/redirect/mod.rs | 7 +- crates/socket-patch-core/src/utils/hatch.rs | 108 ++++++++++++++++-- 2 files changed, 107 insertions(+), 8 deletions(-) diff --git a/crates/socket-patch-core/src/patch/redirect/mod.rs b/crates/socket-patch-core/src/patch/redirect/mod.rs index d81379b3f..5f4ea294e 100644 --- a/crates/socket-patch-core/src/patch/redirect/mod.rs +++ b/crates/socket-patch-core/src/patch/redirect/mod.rs @@ -1114,7 +1114,12 @@ fn rewrite_hatch( for edit in edits { result.edits.push(FileEdit { path: edit.file, - kind: "redirect_hatch_requirement".into(), + kind: if edit.permission { + "redirect_hatch_permission" + } else { + "redirect_hatch_requirement" + } + .into(), action: "rewritten".into(), key: Some(format!("{}@{}", dep.name, dep.version)), original: Some(Value::String(edit.original)), diff --git a/crates/socket-patch-core/src/utils/hatch.rs b/crates/socket-patch-core/src/utils/hatch.rs index 78daf5fc7..745c3982c 100644 --- a/crates/socket-patch-core/src/utils/hatch.rs +++ b/crates/socket-patch-core/src/utils/hatch.rs @@ -257,6 +257,11 @@ pub struct HatchEdit { pub file: String, pub original: String, pub new: String, + /// The direct-reference permission a project-table reference needs + /// (`allow-direct-references = true`, see [`permission_keys`]) rather + /// than a requirement; `original` is the setting it replaced, empty + /// when there was none. + pub permission: bool, } /// What [`rewrite_all`] made of a batch: the Hatch files it changed (path → @@ -345,6 +350,7 @@ fn rewrite_array( file: file.to_owned(), original: text.to_owned(), new: rewritten.clone(), + permission: false, }); let decoration = entry.decor().clone(); *entry = Value::from(rewritten); @@ -550,12 +556,39 @@ fn run_round(documents: &mut BTreeMap<&'static str, DocumentMut>, round: &mut Ro round.fail_rest(&error); return; } - if round.targets.iter().any(needs_permission) { - if let Some(document) = documents.get_mut(permission_file) { - // Judged grantable above. - let _ = enable_permission(document, external); - } + let Some(index) = round.targets.iter().position(needs_permission) else { + return; + }; + let Some(document) = documents.get_mut(permission_file) else { + return; + }; + let previous = permission_setting(document, external); + if previous.as_ref().and_then(Item::as_bool) == Some(true) { + return; + } + // Judged grantable on the copy above, and no requirement rewrite + // touches these tables. + if enable_permission(document, external).is_ok() { + // Recorded on the first dep that needs it, the one a sequential + // run would have granted it for. + round.targets[index].edits.push(HatchEdit { + file: permission_file.to_owned(), + original: previous + .map(|item| format!("allow-direct-references = {}", item.to_string().trim())) + .unwrap_or_default(), + new: "allow-direct-references = true".into(), + permission: true, + }); + } +} + +/// The `allow-direct-references` item at [`permission_keys`]' table, if set. +fn permission_setting(document: &DocumentMut, external: bool) -> Option { + let mut item = document.as_item(); + for key in permission_keys(external) { + item = item.get(key)?; } + item.get("allow-direct-references").cloned() } fn permission_file(external: bool) -> &'static str { @@ -1336,8 +1369,29 @@ mod tests { // stays as written. assert!(batch.steps[1].is_err()); assert!(batch.files["pyproject.toml"].contains("\"attrs==23.1.0\"")); - // pyproject's shadowed environment is not rewritten. - assert!(batch.files["pyproject.toml"].contains("dependencies = [\"click==8.1.7\"]")); + // Pinned output, independent of the shared walk: pyproject's + // shadowed environment is not rewritten, the project reference gets + // its permission, and hatch.toml keeps its majority line ending. + assert_eq!( + batch.files["pyproject.toml"], + "[project]\nname = \"app\"\ndependencies = [\"six @ https://patch.test/six.whl\", \"attrs==23.1.0\"]\n[tool.hatch.envs.default]\ndependencies = [\"click==8.1.7\"]\n\n[tool.hatch.metadata]\nallow-direct-references = true\n" + ); + assert_eq!( + batch.files["hatch.toml"], + "[envs.default]\ndependencies = [\"click @ https://patch.test/click.whl\", \"attrs>=23\"]\n\n[envs.docs]\nextra-dependencies = [\"six @ https://patch.test/six.whl\"]\n" + ); + let six = batch.steps[0].as_ref().unwrap(); + assert_eq!( + six.iter() + .map(|edit| (edit.file.as_str(), edit.original.as_str(), edit.permission)) + .collect::>(), + [ + ("pyproject.toml", "six==1.16.0", false), + ("hatch.toml", "six==1.16.0", false), + ("pyproject.toml", "", true), + ] + ); + assert_eq!(batch.steps[2].as_ref().unwrap().len(), 1); let structural = files(concat!( "[project]\nname = \"app\"\n", @@ -1380,6 +1434,46 @@ mod tests { .all(|step| step.as_ref().unwrap_err().starts_with("hatch.toml:"))); } + /// #1471: a re-run whose only change is restoring the direct-reference + /// permission the user removed still rewrites the file and records the + /// permission edit, and an already-granted permission records nothing. + #[test] + fn batch_rewrite_records_a_permission_only_change() { + let wired = files( + "[project]\nname = \"app\"\ndependencies = [\"six @ https://patch.test/six.whl\"]\n", + ); + let deps = [HatchDep { + name: "six", + version: "1.16.0", + url: "https://patch.test/six.whl", + }]; + assert_batch_matches_sequential(&wired, &deps); + let batch = rewrite_all(&wired, &deps); + assert!(batch.files["pyproject.toml"] + .ends_with("[tool.hatch.metadata]\nallow-direct-references = true\n")); + assert_eq!( + batch.steps[0].as_ref().unwrap(), + &[HatchEdit { + file: "pyproject.toml".into(), + original: String::new(), + new: "allow-direct-references = true".into(), + permission: true, + }] + ); + let mut granted = wired.clone(); + granted.extend(batch.files); + let again = rewrite_all(&granted, &deps); + assert!(again.files.is_empty()); + assert_eq!(again.steps, [Ok(Vec::new())]); + // A `false` the user set is replaced, and recorded as such. + let denied = files("[project]\nname = \"app\"\ndependencies = [\"six==1.16.0\"]\n[tool.hatch.metadata]\nallow-direct-references = false\n"); + assert_batch_matches_sequential(&denied, &deps); + let batch = rewrite_all(&denied, &deps); + let edits = batch.steps[0].as_ref().unwrap(); + assert_eq!(edits.len(), 2); + assert_eq!(edits[1].original, "allow-direct-references = false"); + } + /// #1471: vendored `{root:uri}` deps keep their pip-installer and /// dependency-group refusals inside a batch, beside hosted deps that /// still wire. From 5f1328e85379a8a8609ade4e1b8ab492d9775aa8 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 13:59:23 +0000 Subject: [PATCH 4/4] Write no Hatch files when no dep is wired rewrite_all rendered every parsed document whatever the outcome, so a hatch.toml that doesn't round-trip byte for byte (mixed line endings) came back normalised even when every dep was refused, where the per-dep rewrite wrote nothing. Return no files unless some step succeeded. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Tr9PtQHqFihp4KcdJ1pXCK --- crates/socket-patch-core/src/utils/hatch.rs | 31 ++++++++++++++++++--- 1 file changed, 27 insertions(+), 4 deletions(-) diff --git a/crates/socket-patch-core/src/utils/hatch.rs b/crates/socket-patch-core/src/utils/hatch.rs index 745c3982c..914e7c535 100644 --- a/crates/socket-patch-core/src/utils/hatch.rs +++ b/crates/socket-patch-core/src/utils/hatch.rs @@ -688,10 +688,14 @@ pub fn rewrite_all(files: &BTreeMap, deps: &[HatchDep]) -> Hatch }; } } - HatchBatch { - files: render(files, documents), - steps, - } + // As with `rewrite`, a batch that wired no dep writes nothing: a render + // can differ from the original (line endings) with no edit made. + let files = if steps.iter().any(Result::is_ok) { + render(files, documents) + } else { + BTreeMap::new() + }; + HatchBatch { files, steps } } pub struct HatchPermission { @@ -1474,6 +1478,25 @@ mod tests { assert_eq!(edits[1].original, "allow-direct-references = false"); } + /// #1471: when no dep is wired, nothing is written, even for a + /// hatch.toml whose render is not byte-identical (mixed line endings). + #[test] + fn batch_rewrite_writes_nothing_when_every_dep_is_refused() { + let mut mixed = files("[project]\nname = \"app\"\ndependencies = [\"six==1.16.0\"]\n"); + mixed.insert( + "hatch.toml".into(), + "[envs.default]\ndependencies = [\"click==8.1.7\"]\n\r\n[envs.docs]\nextra-dependencies = [\"six==1.16.0\"]\r\n".into(), + ); + let absent = [HatchDep { + name: "absent", + version: "1.0.0", + url: "https://patch.test/absent.whl", + }]; + assert_batch_matches_sequential(&mixed, &absent); + assert!(rewrite_all(&mixed, &absent).files.is_empty()); + assert!(rewrite_all(&mixed, &[]).files.is_empty()); + } + /// #1471: vendored `{root:uri}` deps keep their pip-installer and /// dependency-group refusals inside a batch, beside hosted deps that /// still wire.