From 68ccb5f00f8db174da2c370fb42751aaccc44655 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Fri, 9 Oct 2026 14:23:42 -0400 Subject: [PATCH 1/5] Scope project-mode Maven crawl to the pom graph WIP: tests and docs to follow. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/crawlers/maven_crawler.rs | 126 +++- .../src/crawlers/maven_scope.rs | 704 ++++++++++++++++++ crates/socket-patch-core/src/crawlers/mod.rs | 1 + .../src/vendor/jvm/maven_reactor.rs | 97 +++ 4 files changed, 926 insertions(+), 2 deletions(-) create mode 100644 crates/socket-patch-core/src/crawlers/maven_scope.rs diff --git a/crates/socket-patch-core/src/crawlers/maven_crawler.rs b/crates/socket-patch-core/src/crawlers/maven_crawler.rs index 07803afcd..8e9331d55 100644 --- a/crates/socket-patch-core/src/crawlers/maven_crawler.rs +++ b/crates/socket-patch-core/src/crawlers/maven_crawler.rs @@ -921,16 +921,55 @@ impl MavenCrawler { /// Crawl all discovered Maven repository paths and return every /// package found. + /// + /// In project mode for a Maven build (a `pom.xml`, no Gradle or + /// Scala-tool build beside it), a Maven local repository is shared by + /// every project on the machine, so only the coordinates the project's + /// dependency graph reaches are kept ([`maven_scope`](super::maven_scope), + /// #265): another project's cached artifacts are not this project's + /// packages. A root pom that is not readable leaves the crawl unscoped. pub async fn crawl_all(&self, options: &CrawlerOptions) -> Vec { + self.crawl_all_with(options, &JvmEnv::from_process()).await + } + + /// [`Self::crawl_all`] under the caches `env` names. + pub async fn crawl_all_with( + &self, + options: &CrawlerOptions, + env: &JvmEnv, + ) -> Vec { let mut packages = Vec::new(); let mut seen = HashSet::new(); + let scoped = options.global_prefix.is_none() && !options.global && { + let cwd = options.cwd.clone(); + run_walk(move || { + layout::has_build(&cwd, BuildTool::Maven) + && !layout::has_build(&cwd, BuildTool::Gradle) + && !layout::is_scala_tool_build(&cwd) + }) + .await + }; - for root in self.get_jvm_cache_roots(options).await { + for root in self.get_jvm_cache_roots_with(options, env).await { + let scope_cwd = + (scoped && root.layout == JvmCacheLayout::Maven2 && !coursier_spelled(&root.path)) + .then(|| options.cwd.clone()); // The walkdir walk and POM reads are blocking: run each repo // on the walk pool so concurrently crawled ecosystems keep // making progress (the dedup set rides along and comes back). let (found, returned_seen) = run_walk(move || { - let found = MavenCrawler.scan_cache_root(&root, &mut seen); + let scope = + scope_cwd.and_then(|cwd| super::maven_scope::project_scope(&cwd, &root.path)); + let mut found = MavenCrawler.scan_cache_root(&root, &mut seen); + if let Some(scope) = scope { + found.retain(|p| { + scope.admits( + p.namespace.as_deref().unwrap_or_default(), + &p.name, + &p.version, + ) + }); + } (found, seen) }) .await; @@ -2483,6 +2522,89 @@ mod tests { assert!(purls.contains("pkg:maven/com.google.guava/guava@32.1.3-jre")); } + /// #265: in project mode a Maven build's crawl keeps only what its + /// poms reach; the rest of the shared local repository (cached by other + /// projects) is not this project's. `--global` still lists everything. + #[tokio::test] + async fn project_mode_crawl_keeps_only_the_projects_graph() { + let dir = tempfile::tempdir().unwrap(); + let (cwd, repo) = (dir.path().join("app"), dir.path().join("m2")); + let pom = |g: &str, a: &str, v: &str, deps: &str| { + format!( + "4.0.0{g}\ + {a}{v}\ + {deps}" + ) + }; + let dep = |g: &str, a: &str, v: &str, scope: &str| { + format!( + "{g}{a}\ + {v}{scope}" + ) + }; + std::fs::create_dir_all(&cwd).unwrap(); + std::fs::write( + cwd.join("pom.xml"), + pom( + "com.example", + "unrelated-app", + "1.0.0", + &dep("junit", "junit", "4.13.2", "test"), + ), + ) + .unwrap(); + for (g, a, v, deps) in [ + ( + "junit", + "junit", + "4.13.2", + dep("org.hamcrest", "hamcrest-core", "1.3", "compile"), + ), + ("org.hamcrest", "hamcrest-core", "1.3", String::new()), + // Cached by another project. + ( + "org.apache.commons", + "commons-lang3", + "3.12.0", + String::new(), + ), + ] { + let d = repo.join(g.replace('.', "/")).join(a).join(v); + std::fs::create_dir_all(&d).unwrap(); + std::fs::write(d.join(format!("{a}-{v}.pom")), pom(g, a, v, &deps)).unwrap(); + } + let env = JvmEnv { + m2_repo: Some(repo.clone()), + gradle: None, + }; + let purls = |packages: Vec| { + let mut p: Vec = packages.into_iter().map(|p| p.purl).collect(); + p.sort(); + p + }; + let local = CrawlerOptions { + cwd: cwd.clone(), + global: false, + global_prefix: None, + }; + assert_eq!( + purls(MavenCrawler::new().crawl_all_with(&local, &env).await), + [ + "pkg:maven/junit/junit@4.13.2", + "pkg:maven/org.hamcrest/hamcrest-core@1.3", + ] + ); + let global = CrawlerOptions { + global: true, + ..local + }; + assert_eq!( + purls(MavenCrawler::new().crawl_all_with(&global, &env).await).len(), + 3, + "a global crawl is not scoped" + ); + } + #[tokio::test] async fn test_crawl_all_deduplication() { let dir = tempfile::tempdir().unwrap(); diff --git a/crates/socket-patch-core/src/crawlers/maven_scope.rs b/crates/socket-patch-core/src/crawlers/maven_scope.rs new file mode 100644 index 000000000..268b28254 --- /dev/null +++ b/crates/socket-patch-core/src/crawlers/maven_scope.rs @@ -0,0 +1,704 @@ +//! The project-mode scope of the Maven local repository (#265, the Maven +//! child of #595). +//! +//! A local repository is shared by every project on the machine, so in +//! project mode its contents are not this project's packages: a hosted scan +//! would pin, and VEX attest, whatever some other build cached. Maven has no +//! lockfile, so the scope is the dependency graph the project's poms +//! declare, walked through the poms the local repository already holds +//! (Maven caches the pom of every artifact, parent and BOM it resolves): +//! +//! - **Seeds**: every `` of the reactor (the root `pom.xml`, +//! its `` / `` recursively, every profile, and the +//! `` each inherits from its parents), any scope. +//! - **Edges**: an artifact's own non-optional `compile` / `runtime` +//! dependencies (and those its parents declare), which is what Maven +//! resolves transitively. +//! - **Versions**: the literal, interpolated through the pom's properties +//! and its parent chain; a version-less declaration takes the managed +//! version (the pom's parent chain, then its imported BOMs), and a +//! management entry of the reactor applies to transitives as Maven +//! applies it. A version this cannot determine (an undefined property, a +//! range, a pom not in the repository) admits every cached version of +//! that artifact instead: the scope over-approximates within an artifact +//! the project names, never across artifacts it does not. +//! +//! Exclusions and mediation are not modelled (both only narrow what Maven +//! resolves), so the scope is a superset of the resolved graph. + +use std::collections::{HashMap, HashSet, VecDeque}; +use std::path::{Path, PathBuf}; +use std::rc::Rc; + +use crate::vendor::jvm::maven_reactor::{pom_model, PomDecl, PomModel}; + +/// `(groupId, artifactId, version)`. +type Gav = (String, String, String); + +/// Most artifacts one walk visits; a bigger graph is left unscoped. +const MAX_NODES: usize = 50_000; +/// Deepest parent chain / BOM import nesting followed. +const MAX_DEPTH: usize = 32; +/// Property interpolation depth cap. +const MAX_INTERPOLATION: usize = 16; +/// Most reactor poms read. +const MAX_REACTOR: usize = 4_096; + +/// The coordinates a project-mode crawl of the local repository keeps. +#[derive(Debug, Default)] +pub(crate) struct ProjectScope { + gavs: HashSet, + /// Artifacts admitted at every cached version. + any_version: HashSet<(String, String)>, +} + +impl ProjectScope { + pub(crate) fn admits(&self, group: &str, artifact: &str, version: &str) -> bool { + let ga = (group.to_string(), artifact.to_string()); + self.any_version.contains(&ga) || self.gavs.contains(&(ga.0, ga.1, version.to_string())) + } +} + +/// The scope of the Maven project at `cwd` over the local repository +/// `repo`; `None` when `cwd/pom.xml` is not a readable pom or the graph is +/// too big to walk (the crawl then stays unscoped). +pub(crate) fn project_scope(cwd: &Path, repo: &Path) -> Option { + let mut walk = Walk { + cwd, + repo, + models: HashMap::new(), + scope: ProjectScope::default(), + queue: VecDeque::new(), + queued: HashSet::new(), + reactor_chains: Vec::new(), + reactor_managed: HashMap::new(), + }; + walk.reactor()?; + walk.run()?; + Some(walk.scope) +} + +/// A pom and where it was read from. +#[derive(Clone)] +struct Node { + model: Rc, + /// The pom's file (for relative parent paths of reactor poms). + path: PathBuf, + /// Read from the checkout (a reactor pom or a local parent). + local: bool, +} + +/// A pom and its parents, nearest first. +type Chain = Vec; + +struct Walk<'w> { + cwd: &'w Path, + repo: &'w Path, + /// Parsed poms by path (`None`: missing or unreadable). + models: HashMap>>, + scope: ProjectScope, + queue: VecDeque, + queued: HashSet, + /// The reactor poms' chains: their management applies to transitives. + reactor_chains: Vec, + /// [`Walk::managed_version`] of each reactor chain, by artifact. + reactor_managed: HashMap<(usize, String, String), Option>>, +} + +impl Walk<'_> { + fn model(&mut self, path: &Path, include_profiles: bool) -> Option> { + if let Some(found) = self.models.get(path) { + return found.clone(); + } + let parsed = crate::utils::fs::read_regular_to_bytes_sync(path) + .ok() + .and_then(|bytes| String::from_utf8(bytes).ok()) + .and_then(|text| pom_model(&text, include_profiles).ok()) + .map(Rc::new); + self.models.insert(path.to_path_buf(), parsed.clone()); + parsed + } + + fn repo_pom(&self, (g, a, v): &Gav) -> Option { + let safe = |s: &str| { + !s.is_empty() + && s != "." + && s != ".." + && !s.contains(['/', '\\', '$', '{', '}']) + && !s.contains("..") + }; + if !(safe(g) && safe(a) && safe(v)) { + return None; + } + Some( + self.repo + .join(g.replace('.', "/")) + .join(a) + .join(v) + .join(format!("{a}-{v}.pom")), + ) + } + + /// `node` and its parents: a reactor pom's local parent by + /// `relativePath` (default `../pom.xml`) when that pom is the declared + /// one, else the parent's pom in the repository. + fn chain(&mut self, node: Node) -> Chain { + let mut chain = vec![node]; + while chain.len() < MAX_DEPTH { + let cur = chain.last().expect("non-empty").clone(); + let Some((pg, pa, pv, rel)) = cur.model.parent.clone() else { + break; + }; + let props = chain.clone(); + let resolve = |v: &Option| v.as_deref().and_then(|v| interpolate(v, &props)); + let (pg, pa, pv) = (resolve(&pg), resolve(&pa), resolve(&pv)); + let local = if cur.local && rel.as_deref() != Some("") { + let rel = rel.unwrap_or_else(|| "../pom.xml".to_string()); + let dir = cur.path.parent().unwrap_or(self.cwd); + let mut path = dir.join(&rel); + if !rel.ends_with(".xml") { + path = path.join("pom.xml"); + } + let inside = normalize(&path).is_some_and(|p| p.starts_with(self.cwd)); + inside + .then(|| self.model(&path, true).map(|m| (m, path))) + .flatten() + .filter(|(m, _)| m.artifact.is_some() && m.artifact == pa) + } else { + None + }; + let next = match local { + Some((model, path)) => Node { + model, + path, + local: true, + }, + None => { + let (Some(pg), Some(pa), Some(pv)) = (pg, pa, pv) else { + break; + }; + let gav = (pg, pa, pv); + self.admit(&gav); + let Some(path) = self.repo_pom(&gav) else { + break; + }; + let Some(model) = self.model(&path, false) else { + break; + }; + Node { + model, + path, + local: false, + } + } + }; + chain.push(next); + } + chain + } + + /// Read the reactor and queue every declaration it makes. + fn reactor(&mut self) -> Option<()> { + let root = self.cwd.join("pom.xml"); + let model = self.model(&root, true)?; + let mut pending = vec![Node { + model, + path: root.clone(), + local: true, + }]; + let mut seen: HashSet = HashSet::from([root]); + while let Some(node) = pending.pop() { + if seen.len() > MAX_REACTOR { + return None; + } + let dir = node.path.parent().unwrap_or(self.cwd).to_path_buf(); + for module in node.model.modules.clone() { + if module.is_empty() || module.contains("${") { + continue; + } + let mut path = dir.join(&module); + if !module.ends_with(".xml") { + path = path.join("pom.xml"); + } + if !normalize(&path).is_some_and(|p| p.starts_with(self.cwd)) + || !seen.insert(path.clone()) + { + continue; + } + if let Some(model) = self.model(&path, true) { + pending.push(Node { + model, + path, + local: true, + }); + } + } + let chain = self.chain(node); + self.reactor_chains.push(chain); + } + let chains = std::mem::take(&mut self.reactor_chains); + for chain in &chains { + for decl in chain.iter().flat_map(|n| n.model.deps.clone()) { + self.declare(&decl, chain, true); + } + // Imported BOMs and parents are part of the build too. + for decl in chain.iter().flat_map(|n| n.model.managed.clone()) { + if is_import(&decl) { + if let Some(gav) = decl_gav(&decl, chain) { + self.admit(&gav); + } + } + } + } + self.reactor_chains = chains; + Some(()) + } + + /// Queue `decl` read in `chain`'s context. A transitive edge also takes + /// any version the reactor's management assigns its artifact. + fn declare(&mut self, decl: &PomDecl, chain: &Chain, direct: bool) { + if !direct { + let reactor = std::mem::take(&mut self.reactor_chains); + for (i, rc) in reactor.iter().enumerate() { + let key = (i, decl.group.clone(), decl.artifact.clone()); + let managed = match self.reactor_managed.get(&key) { + Some(found) => found.clone(), + None => { + let found = self.managed_version(rc, &decl.group, &decl.artifact, 0); + self.reactor_managed.insert(key, found.clone()); + found + } + }; + if let Some(version) = managed { + self.enqueue(&decl.group, &decl.artifact, version); + } + } + self.reactor_chains = reactor; + } + let version = match &decl.version { + Some(raw) => Some(interpolate(raw, chain)), + None => self + .managed_version(chain, &decl.group, &decl.artifact, 0) + .or(Some(None)), + }; + self.enqueue(&decl.group, &decl.artifact, version.flatten()); + } + + /// The version `chain` manages `g:a` at: its parent chain's management, + /// then its imported BOMs'. `None`: not managed; `Some(None)`: managed + /// at a version this cannot determine. + fn managed_version( + &mut self, + chain: &Chain, + g: &str, + a: &str, + depth: usize, + ) -> Option> { + let is_ga = |d: &PomDecl| d.group == g && d.artifact == a && !is_import(d); + for node in chain { + if let Some(decl) = node.model.managed.iter().find(|d| is_ga(d)) { + return Some(decl.version.as_deref().and_then(|v| interpolate(v, chain))); + } + } + if depth >= MAX_DEPTH { + return Some(None); + } + let imports: Vec = chain + .iter() + .flat_map(|n| n.model.managed.iter().filter(|d| is_import(d)).cloned()) + .collect(); + for decl in imports { + let Some(gav) = decl_gav(&decl, chain) else { + continue; + }; + let Some(path) = self.repo_pom(&gav) else { + continue; + }; + let Some(model) = self.model(&path, false) else { + continue; + }; + let bom = self.chain(Node { + model, + path, + local: false, + }); + if let Some(found) = self.managed_version(&bom, g, a, depth + 1) { + return Some(found); + } + } + None + } + + /// Queue `g:a` at `version` (every cached version when unknown). + fn enqueue(&mut self, g: &str, a: &str, version: Option) { + match version.filter(|v| !is_range(v)) { + Some(v) => { + let gav = (g.to_string(), a.to_string(), v); + if self.queued.insert(gav.clone()) { + self.queue.push_back(gav); + } + } + None => { + if !self + .scope + .any_version + .insert((g.to_string(), a.to_string())) + { + return; + } + let dir = self.repo.join(g.replace('.', "/")).join(a); + let Ok(entries) = std::fs::read_dir(&dir) else { + return; + }; + for entry in entries.flatten() { + if !entry.file_type().is_ok_and(|t| t.is_dir()) { + continue; + } + let v = entry.file_name().to_string_lossy().into_owned(); + let gav = (g.to_string(), a.to_string(), v); + if self.queued.insert(gav.clone()) { + self.queue.push_back(gav); + } + } + } + } + } + + fn admit(&mut self, gav: &Gav) { + self.scope.gavs.insert(gav.clone()); + } + + /// Walk the queued artifacts' own dependencies. + fn run(&mut self) -> Option<()> { + while let Some(gav) = self.queue.pop_front() { + if self.scope.gavs.len() > MAX_NODES { + return None; + } + self.admit(&gav); + let Some(path) = self.repo_pom(&gav) else { + continue; + }; + let Some(model) = self.model(&path, false) else { + continue; + }; + let chain = self.chain(Node { + model, + path, + local: false, + }); + let deps: Vec = chain.iter().flat_map(|n| n.model.deps.clone()).collect(); + for decl in deps { + let transitive = + matches!(decl.scope.as_deref(), None | Some("compile" | "runtime")); + if transitive && !decl.optional { + self.declare(&decl, &chain, false); + } + } + } + Some(()) + } +} + +fn is_import(decl: &PomDecl) -> bool { + decl.scope.as_deref() == Some("import") && decl.kind.as_deref() == Some("pom") +} + +fn decl_gav(decl: &PomDecl, chain: &Chain) -> Option { + Some(( + interpolate(&decl.group, chain)?, + interpolate(&decl.artifact, chain)?, + interpolate(decl.version.as_deref()?, chain)?, + )) +} + +fn is_range(version: &str) -> bool { + version.starts_with(['[', '(']) || version.contains(',') +} + +/// `${name}` interpolation in `chain`'s context: the model's own version +/// expressions, then `` up the chain (nearest first). +fn interpolate(value: &str, chain: &Chain) -> Option { + interpolate_at(value, chain, 0) +} + +fn interpolate_at(value: &str, chain: &Chain, depth: usize) -> Option { + if !value.contains("${") { + return Some(value.to_string()); + } + if depth > MAX_INTERPOLATION { + return None; + } + let own = &chain.first()?.model; + let parent_field = |i: usize| -> Option { + let (g, a, v, _) = own.parent.as_ref()?; + [g, a, v][i].clone() + }; + let lookup = |name: &str| -> Option { + match name { + "project.version" | "pom.version" | "version" => { + own.version.clone().or_else(|| parent_field(2)) + } + "project.groupId" | "pom.groupId" | "groupId" => { + own.group.clone().or_else(|| parent_field(0)) + } + "project.artifactId" | "pom.artifactId" | "artifactId" => own.artifact.clone(), + "project.parent.version" | "parent.version" => parent_field(2), + "project.parent.groupId" | "parent.groupId" => parent_field(0), + _ => chain.iter().find_map(|n| n.model.props.get(name).cloned()), + } + }; + let mut out = String::new(); + let mut rest = value; + while let Some(at) = rest.find("${") { + out.push_str(&rest[..at]); + let after = &rest[at + 2..]; + let close = after.find('}')?; + let raw = lookup(&after[..close])?; + out.push_str(&interpolate_at(&raw, chain, depth + 1)?); + rest = &after[close + 1..]; + } + out.push_str(rest); + Some(out) +} + +/// `path` with `.` / `..` components folded (no filesystem access); +/// `None` when it climbs above its root. +fn normalize(path: &Path) -> Option { + use std::path::Component; + let mut out = PathBuf::new(); + for c in path.components() { + match c { + Component::CurDir => {} + Component::ParentDir => { + if !out.pop() { + return None; + } + } + other => out.push(other.as_os_str()), + } + } + Some(out) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn write(root: &Path, rel: &str, body: &str) { + let p = root.join(rel); + std::fs::create_dir_all(p.parent().unwrap()).unwrap(); + std::fs::write(p, body).unwrap(); + } + + fn dep(g: &str, a: &str, v: Option<&str>, extra: &str) -> String { + let v = v.map_or(String::new(), |v| format!("{v}")); + format!( + "{g}{a}{v}{extra}" + ) + } + + fn pom(g: &str, a: &str, v: &str, body: &str) -> String { + format!( + "4.0.0{g}\ + {a}{v}{body}" + ) + } + + /// Cache `g:a:v` in `repo` with `body` as its pom's extra content. + fn cache(repo: &Path, g: &str, a: &str, v: &str, body: &str) { + write( + repo, + &format!("{}/{a}/{v}/{a}-{v}.pom", g.replace('.', "/")), + &pom(g, a, v, body), + ); + } + + fn deps(list: &[String]) -> String { + format!("{}", list.concat()) + } + + #[test] + fn the_scope_is_the_declared_graph_not_the_cache() { + let dir = tempfile::tempdir().unwrap(); + let (cwd, repo) = (dir.path().join("p"), dir.path().join("m2")); + write( + &cwd, + "pom.xml", + &pom( + "com.example", + "app", + "1", + &deps(&[ + dep("junit", "junit", Some("4.13.2"), "test"), + dep("org.apache.commons", "commons-text", Some("${ct}"), ""), + ]) + .replace( + "", + "1.10.0", + ), + ), + ); + cache( + &repo, + "junit", + "junit", + "4.13.2", + &deps(&[dep("org.hamcrest", "hamcrest-core", Some("1.3"), "")]), + ); + cache(&repo, "org.hamcrest", "hamcrest-core", "1.3", ""); + cache( + &repo, + "org.apache.commons", + "commons-text", + "1.10.0", + &deps(&[ + dep("org.apache.commons", "commons-lang3", Some("3.12.0"), ""), + dep("org.example", "test-only", Some("1"), "test"), + dep( + "org.example", + "optional", + Some("1"), + "true", + ), + ]), + ); + cache(&repo, "org.apache.commons", "commons-lang3", "3.12.0", ""); + // Cached by some other project. + cache(&repo, "org.apache.commons", "commons-lang3", "3.11", ""); + cache(&repo, "com.unrelated", "lib", "2.0", ""); + let scope = project_scope(&cwd, &repo).unwrap(); + for (g, a, v) in [ + ("junit", "junit", "4.13.2"), + ("org.hamcrest", "hamcrest-core", "1.3"), + ("org.apache.commons", "commons-text", "1.10.0"), + ("org.apache.commons", "commons-lang3", "3.12.0"), + ] { + assert!(scope.admits(g, a, v), "{g}:{a}:{v}"); + } + for (g, a, v) in [ + ("org.apache.commons", "commons-lang3", "3.11"), + ("com.unrelated", "lib", "2.0"), + ("org.example", "test-only", "1"), + ("org.example", "optional", "1"), + ] { + assert!(!scope.admits(g, a, v), "{g}:{a}:{v}"); + } + } + + #[test] + fn modules_parents_boms_and_management_are_followed() { + let dir = tempfile::tempdir().unwrap(); + let (cwd, repo) = (dir.path().join("p"), dir.path().join("m2")); + // Root: an external parent, a BOM import and reactor management of + // a transitive. + write( + &cwd, + "pom.xml", + &format!( + "4.0.0com.corp\ + corp-parent3\ + rootpom\ + a\ + {}{}\ + ", + dep( + "com.corp", + "corp-bom", + Some("${project.version}"), + "pomimport" + ), + dep("org.example", "managed-transitive", Some("9"), ""), + ), + ); + write( + &cwd, + "a/pom.xml", + &format!( + "4.0.0com.corp\ + root3\ + a{}", + deps(&[ + dep("org.example", "from-bom", None, ""), + dep("org.example", "from-parent", None, ""), + ]) + ), + ); + cache( + &repo, + "com.corp", + "corp-parent", + "3", + &format!( + "{}", + dep("org.example", "from-parent", Some("2"), "") + ), + ); + cache( + &repo, + "com.corp", + "corp-bom", + "3", + &format!( + "{}", + dep("org.example", "from-bom", Some("1"), "") + ), + ); + cache( + &repo, + "org.example", + "from-bom", + "1", + &deps(&[dep("org.example", "managed-transitive", Some("8"), "")]), + ); + cache(&repo, "org.example", "from-parent", "2", ""); + cache(&repo, "org.example", "from-parent", "1", ""); + cache(&repo, "org.example", "managed-transitive", "9", ""); + let scope = project_scope(&cwd, &repo).unwrap(); + for (g, a, v) in [ + ("com.corp", "corp-parent", "3"), + ("com.corp", "corp-bom", "3"), + ("org.example", "from-bom", "1"), + ("org.example", "from-parent", "2"), + ("org.example", "managed-transitive", "9"), + ] { + assert!(scope.admits(g, a, v), "{g}:{a}:{v}"); + } + assert!(!scope.admits("org.example", "from-parent", "1")); + } + + #[test] + fn an_undeterminable_version_admits_every_cached_version_of_that_artifact() { + let dir = tempfile::tempdir().unwrap(); + let (cwd, repo) = (dir.path().join("p"), dir.path().join("m2")); + write( + &cwd, + "pom.xml", + &pom( + "com.example", + "app", + "1", + &deps(&[ + dep("org.example", "undefined", Some("${nowhere}"), ""), + dep("org.example", "ranged", Some("[1,2)"), ""), + ]), + ), + ); + for (a, v) in [("undefined", "1"), ("undefined", "2"), ("ranged", "1.5")] { + cache(&repo, "org.example", a, v, ""); + } + cache(&repo, "org.example", "other", "1", ""); + let scope = project_scope(&cwd, &repo).unwrap(); + assert!(scope.admits("org.example", "undefined", "1")); + assert!(scope.admits("org.example", "undefined", "2")); + assert!(scope.admits("org.example", "ranged", "1.5")); + assert!(!scope.admits("org.example", "other", "1")); + } + + #[test] + fn no_readable_root_pom_is_unscoped() { + let dir = tempfile::tempdir().unwrap(); + assert!(project_scope(dir.path(), dir.path()).is_none()); + write(dir.path(), "pom.xml", ""); + assert!(project_scope(dir.path(), dir.path()).is_none()); + } +} diff --git a/crates/socket-patch-core/src/crawlers/mod.rs b/crates/socket-patch-core/src/crawlers/mod.rs index 2c92ec45c..d221351f6 100644 --- a/crates/socket-patch-core/src/crawlers/mod.rs +++ b/crates/socket-patch-core/src/crawlers/mod.rs @@ -10,6 +10,7 @@ pub mod ivy_cache; pub mod jvm_cache; mod listing; pub mod maven_crawler; +pub(crate) mod maven_scope; #[cfg(test)] mod maven_pom_equivalence_tests; pub mod npm_crawler; diff --git a/crates/socket-patch-core/src/vendor/jvm/maven_reactor.rs b/crates/socket-patch-core/src/vendor/jvm/maven_reactor.rs index e1e2a0e2d..3c684f523 100644 --- a/crates/socket-patch-core/src/vendor/jvm/maven_reactor.rs +++ b/crates/socket-patch-core/src/vendor/jvm/maven_reactor.rs @@ -609,6 +609,103 @@ pub fn contains_module(read: ReadFn<'_>, rel: &str) -> bool { pub(crate) type Gav = (String, String, String); +/// One `` of a [`PomModel`], as written (no interpolation). +#[derive(Debug, Clone)] +pub(crate) struct PomDecl { + pub group: String, + pub artifact: String, + pub version: Option, + pub scope: Option, + pub optional: bool, + pub kind: Option, +} + +/// The parts of a pom a dependency-graph walk reads: coordinates, parent, +/// ``, ``, `` and modules +/// of the project (and of its profiles when asked). Plugin dependencies and +/// exclusions are never declarations. +#[derive(Debug, Clone)] +pub(crate) struct PomModel { + pub group: Option, + pub artifact: Option, + pub version: Option, + /// `(groupId, artifactId, version, relativePath)` of ``. + pub parent: Option<( + Option, + Option, + Option, + Option, + )>, + pub props: BTreeMap, + pub deps: Vec, + pub managed: Vec, + pub modules: Vec, +} + +/// [`PomModel`] of `text`; `include_profiles` adds every profile's +/// dependencies, management and modules (an over-approximation of the +/// active ones). +pub(crate) fn pom_model(text: &str, include_profiles: bool) -> Result { + let doc = Doc::parse(text.to_string())?; + let project = doc.project; + let mut roots = vec![project]; + if include_profiles { + if let Some(profiles) = doc.child(project, "profiles") { + roots.extend(doc.children(profiles, "profile")); + } + } + let decl = |dep: usize| -> Option { + Some(PomDecl { + group: doc.child_text(dep, "groupId")?, + artifact: doc.child_text(dep, "artifactId")?, + version: doc.child_text(dep, "version").filter(|v| !v.is_empty()), + scope: doc.child_text(dep, "scope").filter(|v| !v.is_empty()), + optional: doc.child_text(dep, "optional").as_deref() == Some("true"), + kind: doc.child_text(dep, "type").filter(|v| !v.is_empty()), + }) + }; + let (mut deps, mut managed, mut modules) = (Vec::new(), Vec::new(), Vec::new()); + for &root in &roots { + if let Some(list) = doc.child(root, "dependencies") { + deps.extend(doc.children(list, "dependency").filter_map(decl)); + } + if let Some(list) = doc + .child(root, "dependencyManagement") + .and_then(|dm| doc.child(dm, "dependencies")) + { + managed.extend(doc.children(list, "dependency").filter_map(decl)); + } + for (list, item) in [("modules", "module"), ("subprojects", "subproject")] { + if let Some(list) = doc.child(root, list) { + modules.extend(doc.children(list, item).map(|m| doc.text_of(m))); + } + } + } + let mut props = BTreeMap::new(); + if let Some(p) = doc.child(project, "properties") { + for &c in &doc.nodes[p].children { + props.insert(doc.nodes[c].name.clone(), doc.text_of(c)); + } + } + Ok(PomModel { + group: doc.child_text(project, "groupId"), + artifact: doc.child_text(project, "artifactId"), + version: doc.child_text(project, "version"), + parent: doc.child(project, "parent").map(|p| { + ( + doc.child_text(p, "groupId"), + doc.child_text(p, "artifactId"), + doc.child_text(p, "version"), + doc.child(p, "relativePath").map(|r| doc.text_of(r)), + ) + }), + props, + deps, + managed, + modules, + }) +} + /// Metadata needed to verify upstream parents and imported BOMs in Gradle. pub(crate) struct MetadataModel { pub parent: Option, From b4d1e81171b940f623f42553d1ed598fc07f75a6 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Fri, 9 Oct 2026 14:55:50 -0400 Subject: [PATCH 2/5] Declare the crawled artifacts in Maven scan tests A project-mode Maven crawl now keeps only what the project's poms reach, so the scan tests' poms declare the artifacts they expect to be discovered. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/socket-patch-cli/CLI_CONTRACT.md | 4 +++- crates/socket-patch-cli/tests/e2e_maven.rs | 9 +++++++-- crates/socket-patch-cli/tests/in_process_scan.rs | 13 ++++++++++--- .../socket-patch-core/src/crawlers/maven_scope.rs | 12 ++++++++++-- docs/ecosystems.md | 14 ++++++++++++++ 5 files changed, 44 insertions(+), 8 deletions(-) diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index bb4592f4b..ed4ce723c 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -503,7 +503,9 @@ own coordinates spell their path); then every **Ivy** cache (`-Dsbt.ivy.home= `/cache`, then `~/.ivy2/cache`, whose package directory is the module's `jars/` / `bundles/` / `orbits/`). Every existing location is kept (an empty value counts as unset; a relative one resolves against the project); an Ivy home whose path does not end in `<…ivy…>/cache` is skipped. The crawl is **not scoped** to -the build: as for `~/.m2`, every cached GAV is queried and patched. `--global-prefix` names exactly one +the build: every cached GAV is queried and patched (a pure Maven build's `~/.m2` crawl is +scoped to the coordinates its poms reach; see `docs/ecosystems.md`, "A Maven project's scan is +scoped to its dependency graph"). `--global-prefix` names exactly one root, its layout read from its path. Patch keys are whole files in the package directory (`-.jar`), the same parity as `~/.m2`. A GAV cached in several roots (say `~/.m2`, a Coursier cache and an Ivy cache) is patched and restored in **every** root, one summary event per copy, since the build loads whichever its diff --git a/crates/socket-patch-cli/tests/e2e_maven.rs b/crates/socket-patch-cli/tests/e2e_maven.rs index 9cffa68ea..5878e539b 100644 --- a/crates/socket-patch-cli/tests/e2e_maven.rs +++ b/crates/socket-patch-cli/tests/e2e_maven.rs @@ -152,12 +152,17 @@ async fn scan_discovers_maven_artifacts() { ) .unwrap(); - // Create a pom.xml in the project directory so local mode activates + // A pom.xml in the project directory activates local mode; it declares + // both artifacts, since a project-mode crawl keeps only what the + // project's poms reach (#265). let project_dir = dir.path().join("project"); std::fs::create_dir_all(&project_dir).unwrap(); std::fs::write( project_dir.join("pom.xml"), - r#"4.0.0"#, + r#"4.0.0 + org.apache.commonscommons-lang33.12.0 + com.google.guavaguava32.1.2-jre +"#, ) .unwrap(); diff --git a/crates/socket-patch-cli/tests/in_process_scan.rs b/crates/socket-patch-cli/tests/in_process_scan.rs index 2089818f3..4326efe67 100644 --- a/crates/socket-patch-cli/tests/in_process_scan.rs +++ b/crates/socket-patch-cli/tests/in_process_scan.rs @@ -1429,9 +1429,16 @@ async fn scan_discovers_maven_and_nuget_in_every_mode() { let tmp = tempfile::tempdir().unwrap(); write_root_package_json(tmp.path()); - // Maven: java-project marker + a local repository the crawler reaches - // via MAVEN_REPO_LOCAL (verified by the version dir's `.pom`). - std::fs::write(tmp.path().join("pom.xml"), "\n").unwrap(); + // Maven: a pom declaring the artifact (a project-mode crawl keeps only + // what the project's poms reach, #265) + a local repository the crawler + // reaches via MAVEN_REPO_LOCAL (verified by the version dir's `.pom`). + std::fs::write( + tmp.path().join("pom.xml"), + "org.example\ + foo1.0.0\ + \n", + ) + .unwrap(); let artifact_dir = tmp.path().join("m2repo/org/example/foo/1.0.0"); std::fs::create_dir_all(&artifact_dir).unwrap(); std::fs::write(artifact_dir.join("foo-1.0.0.pom"), "").unwrap(); diff --git a/crates/socket-patch-core/src/crawlers/maven_scope.rs b/crates/socket-patch-core/src/crawlers/maven_scope.rs index 268b28254..378756aab 100644 --- a/crates/socket-patch-core/src/crawlers/maven_scope.rs +++ b/crates/socket-patch-core/src/crawlers/maven_scope.rs @@ -119,6 +119,14 @@ impl Walk<'_> { parsed } + /// Whether `path` stays inside the checkout (lexically). + fn inside(&self, path: &Path) -> bool { + match (normalize(path), normalize(self.cwd)) { + (Some(p), Some(root)) => p.starts_with(root), + _ => false, + } + } + fn repo_pom(&self, (g, a, v): &Gav) -> Option { let safe = |s: &str| { !s.is_empty() @@ -159,7 +167,7 @@ impl Walk<'_> { if !rel.ends_with(".xml") { path = path.join("pom.xml"); } - let inside = normalize(&path).is_some_and(|p| p.starts_with(self.cwd)); + let inside = self.inside(&path); inside .then(|| self.model(&path, true).map(|m| (m, path))) .flatten() @@ -220,7 +228,7 @@ impl Walk<'_> { if !module.ends_with(".xml") { path = path.join("pom.xml"); } - if !normalize(&path).is_some_and(|p| p.starts_with(self.cwd)) + if !self.inside(&path) || !seen.insert(path.clone()) { continue; diff --git a/docs/ecosystems.md b/docs/ecosystems.md index 759d7d182..f6e25db23 100644 --- a/docs/ecosystems.md +++ b/docs/ecosystems.md @@ -582,6 +582,20 @@ Honest limits of the Maven and NuGet flows — documented behavior, not bugs: `originAware=false` and `failIfMissing=false`, so one checksum matches the artifact from any repository and a dependency with no committed checksum still resolves — only a *mismatch* fails. +* **A Maven project's scan is scoped to its dependency graph.** The Maven local + repository is shared by every project on the machine, so in project mode (no + `--global` / `--global-prefix`) a Maven build's crawl (a `pom.xml`, no Gradle or + sbt / Mill / scala-cli build beside it) keeps only the coordinates its poms reach: + every reactor declaration (modules and profiles included, any scope), then each + artifact's own non-optional `compile` / `runtime` dependencies, read from the poms the + repository already holds, with versions from properties, parents, management and + imported BOMs (the reactor's management applies to transitives, as in Maven). A + version that cannot be determined (an undefined property, a range, a pom not in the + repository) admits every cached version of that artifact. Artifacts another project + cached are not scanned, so hosted mode never pins them and `vex` never attests them. + A project that has never been resolved therefore finds nothing; resolve it once + (`mvn -q dependency:resolve`) and scan again. An unreadable root `pom.xml` leaves the + crawl unscoped. * **Local-repository discovery reads coordinates from the path.** `scan` (and every other crawl of `~/.m2/repository`) takes a POM's groupId / artifactId / version from its directory when the file sits at the canonical From f6b8b9459d0414b5b728548ef473dc00f745ea12 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Fri, 9 Oct 2026 15:25:54 -0400 Subject: [PATCH 3/5] Keep scope reads off the scan's critical path The scope walk opens one pom per reachable artifact while the scan only walks directories, so the two now run side by side on the walk pool. A hosted `-socket.` pin keeps its base release in scope, so a rescan of an already-redirected project still sees the packages it pinned. The maven bench fixture declares every cached artifact its direct dependencies do not reach, so its whole cache stays the project's under the scoped crawl. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../socket-patch-bench/src/fixtures/other.rs | 15 ++- .../src/crawlers/maven_crawler.rs | 23 ++++- .../src/crawlers/maven_scope.rs | 92 ++++++++++++++++--- crates/socket-patch-core/src/crawlers/mod.rs | 2 +- .../src/vendor/jvm/maven_reactor.rs | 15 +-- 5 files changed, 121 insertions(+), 26 deletions(-) diff --git a/crates/socket-patch-bench/src/fixtures/other.rs b/crates/socket-patch-bench/src/fixtures/other.rs index 3d5a526ac..693757ce8 100644 --- a/crates/socket-patch-bench/src/fixtures/other.rs +++ b/crates/socket-patch-bench/src/fixtures/other.rs @@ -786,7 +786,20 @@ fn jvm_pom(arts: &[Pkg], p: &Pkg) -> String { pub fn build_maven(t: &mut Tree, size: Size) -> std::io::Result { let arts = jvm_universe("maven", size); let mut pom = String::from("\n\n 4.0.0\n dev.socket.bench\n bench-app\n 1.0.0\n jar\n\n \n 17\n \n\n \n"); - for p in arts.iter().filter(|p| p.direct) { + // A project-mode crawl keeps only what the project's poms reach (#265), + // so every cached artifact the direct dependencies do not reach is + // declared directly too: the fixture's whole cache is the project's. + let mut reached = vec![false; arts.len()]; + let mut queue: Vec = (0..arts.len()).filter(|&i| arts[i].direct).collect(); + while let Some(i) = queue.pop() { + if !std::mem::replace(&mut reached[i], true) { + queue.extend(arts[i].deps.iter().copied()); + } + } + for (i, p) in arts.iter().enumerate() { + if !p.direct && reached[i] { + continue; + } let (g, a) = ga(p); let _ = write!(pom, " \n {g}\n {a}\n {}\n \n", p.version); } diff --git a/crates/socket-patch-core/src/crawlers/maven_crawler.rs b/crates/socket-patch-core/src/crawlers/maven_crawler.rs index 8e9331d55..d4724cae7 100644 --- a/crates/socket-patch-core/src/crawlers/maven_crawler.rs +++ b/crates/socket-patch-core/src/crawlers/maven_crawler.rs @@ -958,9 +958,26 @@ impl MavenCrawler { // on the walk pool so concurrently crawled ecosystems keep // making progress (the dedup set rides along and comes back). let (found, returned_seen) = run_walk(move || { - let scope = - scope_cwd.and_then(|cwd| super::maven_scope::project_scope(&cwd, &root.path)); - let mut found = MavenCrawler.scan_cache_root(&root, &mut seen); + // The scope reads a pom per reachable artifact while the scan + // only walks directories: run them side by side. + let shared = std::sync::Mutex::new(seen); + let mut halves = par_map(vec![false, true], |scope_half| { + if scope_half { + let scope = scope_cwd + .as_ref() + .and_then(|cwd| super::maven_scope::project_scope(cwd, &root.path)); + (None, scope) + } else { + let mut seen = shared.lock().unwrap_or_else(|e| e.into_inner()); + (Some(MavenCrawler.scan_cache_root(&root, &mut seen)), None) + } + }); + let scope = halves.pop().and_then(|(_, scope)| scope); + let mut found = halves + .pop() + .and_then(|(found, _)| found) + .unwrap_or_default(); + let seen = shared.into_inner().unwrap_or_else(|e| e.into_inner()); if let Some(scope) = scope { found.retain(|p| { scope.admits( diff --git a/crates/socket-patch-core/src/crawlers/maven_scope.rs b/crates/socket-patch-core/src/crawlers/maven_scope.rs index 378756aab..c4ee05d03 100644 --- a/crates/socket-patch-core/src/crawlers/maven_scope.rs +++ b/crates/socket-patch-core/src/crawlers/maven_scope.rs @@ -28,7 +28,7 @@ use std::collections::{HashMap, HashSet, VecDeque}; use std::path::{Path, PathBuf}; -use std::rc::Rc; +use std::sync::Arc; use crate::vendor::jvm::maven_reactor::{pom_model, PomDecl, PomModel}; @@ -81,7 +81,7 @@ pub(crate) fn project_scope(cwd: &Path, repo: &Path) -> Option { /// A pom and where it was read from. #[derive(Clone)] struct Node { - model: Rc, + model: Arc, /// The pom's file (for relative parent paths of reactor poms). path: PathBuf, /// Read from the checkout (a reactor pom or a local parent). @@ -95,7 +95,7 @@ struct Walk<'w> { cwd: &'w Path, repo: &'w Path, /// Parsed poms by path (`None`: missing or unreadable). - models: HashMap>>, + models: HashMap>>, scope: ProjectScope, queue: VecDeque, queued: HashSet, @@ -106,19 +106,29 @@ struct Walk<'w> { } impl Walk<'_> { - fn model(&mut self, path: &Path, include_profiles: bool) -> Option> { + fn model(&mut self, path: &Path, include_profiles: bool) -> Option> { if let Some(found) = self.models.get(path) { return found.clone(); } - let parsed = crate::utils::fs::read_regular_to_bytes_sync(path) - .ok() - .and_then(|bytes| String::from_utf8(bytes).ok()) - .and_then(|text| pom_model(&text, include_profiles).ok()) - .map(Rc::new); + let parsed = read_model(path, include_profiles); self.models.insert(path.to_path_buf(), parsed.clone()); parsed } + /// Read and parse the repository poms of `gavs` not read yet. + fn prefetch(&mut self, gavs: &[Gav]) { + let paths: Vec = gavs + .iter() + .filter_map(|gav| self.repo_pom(gav)) + .filter(|path| !self.models.contains_key(path)) + .collect(); + let parsed = paths.into_iter().map(|path| { + let model = read_model(&path, false); + (path, model) + }); + self.models.extend(parsed); + } + /// Whether `path` stays inside the checkout (lexically). fn inside(&self, path: &Path) -> bool { match (normalize(path), normalize(self.cwd)) { @@ -228,9 +238,7 @@ impl Walk<'_> { if !module.ends_with(".xml") { path = path.join("pom.xml"); } - if !self.inside(&path) - || !seen.insert(path.clone()) - { + if !self.inside(&path) || !seen.insert(path.clone()) { continue; } if let Some(model) = self.model(&path, true) { @@ -341,6 +349,12 @@ impl Walk<'_> { fn enqueue(&mut self, g: &str, a: &str, version: Option) { match version.filter(|v| !is_range(v)) { Some(v) => { + // A hosted pin `-socket.` (written by an earlier + // scan) resolves the patched base release: keep the base in + // scope, so a rescan still sees the package it pinned. + if let Some((base, _)) = crate::formats::maven::split_socket_version(&v) { + self.enqueue(g, a, Some(base.to_string())); + } let gav = (g.to_string(), a.to_string(), v); if self.queued.insert(gav.clone()) { self.queue.push_back(gav); @@ -378,16 +392,28 @@ impl Walk<'_> { /// Walk the queued artifacts' own dependencies. fn run(&mut self) -> Option<()> { - while let Some(gav) = self.queue.pop_front() { + while !self.queue.is_empty() { + let wave: Vec = self.queue.drain(..).collect(); + self.prefetch(&wave); + for gav in wave { + self.visit(gav)?; + } + } + Some(()) + } + + /// Admit `gav` and queue its own transitive dependencies. + fn visit(&mut self, gav: Gav) -> Option<()> { + { if self.scope.gavs.len() > MAX_NODES { return None; } self.admit(&gav); let Some(path) = self.repo_pom(&gav) else { - continue; + return Some(()); }; let Some(model) = self.model(&path, false) else { - continue; + return Some(()); }; let chain = self.chain(Node { model, @@ -407,6 +433,15 @@ impl Walk<'_> { } } +/// The pom at `path` parsed, when it is readable. +fn read_model(path: &Path, include_profiles: bool) -> Option> { + crate::utils::fs::read_regular_to_bytes_sync(path) + .ok() + .and_then(|bytes| String::from_utf8(bytes).ok()) + .and_then(|text| pom_model(&text, include_profiles).ok()) + .map(Arc::new) +} + fn is_import(decl: &PomDecl) -> bool { decl.scope.as_deref() == Some("import") && decl.kind.as_deref() == Some("pom") } @@ -702,6 +737,33 @@ mod tests { assert!(!scope.admits("org.example", "other", "1")); } + #[test] + fn a_hosted_pin_keeps_its_base_release_in_scope() { + let dir = tempfile::tempdir().unwrap(); + let (cwd, repo) = (dir.path().join("p"), dir.path().join("m2")); + write( + &cwd, + "pom.xml", + &pom( + "com.example", + "app", + "1", + &deps(&[dep("org.example", "lib", Some("1.0-socket.4d5e6f70"), "")]), + ), + ); + cache( + &repo, + "org.example", + "lib", + "1.0", + &deps(&[dep("org.example", "transitive", Some("2"), "")]), + ); + cache(&repo, "org.example", "transitive", "2", ""); + let scope = project_scope(&cwd, &repo).unwrap(); + assert!(scope.admits("org.example", "lib", "1.0")); + assert!(scope.admits("org.example", "transitive", "2")); + } + #[test] fn no_readable_root_pom_is_unscoped() { let dir = tempfile::tempdir().unwrap(); diff --git a/crates/socket-patch-core/src/crawlers/mod.rs b/crates/socket-patch-core/src/crawlers/mod.rs index d221351f6..9b2767760 100644 --- a/crates/socket-patch-core/src/crawlers/mod.rs +++ b/crates/socket-patch-core/src/crawlers/mod.rs @@ -10,9 +10,9 @@ pub mod ivy_cache; pub mod jvm_cache; mod listing; pub mod maven_crawler; -pub(crate) mod maven_scope; #[cfg(test)] mod maven_pom_equivalence_tests; +pub(crate) mod maven_scope; pub mod npm_crawler; pub mod nuget_crawler; #[cfg(test)] diff --git a/crates/socket-patch-core/src/vendor/jvm/maven_reactor.rs b/crates/socket-patch-core/src/vendor/jvm/maven_reactor.rs index 3c684f523..c706c2edc 100644 --- a/crates/socket-patch-core/src/vendor/jvm/maven_reactor.rs +++ b/crates/socket-patch-core/src/vendor/jvm/maven_reactor.rs @@ -609,6 +609,14 @@ pub fn contains_module(read: ReadFn<'_>, rel: &str) -> bool { pub(crate) type Gav = (String, String, String); +/// `(groupId, artifactId, version, relativePath)` of a pom's ``. +pub(crate) type PomParent = ( + Option, + Option, + Option, + Option, +); + /// One `` of a [`PomModel`], as written (no interpolation). #[derive(Debug, Clone)] pub(crate) struct PomDecl { @@ -630,12 +638,7 @@ pub(crate) struct PomModel { pub artifact: Option, pub version: Option, /// `(groupId, artifactId, version, relativePath)` of ``. - pub parent: Option<( - Option, - Option, - Option, - Option, - )>, + pub parent: Option, pub props: BTreeMap, pub deps: Vec, pub managed: Vec, From 19a470b1c07f2e8e688ed8da28681151d61e9a57 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 20:42:59 +0000 Subject: [PATCH 4/5] Interpolate Maven dependency coordinates in the crawl scope declare interpolated a dependency's version but enqueued its raw groupId and artifactId, so coordinates like ${project.groupId} stayed literal, never resolved to a repository pom and never matched the path-derived crawl names. Those artifacts and their transitives fell out of the project-mode scan. Interpolate all three, as decl_gav already does for BOM imports, and match managed entries the same way. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01KoEk4Y9wedBsjtt9kTUPVY --- .../src/crawlers/maven_scope.rs | 56 +++++++++++++++++-- 1 file changed, 50 insertions(+), 6 deletions(-) diff --git a/crates/socket-patch-core/src/crawlers/maven_scope.rs b/crates/socket-patch-core/src/crawlers/maven_scope.rs index c4ee05d03..6524e5f37 100644 --- a/crates/socket-patch-core/src/crawlers/maven_scope.rs +++ b/crates/socket-patch-core/src/crawlers/maven_scope.rs @@ -273,20 +273,24 @@ impl Walk<'_> { /// Queue `decl` read in `chain`'s context. A transitive edge also takes /// any version the reactor's management assigns its artifact. fn declare(&mut self, decl: &PomDecl, chain: &Chain, direct: bool) { + // Coordinates interpolate like the version (`${project.groupId}`): + // a raw placeholder never names a repository path or crawl entry. + let group = interpolate(&decl.group, chain).unwrap_or_else(|| decl.group.clone()); + let artifact = interpolate(&decl.artifact, chain).unwrap_or_else(|| decl.artifact.clone()); if !direct { let reactor = std::mem::take(&mut self.reactor_chains); for (i, rc) in reactor.iter().enumerate() { - let key = (i, decl.group.clone(), decl.artifact.clone()); + let key = (i, group.clone(), artifact.clone()); let managed = match self.reactor_managed.get(&key) { Some(found) => found.clone(), None => { - let found = self.managed_version(rc, &decl.group, &decl.artifact, 0); + let found = self.managed_version(rc, &group, &artifact, 0); self.reactor_managed.insert(key, found.clone()); found } }; if let Some(version) = managed { - self.enqueue(&decl.group, &decl.artifact, version); + self.enqueue(&group, &artifact, version); } } self.reactor_chains = reactor; @@ -294,10 +298,10 @@ impl Walk<'_> { let version = match &decl.version { Some(raw) => Some(interpolate(raw, chain)), None => self - .managed_version(chain, &decl.group, &decl.artifact, 0) + .managed_version(chain, &group, &artifact, 0) .or(Some(None)), }; - self.enqueue(&decl.group, &decl.artifact, version.flatten()); + self.enqueue(&group, &artifact, version.flatten()); } /// The version `chain` manages `g:a` at: its parent chain's management, @@ -310,7 +314,12 @@ impl Walk<'_> { a: &str, depth: usize, ) -> Option> { - let is_ga = |d: &PomDecl| d.group == g && d.artifact == a && !is_import(d); + let is_ga = |d: &PomDecl| { + let coord = |raw: &str, want: &str| { + raw == want || interpolate(raw, chain).is_some_and(|v| v == want) + }; + coord(&d.group, g) && coord(&d.artifact, a) && !is_import(d) + }; for node in chain { if let Some(decl) = node.model.managed.iter().find(|d| is_ga(d)) { return Some(decl.version.as_deref().and_then(|v| interpolate(v, chain))); @@ -560,6 +569,41 @@ mod tests { format!("{}", list.concat()) } + #[test] + fn property_coordinates_are_interpolated() { + let dir = tempfile::tempdir().unwrap(); + let (cwd, repo) = (dir.path().join("p"), dir.path().join("m2")); + write( + &cwd, + "pom.xml", + &pom( + "com.example", + "app", + "1", + &deps(&[dep("${project.groupId}", "${lib.name}", Some("2"), "")]).replace( + "", + "lib", + ), + ), + ); + cache( + &repo, + "com.example", + "lib", + "2", + &deps(&[dep( + "${project.groupId}", + "lib-core", + Some("${project.version}"), + "", + )]), + ); + cache(&repo, "com.example", "lib-core", "2", ""); + let scope = project_scope(&cwd, &repo).unwrap(); + assert!(scope.admits("com.example", "lib", "2")); + assert!(scope.admits("com.example", "lib-core", "2")); + } + #[test] fn the_scope_is_the_declared_graph_not_the_cache() { let dir = tempfile::tempdir().unwrap(); From 90fe8ec5161a497ad69ee5d1267ab470087179e8 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Fri, 9 Oct 2026 20:50:55 -0400 Subject: [PATCH 5/5] Serialize the project-scope Maven crawl test Its --global half reads the env-driven Coursier and Ivy caches, which the serial Hermetic crawler tests repoint; run concurrently it picked up an extra package (4 != 3). Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/socket-patch-core/src/crawlers/maven_crawler.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/socket-patch-core/src/crawlers/maven_crawler.rs b/crates/socket-patch-core/src/crawlers/maven_crawler.rs index d4724cae7..3575adf6d 100644 --- a/crates/socket-patch-core/src/crawlers/maven_crawler.rs +++ b/crates/socket-patch-core/src/crawlers/maven_crawler.rs @@ -2543,6 +2543,7 @@ mod tests { /// poms reach; the rest of the shared local repository (cached by other /// projects) is not this project's. `--global` still lists everything. #[tokio::test] + #[serial_test::serial] async fn project_mode_crawl_keeps_only_the_projects_graph() { let dir = tempfile::tempdir().unwrap(); let (cwd, repo) = (dir.path().join("app"), dir.path().join("m2"));