From be0c6d24ab26ef846bb92c7153dc19c29f5f4752 Mon Sep 17 00:00:00 2001 From: lick <2188718831@qq.com> Date: Thu, 27 Aug 2026 13:55:08 +0800 Subject: [PATCH 1/2] fix(maven): detect reactors from Java paths --- .../lithe-core/src/execution/configuration.rs | 15 +++ rust/lithe-core/src/project/maven.rs | 76 +++++------ .../lithe-core/src/tests/run_configuration.rs | 124 +++++++++++++++++- shared/contracts/rust-core-api.md | 10 +- shared/fixtures/execution/maven.json | 23 ++++ 5 files changed, 207 insertions(+), 41 deletions(-) diff --git a/rust/lithe-core/src/execution/configuration.rs b/rust/lithe-core/src/execution/configuration.rs index 92c5e4ffd..021071a1c 100644 --- a/rust/lithe-core/src/execution/configuration.rs +++ b/rust/lithe-core/src/execution/configuration.rs @@ -12,6 +12,7 @@ use std::path::{Component, Path, PathBuf}; const VERSION: u32 = 2; const LEGACY_VERSION: u32 = 1; +const GENERATOR_REVISION: &str = "2"; /// Toolchain requirements and `project.json` are separate documents that happen /// to live under `.lithe`. Their schema did not change with run-config v2, so /// they keep their own version and must not be validated against `VERSION`. @@ -390,6 +391,8 @@ pub fn inspect(request: InspectRequest) -> Result { if metadata.fingerprint != fingerprint_from_inputs(¤t_inputs) { let message = if metadata.inputs.is_empty() { "Project inputs changed after run configuration generation".to_string() + } else if metadata.inputs == current_inputs { + "Run configuration generator changed; regenerate configurations".to_string() } else { input_change_summary(&metadata.inputs, ¤t_inputs) }; @@ -1254,6 +1257,7 @@ pub fn create_user_configuration( /// Resolves one configuration into the exact executable, arguments, and environment. pub fn create_launch_plan(request: LaunchPlanRequest) -> Result { + let workspace_root = existing_root(&request.root)?; let resolved = resolve(ResolveRequest { root: request.root, toolchain_candidates: Vec::new(), @@ -1378,6 +1382,13 @@ pub fn create_launch_plan(request: LaunchPlanRequest) -> Result Result, CoreError> { fn fingerprint_from_inputs(inputs: &BTreeMap) -> String { let mut digest = Sha256::new(); + // Detection changes invalidate persisted output even when project files are + // unchanged, so an application upgrade cannot keep launching a stale plan. + digest.update(GENERATOR_REVISION.as_bytes()); + digest.update([0]); for (relative, content_hash) in inputs { digest.update(relative.as_bytes()); digest.update([0]); diff --git a/rust/lithe-core/src/project/maven.rs b/rust/lithe-core/src/project/maven.rs index 34816572b..2f64d2a30 100644 --- a/rust/lithe-core/src/project/maven.rs +++ b/rust/lithe-core/src/project/maven.rs @@ -9,7 +9,7 @@ use quick_xml::events::Event; use quick_xml::Reader; use regex::Regex; use serde::Deserialize; -use std::collections::HashSet; +use std::collections::{BTreeSet, HashSet}; use std::fs; use std::path::{Component, Path, PathBuf}; @@ -164,49 +164,51 @@ pub fn scan(request: MavenScanRequest) -> Result, Core })) } -/// Selects one parseable Maven root from visible workspace paths. The -/// application model currently represents one Maven reactor, so the shallowest -/// valid descriptor wins; lexical ordering makes independent candidates -/// deterministic. Parse failures are retained only when no candidate is valid. +/// Selects one parseable Maven root from visible workspace paths and their +/// ancestors. The application model currently represents one Maven reactor, so +/// the shallowest valid descriptor wins; lexical ordering makes independent +/// candidates deterministic. Parse failures are retained only when no candidate +/// is valid. pub(crate) fn maven_root( root: &Path, paths: &[String], ) -> Result, CoreError> { let canonical_root = root.canonicalize().map_err(CoreError::from)?; - let mut candidates = Vec::new(); - if canonical_root.join("pom.xml").is_file() { - candidates.push((root.to_path_buf(), ".".to_string(), 0)); + let mut candidate_directories = BTreeSet::from([PathBuf::new()]); + for path in paths + .iter() + .filter_map(|path| normalize_relative_path(path)) + { + let mut directory = Path::new(&path).parent(); + while let Some(relative) = directory { + candidate_directories.insert(relative.to_path_buf()); + if relative.as_os_str().is_empty() { + break; + } + directory = relative.parent(); + } } - candidates.extend( - paths - .iter() - .filter_map(|path| normalize_relative_path(path)) - .filter(|path| { - Path::new(path) - .file_name() - .and_then(|name| name.to_str()) - .is_some_and(|name| name.eq_ignore_ascii_case("pom.xml")) - }) - .filter_map(|path| { - let directory = Path::new(&path).parent()?; - let relative_path = if directory.as_os_str().is_empty() { - ".".to_string() - } else { - directory.to_string_lossy().replace('\\', "/") - }; - let candidate = root.join(directory); - let canonical_candidate = candidate.canonicalize().ok()?; - if !canonical_candidate.starts_with(&canonical_root) { - return None; - } - canonical_candidate.join("pom.xml").is_file().then_some(( - candidate, - relative_path, - directory.components().count(), - )) - }), - ); + let mut candidates = candidate_directories + .into_iter() + .filter_map(|directory| { + let candidate = root.join(&directory); + if !candidate.join("pom.xml").is_file() { + return None; + } + let canonical_candidate = candidate.canonicalize().ok()?; + if !canonical_candidate.starts_with(&canonical_root) { + return None; + } + let relative_path = if directory.as_os_str().is_empty() { + ".".to_string() + } else { + directory.to_string_lossy().replace('\\', "/") + }; + let depth = directory.components().count(); + Some((candidate, relative_path, depth)) + }) + .collect::>(); candidates.sort_by(|left, right| { left.2 .cmp(&right.2) diff --git a/rust/lithe-core/src/tests/run_configuration.rs b/rust/lithe-core/src/tests/run_configuration.rs index 5ec022b7a..5f15dff69 100644 --- a/rust/lithe-core/src/tests/run_configuration.rs +++ b/rust/lithe-core/src/tests/run_configuration.rs @@ -162,8 +162,8 @@ fn run_configuration_generation_uses_a_maven_project_below_the_workspace() { "command": "runConfig.generate", "payload": { "root": root, - "paths": ["projects/demo/pom.xml", "projects/demo/service/pom.xml", source], - "modulePaths": ["service"] + "paths": [source], + "modulePaths": [] } }) .to_string(), @@ -190,6 +190,7 @@ fn run_configuration_generation_uses_a_maven_project_below_the_workspace() { .unwrap(); assert_eq!(java_main["cwd"], "projects/demo"); assert_eq!(java_main["extensions"]["maven"]["module"], "service"); + assert_eq!(java_main["toolchains"]["maven"], "project-maven"); assert_eq!( response["data"]["toolchainRequirements"]["toolchains"]["project-jdk"]["minimumVersion"], "21" @@ -233,6 +234,29 @@ fn run_configuration_generation_uses_a_maven_project_below_the_workspace() { .windows(2) .any(|arguments| arguments == ["-pl", "service"])); + let java_plan: Value = serde_json::from_str(&execute_json( + &serde_json::json!({ + "id": "plan-nested-maven-java-main", + "command": "runConfig.createLaunchPlan", + "payload": { + "root": root, + "configurationId": java_main["id"] + } + }) + .to_string(), + )) + .unwrap(); + assert_eq!(java_plan["ok"], true, "{java_plan}"); + assert_eq!( + java_plan["data"]["executable"]["toolchain"], + "project-maven" + ); + assert!(java_plan["data"]["arguments"] + .as_array() + .unwrap() + .iter() + .any(|argument| argument == "-Dexec.mainClass=com.example.App")); + fs::remove_dir_all(root).unwrap(); } @@ -479,6 +503,61 @@ fn plain_java_main_uses_the_jdk_without_maven() { fs::remove_dir_all(root).unwrap(); } +#[test] +fn stale_plain_java_main_is_rejected_when_the_source_belongs_to_maven() { + let root = temporary_root("run-config-stale-maven-main"); + let source = "projects/demo/src/main/java/com/example/App.java"; + fs::create_dir_all(root.join("projects/demo/src/main/java/com/example")).unwrap(); + fs::create_dir_all(root.join(".lithe/run")).unwrap(); + fs::write(root.join("projects/demo/pom.xml"), "").unwrap(); + fs::write( + root.join(source), + "package com.example; class App { public static void main(String[] args) {} }", + ) + .unwrap(); + fs::write( + root.join(".lithe/run/generated.json"), + serde_json::json!({ + "version": 2, + "configurations": [{ + "id": "java-main:com.example.App", + "name": "App", + "provider": "java.main", + "execution": "application", + "cwd": ".", + "toolchains": { "java": "project-jdk" }, + "extensions": { + "maven": { "mainClass": "com.example.App", "module": "." }, + "java": { "source": source } + } + }] + }) + .to_string(), + ) + .unwrap(); + + let plan: Value = serde_json::from_str(&execute_json( + &serde_json::json!({ + "id": "plan-stale-maven-main", + "command": "runConfig.createLaunchPlan", + "payload": { + "root": root, + "configurationId": "java-main:com.example.App" + } + }) + .to_string(), + )) + .unwrap(); + assert_eq!(plan["ok"], false, "{plan}"); + assert_eq!(plan["error"]["code"], "invalid_request"); + assert_eq!( + plan["error"]["message"], + "Java application belongs to a Maven project; regenerate run configurations" + ); + + fs::remove_dir_all(root).unwrap(); +} + #[test] fn resolve_prefers_a_host_provided_local_document() { let root = temporary_root("run-config-host-local"); @@ -1055,6 +1134,47 @@ fn run_configuration_generation_detects_maven_compiler_target() { fs::remove_dir_all(root).unwrap(); } +#[test] +fn run_configuration_inspection_invalidates_an_older_generator_revision() { + let root = temporary_root("run-config-generator-revision"); + fs::create_dir_all(root.join("src")).unwrap(); + fs::write(root.join("src/App.java"), "class App {}").unwrap(); + let generated: Value = serde_json::from_str(&execute_json( + &serde_json::json!({ + "id": "generate-revision", + "command": "runConfig.generate", + "payload": {"root": root, "paths": ["src/App.java"], "modulePaths": []} + }) + .to_string(), + )) + .unwrap(); + let mut document = generated["data"]["generated"].clone(); + document["generator"]["fingerprint"] = serde_json::json!("sha256:legacy"); + fs::create_dir_all(root.join(".lithe/run")).unwrap(); + fs::write( + root.join(".lithe/run/generated.json"), + serde_json::to_string(&document).unwrap(), + ) + .unwrap(); + + let inspected: Value = serde_json::from_str(&execute_json( + &serde_json::json!({ + "id": "inspect-revision", + "command": "runConfig.inspect", + "payload": {"root": root} + }) + .to_string(), + )) + .unwrap(); + assert_eq!(inspected["ok"], true, "{inspected}"); + assert_eq!( + inspected["data"]["diagnostics"][0]["message"], + "Run configuration generator changed; regenerate configurations" + ); + + fs::remove_dir_all(root).unwrap(); +} + #[test] fn run_configuration_inspection_summarizes_changed_inputs() { let root = temporary_root("run-config-input-summary"); diff --git a/shared/contracts/rust-core-api.md b/shared/contracts/rust-core-api.md index c6ef85573..5cb1396c8 100644 --- a/shared/contracts/rust-core-api.md +++ b/shared/contracts/rust-core-api.md @@ -492,7 +492,12 @@ The `runConfig.*` commands implement the versioned project protocol described by the JSON Schemas in this directory. `runConfig.inspect` accepts `root` and never writes files. `runConfig.generate` accepts `root`, relative Java `paths`, and relative `modulePaths`; it returns generated configuration and toolchain -requirement documents for the platform adapter to write atomically. +requirement documents for the platform adapter to write atomically. Maven root +discovery checks `pom.xml` along each supplied path's ancestor chain, so a +reactor nested below the opened workspace does not depend on the platform +including build descriptors in `paths`. Generated fingerprints include both +project inputs and the detector revision; either changing marks persisted +output stale and requires regeneration. `runConfig.resolve` accepts `root`, optional local `toolchainCandidates`, and optional `localDocument`. When `localDocument` is present, Core uses that JSON @@ -543,7 +548,8 @@ environment references. It does not return a shell command or platform executable path. All project paths use `/`, reject absolute paths and `..` traversal, and remain relative to `root`. A `java.main` configuration without a Maven toolchain launches through `project-jdk` and the configuration's Java -source path. +source path only when that source has no Maven ancestor. An older configuration +that omitted the Maven binding is rejected with an instruction to regenerate. `java.codeVision` accepts a workspace root, a target Java path, and Java source paths. It returns declaration locations and usage counts; Git blame attribution diff --git a/shared/fixtures/execution/maven.json b/shared/fixtures/execution/maven.json index 808faa003..1c2ed73ce 100644 --- a/shared/fixtures/execution/maven.json +++ b/shared/fixtures/execution/maven.json @@ -6,6 +6,7 @@ "Only Spring Boot's goal accepts a main class. Quarkus and Micronaut resolve it from the build -- the Micronaut mojo's `mainClass` has no user property at all -- so the annotation scan grafts a class onto `spring-boot.maven` configurations only.", "Debugging is per framework too. `spring-boot:run` forwards JVM arguments verbatim, so JDWP travels among them. Quarkus (`-Ddebug= -Dsuspend=y`) and Micronaut (`-Dmn.debug=true -Dmn.debug.port= -Dmn.debug.suspend=true`) start the agent themselves, so JDWP must NOT also be injected into their JVM arguments: two agents on one port fail to bind and the service never starts. Both are told to suspend because a breakpoint in initialisation is otherwise unreachable -- neither suspends by default.", "Maven modules are declared, not discovered: the detector reads the graph from the root `pom.xml` instead of judging each directory the shared walk visits. A directory-driven scan is wrong in both directions -- `` may name a directory the walk prunes (one called `build` or `out` is invisible), while a leftover `pom.xml` outside the graph is not part of the build at all.", + "A caller only needs to supply workspace-relative Java paths. Maven root discovery checks their ancestor directories, so a reactor nested below the opened workspace is still selected even when the platform does not include `pom.xml` in the request.", "Only `` counts. A plugin under `` pins a version for children without applying it, and one under `` never runs. `pom` packaging is an aggregator: it produces no artifact to run, so a plugin declared there configures its children rather than describing a service.", "Maven is reached through the project toolchain binding rather than a bare program name, because the build may be driven by `./mvnw` instead of an installed `mvn`. Every configuration runs from the reactor root and addresses its module with `-pl`, so `cwd` stays `.` and the module travels in `extensions.maven.module`. The name is therefore what keeps two modules apart: `artifactId` where unique, the module path where two modules share one.", "The annotation scan still supplies the main class, which is the only place it is named. Where a Spring Boot module holds exactly one annotated class, it becomes `extensions.maven.mainClass`; two candidates are ambiguous and are left for `spring-boot:run` to resolve. A class in a module with no framework plugin is still a runnable `java.main` entry." @@ -42,6 +43,28 @@ "reason": "The library module applies no boot plugin. The annotated class contributes its name to the module's service rather than a service of its own." } }, + { + "command": "runConfig.generate", + "payload": { "root": "/workspace", "paths": ["projects/demo/backend/src/main/java/com/demo/DemoApplication.java"] }, + "workspace": { + "projects/demo/pom.xml": "platformpombackend", + "projects/demo/backend/pom.xml": "backendspring-boot-maven-plugin", + "projects/demo/backend/src/main/java/com/demo/DemoApplication.java": "package com.demo;\n@SpringBootApplication\npublic class DemoApplication { public static void main(String[] a) {} }\n" + }, + "expected": { + "configurations": [ + { + "id": "spring-boot.maven:backend", + "provider": "spring-boot.maven", + "cwd": "projects/demo", + "source": "projects/demo/backend/pom.xml", + "toolchains": { "java": "project-jdk", "maven": "project-maven" }, + "extensions": { "maven": { "module": "backend", "mainClass": "com.demo.DemoApplication" } } + } + ], + "reason": "The Java source path is sufficient to discover the nested reactor and bind its module to Maven." + } + }, { "command": "runConfig.generate", "payload": { "root": "/workspace" }, From 8aa9925488eea1292a0cba7d6ab259f61ee62e96 Mon Sep 17 00:00:00 2001 From: lick <2188718831@qq.com> Date: Thu, 27 Aug 2026 16:24:30 +0800 Subject: [PATCH 2/2] fix(maven): scope reactor ownership per Java entry --- .../lithe-core/src/execution/configuration.rs | 55 ++++- rust/lithe-core/src/project/maven.rs | 7 +- .../lithe-core/src/tests/run_configuration.rs | 192 +++++++++++++++++- shared/contracts/rust-core-api.md | 9 +- shared/fixtures/execution/maven.json | 15 +- 5 files changed, 258 insertions(+), 20 deletions(-) diff --git a/rust/lithe-core/src/execution/configuration.rs b/rust/lithe-core/src/execution/configuration.rs index 021071a1c..f2a00c92b 100644 --- a/rust/lithe-core/src/execution/configuration.rs +++ b/rust/lithe-core/src/execution/configuration.rs @@ -459,21 +459,37 @@ pub fn generate(request: GenerateRequest) -> Result { .iter() .map(|value| (value.qualified_name.clone(), value.path.clone())) .collect::>(); + let mut maven_owners = BTreeMap::, Option<(PathBuf, String)>>::new(); let configurations = scanned .configurations .into_iter() - .map(|value| { + .map(|value| -> Result { let provider = match value.kind.as_str() { "javaMain" | "springBoot" => "java.main", "mavenModule" => "maven.module", _ => "java.current-file", }; let id = java_configuration_id(&value); + let owner_key = value.module_path.clone(); + let maven_owner = if let Some(owner) = maven_owners.get(&owner_key) { + owner.clone() + } else { + let owner = maven_owner_for_module( + &root, + maven_root.as_ref(), + value.module_path.as_deref(), + )?; + maven_owners.insert(owner_key, owner.clone()); + owner + }; + let owner_relative_path = maven_owner + .as_ref() + .map(|(_, relative_path)| relative_path.as_str()); let mut maven = serde_json::Map::new(); let module_path = value .module_path .as_deref() - .map(|path| maven_module_path(maven_relative_path, path)) + .map(|path| maven_module_path(owner_relative_path, path)) .unwrap_or_else(|| ".".to_string()); maven.insert("module".to_string(), json!(module_path)); if let Some(main_class) = value.main_class.as_ref() { @@ -484,9 +500,9 @@ pub fn generate(request: GenerateRequest) -> Result { .as_ref() .and_then(|name| main_class_sources.get(name)) .cloned(); - // A workspace without Maven still produces java.main entries. Binding - // project-maven there would force every plain Java class through mvn. - let uses_maven_toolchain = has_maven_project && provider != "java.current-file"; + // Maven ownership is per entry: one workspace can contain standalone + // Java files or multiple independent reactors in the same request. + let uses_maven_toolchain = maven_owner.is_some() && provider != "java.current-file"; let mut toolchains = BTreeMap::new(); toolchains.insert("java".to_string(), "project-jdk".to_string()); if uses_maven_toolchain { @@ -497,7 +513,7 @@ pub fn generate(request: GenerateRequest) -> Result { if let Some(path) = source_path.as_ref() { extensions.insert("java".to_string(), json!({ "source": path })); } - RunConfiguration { + Ok(RunConfiguration { id, name: value.name, provider: provider.to_string(), @@ -510,7 +526,7 @@ pub fn generate(request: GenerateRequest) -> Result { cwd: if provider == "java.current-file" { ".".to_string() } else { - maven_relative_path.unwrap_or(".").to_string() + owner_relative_path.unwrap_or(".").to_string() }, env: BTreeMap::new(), confidence: Confidence::Native, @@ -522,9 +538,9 @@ pub fn generate(request: GenerateRequest) -> Result { extensions, disabled: false, source: source_path, - } + }) }) - .collect::>(); + .collect::, _>>()?; let mut configurations = deduplicate_java_configurations(configurations); let java_entry_count = configurations.len(); if has_java_sources { @@ -611,6 +627,27 @@ fn maven_module_path(maven_root: Option<&str>, path: &str) -> String { } } +fn maven_owner_for_module( + root: &Path, + workspace_maven_root: Option<&(PathBuf, String)>, + module_path: Option<&str>, +) -> Result, CoreError> { + if let Some(module_path) = module_path { + let descriptor = if module_path == "." { + "pom.xml".to_string() + } else { + format!("{module_path}/pom.xml") + }; + return crate::project::maven_root(root, &[descriptor]); + } + + // A root-level Maven project has no relative module path. Nested reactors + // always contribute at least their reactor directory through module inference. + Ok(workspace_maven_root + .filter(|(_, relative_path)| relative_path == ".") + .cloned()) +} + /// Whether a service is a Spring Boot service is decided by the build, not by an /// annotation: `spring-boot-maven-plugin` is what makes `spring-boot:run` work at /// all, and the Maven detector reads it from the declared module graph. The scan diff --git a/rust/lithe-core/src/project/maven.rs b/rust/lithe-core/src/project/maven.rs index 2f64d2a30..f90621ba1 100644 --- a/rust/lithe-core/src/project/maven.rs +++ b/rust/lithe-core/src/project/maven.rs @@ -165,10 +165,9 @@ pub fn scan(request: MavenScanRequest) -> Result, Core } /// Selects one parseable Maven root from visible workspace paths and their -/// ancestors. The application model currently represents one Maven reactor, so -/// the shallowest valid descriptor wins; lexical ordering makes independent -/// candidates deterministic. Parse failures are retained only when no candidate -/// is valid. +/// ancestors. The Maven project scan currently represents one reactor, so the +/// shallowest valid descriptor wins; lexical ordering makes independent candidates +/// deterministic. Parse failures are retained only when no candidate is valid. pub(crate) fn maven_root( root: &Path, paths: &[String], diff --git a/rust/lithe-core/src/tests/run_configuration.rs b/rust/lithe-core/src/tests/run_configuration.rs index 5f15dff69..d969d8828 100644 --- a/rust/lithe-core/src/tests/run_configuration.rs +++ b/rust/lithe-core/src/tests/run_configuration.rs @@ -1,6 +1,8 @@ use super::support::temporary_root; use crate::execute_json; use serde_json::Value; +use sha2::{Digest, Sha256}; +use std::collections::BTreeMap; use std::fs; use std::path::PathBuf; @@ -260,6 +262,177 @@ fn run_configuration_generation_uses_a_maven_project_below_the_workspace() { fs::remove_dir_all(root).unwrap(); } +#[test] +fn nested_maven_generation_keeps_standalone_java_on_the_jdk() { + let root = temporary_root("run-config-mixed-nested-maven"); + let maven_source = "projects/demo/src/main/java/com/example/App.java"; + let standalone_source = "samples/Standalone.java"; + fs::create_dir_all(root.join("projects/demo/src/main/java/com/example")).unwrap(); + fs::create_dir_all(root.join("samples")).unwrap(); + fs::write( + root.join("projects/demo/pom.xml"), + "demo", + ) + .unwrap(); + fs::write( + root.join(maven_source), + "package com.example; class App { public static void main(String[] args) {} }", + ) + .unwrap(); + fs::write( + root.join(standalone_source), + "class Standalone { public static void main(String[] args) {} }", + ) + .unwrap(); + + let response: Value = serde_json::from_str(&execute_json( + &serde_json::json!({ + "id": "generate-mixed-nested-maven", + "command": "runConfig.generate", + "payload": { + "root": root, + "paths": [maven_source, standalone_source], + "modulePaths": [] + } + }) + .to_string(), + )) + .unwrap(); + assert_eq!(response["ok"], true, "{response}"); + let configurations = response["data"]["generated"]["configurations"] + .as_array() + .unwrap(); + let maven_main = configurations + .iter() + .find(|value| value["id"] == "java-main:com.example.App") + .unwrap(); + assert_eq!(maven_main["cwd"], "projects/demo"); + assert_eq!(maven_main["toolchains"]["maven"], "project-maven"); + let standalone = configurations + .iter() + .find(|value| value["id"] == "java-main:Standalone") + .unwrap(); + assert_eq!(standalone["cwd"], "."); + assert!(standalone["toolchains"]["maven"].is_null()); + assert_eq!(standalone["source"], standalone_source); + + fs::create_dir_all(root.join(".lithe/run")).unwrap(); + fs::write( + root.join(".lithe/run/generated.json"), + serde_json::to_string(&response["data"]["generated"]).unwrap(), + ) + .unwrap(); + let plan: Value = serde_json::from_str(&execute_json( + &serde_json::json!({ + "id": "plan-mixed-standalone", + "command": "runConfig.createLaunchPlan", + "payload": { + "root": root, + "configurationId": "java-main:Standalone" + } + }) + .to_string(), + )) + .unwrap(); + assert_eq!(plan["ok"], true, "{plan}"); + assert_eq!(plan["data"]["executable"]["toolchain"], "project-jdk"); + assert_eq!(plan["data"]["workingDirectory"], "."); + assert_eq!( + plan["data"]["arguments"], + serde_json::json!([standalone_source]) + ); + + fs::remove_dir_all(root).unwrap(); +} + +#[test] +fn java_mains_use_their_own_independent_nested_maven_reactors() { + let root = temporary_root("run-config-independent-maven-reactors"); + let alpha_source = "services/alpha/src/main/java/example/Alpha.java"; + let beta_source = "services/beta/src/main/java/example/Beta.java"; + fs::create_dir_all(root.join("services/alpha/src/main/java/example")).unwrap(); + fs::create_dir_all(root.join("services/beta/src/main/java/example")).unwrap(); + fs::write( + root.join("services/alpha/pom.xml"), + "alpha", + ) + .unwrap(); + fs::write( + root.join("services/beta/pom.xml"), + "beta", + ) + .unwrap(); + fs::write( + root.join(alpha_source), + "package example; class Alpha { public static void main(String[] args) {} }", + ) + .unwrap(); + fs::write( + root.join(beta_source), + "package example; class Beta { public static void main(String[] args) {} }", + ) + .unwrap(); + + let response: Value = serde_json::from_str(&execute_json( + &serde_json::json!({ + "id": "generate-independent-maven-reactors", + "command": "runConfig.generate", + "payload": { + "root": root, + "paths": [alpha_source, beta_source], + "modulePaths": [] + } + }) + .to_string(), + )) + .unwrap(); + assert_eq!(response["ok"], true, "{response}"); + let configurations = response["data"]["generated"]["configurations"] + .as_array() + .unwrap(); + let alpha = configurations + .iter() + .find(|value| value["id"] == "java-main:example.Alpha") + .unwrap(); + let beta = configurations + .iter() + .find(|value| value["id"] == "java-main:example.Beta") + .unwrap(); + assert_eq!(alpha["cwd"], "services/alpha"); + assert_eq!(beta["cwd"], "services/beta"); + assert_eq!(alpha["extensions"]["maven"]["module"], "."); + assert_eq!(beta["extensions"]["maven"]["module"], "."); + assert_eq!(alpha["toolchains"]["maven"], "project-maven"); + assert_eq!(beta["toolchains"]["maven"], "project-maven"); + + fs::create_dir_all(root.join(".lithe/run")).unwrap(); + fs::write( + root.join(".lithe/run/generated.json"), + serde_json::to_string(&response["data"]["generated"]).unwrap(), + ) + .unwrap(); + let beta_plan: Value = serde_json::from_str(&execute_json( + &serde_json::json!({ + "id": "plan-beta-reactor", + "command": "runConfig.createLaunchPlan", + "payload": { + "root": root, + "configurationId": "java-main:example.Beta" + } + }) + .to_string(), + )) + .unwrap(); + assert_eq!(beta_plan["ok"], true, "{beta_plan}"); + assert_eq!(beta_plan["data"]["workingDirectory"], "services/beta"); + assert_eq!( + beta_plan["data"]["executable"]["toolchain"], + "project-maven" + ); + + fs::remove_dir_all(root).unwrap(); +} + #[test] fn run_configuration_generation_deduplicates_nested_checkout_sources() { let root = temporary_root("run-config-worktree-duplicate"); @@ -1149,7 +1322,12 @@ fn run_configuration_inspection_invalidates_an_older_generator_revision() { )) .unwrap(); let mut document = generated["data"]["generated"].clone(); - document["generator"]["fingerprint"] = serde_json::json!("sha256:legacy"); + let legacy_fingerprint = legacy_generator_fingerprint(&document["generator"]["inputs"]); + assert_ne!( + document["generator"]["fingerprint"], + serde_json::json!(legacy_fingerprint) + ); + document["generator"]["fingerprint"] = serde_json::json!(legacy_fingerprint); fs::create_dir_all(root.join(".lithe/run")).unwrap(); fs::write( root.join(".lithe/run/generated.json"), @@ -1175,6 +1353,18 @@ fn run_configuration_inspection_invalidates_an_older_generator_revision() { fs::remove_dir_all(root).unwrap(); } +fn legacy_generator_fingerprint(inputs: &Value) -> String { + let inputs = serde_json::from_value::>(inputs.clone()).unwrap(); + let mut digest = Sha256::new(); + for (relative, content_hash) in inputs { + digest.update(relative.as_bytes()); + digest.update([0]); + digest.update(content_hash.as_bytes()); + digest.update([0]); + } + format!("sha256:{:x}", digest.finalize()) +} + #[test] fn run_configuration_inspection_summarizes_changed_inputs() { let root = temporary_root("run-config-input-summary"); diff --git a/shared/contracts/rust-core-api.md b/shared/contracts/rust-core-api.md index 5cb1396c8..91ae46085 100644 --- a/shared/contracts/rust-core-api.md +++ b/shared/contracts/rust-core-api.md @@ -495,9 +495,12 @@ and relative `modulePaths`; it returns generated configuration and toolchain requirement documents for the platform adapter to write atomically. Maven root discovery checks `pom.xml` along each supplied path's ancestor chain, so a reactor nested below the opened workspace does not depend on the platform -including build descriptors in `paths`. Generated fingerprints include both -project inputs and the detector revision; either changing marks persisted -output stale and requires regeneration. +including build descriptors in `paths`. Maven ownership is resolved per Java +entry: standalone sources keep the JDK launch path, while entries from +independent nested reactors retain their own reactor working directory and +module selector. Generated fingerprints include both project inputs and the +detector revision; either changing marks persisted output stale and requires +regeneration. `runConfig.resolve` accepts `root`, optional local `toolchainCandidates`, and optional `localDocument`. When `localDocument` is present, Core uses that JSON diff --git a/shared/fixtures/execution/maven.json b/shared/fixtures/execution/maven.json index 1c2ed73ce..7cbb00713 100644 --- a/shared/fixtures/execution/maven.json +++ b/shared/fixtures/execution/maven.json @@ -45,11 +45,12 @@ }, { "command": "runConfig.generate", - "payload": { "root": "/workspace", "paths": ["projects/demo/backend/src/main/java/com/demo/DemoApplication.java"] }, + "payload": { "root": "/workspace", "paths": ["projects/demo/backend/src/main/java/com/demo/DemoApplication.java", "samples/Standalone.java"] }, "workspace": { "projects/demo/pom.xml": "platformpombackend", "projects/demo/backend/pom.xml": "backendspring-boot-maven-plugin", - "projects/demo/backend/src/main/java/com/demo/DemoApplication.java": "package com.demo;\n@SpringBootApplication\npublic class DemoApplication { public static void main(String[] a) {} }\n" + "projects/demo/backend/src/main/java/com/demo/DemoApplication.java": "package com.demo;\n@SpringBootApplication\npublic class DemoApplication { public static void main(String[] a) {} }\n", + "samples/Standalone.java": "public class Standalone { public static void main(String[] a) {} }\n" }, "expected": { "configurations": [ @@ -60,9 +61,17 @@ "source": "projects/demo/backend/pom.xml", "toolchains": { "java": "project-jdk", "maven": "project-maven" }, "extensions": { "maven": { "module": "backend", "mainClass": "com.demo.DemoApplication" } } + }, + { + "id": "java-main:Standalone", + "provider": "java.main", + "cwd": ".", + "source": "samples/Standalone.java", + "toolchains": { "java": "project-jdk" }, + "extensions": { "maven": { "module": ".", "mainClass": "Standalone" }, "java": { "source": "samples/Standalone.java" } } } ], - "reason": "The Java source path is sufficient to discover the nested reactor and bind its module to Maven." + "reason": "The Maven Java source discovers and binds to the nested reactor, while the standalone source in the same request remains a direct JDK launch." } }, {