diff --git a/.agents/notes/implemented/architecture/2026-09-18-java-project-build-and-launch-boundary.md b/.agents/notes/implemented/architecture/2026-09-18-java-project-build-and-launch-boundary.md index b5d14663a9..09f19fff99 100644 --- a/.agents/notes/implemented/architecture/2026-09-18-java-project-build-and-launch-boundary.md +++ b/.agents/notes/implemented/architecture/2026-09-18-java-project-build-and-launch-boundary.md @@ -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`。 diff --git a/.agents/notes/implemented/architecture/2026-09-21-java-entrypoints-owned-by-jdt.md b/.agents/notes/implemented/architecture/2026-09-21-java-entrypoints-owned-by-jdt.md index a0f98c6013..1603de902d 100644 --- a/.agents/notes/implemented/architecture/2026-09-21-java-entrypoints-owned-by-jdt.md +++ b/.agents/notes/implemented/architecture/2026-09-21-java-entrypoints-owned-by-jdt.md @@ -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 方法长什么样”“测试注解叫什么”的规则, diff --git a/macos/Tests/LitheTests/RunConfigurationIntegrationTests.swift b/macos/Tests/LitheTests/RunConfigurationIntegrationTests.swift index 9694fd0302..61e6d9c44b 100644 --- a/macos/Tests/LitheTests/RunConfigurationIntegrationTests.swift +++ b/macos/Tests/LitheTests/RunConfigurationIntegrationTests.swift @@ -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() @@ -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", @@ -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( @@ -4085,9 +4101,9 @@ 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"), @@ -4095,7 +4111,11 @@ struct RunConfigurationIntegrationTests { 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) } diff --git a/rust/lithe-core/src/execution/configuration.rs b/rust/lithe-core/src/execution/configuration.rs index d6c97384e4..cc2c36dd7b 100644 --- a/rust/lithe-core/src/execution/configuration.rs +++ b/rust/lithe-core/src/execution/configuration.rs @@ -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`. @@ -628,7 +628,6 @@ pub fn generate(request: GenerateRequest) -> Result { .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"), @@ -645,16 +644,24 @@ pub fn generate(request: GenerateRequest) -> Result { 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::>(); + 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::>(); let mut maven_owners = BTreeMap::, Option<(PathBuf, String)>>::new(); let configurations = scanned @@ -775,7 +782,7 @@ pub fn generate(request: GenerateRequest) -> Result { 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. @@ -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, + 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. diff --git a/rust/lithe-core/src/execution/detectors/maven.rs b/rust/lithe-core/src/execution/detectors/maven.rs index cb24001246..cdd398bc47 100644 --- a/rust/lithe-core/src/execution/detectors/maven.rs +++ b/rust/lithe-core/src/execution/detectors/maven.rs @@ -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 @@ -115,8 +115,8 @@ fn service_names(modules: &[DeclaredModule]) -> BTreeMap { /// 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, diff --git a/rust/lithe-core/src/tests/detectors.rs b/rust/lithe-core/src/tests/detectors.rs index de0e01a9f6..27fe10c1a8 100644 --- a/rust/lithe-core/src/tests/detectors.rs +++ b/rust/lithe-core/src/tests/detectors.rs @@ -73,6 +73,42 @@ fn generated_configurations(root: &Path) -> Vec { .unwrap() } +/// Supplies explicit upstream facts; the helper never infers mains from a POM. +fn generated_spring_configurations(root: &Path, modules: &[&str]) -> Vec { + let paths = modules + .iter() + .map(|module| { + if *module == "." { + "src/main/java/FixtureApplication.java".to_string() + } else { + format!("{module}/src/main/java/FixtureApplication.java") + } + }) + .collect::>(); + for path in &paths { + let file = root.join(path); + fs::create_dir_all(file.parent().unwrap()).unwrap(); + fs::write(file, "@SpringBootApplication class FixtureApplication { public static void main(String[] args) {} }").unwrap(); + } + let facts = paths + .iter() + .map(|path| (path.as_str(), "FixtureApplication")) + .collect::>(); + let response: Value = serde_json::from_str(&execute_json( + &serde_json::json!({ + "id":"generate-confirmed", "command":"runConfig.generate", + "payload":{"root":root,"paths":paths,"javaEntrypoints":jdt_entrypoints(&facts)} + }) + .to_string(), + )) + .unwrap(); + assert_eq!(response["ok"], true, "{response}"); + response["data"]["generated"]["configurations"] + .as_array() + .unwrap() + .clone() +} + /// The headline behaviour: one project, six ecosystems, every service found /// without the user configuring anything. #[test] @@ -333,7 +369,7 @@ fn every_builtin_detector_produces_a_launch_plan() { fs::write(root.join("Makefile"), "serve:\n\t@echo serve\n").unwrap(); fs::write(root.join("justfile"), "watch:\n echo watch\n").unwrap(); - let configurations = generated_configurations(&root); + let configurations = generated_spring_configurations(&root, &["maven"]); let generated = serde_json::json!({ "version": 2, "configurations": configurations @@ -368,7 +404,7 @@ fn every_builtin_detector_produces_a_launch_plan() { &serde_json::json!({ "id": format!("plan-{provider}"), "command": "runConfig.createLaunchPlan", - "payload": {"root": root, "configurationId": id} + "payload": {"root": root, "configurationId": id, "javaLaunch": {"mainClass":"FixtureApplication","classPaths":["/fixture/classes"],"modulePaths":[]}} }) .to_string(), )) @@ -1022,10 +1058,9 @@ fn python_detector_labels_a_declared_django_project() { fs::remove_dir_all(root).unwrap(); } -/// `spring-boot-maven-plugin` is what makes `spring-boot:run` work, so it -- not -/// an annotation in a source file -- is what decides a module is a service. The -/// configuration runs Maven from the reactor root and addresses the module with -/// `-pl`, which is why `cwd` stays `.` while the module lives in `extensions`. +/// A confirmed Spring entry plus the applied plugin selects the service. The +/// configuration keeps the reactor-relative module identity for toolchain and +/// build selection; confirmed Java launch metadata owns the runtime paths. #[test] fn maven_detector_finds_the_module_that_applies_the_boot_plugin() { let root = temporary_root("detect-maven-boot"); @@ -1047,7 +1082,7 @@ fn maven_detector_finds_the_module_that_applies_the_boot_plugin() { ) .unwrap(); - let configurations = generated_configurations(&root); + let configurations = generated_spring_configurations(&root, &["backend"]); let service = configurations .iter() .find(|item| item["id"] == "spring-boot.maven:backend") @@ -1097,7 +1132,7 @@ fn maven_detector_reads_modules_the_directory_walk_prunes() { ) .unwrap(); - let service = generated_configurations(&root) + let service = generated_spring_configurations(&root, &["build"]) .into_iter() .find(|item| item["id"] == "spring-boot.maven:builder-service") .unwrap(); @@ -1122,7 +1157,7 @@ fn maven_detector_ignores_plugins_that_are_declared_but_not_applied() { .unwrap(); assert!( - !generated_configurations(&root) + !generated_spring_configurations(&root, &["."]) .iter() .any(|item| item["provider"] == "spring-boot.maven"), "pluginManagement must not make a module runnable" @@ -1149,7 +1184,7 @@ fn maven_detector_skips_the_aggregator_that_configures_its_children() { ) .unwrap(); - let ids = generated_configurations(&root) + let ids = generated_spring_configurations(&root, &["service-a"]) .into_iter() .filter(|item| item["provider"] == "spring-boot.maven") .map(|item| item["id"].as_str().unwrap().to_string()) @@ -1183,7 +1218,7 @@ fn maven_detector_keeps_modules_that_share_an_artifact_id() { .unwrap(); } - let ids = generated_configurations(&root) + let ids = generated_spring_configurations(&root, &["team-a/gateway", "team-b/gateway"]) .into_iter() .filter(|item| item["provider"] == "spring-boot.maven") .map(|item| item["id"].as_str().unwrap().to_string()) @@ -1231,7 +1266,7 @@ fn maven_detector_produces_the_same_ids_whatever_paths_the_caller_passes() { &serde_json::json!({ "id": "generate", "command": "runConfig.generate", - "payload": {"root": root, "paths": paths} + "payload": {"root": root, "paths": paths, "javaEntrypoints": jdt_entrypoints(&[("backend/src/main/java/com/demo/DemoApplication.java", "com.demo.DemoApplication")])} }) .to_string(), )) @@ -1301,7 +1336,7 @@ fn maven_detector_separates_the_frameworks_by_the_plugin_each_module_applies() { ) .unwrap(); - let configurations = generated_configurations(&root); + let configurations = generated_spring_configurations(&root, &["boot-api"]); for (id, provider, module) in [ ( "spring-boot.maven:boot-api", @@ -1377,10 +1412,14 @@ fn maven_detector_matches_plugins_by_coordinates_not_by_artifact_id_alone() { .unwrap(); } - let ids = generated_configurations(&root) - .into_iter() - .filter_map(|item| item["id"].as_str().map(str::to_string)) - .collect::>(); + let ids = generated_spring_configurations( + &root, + &["custom-group", "bare-name", "expression-group", "real-boot"], + ) + .into_iter() + .filter(|item| item["provider"] == "spring-boot.maven") + .filter_map(|item| item["id"].as_str().map(str::to_string)) + .collect::>(); assert!( ids.contains(&"spring-boot.maven:real-boot".to_string()), @@ -1746,7 +1785,7 @@ fn maven_detector_inherits_the_boot_plugin_from_the_aggregator() { ) .unwrap(); - let ids = generated_configurations(&root) + let ids = generated_spring_configurations(&root, &["service-web"]) .into_iter() .filter(|item| item["provider"] == "spring-boot.maven") .map(|item| item["id"].as_str().unwrap().to_string()) @@ -1780,7 +1819,7 @@ fn maven_detector_inherits_the_boot_plugin_across_several_levels() { ) .unwrap(); - let ids = generated_configurations(&root) + let ids = generated_spring_configurations(&root, &["team/service-web"]) .into_iter() .filter(|item| item["provider"] == "spring-boot.maven") .map(|item| item["id"].as_str().unwrap().to_string()) @@ -1811,7 +1850,7 @@ fn maven_detector_does_not_inherit_a_plugin_managed_parent() { .unwrap(); assert!( - !generated_configurations(&root) + !generated_spring_configurations(&root, &["service-web"]) .iter() .any(|item| item["provider"] == "spring-boot.maven"), "a managed plugin must stay inert even when a parent declares it" @@ -1839,7 +1878,7 @@ fn maven_detector_does_not_treat_aggregation_as_inheritance() { ) .unwrap(); - let ids = generated_configurations(&root) + let ids = generated_spring_configurations(&root, &["service-web"]) .into_iter() .filter(|item| item["provider"] == "spring-boot.maven") .map(|item| item["id"].as_str().unwrap().to_string()) @@ -1874,7 +1913,7 @@ fn maven_detector_does_not_match_a_parent_by_artifact_id_alone() { ) .unwrap(); - let providers = generated_configurations(&root) + let providers = generated_spring_configurations(&root, &["service-web"]) .into_iter() .filter(|item| { item["id"] == "spring-boot.maven:service-web" @@ -1910,7 +1949,7 @@ fn maven_detector_does_not_inherit_from_a_different_parent_version() { .unwrap(); assert!( - !generated_configurations(&root) + !generated_spring_configurations(&root, &["service-web"]) .iter() .any(|item| item["provider"] == "spring-boot.maven"), "a version the reactor does not contain supplies nothing" @@ -1949,7 +1988,7 @@ fn maven_detector_inherits_from_the_reactor_module_its_coordinates_name() { ) .unwrap(); - let providers = generated_configurations(&root) + let providers = generated_spring_configurations(&root, &["service-web"]) .into_iter() .filter(|item| { item["id"] == "spring-boot.maven:service-web" @@ -1985,7 +2024,7 @@ fn maven_detector_stops_at_a_plugin_that_opts_out_of_inheritance() { .unwrap(); assert!( - !generated_configurations(&root) + !generated_spring_configurations(&root, &["service-web"]) .iter() .any(|item| item["provider"] == "spring-boot.maven"), "a plugin marked non-inheritable must not reach the child" @@ -2024,7 +2063,7 @@ fn maven_detector_keeps_a_plugin_stopped_across_several_levels() { .unwrap(); assert!( - !generated_configurations(&root) + !generated_spring_configurations(&root, &["b/c", "b/c/d"]) .iter() .any(|item| item["provider"] == "spring-boot.maven"), "a plugin stopped at `b` must not reappear on `c` or `d`" @@ -2060,7 +2099,7 @@ fn maven_detector_restores_a_plugin_the_child_declares_itself() { ) .unwrap(); - let ids = generated_configurations(&root) + let ids = generated_spring_configurations(&root, &["b/c/d"]) .into_iter() .filter(|item| item["provider"] == "spring-boot.maven") .map(|item| item["id"].as_str().unwrap().to_string()) @@ -2091,7 +2130,7 @@ fn maven_detector_does_not_inherit_through_an_unresolved_expression() { ) .unwrap(); - let providers = generated_configurations(&root) + let providers = generated_spring_configurations(&root, &["service-web"]) .into_iter() .filter(|item| { item["id"] == "spring-boot.maven:service-web" @@ -2126,7 +2165,7 @@ fn maven_detector_still_inherits_through_literal_coordinates() { ) .unwrap(); - let ids = generated_configurations(&root) + let ids = generated_spring_configurations(&root, &["service-web"]) .into_iter() .filter(|item| item["provider"] == "spring-boot.maven") .map(|item| item["id"].as_str().unwrap().to_string()) @@ -2162,7 +2201,7 @@ fn maven_detector_inherits_plugins_by_coordinates_not_by_artifact_id_alone() { ) .unwrap(); - let ids = generated_configurations(&root) + let ids = generated_spring_configurations(&root, &["quarkus-module", "plain-module"]) .into_iter() .filter_map(|item| item["id"].as_str().map(str::to_string)) .collect::>(); @@ -2201,7 +2240,7 @@ fn maven_detector_child_declaration_does_not_hide_a_same_named_inherited_plugin( ) .unwrap(); - let ids = generated_configurations(&root) + let ids = generated_spring_configurations(&root, &["service-web", "plain-inherit"]) .into_iter() .filter_map(|item| item["id"].as_str().map(str::to_string)) .collect::>(); diff --git a/rust/lithe-core/src/tests/run_configuration.rs b/rust/lithe-core/src/tests/run_configuration.rs index de69ebb268..d60410f106 100644 --- a/rust/lithe-core/src/tests/run_configuration.rs +++ b/rust/lithe-core/src/tests/run_configuration.rs @@ -6,6 +6,98 @@ use std::collections::BTreeMap; use std::fs; use std::path::PathBuf; +/// A Maven goal is not evidence of a launchable application. Only confirmed +/// production entries can promote that exact module to a Spring service. +#[test] +fn spring_services_require_unique_confirmed_entries_in_their_own_module() { + let root = temporary_root("spring-confirmed-services"); + struct Cleanup(PathBuf); + impl Drop for Cleanup { + fn drop(&mut self) { + crate::execution::generated_layer::forget(&self.0); + let _ = fs::remove_dir_all(&self.0); + } + } + let _cleanup = Cleanup(root.clone()); + fs::create_dir_all(&root).unwrap(); + let modules = ["app", "library", "library/nested", "ambiguous", "test-only"]; + let declarations = modules + .iter() + .map(|module| format!("{module}")) + .collect::(); + fs::write(root.join("pom.xml"), format!( + "exampleparent1pom{declarations}org.springframework.bootspring-boot-maven-plugin" + )).unwrap(); + for module in modules { + fs::create_dir_all(root.join(module)).unwrap(); + let parent = if module.contains('/') { + "../../pom.xml" + } else { + "../pom.xml" + }; + fs::write(root.join(module).join("pom.xml"), format!( + "exampleparent1{parent}{}", module.replace('/', "-") + )).unwrap(); + } + let sources = [ + ("app/src/main/java/App.java", "App", true), + ("library/src/main/java/Tool.java", "Tool", false), + ("library/nested/src/main/java/Nested.java", "Nested", true), + ("ambiguous/src/main/java/First.java", "First", true), + ("ambiguous/src/main/java/Second.java", "Second", true), + ("test-only/src/test/java/TestApp.java", "TestApp", true), + ]; + for (path, class, spring) in sources { + let path = root.join(path); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + let annotation = if spring { + "@SpringBootApplication " + } else { + "" + }; + fs::write( + path, + format!("{annotation}class {class} {{ public static void main(String[] args) {{}} }}"), + ) + .unwrap(); + } + let paths = sources.iter().map(|(path, _, _)| *path).collect::>(); + let facts = sources + .iter() + .map(|(path, class, _)| (*path, *class)) + .collect::>(); + let generated = generate_with(&root, &paths, Some(jdt_entrypoints(&facts))); + assert_eq!(generated["ok"], true, "{generated}"); + let entries = generated["data"]["generated"]["configurations"] + .as_array() + .unwrap(); + let services = entries + .iter() + .filter(|entry| entry["provider"] == "spring-boot.maven") + .map(|entry| entry["extensions"]["maven"]["module"].as_str().unwrap()) + .collect::>(); + assert_eq!(services, ["app", "library/nested"]); + // Classification must not suppress JDT's test, utility or ambiguous mains. + assert_eq!( + entries + .iter() + .filter(|entry| entry["provider"] == "java.main") + .count(), + sources.len() + ); + let pending = generate_with(&root, &paths, None); + assert_eq!( + pending["data"]["generated"]["configurations"], + generated["data"]["generated"]["configurations"] + ); + let removed = generate_with(&root, &paths, Some(jdt_entrypoints(&[]))); + assert!(!removed["data"]["generated"]["configurations"] + .as_array() + .unwrap() + .iter() + .any(|entry| entry["provider"] == "spring-boot.maven")); +} + #[test] fn saved_provider_tasks_do_not_suppress_detection_after_restart() { let root = temporary_root("saved-task-detection"); @@ -26,6 +118,13 @@ fn saved_provider_tasks_do_not_suppress_detection_after_restart() { spring-boot-maven-plugin"#, ) .unwrap(); + let source = "src/main/java/App.java"; + fs::create_dir_all(root.join("src/main/java")).unwrap(); + fs::write( + root.join(source), + "@SpringBootApplication class App { public static void main(String[] args) {} }", + ) + .unwrap(); let call = |command: &str, mut payload: Value| -> Value { payload["root"] = serde_json::json!(root); let response: Value = serde_json::from_str(&execute_json( @@ -60,7 +159,7 @@ fn saved_provider_tasks_do_not_suppress_detection_after_restart() { assert_eq!(inspected["requiresGeneration"], true); call( "runConfig.generate", - serde_json::json!({"paths":["pom.xml"]}), + serde_json::json!({"paths":["pom.xml",source],"javaEntrypoints":jdt_entrypoints(&[(source,"App")])}), ); let inspected = call( "runConfig.inspect", @@ -225,7 +324,7 @@ fn run_configuration_commands_generate_merge_and_plan() { fs::write(root.join("src/main/java/com/example/App.java"), "package com.example; @SpringBootApplication class App { public static void main(String[] args) {} }").expect("source should be writable"); fs::write(root.join("pom.xml"), "demo21org.springframework.bootspring-boot-maven-plugin").expect("pom should be writable"); - let request = serde_json::json!({"id":"generate","command":"runConfig.generate","payload":{"root":root,"paths":["src/main/java/com/example/App.java"],"modulePaths":[]}}); + let request = serde_json::json!({"id":"generate","command":"runConfig.generate","payload":{"root":root,"paths":["src/main/java/com/example/App.java"],"modulePaths":[],"javaEntrypoints":jdt_entrypoints(&[("src/main/java/com/example/App.java","com.example.App")])}}); let generated: Value = serde_json::from_str(&execute_json(&request.to_string())) .expect("generate response should be JSON"); assert_eq!(generated["ok"], true); diff --git a/shared/contracts/application-boundary.md b/shared/contracts/application-boundary.md index ad260b5f59..f50c1422d0 100644 --- a/shared/contracts/application-boundary.md +++ b/shared/contracts/application-boundary.md @@ -695,7 +695,7 @@ source target, Java Debug Server builds the owning project and resolves its runtime classpath/module path, Core projects those structured paths into a direct JDK launch, and the Run host starts that one JVM. Maven still defines the project model, but Run never sends either target through a reactor-wide Java -launch goal. A Spring Boot service without a resolved Java entry source retains +launch goal. A user-authored or legacy Spring Boot service without a resolved Java entry source retains its Maven-goal compatibility path. Run and Debug share the Java preparation path so module selection, generated sources, test-source mains, and dependency paths do not drift. diff --git a/shared/contracts/rust-core-api.md b/shared/contracts/rust-core-api.md index 2cd048b917..01f50e6d32 100644 --- a/shared/contracts/rust-core-api.md +++ b/shared/contracts/rust-core-api.md @@ -3302,6 +3302,16 @@ selected Node installation. They do not fall back to a PATH shim from another installation, and Windows executable extensions take precedence over extensionless shell scripts. +Automatic Spring service generation requires both an applied Maven plugin and +exactly one JDT-confirmed, Spring-classified non-test entry in the same module. +An inherited plugin alone cannot promote a library to a runnable service. +Nested-module entries do not promote their parent. Ambiguous, utility and test +mains remain independently selectable Java entries. Module assignment consumes +the confirmed source set even when the caller supplies no editor paths. +Absent discovery may reuse previously confirmed entries; an explicit empty result +removes automatic services. Existing service IDs and user-authored Maven-goal +compatibility configurations retain their established meaning. + `runConfig.createLaunchPlan` accepts `root`, `configurationId`, optional `currentFile` and `classPath`, optional `debugPort`, and optional `localDocument`. Maven-backed Run and Debug callers may also supply the same diff --git a/shared/platform-feature-matrix/features/run-discovery.json b/shared/platform-feature-matrix/features/run-discovery.json index ac0895af4c..988865d8c6 100644 --- a/shared/platform-feature-matrix/features/run-discovery.json +++ b/shared/platform-feature-matrix/features/run-discovery.json @@ -16,7 +16,8 @@ "rust/lithe-core/src/execution/workspace_scan.rs", "rust/lithe-core/src/execution/configuration.rs", "rust/lithe-core/src/execution/generated_layer.rs", - "macos/Sources/Lithe/Platform/MacOS/RunConfiguration/MacRunConfigurationStore.swift" + "macos/Sources/Lithe/Platform/MacOS/RunConfiguration/MacRunConfigurationStore.swift", + "rust/lithe-core/src/tests/run_configuration.rs" ], "implementationStatus": "implemented", "verificationStatus": "pending" @@ -46,12 +47,13 @@ "rust/lithe-core/src/execution/workspace_scan.rs", "rust/lithe-core/src/execution/configuration.rs", "rust/lithe-core/src/execution/generated_layer.rs", - "windows/tauri/src/features/run/stores/run-project-load.test.ts" + "windows/tauri/src/features/run/stores/run-project-load.test.ts", + "rust/lithe-core/src/tests/run_configuration.rs" ], "implementationStatus": "implemented", "verificationStatus": "pending" }, "owner": "Run", - "verification": "验证 Spring Boot、Java、Maven、Gradle、npm、Cargo、Go、Python 和 Docker Compose 入口识别;Maven 框架 plugin 只在父 POM 声明时,子模块沿 链继承后仍识别为对应服务,父 POM 按 groupId:artifactId:version 完整坐标匹配、仅同名 artifactId 或版本不符的不继承、含未解析 ${...} 表达式的坐标不作数,聚合但未声明 的模块不继承、false 的 plugin 不下传且对更深层级保持阻断、 声明保持不生效、框架 plugin 按完整坐标 groupId:artifactId 识别:每个框架的全部官方坐标组均被接受(Quarkus 的 io.quarkus 与平台再发布的 io.quarkus.platform,Micronaut 3.x 的 io.micronaut.build 与 4+ 的 io.micronaut.maven,Spring Boot 的 org.springframework.boot)、省略 的声明按 Maven 默认组 org.apache.maven.plugins 处理,其他组下的同名插件与含 ${...} 表达式的 group 不识别为框架,子模块的同名自定义 plugin 也不遮蔽父继承的正品;源码根与报告目录的提取同样按完整坐标匹配,build-helper 执行级源根与 Surefire/Failsafe 独立 reportsDirectory 均有官方组与自定义组对照;Java 运行入口缺少主类时 inspect 报 staleJavaEntrypoint 并提示重新生成,重新生成后由 JDT 答案恢复;多模块项目把服务工作目录改成模块目录后,服务仍留在列表中并以该目录启动;填写不存在的目录或 ${workspaceFolder} 等变量时,保存被拒绝并提示原因;在 Windows 确认重新扫描服务使用双箭头刷新图标,配置按语言生态、类型与实例呈现三栏布局,Java 下展示 Maven、Gradle 与 Spring Boot 等已识别类型,Node.js 下展示包管理器与框架;语言切换和外部选中能定位配置,空生态不显示,语言和配置栏不显示重复栏头,生态不显示配置数量;语言侧栏通过拖动阈值折叠和展开,收起与展开阈值不同以避免边界跳动,不显示专门折叠按钮;键盘可调整宽度及恢复展开,保存展开宽度与拖动折叠状态,拖动期间只更新局部宽度和紧凑标记,文字与图标对齐即时切换,结束后保存;语言行、类型行和实例行使用一致的紧凑行高,去掉列表上下留白和选中实例的圆角边距;窄面板自动缩成带提示的图标栏且扩大后恢复,右侧输出保留可用空间,窗口缩放或卸载时清理正在进行的拖动;所选配置的日志、运行状态、顶部停止按钮、清空输出与标准输入均指向其自身会话;保持选中 application 并后台启动服务后,顶部停止仍指向 primary,不停止后台服务,左侧使用 SVG 箭头和类型图标,基础设施默认折叠,选择配置自动展开所在分组,运行、停止、编辑和完整名称提示可用;根据 Core 提供的 npm、Gradle 和 Python 元数据展示明确识别的框架图标,构建与测试任务保留工具图标,未知类型回退通用图标;同名自定义 provider 与已知框架分别分组且折叠状态独立,相同已知框架跨 Maven/Gradle provider 合组,项目与基础设施不合组;Java 生态分组使用 Java 文件类型图标,Java 启动类实例使用原始 Class 与 RunnableMark SVG 同画布叠加图标;Maven 启动的框架服务使用 MavenRunConfigurationType 对应的 Maven 图标,Make 外部任务分组使用任务文件夹、实例使用 Shell 配置图标,均保留原始明暗 SVG,运行或失败不改变类型图标,深浅主题均可辨认;macOS Run 缺少配置且可重新生成时,检查空状态文字与链接在深浅主题下按 IDEA 工具窗口样式显示、窄面板不截断,并确认“识别并生成”仍触发原有生成流程;应用语言设为英文而系统语言为中文时,Run 空状态及其他本面板文案仍应显示英文;Run 顶部标题字号、颜色、41pt 高度和底部分隔线应与 Terminal、Git Log 等工具窗口一致;有项目配置或 Current File 正在运行/已有退出结果时,运行、重启、重新扫描和软换行按钮显示在独立的 36pt 操作栏,检查 16pt IDEA 图标、22pt 共享按钮的悬停、按下、选中及禁用状态;操作栏只保留一个运行/重运行动作:首次启动显示 Run,当前配置已运行过后显示 Rerun;右侧固定停止按钮,未运行禁用、运行中启用,两者始终指向当前选中配置;操作栏不重复显示 Running 圆点及状态文字,重运行按钮复用顶部工具栏的绿色 rerun_stroke 图标;标题栏菜单中的清空输出与原操作一致,收起按钮沿用共享样式;仅有 Current File 的项目启动后也可从 Run 操作栏停止,结束后保留重运行入口;Run 内容区不额外插入 Java 项目准备状态行,准备进度和失败入口保留在工作台状态栏;Run 标题、操作栏、配置列表标题、控制台标签底边及两侧纵向分割线统一使用工具窗口边框颜色和 1pt 绘制,左侧图标栏不出现 5pt 实心粗线,栏内分组线与 Git Log 一致使用共享 divider 颜色、22pt 宽度、1pt 高度及上下 3pt 间距;操作按钮组之间使用专用 ActionToolbar.separator,详情编辑按钮的边框由共享按钮样式负责,拖动命中范围保持不变;配置树直接显示分组与数量,不显示 Application types 标题;默认隐藏勾选框,通过更多菜单或右键进入批量选择,退出清空选择;配置单行显示名称,类型及服务地址放到悬停提示,行内仅运行/停止,编辑/固定/打开服务保留在共享右键菜单,固定后显示标记;分组与配置使用共享树行高、缩进及焦点选择样式;Run 范围筛选以固定图标栏呈现,应用范围筛选及当前运行导航使用中性 ToolWindowRun 图标,实际运行操作继续使用绿色 Execute 图标;全部采用 IDEA groups、服务采用 server 图标,固定、应用及任务沿用对应 IDEA 原始资源;检查所有图标的 16pt 尺寸、明暗 SVG 自动切换、提示和筛选,右键勾选范围和配置行勾选共享同一批量选择;配置行使用 IDEA 固定、编辑、运行及停止图标,悬停与点击维持箭头光标;配置过期提示使用共享中性通知表面及警告图标,操作仍可重新识别;Run 标题栏仅显示 Run,移除重复配置名与右侧状态胶囊;Console 和 Configuration details 标签位于运行操作按钮组右侧,配置列表选中项表示当前配置,内容区直接显示日志或详情;配置详情使用共享 13pt 字体、原生 Grid 自适应标签列、可选择且可换行的路径及值,编辑配置按钮复用共享 LitheCommitDialogButtonStyle 与 IDEA EditSource 图标,检查深浅主题、窄列及点击跳转设置,服务链接保留原动作;两个标签使用共享 LitheToolWindowTabStyle,检查 13pt 文字、28pt 标签、原始明暗 SVG、悬停及内容区聚焦/失焦状态,保留标签切换;Run 与 Debug 均启用共享 OutputTextView 的 IDEA 渲染入口,运行日志采用一致的 13pt 字体、1.2 倍行高和 ANSI 配色,不给普通 INFO 日志整行染蓝,源码链接继续跳转;Copy 与 Jump to latest 为共享图标按钮,复制不带 ANSI,向上滚动后可恢复跟随;其他配置恢复状态保留各自提示。 Current File 运行一次后修改未保存并切换编辑标签,重运行应先保存原运行文件再构建启动计划;原文件保存失败时提示错误并中止重启,当前焦点文档不被误保存;调整 Run 面板高度,外层横向操作栏固定为 36pt,剩余高度交给配置树和控制台。 缓存目录不可访问时仍可发现兄弟目录和隐藏源码;可选子目录不可读时保留其旧配置并显示 incompleteProjectScan,不误报删除或新鲜;根目录不可读仍失败,完整重新生成后解除未知基线。 两端 Run 面板显示扫描不完整的共享中英文提示;恢复访问权限后重新识别。 Java 文件枚举达到 8,000 条上限后不再访问后续目录,返回顺序稳定;不可读兄弟目录不阻断结果,完整指纹扫描仍继续遍历。 保存 Maven/Gradle 构建任务后重启,确认用户任务和 Spring Boot 等自动入口同时恢复;再次加载不重复生成。", + "verification": "验证 Spring Boot、Java、Maven、Gradle、npm、Cargo、Go、Python 和 Docker Compose 入口识别;Maven 框架 plugin 只在父 POM 声明时,子模块沿 链继承后仍识别为对应服务,父 POM 按 groupId:artifactId:version 完整坐标匹配、仅同名 artifactId 或版本不符的不继承、含未解析 ${...} 表达式的坐标不作数,聚合但未声明 的模块不继承、false 的 plugin 不下传且对更深层级保持阻断、 声明保持不生效、框架 plugin 按完整坐标 groupId:artifactId 识别:每个框架的全部官方坐标组均被接受(Quarkus 的 io.quarkus 与平台再发布的 io.quarkus.platform,Micronaut 3.x 的 io.micronaut.build 与 4+ 的 io.micronaut.maven,Spring Boot 的 org.springframework.boot)、省略 的声明按 Maven 默认组 org.apache.maven.plugins 处理,其他组下的同名插件与含 ${...} 表达式的 group 不识别为框架,子模块的同名自定义 plugin 也不遮蔽父继承的正品;源码根与报告目录的提取同样按完整坐标匹配,build-helper 执行级源根与 Surefire/Failsafe 独立 reportsDirectory 均有官方组与自定义组对照;Java 运行入口缺少主类时 inspect 报 staleJavaEntrypoint 并提示重新生成,重新生成后由 JDT 答案恢复;多模块项目把服务工作目录改成模块目录后,服务仍留在列表中并以该目录启动;填写不存在的目录或 ${workspaceFolder} 等变量时,保存被拒绝并提示原因;在 Windows 确认重新扫描服务使用双箭头刷新图标,配置按语言生态、类型与实例呈现三栏布局,Java 下展示 Maven、Gradle 与 Spring Boot 等已识别类型,Node.js 下展示包管理器与框架;语言切换和外部选中能定位配置,空生态不显示,语言和配置栏不显示重复栏头,生态不显示配置数量;语言侧栏通过拖动阈值折叠和展开,收起与展开阈值不同以避免边界跳动,不显示专门折叠按钮;键盘可调整宽度及恢复展开,保存展开宽度与拖动折叠状态,拖动期间只更新局部宽度和紧凑标记,文字与图标对齐即时切换,结束后保存;语言行、类型行和实例行使用一致的紧凑行高,去掉列表上下留白和选中实例的圆角边距;窄面板自动缩成带提示的图标栏且扩大后恢复,右侧输出保留可用空间,窗口缩放或卸载时清理正在进行的拖动;所选配置的日志、运行状态、顶部停止按钮、清空输出与标准输入均指向其自身会话;保持选中 application 并后台启动服务后,顶部停止仍指向 primary,不停止后台服务,左侧使用 SVG 箭头和类型图标,基础设施默认折叠,选择配置自动展开所在分组,运行、停止、编辑和完整名称提示可用;根据 Core 提供的 npm、Gradle 和 Python 元数据展示明确识别的框架图标,构建与测试任务保留工具图标,未知类型回退通用图标;同名自定义 provider 与已知框架分别分组且折叠状态独立,相同已知框架跨 Maven/Gradle provider 合组,项目与基础设施不合组;Java 生态分组使用 Java 文件类型图标,Java 启动类实例使用原始 Class 与 RunnableMark SVG 同画布叠加图标;Maven 启动的框架服务使用 MavenRunConfigurationType 对应的 Maven 图标,Make 外部任务分组使用任务文件夹、实例使用 Shell 配置图标,均保留原始明暗 SVG,运行或失败不改变类型图标,深浅主题均可辨认;macOS Run 缺少配置且可重新生成时,检查空状态文字与链接在深浅主题下按 IDEA 工具窗口样式显示、窄面板不截断,并确认“识别并生成”仍触发原有生成流程;应用语言设为英文而系统语言为中文时,Run 空状态及其他本面板文案仍应显示英文;Run 顶部标题字号、颜色、41pt 高度和底部分隔线应与 Terminal、Git Log 等工具窗口一致;有项目配置或 Current File 正在运行/已有退出结果时,运行、重启、重新扫描和软换行按钮显示在独立的 36pt 操作栏,检查 16pt IDEA 图标、22pt 共享按钮的悬停、按下、选中及禁用状态;操作栏只保留一个运行/重运行动作:首次启动显示 Run,当前配置已运行过后显示 Rerun;右侧固定停止按钮,未运行禁用、运行中启用,两者始终指向当前选中配置;操作栏不重复显示 Running 圆点及状态文字,重运行按钮复用顶部工具栏的绿色 rerun_stroke 图标;标题栏菜单中的清空输出与原操作一致,收起按钮沿用共享样式;仅有 Current File 的项目启动后也可从 Run 操作栏停止,结束后保留重运行入口;Run 内容区不额外插入 Java 项目准备状态行,准备进度和失败入口保留在工作台状态栏;Run 标题、操作栏、配置列表标题、控制台标签底边及两侧纵向分割线统一使用工具窗口边框颜色和 1pt 绘制,左侧图标栏不出现 5pt 实心粗线,栏内分组线与 Git Log 一致使用共享 divider 颜色、22pt 宽度、1pt 高度及上下 3pt 间距;操作按钮组之间使用专用 ActionToolbar.separator,详情编辑按钮的边框由共享按钮样式负责,拖动命中范围保持不变;配置树直接显示分组与数量,不显示 Application types 标题;默认隐藏勾选框,通过更多菜单或右键进入批量选择,退出清空选择;配置单行显示名称,类型及服务地址放到悬停提示,行内仅运行/停止,编辑/固定/打开服务保留在共享右键菜单,固定后显示标记;分组与配置使用共享树行高、缩进及焦点选择样式;Run 范围筛选以固定图标栏呈现,应用范围筛选及当前运行导航使用中性 ToolWindowRun 图标,实际运行操作继续使用绿色 Execute 图标;全部采用 IDEA groups、服务采用 server 图标,固定、应用及任务沿用对应 IDEA 原始资源;检查所有图标的 16pt 尺寸、明暗 SVG 自动切换、提示和筛选,右键勾选范围和配置行勾选共享同一批量选择;配置行使用 IDEA 固定、编辑、运行及停止图标,悬停与点击维持箭头光标;配置过期提示使用共享中性通知表面及警告图标,操作仍可重新识别;Run 标题栏仅显示 Run,移除重复配置名与右侧状态胶囊;Console 和 Configuration details 标签位于运行操作按钮组右侧,配置列表选中项表示当前配置,内容区直接显示日志或详情;配置详情使用共享 13pt 字体、原生 Grid 自适应标签列、可选择且可换行的路径及值,编辑配置按钮复用共享 LitheCommitDialogButtonStyle 与 IDEA EditSource 图标,检查深浅主题、窄列及点击跳转设置,服务链接保留原动作;两个标签使用共享 LitheToolWindowTabStyle,检查 13pt 文字、28pt 标签、原始明暗 SVG、悬停及内容区聚焦/失焦状态,保留标签切换;Run 与 Debug 均启用共享 OutputTextView 的 IDEA 渲染入口,运行日志采用一致的 13pt 字体、1.2 倍行高和 ANSI 配色,不给普通 INFO 日志整行染蓝,源码链接继续跳转;Copy 与 Jump to latest 为共享图标按钮,复制不带 ANSI,向上滚动后可恢复跟随;其他配置恢复状态保留各自提示。 Current File 运行一次后修改未保存并切换编辑标签,重运行应先保存原运行文件再构建启动计划;原文件保存失败时提示错误并中止重启,当前焦点文档不被误保存;调整 Run 面板高度,外层横向操作栏固定为 36pt,剩余高度交给配置树和控制台。 缓存目录不可访问时仍可发现兄弟目录和隐藏源码;可选子目录不可读时保留其旧配置并显示 incompleteProjectScan,不误报删除或新鲜;根目录不可读仍失败,完整重新生成后解除未知基线。 两端 Run 面板显示扫描不完整的共享中英文提示;恢复访问权限后重新识别。 Java 文件枚举达到 8,000 条上限后不再访问后续目录,返回顺序稳定;不可读兄弟目录不阻断结果,完整指纹扫描仍继续遍历。 保存 Maven/Gradle 构建任务后重启,确认用户任务和 Spring Boot 等自动入口同时恢复;再次加载不重复生成。 自动 Spring 服务必须同时具备 Maven 插件和同模块唯一的 JDT 已确认非测试 Spring 入口;继承插件的库模块、只有测试 main 的模块、只有子模块入口的父模块不能成为服务,多入口保留独立 Java 配置。验证传空 paths 不影响确认入口的模块归属、JDT 未就绪保留已确认结果、明确空结果移除自动服务,已有服务 ID 和用户配置保持兼容。", "notes": " Windows普通Java文件的main入口在工作区搜索遗漏时,先保存并让JDT对当前文件确认,再生成命名运行配置;拒绝过期入口、准备失败和工作区切换结果。Java入口源码按容错UTF-8读取(非法字节替换为占位符),GBK项目不再因单个非UTF-8文件导致整个运行列表生成失败,Spring Boot注解等ASCII分类标记不受影响。 macOS 运行就绪要求匹配快照且工具链和配置选择加载完成,加载中的清单不可作为启动许可;受控工具链与快照测试验证完成前后状态,以及 Current File 不被迟到的默认配置覆盖。 远程工作区尚未接入运行配置发现:Core 对 runConfig.inspect 返回 NotSupported,仅 runConfig.selectJava 可用;共享扫描的远程契约与进度见 remote-development-workspace。 识别结果只保存在 Core 进程内存中,宿主不再写入工作区的 .lithe/run/generated.json、.lithe/toolchains/requirements.json、.lithe/project.json 和 .lithe/.gitignore;上次的 Java 入口缓存在应用缓存目录(run-configurations/),旧版本留下的 generated.json 只作只读的上次入口来源。本进程尚未识别的工作区在加载时自动识别(只读扫描、不启动进程),自动识别不提示入口数量;未写 defaultRunConfiguration 时由 Core 优先选框架服务。识别前 JDK 选择从 pom.xml、.java-version 与 Maven Wrapper 推导最低版本。 保存过构建任务的工作区重启后仍按 Core requiresGeneration 标记自动识别,保留用户任务并恢复其他检测入口;同一进程完成识别后不重复生成。" }