Skip to content

Commit 940e6b7

Browse files
Fix Maven 4 implicit subprojects missed outside vendored (#1809) (#1886)
* Start fix for #1809 Assisted-by: Claude Code:claude-opus-5-5 * Treat Maven 4 implicit subprojects as a reactor A Maven 4 root (model 4.1.0, pom packaging, no <subprojects> list) builds every direct subdirectory holding a pom.xml. Only the vendored planner knew this. Hosted scan pinned just the root pom while a module kept its own version, VEX attested that pin as not_affected, the sbt ambiguity guard let such a root through, and the project scope of the Maven cache skipped the modules' dependencies. Every caller now asks one maven_reactor predicate that follows the planner's own discovery rule, including the discoverSubprojects opt-out in .mvn/maven.config. The hosted engine reads each subdirectory's pom.xml beside such a root so the rewriter can refuse it as a reactor. Fixes #1809 Assisted-by: Claude Code:claude-opus-5-5 * Share one subproject probe; skip refused pins Review follow-ups for the Maven 4 reactor fix. The hosted engine, VEX and the Maven cache scope now find discovered subprojects through the planner's own probe, so a .git directory or a directory named pom.xml never makes a root a reactor in one place but not another. A refused reactor root pins nothing, so the hosted confirmation probe no longer counts a suffixed version an older release left in it, or one a module pom names, as a redirected dependency. The CLI contract now describes Maven 4 discovered reactors. Refs #1809 Assisted-by: Claude Code:claude-opus-5-5 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent cf9fd1f commit 940e6b7

12 files changed

Lines changed: 496 additions & 63 deletions

File tree

‎crates/socket-patch-cli/CLI_CONTRACT.md‎

Lines changed: 2 additions & 2 deletions
Large diffs are not rendered by default.

‎crates/socket-patch-core/src/crawlers/maven_scope.rs‎

Lines changed: 82 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@
99
//! (Maven caches the pom of every artifact, parent and BOM it resolves):
1010
//!
1111
//! - **Seeds**: every `<dependency>` of the reactor (the root `pom.xml`,
12-
//! its `<modules>` / `<subprojects>` recursively, every profile, and the
12+
//! its `<modules>` / `<subprojects>` recursively (and the subprojects
13+
//! Maven 4 discovers under a root listing none), every profile, and the
1314
//! `<dependencies>` each inherits from its parents), any scope.
1415
//! - **Edges**: an artifact's own non-optional `compile` / `runtime`
1516
//! dependencies (and those its parents declare), which is what Maven
@@ -30,7 +31,9 @@ use std::collections::{HashMap, HashSet, VecDeque};
3031
use std::path::{Path, PathBuf};
3132
use std::sync::Arc;
3233

33-
use crate::vendor::jvm::maven_reactor::{pom_model, PomDecl, PomModel};
34+
use crate::vendor::jvm::maven_reactor::{
35+
implicit_subprojects, may_discover_subprojects, pom_model, PomDecl, PomModel, MAVEN_CONFIG,
36+
};
3437

3538
/// `(groupId, artifactId, version)`.
3639
type Gav = (String, String, String);
@@ -225,12 +228,17 @@ impl Walk<'_> {
225228
local: true,
226229
}];
227230
let mut seen: HashSet<PathBuf> = HashSet::from([root]);
231+
let config = crate::utils::fs::read_regular_to_bytes_sync(&self.cwd.join(MAVEN_CONFIG))
232+
.ok()
233+
.map(|b| String::from_utf8_lossy(&b).into_owned());
228234
while let Some(node) = pending.pop() {
229235
if seen.len() > MAX_REACTOR {
230236
return None;
231237
}
232238
let dir = node.path.parent().unwrap_or(self.cwd).to_path_buf();
233-
for module in node.model.modules.clone() {
239+
let mut modules = node.model.modules.clone();
240+
modules.extend(discovered_subprojects(&node.path, &dir, config.as_deref()));
241+
for module in modules {
234242
if module.is_empty() || module.contains("${") {
235243
continue;
236244
}
@@ -461,6 +469,40 @@ impl Walk<'_> {
461469
}
462470
}
463471

472+
/// The subprojects Maven 4 discovers under the pom at `path` (#1809): when
473+
/// it lists none ([`may_discover_subprojects`]), the direct subdirectories
474+
/// of `dir` holding a `pom.xml` ([`implicit_subprojects`]), as `<module>`
475+
/// names.
476+
fn discovered_subprojects(path: &Path, dir: &Path, config: Option<&str>) -> Vec<String> {
477+
let Ok(bytes) = crate::utils::fs::read_regular_to_bytes_sync(path) else {
478+
return Vec::new();
479+
};
480+
if !may_discover_subprojects(&String::from_utf8_lossy(&bytes), config) {
481+
return Vec::new();
482+
}
483+
// The planners' listing: bare names, directories (followed through a
484+
// link) ending in `/`.
485+
let list = |rel: &str| -> Vec<String> {
486+
let Ok(entries) = std::fs::read_dir(dir.join(rel)) else {
487+
return Vec::new();
488+
};
489+
entries
490+
.flatten()
491+
.filter_map(|e| {
492+
let name = e.file_name().to_str()?.to_string();
493+
Some(if e.path().is_dir() {
494+
format!("{name}/")
495+
} else {
496+
name
497+
})
498+
})
499+
.collect()
500+
};
501+
let mut out = implicit_subprojects(&list, "");
502+
out.sort();
503+
out
504+
}
505+
464506
/// The pom at `path` parsed, when it is readable.
465507
fn read_model(path: &Path, include_profiles: bool) -> Option<Arc<PomModel>> {
466508
crate::utils::fs::read_regular_to_bytes_sync(path)
@@ -754,6 +796,43 @@ mod tests {
754796
assert!(!scope.admits("new.group", "lib", "3"));
755797
}
756798

799+
/// #1809: Maven 4 builds every direct subdirectory holding a `pom.xml`
800+
/// of a 4.1.0 `pom`-packaged root that lists no subprojects, so their
801+
/// dependencies seed the scope; with discovery turned off in
802+
/// `.mvn/maven.config` they do not.
803+
#[test]
804+
fn maven_4_discovered_subprojects_seed_the_scope() {
805+
let dir = tempfile::tempdir().unwrap();
806+
let (cwd, repo) = (dir.path().join("p"), dir.path().join("m2"));
807+
write(
808+
&cwd,
809+
"pom.xml",
810+
"<project xmlns=\"http://maven.apache.org/POM/4.1.0\"><modelVersion>4.1.0</modelVersion>\
811+
<groupId>com.example</groupId><artifactId>root</artifactId><version>1</version>\
812+
<packaging>pom</packaging></project>",
813+
);
814+
write(
815+
&cwd,
816+
"a/pom.xml",
817+
&format!(
818+
"<project xmlns=\"http://maven.apache.org/POM/4.1.0\"><modelVersion>4.1.0</modelVersion>\
819+
<parent/><artifactId>a</artifactId>{}</project>",
820+
deps(&[dep("org.example", "lib", Some("1.0"), "")])
821+
),
822+
);
823+
cache(&repo, "org.example", "lib", "1.0", "");
824+
let scope = project_scope(&cwd, &repo).unwrap();
825+
assert!(scope.admits("org.example", "lib", "1.0"));
826+
827+
write(
828+
&cwd,
829+
".mvn/maven.config",
830+
"-Dmaven.project.discoverSubprojects=false\n",
831+
);
832+
let scope = project_scope(&cwd, &repo).unwrap();
833+
assert!(!scope.admits("org.example", "lib", "1.0"));
834+
}
835+
757836
#[test]
758837
fn modules_parents_boms_and_management_are_followed() {
759838
let dir = tempfile::tempdir().unwrap();

‎crates/socket-patch-core/src/hosted/engine.rs‎

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -762,6 +762,27 @@ pub async fn read_candidate_files(
762762
read_go_workspace(view, unreadable, &mut out).await;
763763
}
764764

765+
// A Maven 4 root that discovers its subprojects (#1809): each direct
766+
// subdirectory holding a `pom.xml` is a module, which the pom rewriter
767+
// must see to refuse the root as a reactor (it pins only the root pom).
768+
// Advisory: keyed `<dir>/pom.xml`, never rewritten, and one that cannot
769+
// be read still counts as present.
770+
if candidates.iter().any(|c| c.dep.ecosystem == "maven") {
771+
if let Some(pom) = out.files.get("pom.xml") {
772+
let config = out.files.get(crate::patch::redirect::MVN_CONFIG);
773+
if crate::vendor::jvm::maven_reactor::may_discover_subprojects(
774+
pom,
775+
config.map(String::as_str),
776+
) {
777+
for dir in crate::vendor::jvm::maven_reactor::implicit_subprojects_in(view).await {
778+
let rel = format!("{dir}/pom.xml");
779+
let text = read_advisory(view, unreadable, &rel).await;
780+
out.files.insert(rel, text.unwrap_or_default());
781+
}
782+
}
783+
}
784+
}
785+
765786
// NuGet merges the user config and every parent directory's config
766787
// under the project's own: a catch-all mapping the rewriter creates
767788
// must name their sources too (#354). Disk only (the in-memory engine
@@ -3283,10 +3304,23 @@ fn confirm(
32833304
fn is_npm_manifest(name: &str) -> bool {
32843305
name == "package.json" || name.ends_with("/package.json")
32853306
}
3307+
// A Maven reactor root the pom rewriter refused pins nothing, so a
3308+
// suffixed version an earlier release left in it, or in `.mvn/`,
3309+
// proves nothing (#1809); nor do the module poms read beside a Maven 4
3310+
// root, advisory input only.
3311+
let maven_reactor_refused = rewrite
3312+
.warnings
3313+
.iter()
3314+
.any(|w| w.code == "redirect_maven_multimodule_unsupported");
3315+
let is_unpinned_maven = |name: &str| {
3316+
name.ends_with("/pom.xml")
3317+
|| maven_reactor_refused && (name == "pom.xml" || name.starts_with(".mvn/"))
3318+
};
32863319
let final_texts: Vec<(&str, &String)> = files
32873320
.iter()
32883321
.filter(|(name, _)| !(pdm_inactive && name.as_str() == "pdm.lock"))
32893322
.filter(|(name, _)| !is_npm_manifest(name))
3323+
.filter(|(name, _)| !is_unpinned_maven(name))
32903324
.filter(|(name, _)| !crate::patch::redirect::sbt::is_synthetic_key(name))
32913325
.map(|(name, content)| (name.as_str(), rewrite.files.get(name).unwrap_or(content)))
32923326
.chain(
@@ -5373,6 +5407,69 @@ snapshots:
53735407
assert!(done.confirmed.is_empty(), "{:?}", done.confirmed);
53745408
}
53755409

5410+
/// #1809: a Maven 4 root whose subprojects Maven discovers (model
5411+
/// 4.1.0, `pom` packaging, no list) is a reactor once a direct
5412+
/// subdirectory holds a `pom.xml`. The engine reads that pom beside
5413+
/// the root, on disk and in memory, so the pom rewriter refuses the
5414+
/// root instead of pinning only it while the module keeps its version.
5415+
#[tokio::test]
5416+
async fn a_maven_4_implicit_reactor_root_is_refused() {
5417+
let root = "<project xmlns=\"http://maven.apache.org/POM/4.1.0\">\n <modelVersion>4.1.0</modelVersion>\n <groupId>x</groupId>\n <artifactId>y</artifactId>\n <version>1</version>\n <packaging>pom</packaging>\n <dependencies>\n <dependency>\n <groupId>com.socketfixture</groupId>\n <artifactId>victim</artifactId>\n <version>1.10.0</version>\n </dependency>\n </dependencies>\n</project>\n";
5418+
let child = "<project xmlns=\"http://maven.apache.org/POM/4.1.0\">\n <modelVersion>4.1.0</modelVersion>\n <parent/>\n <artifactId>a</artifactId>\n</project>\n";
5419+
let refused = |read: &CandidateFiles, done: &Rewritten| {
5420+
assert!(
5421+
read.files.contains_key("a/pom.xml"),
5422+
"{:?}",
5423+
read.files.keys()
5424+
);
5425+
assert!(!done.rewrite.files.contains_key("pom.xml"));
5426+
assert!(done.confirmed.is_empty(), "{:?}", done.confirmed);
5427+
assert!(done
5428+
.rewrite
5429+
.warnings
5430+
.iter()
5431+
.any(|w| w.code == "redirect_maven_multimodule_unsupported"));
5432+
};
5433+
5434+
let mut p = MemoryProject::new();
5435+
p.insert_text("pom.xml", root);
5436+
p.insert_text("a/pom.xml", child);
5437+
let (read, done) = gradle_rewrite(&p).await;
5438+
refused(&read, &done);
5439+
5440+
let tmp = tempfile::tempdir().unwrap();
5441+
std::fs::write(tmp.path().join("pom.xml"), root).unwrap();
5442+
std::fs::create_dir(tmp.path().join("a")).unwrap();
5443+
std::fs::write(tmp.path().join("a/pom.xml"), child).unwrap();
5444+
// A directory without a pom is no subproject.
5445+
std::fs::create_dir(tmp.path().join("src")).unwrap();
5446+
let (read, done) = gradle_rewrite_in(&ProjectView::Disk(tmp.path())).await;
5447+
refused(&read, &done);
5448+
assert!(!read.files.contains_key("src/pom.xml"));
5449+
5450+
// A suffixed version an earlier release pinned into the root, or one
5451+
// a module names, confirms nothing: the refused root pins nothing.
5452+
let stale = root.replace("1.10.0", "1.10.0-socket.4d5e6f70");
5453+
let named = child.replace(
5454+
"</artifactId>",
5455+
"</artifactId>\n <properties><v>1.10.0-socket.4d5e6f70</v></properties>",
5456+
);
5457+
for (r, c) in [(stale.as_str(), child), (root, named.as_str())] {
5458+
let mut p = MemoryProject::new();
5459+
p.insert_text("pom.xml", r);
5460+
p.insert_text("a/pom.xml", c);
5461+
let (read, done) = gradle_rewrite(&p).await;
5462+
refused(&read, &done);
5463+
}
5464+
5465+
// Without a subproject the root is a single pom, pinned as before.
5466+
std::fs::remove_dir_all(tmp.path().join("a")).unwrap();
5467+
let (read, done) = gradle_rewrite_in(&ProjectView::Disk(tmp.path())).await;
5468+
assert!(!read.files.keys().any(|k| k.ends_with("/pom.xml")));
5469+
assert!(done.rewrite.files["pom.xml"].contains("1.10.0-socket.4d5e6f70"));
5470+
assert_eq!(done.confirmed.len(), 1);
5471+
}
5472+
53765473
/// An sbt build beside a Gradle build: the Gradle planner pinning a
53775474
/// uuid the sbt rewriter refused (here: no resolution evidence) does
53785475
/// not confirm it — the sbt build still loads the upstream artifact.

‎crates/socket-patch-core/src/patch/redirect/gradle.rs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -602,7 +602,7 @@ pub fn wiring_files(files: &BTreeMap<String, String>) -> Vec<String> {
602602
}
603603

604604
/// A [`crate::gradle::ListFn`] over the keys of `files`.
605-
fn key_list(files: &BTreeMap<String, String>, dir: &str) -> Vec<String> {
605+
pub(crate) fn key_list(files: &BTreeMap<String, String>, dir: &str) -> Vec<String> {
606606
let prefix = if dir.is_empty() {
607607
String::new()
608608
} else {

‎crates/socket-patch-core/src/patch/redirect/mod.rs‎

Lines changed: 66 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8656,10 +8656,13 @@ fn rewrite_maven_pom(
86568656
// would keep the unpatched upstream jar while the dep counts as
86578657
// redirected and VEX attests it (#261). Refuse the whole root, untouched;
86588658
// vendored mode's reactor planner rewrites each module's declaration.
8659-
if pom
8660-
.as_deref()
8661-
.is_some_and(crate::vendor::jvm::maven_reactor::declares_modules)
8662-
{
8659+
// Maven 4 subprojects found by discovery count too (#1809): the engine
8660+
// reads each direct subdirectory's `pom.xml` beside such a root.
8661+
if pom.is_some() && {
8662+
let read = |rel: &str| files.get(rel).map(|t| t.as_bytes().to_vec());
8663+
let list = |dir: &str| gradle::key_list(files, dir);
8664+
crate::vendor::jvm::maven_reactor::is_reactor_root(&read, &list)
8665+
} {
86638666
let gas: Vec<String> = maven
86648667
.iter()
86658668
.map(|dep| match &dep.namespace {
@@ -8670,7 +8673,7 @@ fn rewrite_maven_pom(
86708673
result.warnings.push(RewriteWarning {
86718674
code: "redirect_maven_multimodule_unsupported".into(),
86728675
detail: format!(
8673-
"pom.xml declares <modules>/<subprojects>; hosted mode reads only the root pom, so a module's own <version> of {} would stay unpatched. pom.xml and .mvn/ were left as they are and the dep is not counted as redirected; use `scan --mode vendored`, which pins each module's declaration",
8676+
"pom.xml heads a multi-module reactor (<modules>/<subprojects>, or Maven 4 subproject discovery); hosted mode reads only the root pom, so a module's own <version> of {} would stay unpatched. pom.xml and .mvn/ were left as they are and the dep is not counted as redirected; use `scan --mode vendored`, which pins each module's declaration",
86748677
gas.join(", ")
86758678
),
86768679
});
@@ -10478,6 +10481,64 @@ mod tests {
1047810481
assert!(!warning_codes(&r).contains(&"redirect_maven_multimodule_unsupported"));
1047910482
}
1048010483

10484+
/// #1809: a Maven 4 (model 4.1.0) `pom`-packaged root that lists no
10485+
/// subprojects builds every direct subdirectory holding a `pom.xml`, so
10486+
/// it is a reactor like a declared one and refused untouched. Without
10487+
/// such a subdirectory, under a 4.0.0 model, or with discovery turned
10488+
/// off in `.mvn/maven.config`, it is a single pom and still rewritten.
10489+
#[test]
10490+
fn maven_4_implicit_subprojects_root_is_refused() {
10491+
let dep = "<dependencies>\n <dependency>\n <groupId>org.slf4j</groupId>\n <artifactId>slf4j-api</artifactId>\n <version>1.7.36</version>\n </dependency>\n </dependencies>\n";
10492+
let root = |model: &str| {
10493+
format!(
10494+
"<project xmlns=\"http://maven.apache.org/POM/{model}\">\n <modelVersion>{model}</modelVersion>\n <groupId>com.example</groupId>\n <artifactId>root</artifactId>\n <version>1.0.0</version>\n <packaging>pom</packaging>\n {dep}</project>\n"
10495+
)
10496+
};
10497+
let child = "<project xmlns=\"http://maven.apache.org/POM/4.1.0\">\n <modelVersion>4.1.0</modelVersion>\n <parent/>\n <artifactId>child</artifactId>\n <dependencies>\n <dependency>\n <groupId>org.slf4j</groupId>\n <artifactId>slf4j-api</artifactId>\n <version>1.7.36</version>\n </dependency>\n </dependencies>\n</project>\n";
10498+
let tree = |model: &str, extra: &[(&str, &str)]| {
10499+
let mut files = BTreeMap::from([("pom.xml".to_string(), root(model))]);
10500+
files.extend(extra.iter().map(|(k, v)| (k.to_string(), v.to_string())));
10501+
rewrite_registry_redirect(&files, &[maven_override()])
10502+
};
10503+
10504+
let r = tree("4.1.0", &[("child/pom.xml", child)]);
10505+
assert!(
10506+
r.files.is_empty() && r.edits.is_empty(),
10507+
"an implicit reactor root must not be edited: files={:?} edits={:?}",
10508+
r.files.keys(),
10509+
r.edits
10510+
);
10511+
assert_eq!(
10512+
warning_codes(&r),
10513+
vec!["redirect_maven_multimodule_unsupported"]
10514+
);
10515+
assert!(r.warnings[0]
10516+
.detail
10517+
.contains("Maven 4 subproject discovery"));
10518+
10519+
let off = "-Dmaven.project.discoverSubprojects=false\n";
10520+
for (case, r) in [
10521+
("no subproject", tree("4.1.0", &[])),
10522+
("model 4.0.0", tree("4.0.0", &[("child/pom.xml", child)])),
10523+
(
10524+
"discovery off",
10525+
tree(
10526+
"4.1.0",
10527+
&[("child/pom.xml", child), (".mvn/maven.config", off)],
10528+
),
10529+
),
10530+
] {
10531+
assert!(
10532+
r.files["pom.xml"].contains(MAVEN_SUFFIXED),
10533+
"{case}: a single pom is still rewritten"
10534+
);
10535+
assert!(
10536+
!warning_codes(&r).contains(&"redirect_maven_multimodule_unsupported"),
10537+
"{case}"
10538+
);
10539+
}
10540+
}
10541+
1048110542
/// A direct declaration whose groupId / artifactId is a property of
1048210543
/// the pom's own `<properties>` is the GA (#683): its literal is
1048310544
/// rewritten, not shadowed by an added `<dependencyManagement>` pin

‎crates/socket-patch-core/src/vendor/jvm/gradle.rs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3418,7 +3418,7 @@ mod tests {
34183418
fn detect_routes_gradle_only_roots_here() {
34193419
let files = fs(&[("build.gradle", "")]);
34203420
let read = |rel: &str| files.get(rel).cloned();
3421-
assert_eq!(detect(&read), Shape::Gradle);
3421+
assert_eq!(detect(&read, &super::super::no_list), Shape::Gradle);
34223422
}
34233423

34243424
#[test]

0 commit comments

Comments
 (0)