Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -87,10 +87,12 @@ Run 和 Debug 共用同一套 Java 项目准备逻辑。配置中的 Maven 信
导入、Profile、settings.xml 和项目模型;Maven 工具窗口、框架 goal、测试与显式
Maven 任务仍走 Maven 启动计划。

Spring Boot 检测仍由 Maven 插件决定“它是不是服务”,不会把普通依赖模块误报成
服务。扫描到唯一的 `@SpringBootApplication` 源码后,配置同时记录入口类和源码;
此时 Run 走上述 JDT 直启流程。没有 Java 入口源码的特殊项目(例如入口不在可见
Java 源码中)仍可保留 Spring Boot Maven goal 兼容路径。
Maven 插件只提供 Spring Boot 服务候选;自动服务还要求同模块恰好有一个
JDT 已确认、被分类为 Spring Boot 的非测试入口,再记录入口类和源码并走上述
JDT 直启流程。继承插件的库模块、只有测试入口的模块和入口位于子模块的父模块
不会因此成为服务;多入口保留独立 Java 配置。具体规则见
[Java 入口点归属](2026-09-21-java-entrypoints-owned-by-jdt.md)。没有入口源码的
用户自建或旧版配置仍保留 Maven goal 兼容路径,自动发现不再生成这类候选。
生成器 revision 随这项行为提升,旧工作区会自动重新生成配置,不要求用户删除
`.lithe/run/generated.json`。

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,24 @@ Windows 会先同步当前编辑文档再请求该操作。两端都不再解析
比对。输入指纹不再对全部 Java 源码做内容哈希(见
`.agents/notes/implemented/bug-fix/2026-09-22-run-panel-fingerprint-loading.md`)。

### Spring 服务也必须有已确认的入口

Maven 的 Spring Boot 插件可以由父模块继承;继承到插件的库模块不一定包含
可运行应用。自动生成时,插件检测只提供候选,只有同一模块中恰好存在一个
JDT 已确认、被分类为 Spring Boot 的非测试入口,Core 才保留服务配置并绑定
入口源码。普通工具 main 和测试 main 仍以独立 Java 入口出现,多个 Spring
入口则保留各自的 Java 配置,不猜测模块默认启动哪个类。

例如父项目把插件传给 `app` 和 `library`,只有 `app` 有确认的应用入口时,
服务列表只增加 `app`。不要因为 `library/nested` 有入口而把 `library` 也当作
服务:匹配使用最具体模块的相等关系,不使用源码路径前缀来借用子模块入口。
模块位置从已确认入口的源码集合推导,不依赖编辑器此次上传的文件列表是否完整。

JDT 尚未完成发现时,已有确认入口可沿用上次生成结果;没有上次结果就不虚构
服务。明确返回空入口后移除自动服务。用户主动保存的旧 Maven-goal 配置仍保留
兼容启动路径;这条规则只收紧自动发现。生成器 revision 同步增加,使旧缓存
不能长期保留误识别的库模块。已有真实服务的 ID 不变,用户覆盖配置仍可关联。

### 不要这样做

- 不要在 Core 或平台代码里写“main 方法长什么样”“测试注解叫什么”的规则,
Expand Down
28 changes: 24 additions & 4 deletions macos/Tests/LitheTests/RunConfigurationIntegrationTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3396,6 +3396,22 @@ struct RunConfigurationIntegrationTests {
#expect(mavenOptions["jvmArguments"] as? [String] == ["-Dlabel=hello world", "-Xmx2g"])
}

private func generateConfirmedSpringService(at root: URL, store: MacRunConfigurationStore) throws {
// A plugin declaration alone no longer establishes an application. Model
// the confirmed JDT entry so these tests exercise persistence, not detection failure.
let sourcePath = "src/main/java/demo/App.java"
let source = root.appendingPathComponent(sourcePath)
try FileManager.default.createDirectory(at: source.deletingLastPathComponent(),
withIntermediateDirectories: true)
try Data("package demo; @SpringBootApplication public class App { public static void main(String[] args) {} }".utf8)
.write(to: source)
_ = try store.generate(at: root, files: [source], modulePaths: [], javaEntrypoints: JavaEntrypoints(
entries: [JavaEntrypoint(sourcePath: sourcePath, mainClass: "demo.App", projectName: "demo")]
))
let resolved = try store.resolve(at: root, toolchainCandidates: [])
_ = try #require(resolved.configurations.first { $0.configuration.id == "spring-boot.maven:demo" })
}

@Test
func serviceToolchainOverridesRoundTripThroughRustConfigurationDocuments() throws {
let core = RustCoreBridge()
Expand All @@ -3412,7 +3428,7 @@ struct RunConfigurationIntegrationTests {
preferences: RunTestKeyValueStore()
)
// Core identifies the project in memory; the workspace holds only user documents.
_ = try store.generate(at: root, files: [], modulePaths: [], javaEntrypoints: nil)
try generateConfirmedSpringService(at: root, store: store)
try store.saveOptions(
RunOptions(
javaHomePath: "/test/jdk-21",
Expand Down Expand Up @@ -3456,7 +3472,7 @@ struct RunConfigurationIntegrationTests {
preferences: RunTestKeyValueStore()
)
// Core identifies the project in memory; the workspace holds only user documents.
_ = try store.generate(at: root, files: [], modulePaths: [], javaEntrypoints: nil)
try generateConfirmedSpringService(at: root, store: store)

try store.saveOptions(
RunOptions(
Expand Down Expand Up @@ -4085,17 +4101,21 @@ struct RunConfigurationIntegrationTests {
storage: storage,
preferences: RunTestKeyValueStore()
)
_ = try store.generate(at: root, files: [], modulePaths: [], javaEntrypoints: nil)
try generateConfirmedSpringService(at: root, store: store)

#expect(throws: (any Error).self) {
do {
try store.saveEditorChanges(
RunOptions(),
toolchain: ProjectToolchainSelection(javaHomePath: "/opt/jdk-21"),
configurationID: "spring-boot.maven:demo",
scope: .project,
at: root
)
Issue.record("Expected the project document write to fail")
} catch let failure as RunConfigurationEditorSaveFailure {
#expect(failure.stage == .write)
}
#expect(storage.writeCount > 0)
#expect(try storage.readData(from: localURL, options: []) == oldLocal)
#expect(try storage.readData(from: projectURL, options: []) == oldProject)
}
Expand Down
83 changes: 43 additions & 40 deletions rust/lithe-core/src/execution/configuration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ const VERSION: u32 = 2;
const LEGACY_VERSION: u32 = 1;
// Bumped when generation changes what a workspace should contain: a detected
// layer from an older generator is reported stale instead of trusted.
const GENERATOR_REVISION: &str = "11";
const GENERATOR_REVISION: &str = "12";
/// 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`.
Expand Down Expand Up @@ -628,7 +628,6 @@ pub fn generate(request: GenerateRequest) -> Result<Value, CoreError> {
.into_iter()
.map(|path| workspace_maven_path(maven_relative_path, &path))
.collect();
let module_paths = inferred_maven_module_paths(&root, &paths, configured_module_paths);
// Note: entry-point ownership is recorded in .agents/notes/implemented/architecture/2026-09-21-java-entrypoints-owned-by-jdt.md
let (mut java_entrypoints, java_entrypoints_origin) = match request.java_entrypoints {
Some(facts) => (facts, "languageService"),
Expand All @@ -645,16 +644,24 @@ pub fn generate(request: GenerateRequest) -> Result<Value, CoreError> {
java_entrypoints
.entries
.retain(|entry| !is_nested_checkout_path(&entry.source_path));
// Module ownership must follow the confirmed source set, even when the
// caller passes an empty or partial editor file list.
let entry_sources = java_entrypoints
.entries
.iter()
.map(|entry| entry.source_path.clone())
.collect::<Vec<_>>();
let module_paths = inferred_maven_module_paths(&root, &entry_sources, configured_module_paths);
let scanned = crate::languages::run_configurations_from_entrypoints(
&root,
&java_entrypoints,
&module_paths,
)?;
let annotated_main_classes = scanned
.main_classes
let spring_entrypoints = scanned
.configurations
.iter()
.filter(|value| value.is_spring_boot)
.map(|value| (value.path.clone(), value.qualified_name.clone()))
.filter(|value| value.kind == "springBoot")
.cloned()
.collect::<Vec<_>>();
let mut maven_owners = BTreeMap::<Option<String>, Option<(PathBuf, String)>>::new();
let configurations = scanned
Expand Down Expand Up @@ -775,7 +782,7 @@ pub fn generate(request: GenerateRequest) -> Result<Value, CoreError> {
maven_root.as_ref().map(|(path, _)| path.as_path()),
&claimed,
)?;
adopt_annotated_main_classes(&mut detected, &annotated_main_classes, maven_relative_path);
retain_confirmed_spring_services(&mut detected, &spring_entrypoints, maven_relative_path);
// Counts real entry points, not documents: the always-present "Current File"
// fallback is excluded so an empty project still reports zero and the UI can
// say so, while a detected npm service correctly reports one.
Expand Down Expand Up @@ -1077,52 +1084,48 @@ fn java_module_identity(module: Option<&str>) -> String {
}
}

/// Copies a scanned `@SpringBootApplication` class onto the module that declares
/// the Maven plugin.
///
/// The detector knows a module is a service but not which class boots it. A
/// unique scanned source lets the language service select the exact target; two
/// candidates remain ambiguous and must not be guessed.
fn adopt_annotated_main_classes(
configurations: &mut [RunConfiguration],
annotated: &[(String, String)],
/// Keeps a Spring service only when JDT confirmed a unique application entry
/// in that exact module. An applied (possibly inherited) Maven plugin is only
/// a launch capability, never proof that a library module is an application.
/// Ambiguous modules retain their individually selectable Java entries.
fn retain_confirmed_spring_services(
configurations: &mut Vec<RunConfiguration>,
entries: &[crate::protocol::JavaRunConfigurationResponse],
maven_root: Option<&str>,
) {
for configuration in configurations
.iter_mut()
.filter(|item| item.provider == "spring-boot.maven")
{
configurations.retain_mut(|configuration| {
if configuration.provider != "spring-boot.maven" {
return true;
}
let Some(module) = configuration.module() else {
continue;
return false;
};
// Exactly one candidate under the module, or none: two classes in one
// module is ambiguous, and guessing would start the wrong service.
let mut matches = annotated
.iter()
.filter(|(path, _)| within_maven_module(path, &module, maven_root))
.map(|(path, qualified_name)| (path, qualified_name));
let Some((source_path, main_class)) = matches.next() else {
continue;
let workspace_module = workspace_maven_path(maven_root, &module);
let mut matches = entries.iter().filter(|entry| {
// A nested module's main cannot make its parent launchable. Test
// mains remain Java entries and do not promote a library to a service.
java_module_identity(entry.module_path.as_deref()) == workspace_module
&& entry.source_set != crate::protocol::JavaSourceSetResponse::Test
});
let Some(entry) = matches.next() else {
return false;
};
if matches.next().is_some() {
continue;
return false;
}
let maven = configuration
.extensions
.entry("maven".to_string())
.or_insert_with(|| json!({}));
if let Some(object) = maven.as_object_mut() {
object.insert("mainClass".to_string(), json!(main_class));
object.insert("mainClass".to_string(), json!(entry.main_class));
}
configuration
.extensions
.insert("java".to_string(), json!({ "source": source_path }));
}
}

fn within_maven_module(path: &str, module: &str, maven_root: Option<&str>) -> bool {
let workspace_module = workspace_maven_path(maven_root, module);
workspace_module == "." || path.starts_with(&format!("{workspace_module}/"))
configuration.extensions.insert(
"java".to_string(),
json!({ "source": entry.source_path, "sourceSet": entry.source_set }),
);
true
});
}

/// Translates detector output into the run-configuration contract.
Expand Down
8 changes: 4 additions & 4 deletions rust/lithe-core/src/execution/detectors/maven.rs
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
//! Maven service discovery from the declared reactor and applied plugins.
//! Maven service candidates from the declared reactor and applied plugins.

use super::super::types::Confidence;
use super::{Detected, DirectoryContext};
use crate::project::{declared_modules, DeclaredModule};
use std::collections::BTreeMap;

/// Plugins that make a module a runnable service, paired with the provider that
/// Plugins that offer a framework launch goal, paired with the provider that
/// records how the launch layer must address it.
///
/// A framework is identified by the plugin it applies rather than by a
Expand Down Expand Up @@ -115,8 +115,8 @@ fn service_names(modules: &[DeclaredModule]) -> BTreeMap<String, String> {

/// These frameworks use project toolchain bindings rather than a command on
/// `PATH`. The main class is deliberately absent: the Java scan supplies it only
/// when a unique `@SpringBootApplication` source was read. Such Spring services
/// launch through JDT metadata; other framework services retain their Maven goal.
/// after JDT confirms a unique Spring application entry in this module. The
/// generator drops unmatched Spring candidates; other frameworks retain their Maven goal.
fn service(
ctx: &DirectoryContext,
module: &DeclaredModule,
Expand Down
Loading
Loading