diff --git a/.agents/notes/implemented/architecture/2026-09-26-maven-dependency-tree-output-file.md b/.agents/notes/implemented/architecture/2026-09-26-maven-dependency-tree-output-file.md new file mode 100644 index 000000000..faa2f618a --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-26-maven-dependency-tree-output-file.md @@ -0,0 +1,152 @@ +# Agent 笔记:Maven 依赖树从插件输出文件读取 + +状态:已实现 + +## 先说结论 + +Maven 工具窗口的依赖树以前是从 Maven 进程的整段控制台输出里解析出来的。 +依赖树和普通日志共用 500,000 字符的上限,大型项目(例如 6,000 个依赖节点, +约 700 KB 文本)会报 `Maven dependency output exceeded the supported limit.`, +依赖根本打不开(#890)。 + +现在依赖树由 `maven-dependency-plugin` 通过 `-DoutputFile` 写进一个平台临时 +文件,Rust Core 从文件读取并解析。控制台输出只当日志,不再参与数据解析。 +开发者以后要记住:**依赖数据走文件,日志走控制台,两条通道不能再合并**; +临时文件由平台创建和删除,Core 只负责校验和解析。 + +## 问题 + +- 依赖数据和日志混在同一个通道里。Maven 下载、警告、插件日志都占用依赖树 + 的预算,合法的大型树会因为日志多而失败。 +- macOS 与 Windows 各自在平台层累积整段输出、各自计数(Swift 按字符簇、 + TypeScript 按 UTF-16、Core 按 Unicode 标量),同一份输出在三处的“长度” + 不一样。 +- 解析器会跳过任何看不懂的行。项目 POM 如果覆盖了插件的输出格式,结果会被 + 当成一棵“更短的树”显示成功,而不是报错。 +- verbose 模式里的“依赖管理前版本/scope(`version managed from`、 + `scope managed from`)”和“scope 调解(`scope updated from`、 + `scope not updated to`)”被直接丢弃,界面无法说明某个版本为什么是现在这样。 + +## 决策 + +### 数据来源 + +- `maven.dependencyPlan` 必须收到平台提供的绝对路径 `outputFile`。Core 生成 + 固定调用:`maven-dependency-plugin:3.8.1:tree -Dverbose=true + -DoutputType=text -Dtokens=standard -DoutputFile= + -DoutputEncoding=UTF-8 -DappendOutput=false`。影响格式的属性全部显式 + 传入,用户或项目默认值不能改变 Core 要解析的格式。 +- 一次调用只运行一个工程:模块查询用 `-pl `,reactor 根用 `-N`。 + 否则 reactor 里每个工程都会覆盖同一个文件,根查询会显示 Maven 最后访问的 + 那个模块。 +- 依赖语义仍归 Maven:冲突调解、依赖管理、scope 调解都由 Maven 的解析库 + (Maven Resolver 的 verbose 冲突模式)决定。Core 只把每个节点的标注翻译 + 成显式字段:`resolution`、`selectedVersion`、`premanagedVersion`、 + `premanagedScope`、`originalScope`、`ignoredScope`。字段语义与 IntelliJ + IDEA 的 `MavenArtifactNode` 对齐。 + +### Core 的读取规则(`rust/lithe-core/src/project/maven_dependency_tree.rs`) + +- 插件版本固定,所以文本格式固定。第一行必须是模块自身的坐标,之后每一行 + 都必须是合法节点,且只能带该版本会写出的标注。不认识的行、标注或编码 + 返回 `parse_failed`,不返回残缺的树。 +- 上限来自树本身:最多 10,000 个节点、64 层,单行 4 KiB,文件总字节数由 + “(节点上限 + 根行) × 单行上限”推出。这些上限和日志量无关。 +- Maven 正常退出但文件不存在时返回 `process_failed`,通常说明项目 POM 为 + 依赖插件配置了别的输出位置。 + +### 临时文件归平台所有 + +- **Windows**:Tauri 宿主提供 `maven_create_dependency_output` / + `maven_remove_dependency_output`,文件放在应用缓存目录的 + `maven-dependency-trees/` 下,以会话 ID 命名;应用是单实例,启动时清空 + 整个目录。前端 `maven.store.ts` 的所有结束路径(读完、取消、超时、失败、 + 配置失效、被新请求取代)都经过 `releaseDependencySession`。 +- **macOS**:端口 `MavenDependencyOutputStoring`(`LitheCoreContracts`)由 + `MacMavenDependencyOutputStore` 实现。允许多个 Lithe 同时运行,所以每个 + 进程使用以 PID 命名的子目录,创建时只删除进程已退出的目录。`MavenService` + 的结束路径统一经过 `releaseDependencyOutputFile`。 +- 两端都在 Core 读完之后才删除文件;一次读取开始后,新的请求或取消不会删掉 + 正在被读取的文件。 + +### 正确做法 + +- 新增依赖相关字段时,从插件写出的标注或上游结构化模型里取值,在 Core 中 + 变成显式字段,再由两端界面展示。 +- 平台只负责路径、生命周期和清理;解析、上限和错误分类都在 Core。 + +### 不要这样做 + +- 不要重新订阅依赖进程的控制台输出来拼接依赖树,也不要为“大项目又超限了” + 调大某个字符上限。那会把日志量重新变成数据上限。 +- 不要把树文件写进用户项目目录(例如模块的 `target/`),那会触发文件监听、 + 被 `mvn clean` 删除,还可能被提交。 +- 不要在 Core 里用 `std::env::temp_dir()` 自行决定临时目录;目录和清理策略 + 属于平台。 + +## 考虑过的备选方案 + +### 通过 JDT LS / m2e 获取结构化依赖树 + +最有吸引力的地方是数据完全结构化:Eclipse m2e 的 +`MavenModelManager.readDependencyTree` 在 `org.eclipse.m2e.core` 里,JDT LS +自带它,Lithe 也已经把 profiles 和 settings 传给 m2e。没有采用的原因:它使用 +m2e 内嵌的 Maven,而不是用户配置的 Maven 或 mvnd,违反“依赖树与项目 Maven +一致”的要求;仓库没有 Java 扩展 bundle 的构建和发布链路,需要新增并长期维护; +而且必须等 JDT LS 启动并完成项目导入才能工作。若以后 Lithe 需要自己的 JDT LS +扩展 bundle,可重新评估,Core 的返回契约无需改变。 + +### 使用插件的 JSON 输出(`-DoutputType=json`) + +看起来能省掉文本解析。没有采用:3.8.1 与 3.9.0 的 `JsonDependencyNodeVisitor` +只输出坐标、scope 和 optional,冲突、重复和依赖管理信息全部丢失,字符串也 +没有转义。 + +### 像 IntelliJ IDEA 一样运行独立的 Maven Server 进程 + +这是最完整的方案:独立 JVM 中嵌入用户选择的 Maven,直接调用解析库并返回 +结构化节点。没有采用:需要处理多个 Maven 版本的类加载与进程协议,成本远高于 +本问题需要的范围。 + +### 只把控制台上限调大(例如 8 MiB) + +改动最小,但依赖树仍然和日志共用预算,下一个更大的项目会再次失败,#890 明确 +不接受这种修复。 + +## 后果 + +- 收益:合法的大型依赖树不再因为日志量失败;两端共用一个字节上限;格式漂移 + 会明确报错;界面能显示依赖管理和 scope 调解信息;mvnd 等控制台格式不同的 + 执行器也不影响数据。 +- 代价:两端各多了一份临时文件的生命周期代码;Maven 在被停止后才写出的文件 + 要等下一次启动清理(Windows)或进程目录清理(macOS)。 +- 重新评估的触发条件:升级插件版本(格式可能变化,需同步更新解析器和 + fixture);或决定引入 JDT LS 扩展 bundle / Maven Server。 + +## 验证 + +- Rust Core:`cargo test --manifest-path rust/lithe-core/Cargo.toml maven_depend` + 覆盖计划参数(含 `-N` 与 `outputFile` 校验)、共享 fixture + `shared/fixtures/maven/dependency-tree-v2.json`(LF 与 CRLF)、6,000 与 + 10,000 节点的大树、字节/节点/行长/深度上限、格式漂移与缺失文件。完整校验运行 + `./scripts/verify-rust-core.sh`。 +- Rust Core 注释规范:`./scripts/verify-rust-core-comments.sh`。 +- 共享契约:`./scripts/verify-shared-contracts.sh`。 +- Windows 前端:在 `windows/tauri` 运行 + `bun test --preload ./src/test-utils/vite-assets.ts src/features/maven`,覆盖 + 每条结束路径都删除临时文件、读完才删除、删除失败只记日志。 +- macOS:`./scripts/test-macos.sh`,其中 `ExecutionModuleTests` 覆盖服务生命周期, + `MavenRuntimeTests` 验证 fixture 解码与 PID 目录清理。普通 Swift 单测不链接 + Core,`MavenDependencyTreeBridgeTests` 在 `./scripts/verify-rust-core.sh` 的 + Core 链接测试中通过真实 Core 规划并读取树文件。 + +## 适用范围 + +- `rust/lithe-core/src/project/maven_dependency_tree.rs` +- `rust/lithe-core/src/project/maven.rs` +- `shared/contracts/rust-core-api.md` +- `shared/fixtures/maven/dependency-tree-v2.json` +- `windows/tauri/src-tauri/src/maven.rs` +- `windows/tauri/src/features/maven/` +- `macos/Sources/LitheExecutionModule/Services/MavenService.swift` +- `macos/Sources/Lithe/Platform/MacOS/Persistence/MacMavenDependencyOutputStore.swift` diff --git a/docs/development/platform-parity-matrix.csv b/docs/development/platform-parity-matrix.csv index 244945e90..5ab1b5455 100644 --- a/docs/development/platform-parity-matrix.csv +++ b/docs/development/platform-parity-matrix.csv @@ -24,7 +24,7 @@ ai-chat-history,AI,AI 对话,对话历史、置顶与归档,未实现,不适用, ai-chat-providers,AI,AI 对话,多 Provider 与工作区范围,未实现,不适用,已实现,待验证,AI,Windows 验证 Provider 切换、凭据边界、工作区范围和模型错误提示。,,macos/Sources/Lithe/Platform/MacOS/AI,windows/tauri/src/features/ai/services/providers; windows/tauri/src/features/ai/lib/ai-workspace-scope.ts java-runtime-discovery,Java,Java/Maven,JDK 与 Maven 工具链发现,已实现,待验证,已实现,待验证,Java / Maven,配置多个 JDK/Maven 候选,验证发现、选择、版本不匹配和错误提示。,,macos/Sources/Lithe/Services/Java; macos/Sources/Lithe/Views/Run,windows/tauri/src/features/maven; windows/tauri/src/features/workspace java-maven-project,Java,Java/Maven,项目根、模块与源码集识别,已实现,待验证,已实现,待验证,Java / Maven,使用单模块、多模块和非标准源码目录 fixture 对比项目模型。,,macos/Sources/Lithe/Services/Java; shared/contracts/application-boundary.md,windows/tauri/src/features/maven; windows/tauri/src/features/workspace -java-maven-profiles-dependencies,Java,Java/Maven,Profile 与依赖树,已实现,待验证,已实现,待验证,Java / Maven,切换 Maven profile 并刷新依赖树,确认模块归属、顺序和错误边界。,,macos/Sources/Lithe/Views/Run; macos/Sources/Lithe/Services/Java,windows/tauri/src/features/maven +java-maven-profiles-dependencies,Java,Java/Maven,Profile 与依赖树,已实现,待验证,已实现,待验证,Java / Maven,"切换 Maven profile 并刷新依赖树,确认模块归属、顺序、版本冲突/重复/依赖管理标注和错误边界;在依赖节点超过 6,000 个的项目中确认依赖树完整加载。",,macos/Sources/Lithe/Views/Run; macos/Sources/Lithe/Services/Java; macos/Sources/LitheExecutionModule/Services/MavenService.swift; macos/Sources/Lithe/Platform/MacOS/Persistence/MacMavenDependencyOutputStore.swift; rust/lithe-core/src/project/maven_dependency_tree.rs,windows/tauri/src/features/maven; windows/tauri/src-tauri/src/maven.rs; rust/lithe-core/src/project/maven_dependency_tree.rs java-maven-toolbar-tooltips,Java,Java/Maven,Maven 工具栏图标悬停说明,已实现,待验证,已实现,待验证,Java / Maven,在 macOS 和 Windows 的 Maven 工具窗口逐一悬停运行、执行目标、重新加载、跳过测试、折叠和设置图标,确认提示与动作一致、提示框宽度适配文字;再检查禁用按钮和窄窗口中的提示换行与边缘约束。,,macos/Sources/Lithe/Views/Run/MavenView.swift; macos/Sources/Lithe/Views/Workbench/WorkbenchHoverTooltip.swift; macos/Tests/LitheTests/WorkbenchHoverTooltipTests.swift,windows/tauri/src/features/maven/components/maven-pane.tsx java-build-diagnostics,Java,Java/Maven,构建输出与编译诊断,已实现,待验证,已实现,待验证,Java / Maven,使用成功、编译失败和进程失败构建 fixture,对比诊断位置、输出和退出状态。,,macos/Sources/Lithe/Views/Run; macos/Sources/Lithe/Services/Diagnostics,windows/tauri/src/features/maven; windows/tauri/src/features/diagnostics spring-index,Java,Spring / MyBatis,Spring 配置、Bean 与 Endpoint 索引,已实现,待验证,已实现,待验证,Java / Spring,使用 Spring fixture 对比配置、Bean、Endpoint 索引、刷新和失效处理。,,macos/Sources/Lithe/Application/Features; macos/Sources/Lithe/Views/Run; shared/fixtures/spring,windows/tauri/src/features/spring; shared/fixtures/spring diff --git a/docs/development/platform-parity-matrix.md b/docs/development/platform-parity-matrix.md index e26e36fcb..d68d7d4f2 100644 --- a/docs/development/platform-parity-matrix.md +++ b/docs/development/platform-parity-matrix.md @@ -118,7 +118,7 @@ | --- | --- | --- | --- | --- | --- | --- | | Java/Maven | **JDK 与 Maven 工具链发现**
java-runtime-discovery | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Services/Java`、`macos/Sources/Lithe/Views/Run` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/maven`、`windows/tauri/src/features/workspace` | Java / Maven | 配置多个 JDK/Maven 候选,验证发现、选择、版本不匹配和错误提示。 | | | Java/Maven | **项目根、模块与源码集识别**
java-maven-project | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Services/Java`、`shared/contracts/application-boundary.md` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/maven`、`windows/tauri/src/features/workspace` | Java / Maven | 使用单模块、多模块和非标准源码目录 fixture 对比项目模型。 | | -| Java/Maven | **Profile 与依赖树**
java-maven-profiles-dependencies | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Run`、`macos/Sources/Lithe/Services/Java` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/maven` | Java / Maven | 切换 Maven profile 并刷新依赖树,确认模块归属、顺序和错误边界。 | | +| Java/Maven | **Profile 与依赖树**
java-maven-profiles-dependencies | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Run`、`macos/Sources/Lithe/Services/Java`、`macos/Sources/LitheExecutionModule/Services/MavenService.swift`、`macos/Sources/Lithe/Platform/MacOS/Persistence/MacMavenDependencyOutputStore.swift`、`rust/lithe-core/src/project/maven_dependency_tree.rs` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/maven`、`windows/tauri/src-tauri/src/maven.rs`、`rust/lithe-core/src/project/maven_dependency_tree.rs` | Java / Maven | 切换 Maven profile 并刷新依赖树,确认模块归属、顺序、版本冲突/重复/依赖管理标注和错误边界;在依赖节点超过 6,000 个的项目中确认依赖树完整加载。 | | | Java/Maven | **Maven 工具栏图标悬停说明**
java-maven-toolbar-tooltips | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Run/MavenView.swift`、`macos/Sources/Lithe/Views/Workbench/WorkbenchHoverTooltip.swift`、`macos/Tests/LitheTests/WorkbenchHoverTooltipTests.swift` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/maven/components/maven-pane.tsx` | Java / Maven | 在 macOS 和 Windows 的 Maven 工具窗口逐一悬停运行、执行目标、重新加载、跳过测试、折叠和设置图标,确认提示与动作一致、提示框宽度适配文字;再检查禁用按钮和窄窗口中的提示换行与边缘约束。 | | | Java/Maven | **构建输出与编译诊断**
java-build-diagnostics | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Run`、`macos/Sources/Lithe/Services/Diagnostics` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/maven`、`windows/tauri/src/features/diagnostics` | Java / Maven | 使用成功、编译失败和进程失败构建 fixture,对比诊断位置、输出和退出状态。 | | | Spring / MyBatis | **Spring 配置、Bean 与 Endpoint 索引**
spring-index | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Application/Features`、`macos/Sources/Lithe/Views/Run`、`shared/fixtures/spring` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/spring`、`shared/fixtures/spring` | Java / Spring | 使用 Spring fixture 对比配置、Bean、Endpoint 索引、刷新和失效处理。 | | diff --git a/macos/Resources/en.lproj/Localizable.strings b/macos/Resources/en.lproj/Localizable.strings index f615c7dda..911a08301 100644 --- a/macos/Resources/en.lproj/Localizable.strings +++ b/macos/Resources/en.lproj/Localizable.strings @@ -304,9 +304,15 @@ "Dependency resolution cancelled" = "Dependency resolution cancelled"; "No dependencies" = "No dependencies"; "Open module pom.xml" = "Open module pom.xml"; -" (duplicate omitted)" = " (duplicate omitted)"; -" (conflict -> %@)" = " (conflict -> %@)"; "selected version" = "selected version"; +" (%@)" = " (%@)"; +"; " = "; "; +"duplicate omitted" = "duplicate omitted"; +"conflict -> %@" = "conflict -> %@"; +"version managed from %@" = "version managed from %@"; +"scope managed from %@" = "scope managed from %@"; +"scope updated from %@" = "scope updated from %@"; +"scope not updated to %@" = "scope not updated to %@"; "Unable to create the Maven dependency plan." = "Unable to create the Maven dependency plan."; "No Maven executable was found. Choose Maven Home or an executable in Maven Settings." = "No Maven executable was found. Choose Maven Home or an executable in Maven Settings."; "Unable to start Maven dependency resolution: %@" = "Unable to start Maven dependency resolution: %@"; @@ -314,7 +320,6 @@ "Maven dependency resolution exited with code %@." = "Maven dependency resolution exited with code %@."; "Unable to parse Maven dependencies for this module." = "Unable to parse Maven dependencies for this module."; "Unable to resolve Maven dependencies." = "Unable to resolve Maven dependencies."; -"Maven dependency output exceeded the supported limit." = "Maven dependency output exceeded the supported limit."; "Source Roots" = "Source Roots"; "main Java" = "main Java"; "main resources" = "main resources"; diff --git a/macos/Resources/zh-Hans.lproj/Localizable.strings b/macos/Resources/zh-Hans.lproj/Localizable.strings index 60ac70030..905b7d4c4 100644 --- a/macos/Resources/zh-Hans.lproj/Localizable.strings +++ b/macos/Resources/zh-Hans.lproj/Localizable.strings @@ -1520,9 +1520,15 @@ "Dependency resolution cancelled" = "依赖解析已取消"; "No dependencies" = "无依赖"; "Open module pom.xml" = "打开所属模块的 pom.xml"; -" (duplicate omitted)" = "(重复依赖已省略)"; -" (conflict -> %@)" = "(版本冲突 → %@)"; "selected version" = "已选版本"; +" (%@)" = "(%@)"; +"; " = ";"; +"duplicate omitted" = "重复依赖已省略"; +"conflict -> %@" = "版本冲突 → %@"; +"version managed from %@" = "依赖管理前版本为 %@"; +"scope managed from %@" = "依赖管理前 scope 为 %@"; +"scope updated from %@" = "scope 由 %@ 提升"; +"scope not updated to %@" = "未提升为 %@ scope"; "Unable to create the Maven dependency plan." = "无法创建 Maven 依赖解析计划。"; "No Maven executable was found. Choose Maven Home or an executable in Maven Settings." = "未找到 Maven 可执行文件,请在 Maven 设置中选择 Maven 主目录或可执行文件。"; "Unable to start Maven dependency resolution: %@" = "无法启动 Maven 依赖解析:%@"; @@ -1530,7 +1536,6 @@ "Maven dependency resolution exited with code %@." = "Maven 依赖解析已退出,退出码为 %@。"; "Unable to parse Maven dependencies for this module." = "无法解析此模块的 Maven 依赖。"; "Unable to resolve Maven dependencies." = "无法解析 Maven 依赖。"; -"Maven dependency output exceeded the supported limit." = "Maven 依赖输出超出支持的大小限制。"; "Source Roots" = "源码目录"; "main Java" = "主代码"; "main resources" = "主资源"; diff --git a/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift b/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift index 68e30166c..99c2d6eb2 100644 --- a/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift +++ b/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift @@ -427,6 +427,10 @@ struct RustCoreBridge: Sendable, IncrementalLanguageServerRuntimeCore { let scope: String let resolution: String let selectedVersion: String? + let premanagedVersion: String? + let premanagedScope: String? + let originalScope: String? + let ignoredScope: String? let children: [Dependency] enum CodingKeys: String, CodingKey { @@ -439,6 +443,10 @@ struct RustCoreBridge: Sendable, IncrementalLanguageServerRuntimeCore { case scope case resolution case selectedVersion + case premanagedVersion + case premanagedScope + case originalScope + case ignoredScope case children } @@ -460,6 +468,10 @@ struct RustCoreBridge: Sendable, IncrementalLanguageServerRuntimeCore { scope: scope, resolution: resolution, selectedVersion: selectedVersion, + premanagedVersion: premanagedVersion, + premanagedScope: premanagedScope, + originalScope: originalScope, + ignoredScope: ignoredScope, children: try children.map { try $0.makeModel() } ) } @@ -1509,6 +1521,7 @@ struct RustCoreBridge: Sendable, IncrementalLanguageServerRuntimeCore { let root: String let context: MavenLaunchContext let module: String? + let outputFile: String } private struct MarkdownRenderRequest: Encodable { @@ -1978,7 +1991,7 @@ struct RustCoreBridge: Sendable, IncrementalLanguageServerRuntimeCore { private struct MavenDependenciesRequest: Encodable { let modulePath: String - let output: String + let outputFile: String } private struct RunConfigurationInspectRequest: Encodable { @@ -2522,25 +2535,30 @@ struct RustCoreBridge: Sendable, IncrementalLanguageServerRuntimeCore { func mavenDependencyPlan( at rootURL: URL, context: MavenLaunchContext, - module: String? + module: String?, + outputFile: URL ) -> Result { executeResult( command: "maven.dependencyPlan", payload: MavenDependencyPlanRequest( root: rootURL.standardizedFileURL.path, context: context, - module: module + module: module, + outputFile: outputFile.standardizedFileURL.path ) ) } func mavenDependencies( modulePath: String, - output: String + outputFile: URL ) -> Result { executeResult( command: "maven.dependencies", - payload: MavenDependenciesRequest(modulePath: modulePath, output: output) + payload: MavenDependenciesRequest( + modulePath: modulePath, + outputFile: outputFile.standardizedFileURL.path + ) ) } diff --git a/macos/Sources/Lithe/Core/Rust/RustJavaMavenOperations.swift b/macos/Sources/Lithe/Core/Rust/RustJavaMavenOperations.swift index 76df21fd6..3fc5908be 100644 --- a/macos/Sources/Lithe/Core/Rust/RustJavaMavenOperations.swift +++ b/macos/Sources/Lithe/Core/Rust/RustJavaMavenOperations.swift @@ -206,16 +206,22 @@ struct RustJavaMavenOperations: JavaMavenOperations, Sendable { func mavenDependencyPlan( at rootURL: URL, context: MavenLaunchContext, - module: String? + module: String?, + outputFile: URL ) throws -> MavenLaunchPlan { - try core.mavenDependencyPlan(at: rootURL, context: context, module: module) + try core.mavenDependencyPlan( + at: rootURL, + context: context, + module: module, + outputFile: outputFile + ) .mapError(MavenOperationError.init) .get() .makeModel() } - func mavenDependencies(modulePath: String, output: String) throws -> MavenDependencyTree { - try core.mavenDependencies(modulePath: modulePath, output: output) + func mavenDependencies(modulePath: String, outputFile: URL) throws -> MavenDependencyTree { + try core.mavenDependencies(modulePath: modulePath, outputFile: outputFile) .mapError(MavenOperationError.init) .get() .makeModel() diff --git a/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift b/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift index 75599e014..b897bae2b 100644 --- a/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift +++ b/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift @@ -363,7 +363,8 @@ final class MacServiceContainer { process: MacStreamingProcess(processRegistry: processRegistry, moduleID: .execution), dependencyProcess: MacStreamingProcess(processRegistry: processRegistry, moduleID: .execution), mavenOperations: javaMavenOperations, - configurationStore: MacMavenConfigurationStore(storage: fileStorage) + configurationStore: MacMavenConfigurationStore(storage: fileStorage), + dependencyOutputs: MacMavenDependencyOutputStore() ), run: RunService( runtime: runtimeService, diff --git a/macos/Sources/Lithe/Platform/MacOS/Persistence/MacMavenDependencyOutputStore.swift b/macos/Sources/Lithe/Platform/MacOS/Persistence/MacMavenDependencyOutputStore.swift new file mode 100644 index 000000000..1cec6f030 --- /dev/null +++ b/macos/Sources/Lithe/Platform/MacOS/Persistence/MacMavenDependencyOutputStore.swift @@ -0,0 +1,96 @@ +import Darwin +import Foundation +import LitheCoreContracts + +/// Scratch files the Maven dependency plugin writes one module's tree to. +/// +/// Several Lithe builds can run at the same time, so each process uses its own +/// directory named after its process identifier and, when created, removes only +/// the directories of processes that have exited. Dependency operations remove +/// their own files; that cleanup covers a crash, or a tree Maven finished +/// writing after its operation was stopped. +/// +/// Note: the data-source decision is recorded in .agents/notes/implemented/architecture/2026-09-26-maven-dependency-tree-output-file.md +struct MacMavenDependencyOutputStore: MavenDependencyOutputStoring { + private let directoryURL: URL + + init( + rootURL: URL = FileManager.default.temporaryDirectory + .appendingPathComponent("Lithe", isDirectory: true) + .appendingPathComponent("maven-dependency-trees", isDirectory: true), + processIdentifier: pid_t = ProcessInfo.processInfo.processIdentifier, + isProcessRunning: (pid_t) -> Bool = MacMavenDependencyOutputStore.isProcessRunning + ) { + directoryURL = rootURL.appendingPathComponent(String(processIdentifier), isDirectory: true) + Self.removeAbandonedDirectories( + in: rootURL, + keeping: processIdentifier, + isProcessRunning: isProcessRunning + ) + } + + func makeDependencyOutputFile(operationID: String) throws -> URL { + // Operation IDs become file names; accept only UUID-style identifiers. + guard !operationID.isEmpty, + operationID.count <= 128, + operationID.unicodeScalars.allSatisfy({ + $0.isASCII && (CharacterSet.alphanumerics.contains($0) || $0 == "-") + }) else { + throw MavenOperationError( + code: "invalid_request", + message: "The Maven dependency operation is invalid." + ) + } + try FileManager.default.createDirectory(at: directoryURL, withIntermediateDirectories: true) + let fileURL = directoryURL.appendingPathComponent(operationID + ".txt", isDirectory: false) + // Core must only ever read what this operation's Maven wrote. + try removeIfPresent(fileURL) + return fileURL + } + + func removeDependencyOutputFile(_ fileURL: URL) { + do { + try removeIfPresent(fileURL) + } catch { + NSLog("[maven] Could not remove dependency-tree scratch file: \(error.localizedDescription)") + } + } + + private func removeIfPresent(_ fileURL: URL) throws { + do { + try FileManager.default.removeItem(at: fileURL) + } catch CocoaError.fileNoSuchFile { + return + } + } + + private static func removeAbandonedDirectories( + in rootURL: URL, + keeping processIdentifier: pid_t, + isProcessRunning: (pid_t) -> Bool + ) { + guard let entries = try? FileManager.default.contentsOfDirectory( + at: rootURL, + includingPropertiesForKeys: nil + ) else { + // A missing root means no earlier process created scratch files. + return + } + for entry in entries { + guard let owner = pid_t(entry.lastPathComponent), + owner != processIdentifier, + !isProcessRunning(owner) else { continue } + do { + try FileManager.default.removeItem(at: entry) + } catch { + NSLog("[maven] Could not remove abandoned dependency-tree files: \(error.localizedDescription)") + } + } + } + + static func isProcessRunning(_ processIdentifier: pid_t) -> Bool { + // Signal 0 checks for existence only. EPERM means the process exists + // but belongs to another user, which still owns its directory. + Darwin.kill(processIdentifier, 0) == 0 || errno == EPERM + } +} diff --git a/macos/Sources/Lithe/Views/Run/MavenDependencyLocalization.swift b/macos/Sources/Lithe/Views/Run/MavenDependencyLocalization.swift index cffde2713..5432bff00 100644 --- a/macos/Sources/Lithe/Views/Run/MavenDependencyLocalization.swift +++ b/macos/Sources/Lithe/Views/Run/MavenDependencyLocalization.swift @@ -16,18 +16,34 @@ struct MavenDependencyLocalization { func subtitle(_ dependency: MavenDependency) -> String { let classifier = dependency.classifier.map { ":" + $0 } ?? "" - let marker: String + // Maven's own verbose annotations, in the order `dependency:tree` prints them. + var markers: [String] = [] + if let version = dependency.premanagedVersion { + markers.append(String(format: text("version managed from %@"), version)) + } + if let scope = dependency.premanagedScope { + markers.append(String(format: text("scope managed from %@"), scope)) + } + if let scope = dependency.originalScope { + markers.append(String(format: text("scope updated from %@"), scope)) + } + if let scope = dependency.ignoredScope { + markers.append(String(format: text("scope not updated to %@"), scope)) + } switch dependency.resolution { case .resolved: - marker = "" + break case .omittedDuplicate: - marker = text(" (duplicate omitted)") + markers.append(text("duplicate omitted")) case .omittedConflict: - marker = String(format: text(" (conflict -> %@)"), - dependency.selectedVersion ?? text("selected version")) + markers.append(String(format: text("conflict -> %@"), + dependency.selectedVersion ?? text("selected version"))) } + let annotations = markers.isEmpty + ? "" + : String(format: text(" (%@)"), markers.joined(separator: text("; "))) return dependency.groupID + ":" + dependency.version + ":" + dependency.type - + classifier + " [" + dependency.scope + "]" + marker + + classifier + " [" + dependency.scope + "]" + annotations } func error(_ message: String) -> String { diff --git a/macos/Sources/LitheCoreContracts/Execution/MavenContracts.swift b/macos/Sources/LitheCoreContracts/Execution/MavenContracts.swift index e87e1e7df..8aa4e037b 100644 --- a/macos/Sources/LitheCoreContracts/Execution/MavenContracts.swift +++ b/macos/Sources/LitheCoreContracts/Execution/MavenContracts.swift @@ -423,6 +423,14 @@ package struct MavenDependency: Equatable, Sendable { package let scope: String package let resolution: MavenDependencyResolution package let selectedVersion: String? + /// Version before dependency management replaced it with `version`. + package let premanagedVersion: String? + /// Scope before dependency management replaced it with `scope`. + package let premanagedScope: String? + /// Declared scope before mediation widened it to `scope`. + package let originalScope: String? + /// Wider scope from another path that mediation did not apply here. + package let ignoredScope: String? package let children: [MavenDependency] package init( @@ -435,6 +443,10 @@ package struct MavenDependency: Equatable, Sendable { scope: String, resolution: MavenDependencyResolution, selectedVersion: String?, + premanagedVersion: String? = nil, + premanagedScope: String? = nil, + originalScope: String? = nil, + ignoredScope: String? = nil, children: [MavenDependency] ) { self.modulePath = modulePath @@ -446,6 +458,10 @@ package struct MavenDependency: Equatable, Sendable { self.scope = scope self.resolution = resolution self.selectedVersion = selectedVersion + self.premanagedVersion = premanagedVersion + self.premanagedScope = premanagedScope + self.originalScope = originalScope + self.ignoredScope = ignoredScope self.children = children } } @@ -520,12 +536,15 @@ package protocol MavenProjectOperations: Sendable { module: String?, goals: [String] ) throws -> MavenLaunchPlan + /// Plans a dependency-tree query that writes the tree to `outputFile`. func mavenDependencyPlan( at rootURL: URL, context: MavenLaunchContext, - module: String? + module: String?, + outputFile: URL ) throws -> MavenLaunchPlan - func mavenDependencies(modulePath: String, output: String) throws -> MavenDependencyTree + /// Reads the tree a finished dependency plan wrote to `outputFile`. + func mavenDependencies(modulePath: String, outputFile: URL) throws -> MavenDependencyTree func mavenDiagnostics(output: String, projectRoot: URL) -> [MavenBuildIssue] func mavenTestResults(output: String, projectRoot: URL) -> MavenTestResults? func mavenTestResults( @@ -539,7 +558,8 @@ extension MavenProjectOperations { package func mavenDependencyPlan( at _: URL, context _: MavenLaunchContext, - module _: String? + module _: String?, + outputFile _: URL ) throws -> MavenLaunchPlan { throw MavenOperationError( code: "not_supported", @@ -549,7 +569,7 @@ extension MavenProjectOperations { package func mavenDependencies( modulePath _: String, - output _: String + outputFile _: URL ) throws -> MavenDependencyTree { throw MavenOperationError( code: "not_supported", @@ -569,6 +589,20 @@ extension MavenProjectOperations { } } +/// Platform-owned scratch files that each hold one Maven dependency tree. +/// +/// The dependency plugin writes a module's tree to one of these files rather +/// than to the console, so the tree never shares a size budget with Maven's +/// log. Every dependency operation gets its own file and removes it once the +/// operation can no longer produce a result. +package protocol MavenDependencyOutputStoring: Sendable { + /// Returns a fresh absolute file for one operation, removing any file an + /// earlier operation left at that location. + func makeDependencyOutputFile(operationID: String) throws -> URL + /// Removes an operation's file. A file Maven never wrote is not an error. + func removeDependencyOutputFile(_ fileURL: URL) +} + package protocol MavenConfigurationStoring: Sendable { func loadMavenConfiguration( workspaceURL: URL, diff --git a/macos/Sources/LitheExecutionModule/Services/MavenService.swift b/macos/Sources/LitheExecutionModule/Services/MavenService.swift index 91c49905b..61b2f54c9 100644 --- a/macos/Sources/LitheExecutionModule/Services/MavenService.swift +++ b/macos/Sources/LitheExecutionModule/Services/MavenService.swift @@ -113,6 +113,7 @@ package final class MavenService: ObservableObject { private let process: any StreamingProcess private let dependencyProcess: any StreamingProcess private let mavenOperations: any MavenProjectOperations + private let dependencyOutputs: (any MavenDependencyOutputStoring)? private let runtimeService: any MavenRuntimePort private let configurationWriter: MavenConfigurationWriter private var workspaceURL: URL? @@ -126,13 +127,13 @@ package final class MavenService: ObservableObject { private var dependencyLoadID = UUID() private var activeDependencyOperationID: String? private var activeDependencyModulePath: String? - private var dependencyOutput = "" + /// Scratch file the active dependency operation's Maven writes its tree to. + private var activeDependencyOutputFile: URL? private var dependencyTimedOut = false private var configurationRevision = 0 private var configurationFingerprint: String? private var fingerprintRevision = 0 private let maximumOutputCharacters = 500_000 - private let maximumDependencyOutputCharacters = 500_000 private let dependencyTimeoutMilliseconds = 60_000 package init( @@ -140,12 +141,14 @@ package final class MavenService: ObservableObject { process: any StreamingProcess, dependencyProcess: any StreamingProcess, mavenOperations: any MavenProjectOperations, - configurationStore: (any MavenConfigurationStoring)? = nil + configurationStore: (any MavenConfigurationStoring)? = nil, + dependencyOutputs: (any MavenDependencyOutputStoring)? = nil ) { self.runtimeService = runtimeService self.process = process self.dependencyProcess = dependencyProcess self.mavenOperations = mavenOperations + self.dependencyOutputs = dependencyOutputs configurationWriter = MavenConfigurationWriter(store: configurationStore) process.onOutput = { [weak self] chunk in Task { @MainActor [weak self] in @@ -164,12 +167,9 @@ package final class MavenService: ObservableObject { self?.consumeLifecycle(event) } } - dependencyProcess.onOutput = { [weak self] chunk in - Task { @MainActor [weak self] in - guard self?.activeDependencyOperationID != nil else { return } - self?.appendDependencyOutput(chunk) - } - } + // The dependency process writes its tree to a scratch file; its + // console output is Maven's log, not dependency data. + dependencyProcess.onOutput = { _ in } dependencyProcess.onTermination = { [weak self] exitCode in Task { @MainActor [weak self] in guard self?.activeDependencyOperationID != nil else { return } @@ -484,9 +484,22 @@ package final class MavenService: ObservableObject { dependencyLoadID = loadID activeDependencyModulePath = modulePath activeDependencyOperationID = nil - dependencyOutput = "" dependencyTimedOut = false dependencyStates[modulePath] = .loading + let outputFile: URL + do { + guard let dependencyOutputs else { + throw MavenOperationError( + code: "not_supported", + message: "Maven dependency resolution is unavailable." + ) + } + outputFile = try dependencyOutputs.makeDependencyOutputFile(operationID: loadID.uuidString) + } catch { + failDependency(modulePath: modulePath, message: error.localizedDescription) + return + } + activeDependencyOutputFile = outputFile let operations = mavenOperations Task { [weak self] in let result = await Task.detached(priority: .utility) { @@ -495,7 +508,8 @@ package final class MavenService: ObservableObject { plan: try operations.mavenDependencyPlan( at: workspaceURL, context: context, - module: modulePath == "." ? nil : modulePath + module: modulePath == "." ? nil : modulePath, + outputFile: outputFile ), errorMessage: nil ) @@ -732,7 +746,6 @@ package final class MavenService: ObservableObject { private func finishDependencyProcess(exitCode: Int32) { guard let modulePath = activeDependencyModulePath else { return } let loadID = dependencyLoadID - let output = dependencyOutput let timedOut = dependencyTimedOut activeDependencyOperationID = nil dependencyTimedOut = false @@ -750,12 +763,27 @@ package final class MavenService: ObservableObject { ) return } + guard let outputFile = activeDependencyOutputFile else { + failDependency( + modulePath: modulePath, + message: "Unable to parse Maven dependencies for this module." + ) + return + } + // The read below now owns the file, so a later cancellation or a new + // request cannot remove it while Core is still reading it. + activeDependencyOutputFile = nil let operations = mavenOperations + let dependencyOutputs = dependencyOutputs Task { [weak self] in let result = await Task.detached(priority: .utility) { + defer { dependencyOutputs?.removeDependencyOutputFile(outputFile) } do { return MavenDependencyParseResult( - tree: try operations.mavenDependencies(modulePath: modulePath, output: output), + tree: try operations.mavenDependencies( + modulePath: modulePath, + outputFile: outputFile + ), errorMessage: nil ) } catch { @@ -770,7 +798,6 @@ package final class MavenService: ObservableObject { self.activeDependencyModulePath == modulePath, case .loading = self.dependencyState(for: modulePath) else { return } self.activeDependencyModulePath = nil - self.dependencyOutput = "" if let tree = result.tree { self.dependencyStates[modulePath] = .ready(tree.dependencies) } else { @@ -803,27 +830,23 @@ package final class MavenService: ObservableObject { } } - private func appendDependencyOutput(_ value: String) { - guard let modulePath = activeDependencyModulePath else { return } - dependencyOutput.append(value.replacingOccurrences(of: "\r", with: "")) - guard dependencyOutput.count <= maximumDependencyOutputCharacters else { - dependencyProcess.stop() - failDependency( - modulePath: modulePath, - message: "Maven dependency output exceeded the supported limit." - ) - return - } - } - private func failDependency(modulePath: String, message: String) { dependencyStates[modulePath] = .failed(message) activeDependencyOperationID = nil activeDependencyModulePath = nil - dependencyOutput = "" + releaseDependencyOutputFile() dependencyTimedOut = false } + /// Removes the active operation's scratch file once it can no longer + /// produce a result. A file Maven writes after a stop is left for the + /// store's startup cleanup rather than read by a later operation. + private func releaseDependencyOutputFile() { + guard let outputFile = activeDependencyOutputFile else { return } + activeDependencyOutputFile = nil + dependencyOutputs?.removeDependencyOutputFile(outputFile) + } + private func cancelActiveDependency(markCancelled: Bool) { dependencyLoadID = UUID() let modulePath = activeDependencyModulePath @@ -832,7 +855,7 @@ package final class MavenService: ObservableObject { } activeDependencyOperationID = nil activeDependencyModulePath = nil - dependencyOutput = "" + releaseDependencyOutputFile() dependencyTimedOut = false if markCancelled, let modulePath { dependencyStates[modulePath] = .cancelled diff --git a/macos/Tests/LitheExecutionModuleTests/ExecutionModuleTests.swift b/macos/Tests/LitheExecutionModuleTests/ExecutionModuleTests.swift index 326e43812..351508ce5 100644 --- a/macos/Tests/LitheExecutionModuleTests/ExecutionModuleTests.swift +++ b/macos/Tests/LitheExecutionModuleTests/ExecutionModuleTests.swift @@ -1722,11 +1722,13 @@ struct ExecutionModuleTests { ) let buildProcess = MavenRecordingProcess() let dependencyProcess = MavenRecordingProcess() + let outputs = RecordingDependencyOutputs() let service = MavenService( runtimeService: MavenRecordingRuntime(), process: buildProcess, dependencyProcess: dependencyProcess, - mavenOperations: operations + mavenOperations: operations, + dependencyOutputs: outputs ) await service.loadProject(at: workspace, files: [project.pomURL, module.url]) @@ -1745,6 +1747,9 @@ struct ExecutionModuleTests { #expect(request.timeoutMilliseconds == 60_000) #expect(!buildProcess.isRunning) #expect(operations.lastDependencyModule == "service") + let outputFile = try #require(outputs.created.first) + #expect(operations.lastDependencyPlanFile == outputFile) + // Console output is Maven's log; only the file carries the tree. dependencyProcess.onOutput?("[INFO] dependency tree\n") dependencyProcess.onTermination?(0) let state = await dependencyState( @@ -1754,7 +1759,8 @@ struct ExecutionModuleTests { ) #expect(state == .ready([dependency])) - #expect(operations.lastDependencyOutput == "[INFO] dependency tree\n") + #expect(operations.lastDependencyReadFile == outputFile) + #expect(outputs.removed == [outputFile]) let projection = MavenFeatureModel(service: service) #expect(projection.resolvedDependencyArtifactPaths(modulePath: "service").isEmpty == (configuredRepository == nil)) @@ -1796,7 +1802,8 @@ struct ExecutionModuleTests { let dependencyProcess = MavenRecordingProcess() let maven = MavenService( runtimeService: MavenRecordingRuntime(), process: MavenRecordingProcess(), - dependencyProcess: dependencyProcess, mavenOperations: operations + dependencyProcess: dependencyProcess, mavenOperations: operations, + dependencyOutputs: RecordingDependencyOutputs() ) let graph = makeTestGraph(mavenService: maven) defer { graph.run.reset(); graph.maven.reset() } @@ -1853,11 +1860,13 @@ struct ExecutionModuleTests { configurationFingerprint: "sha256:dependency" ) let dependencyProcess = MavenRecordingProcess() + let outputs = RecordingDependencyOutputs() let service = MavenService( runtimeService: MavenRecordingRuntime(), process: MavenRecordingProcess(), dependencyProcess: dependencyProcess, - mavenOperations: RecordingMavenOperations(project: project, plan: plan) + mavenOperations: RecordingMavenOperations(project: project, plan: plan), + dependencyOutputs: outputs ) await service.loadProject(at: workspace, files: [project.pomURL]) @@ -1869,6 +1878,8 @@ struct ExecutionModuleTests { #expect(!dependencyProcess.isRunning) #expect(service.dependencyState(for: ".") == .cancelled) #expect(!service.isResolvingDependencies) + #expect(outputs.removed == outputs.created) + #expect(outputs.created.count == 1) } @Test @@ -1893,11 +1904,13 @@ struct ExecutionModuleTests { configurationFingerprint: "sha256:dependency" ) let dependencyProcess = MavenRecordingProcess() + let outputs = RecordingDependencyOutputs() let service = MavenService( runtimeService: MavenRecordingRuntime(), process: MavenRecordingProcess(), dependencyProcess: dependencyProcess, - mavenOperations: RecordingMavenOperations(project: project, plan: plan) + mavenOperations: RecordingMavenOperations(project: project, plan: plan), + dependencyOutputs: outputs ) await service.loadProject(at: workspace, files: [project.pomURL]) @@ -1926,6 +1939,99 @@ struct ExecutionModuleTests { return } #expect(message == "Maven dependency resolution timed out after 60 seconds.") + #expect(outputs.removed == outputs.created) + #expect(outputs.created.count == 1) + } + + @Test + func mavenServiceRemovesTheTreeFileWhenMavenFailsOrIsSuperseded() async throws { + let workspace = URL(fileURLWithPath: "/workspace", isDirectory: true) + let project = MavenProject( + rootURL: workspace, + pomURL: workspace.appendingPathComponent("pom.xml"), + groupID: "dev.lithe", + artifactID: "demo", + version: "1.0", + packaging: "jar", + modules: [], + profiles: [], + hasWrapper: false + ) + let plan = MavenLaunchPlan( + version: 1, + toolchain: "project-maven", + arguments: ["dependency:tree"], + workingDirectory: ".", + configurationFingerprint: "sha256:dependency" + ) + let operations = RecordingMavenOperations(project: project, plan: plan) + let dependencyProcess = MavenRecordingProcess() + let outputs = RecordingDependencyOutputs() + let service = MavenService( + runtimeService: MavenRecordingRuntime(), + process: MavenRecordingProcess(), + dependencyProcess: dependencyProcess, + mavenOperations: operations, + dependencyOutputs: outputs + ) + await service.loadProject(at: workspace, files: [project.pomURL]) + + service.loadDependencies(for: ".") + _ = try #require(await dependencyProcess.nextStart(timeout: .seconds(1))) + dependencyProcess.onTermination?(1) + let failed = await dependencyState( + service, + modulePath: ".", + matching: { if case .failed = $0 { true } else { false } } + ) + #expect(failed == .failed("Maven dependency resolution exited with code 1.")) + #expect(operations.lastDependencyReadFile == nil) + #expect(outputs.removed == outputs.created) + + // A retry gets a fresh file; changing Skip Tests invalidates it mid-run. + service.loadDependencies(for: ".") + _ = try #require(await dependencyProcess.nextStart(timeout: .seconds(1))) + #expect(outputs.created.count == 2) + #expect(outputs.created[0] != outputs.created[1]) + service.setSkipTests(true) + #expect(outputs.removed == outputs.created) + #expect(!dependencyProcess.isRunning) + } + + @Test + func mavenServiceFailsVisiblyWithoutADependencyOutputStore() async throws { + let workspace = URL(fileURLWithPath: "/workspace", isDirectory: true) + let project = MavenProject( + rootURL: workspace, + pomURL: workspace.appendingPathComponent("pom.xml"), + groupID: "dev.lithe", + artifactID: "demo", + version: "1.0", + packaging: "jar", + modules: [], + profiles: [], + hasWrapper: false + ) + let plan = MavenLaunchPlan( + version: 1, + toolchain: "project-maven", + arguments: ["dependency:tree"], + workingDirectory: ".", + configurationFingerprint: "sha256:dependency" + ) + let dependencyProcess = MavenRecordingProcess() + let service = MavenService( + runtimeService: MavenRecordingRuntime(), + process: MavenRecordingProcess(), + dependencyProcess: dependencyProcess, + mavenOperations: RecordingMavenOperations(project: project, plan: plan) + ) + await service.loadProject(at: workspace, files: [project.pomURL]) + + service.loadDependencies(for: ".") + + #expect(service.dependencyState(for: ".") == .failed("Maven dependency resolution is unavailable.")) + #expect(!dependencyProcess.isRunning) } private func factory(recorder: Recorder) -> ModuleFactory { @@ -2426,7 +2532,8 @@ private final class RecordingMavenOperations: MavenProjectOperations, @unchecked private var recordedModule: String? private var recordedGoals: [String] = [] private var recordedDependencyModule: String? - private var recordedDependencyOutput: String? + private var recordedDependencyPlanFile: URL? + private var recordedDependencyReadFile: URL? init( project: MavenProject, @@ -2462,10 +2569,16 @@ private final class RecordingMavenOperations: MavenProjectOperations, @unchecked return recordedDependencyModule } - var lastDependencyOutput: String? { + var lastDependencyPlanFile: URL? { lock.lock() defer { lock.unlock() } - return recordedDependencyOutput + return recordedDependencyPlanFile + } + + var lastDependencyReadFile: URL? { + lock.lock() + defer { lock.unlock() } + return recordedDependencyReadFile } func scanMavenProject(at rootURL: URL, files: [URL]) throws -> MavenProject? { @@ -2491,22 +2604,57 @@ private final class RecordingMavenOperations: MavenProjectOperations, @unchecked func mavenDependencyPlan( at rootURL: URL, context: MavenLaunchContext, - module: String? + module: String?, + outputFile: URL ) throws -> MavenLaunchPlan { lock.lock() recordedDependencyModule = module + recordedDependencyPlanFile = outputFile lock.unlock() return plan } - func mavenDependencies(modulePath: String, output: String) throws -> MavenDependencyTree { + func mavenDependencies(modulePath: String, outputFile: URL) throws -> MavenDependencyTree { lock.lock() - recordedDependencyOutput = output + recordedDependencyReadFile = outputFile lock.unlock() return dependencyTree } } +/// Records the scratch files a dependency operation creates and removes. +private final class RecordingDependencyOutputs: MavenDependencyOutputStoring, @unchecked Sendable { + private let lock = NSLock() + private var createdFiles: [URL] = [] + private var removedFiles: [URL] = [] + + var created: [URL] { + lock.lock() + defer { lock.unlock() } + return createdFiles + } + + var removed: [URL] { + lock.lock() + defer { lock.unlock() } + return removedFiles + } + + func makeDependencyOutputFile(operationID: String) throws -> URL { + let file = URL(fileURLWithPath: "/scratch/\(operationID).txt") + lock.lock() + createdFiles.append(file) + lock.unlock() + return file + } + + func removeDependencyOutputFile(_ fileURL: URL) { + lock.lock() + removedFiles.append(fileURL) + lock.unlock() + } +} + private final class FingerprintingMavenOperations: MavenProjectOperations, @unchecked Sendable { private let project: MavenProject diff --git a/macos/Tests/LitheTests/MavenDependencyLocalizationTests.swift b/macos/Tests/LitheTests/MavenDependencyLocalizationTests.swift index 3889532e2..c3276dc29 100644 --- a/macos/Tests/LitheTests/MavenDependencyLocalizationTests.swift +++ b/macos/Tests/LitheTests/MavenDependencyLocalizationTests.swift @@ -68,4 +68,27 @@ struct MavenDependencyLocalizationTests { #expect(chinese.subtitle(dependency) == "org.example:1.0:jar:tests [test](版本冲突 → 2.0)") #expect(english.subtitle(dependency) == "org.example:1.0:jar:tests [test] (conflict -> 2.0)") } + + @Test + func dependencySubtitleKeepsEveryMavenAnnotationInItsOrder() throws { + let chinese = try localization(.simplifiedChinese) + let english = try localization(.english) + let dependency = MavenDependency( + modulePath: ".", groupID: "org.example", artifactID: "demo", version: "2.1", + type: "jar", classifier: nil, scope: "compile", resolution: .omittedDuplicate, + selectedVersion: nil, premanagedVersion: "0.9.0", premanagedScope: "test", + originalScope: "runtime", ignoredScope: "compile", children: [] + ) + #expect(english.subtitle(dependency) == "org.example:2.1:jar [compile] (version managed from 0.9.0; " + + "scope managed from test; scope updated from runtime; scope not updated to compile; duplicate omitted)") + #expect(chinese.subtitle(dependency) == "org.example:2.1:jar [compile](依赖管理前版本为 0.9.0;" + + "依赖管理前 scope 为 test;scope 由 runtime 提升;未提升为 compile scope;重复依赖已省略)") + + let plain = MavenDependency( + modulePath: ".", groupID: "org.example", artifactID: "demo", version: "1.0", + type: "jar", classifier: nil, scope: "compile", resolution: .resolved, + selectedVersion: nil, children: [] + ) + #expect(english.subtitle(plain) == "org.example:1.0:jar [compile]") + } } diff --git a/macos/Tests/LitheTests/MavenDependencyTreeBridgeTests.swift b/macos/Tests/LitheTests/MavenDependencyTreeBridgeTests.swift new file mode 100644 index 000000000..76a8c6708 --- /dev/null +++ b/macos/Tests/LitheTests/MavenDependencyTreeBridgeTests.swift @@ -0,0 +1,55 @@ +import Foundation +import LitheCoreContracts +import Testing +@testable import Lithe + +/// Plans and reads a dependency-tree file through the linked Rust Core. +/// +/// The ordinary Swift unit lane does not link Core, so this suite runs in the +/// Core-linked lane of `scripts/verify-rust-core.sh`. +@Suite("Maven dependency tree through Rust Core") +struct MavenDependencyTreeBridgeTests { + @Test(.enabled(if: RustCoreBridge().isAvailable, "Requires the linked Rust Core integration library")) + func mavenDependencyTreeFileIsPlannedAndReadThroughCore() throws { + try #require(RustCoreBridge().isAvailable) + let fixture = try MavenDependencyTreeFixture.load() + let testRoot = FileManager.default.temporaryDirectory + .appendingPathComponent("lithe-maven-tree-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: testRoot) } + try FileManager.default.createDirectory(at: testRoot, withIntermediateDirectories: true) + try Data("service".utf8) + .write(to: testRoot.appendingPathComponent("pom.xml")) + let outputFile = testRoot.appendingPathComponent("scratch/tree.txt") + let operations = RustJavaMavenOperations(core: RustCoreBridge()) + + let plan = try operations.mavenDependencyPlan( + at: testRoot, + context: MavenLaunchContext( + reactorPath: ".", + profiles: [], + settingsPath: nil, + skipTests: false, + mavenExecutablePath: nil, + javaHomePath: nil + ), + module: nil, + outputFile: outputFile + ) + #expect(plan.arguments.contains("-DoutputFile=" + outputFile.standardizedFileURL.path)) + #expect(plan.arguments.contains("-N")) + + try Data(fixture.treeFile.utf8).write(to: testRoot.appendingPathComponent("tree.txt")) + let tree = try operations.mavenDependencies( + modulePath: fixture.modulePath, + outputFile: testRoot.appendingPathComponent("tree.txt") + ) + #expect(tree == (try fixture.expected.makeModel())) + + #expect(throws: MavenOperationError.self) { + try operations.mavenDependencies( + modulePath: fixture.modulePath, + outputFile: testRoot.appendingPathComponent("never-written.txt") + ) + } + } +} diff --git a/macos/Tests/LitheTests/MavenRuntimeTests.swift b/macos/Tests/LitheTests/MavenRuntimeTests.swift index 3c7bdb4a5..c2c1d6cde 100644 --- a/macos/Tests/LitheTests/MavenRuntimeTests.swift +++ b/macos/Tests/LitheTests/MavenRuntimeTests.swift @@ -146,21 +146,71 @@ struct MavenRuntimeTests { let fixture = try Self.dependencyTreeFixture() let tree = try fixture.expected.makeModel() - #expect(fixture.version == 1) + #expect(fixture.version == 2) #expect(tree.modulePath == fixture.modulePath) - #expect(fixture.output.contains("omitted for conflict with 2.0")) + #expect(fixture.treeFile.contains("omitted for conflict with 2.0")) #expect(tree.dependencies.map(\.artifactID) == [ - "compile-lib", "provided-lib", "runtime-lib", "test-lib" + "compile-lib", "managed-lib", "provided-lib", "runtime-lib", "test-lib" ]) let compileDependency = try #require(tree.dependencies.first) let conflict = try #require(compileDependency.children.first) #expect(conflict.resolution == .omittedConflict) #expect(conflict.selectedVersion == "2.0") + #expect(conflict.premanagedVersion == "1.1") + let managed = try #require(tree.dependencies.first { $0.artifactID == "managed-lib" }) + #expect(managed.premanagedVersion == "0.9.0") + #expect(managed.ignoredScope == "compile") + let provided = try #require(tree.dependencies.first { $0.artifactID == "provided-lib" }) + #expect(provided.premanagedScope == "compile") + let runtime = try #require(tree.dependencies.first { $0.artifactID == "runtime-lib" }) + #expect(runtime.children.first?.originalScope == "test") let testDependency = try #require(tree.dependencies.last) #expect(testDependency.classifier == "tests") #expect(testDependency.scope == "test") } + @Test + func dependencyOutputStoreKeepsFilesPerProcessAndRemovesAbandonedOnes() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("lithe-maven-outputs-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let running = root.appendingPathComponent("200", isDirectory: true) + let exited = root.appendingPathComponent("300", isDirectory: true) + let unrelated = root.appendingPathComponent("notes", isDirectory: true) + for directory in [running, exited, unrelated] { + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + } + + let store = MacMavenDependencyOutputStore( + rootURL: root, + processIdentifier: 100, + isProcessRunning: { $0 == 200 } + ) + + // Another running Lithe keeps its files; an exited one's are removed. + #expect(FileManager.default.fileExists(atPath: running.path)) + #expect(!FileManager.default.fileExists(atPath: exited.path)) + #expect(FileManager.default.fileExists(atPath: unrelated.path)) + + let operationID = UUID().uuidString + let file = try store.makeDependencyOutputFile(operationID: operationID) + #expect(file.deletingLastPathComponent().lastPathComponent == "100") + try Data("stale".utf8).write(to: file) + #expect(try store.makeDependencyOutputFile(operationID: operationID) == file) + #expect(!FileManager.default.fileExists(atPath: file.path)) + + try Data("tree".utf8).write(to: file) + store.removeDependencyOutputFile(file) + #expect(!FileManager.default.fileExists(atPath: file.path)) + store.removeDependencyOutputFile(file) + + for operationID in ["", "../escape", "a/b", "a b"] { + #expect(throws: MavenOperationError.self) { + try store.makeDependencyOutputFile(operationID: operationID) + } + } + } + @Test func mavenConfigurationSeparatesPortableAndLocalPaths() throws { let testRoot = FileManager.default.temporaryDirectory @@ -478,18 +528,7 @@ struct MavenRuntimeTests { } private static func dependencyTreeFixture() throws -> MavenDependencyTreeFixture { - let repositoryRoot = URL(fileURLWithPath: #filePath) - .deletingLastPathComponent() - .deletingLastPathComponent() - .deletingLastPathComponent() - .deletingLastPathComponent() - let url = repositoryRoot.appendingPathComponent( - "shared/fixtures/maven/dependency-tree-v1.json" - ) - return try JSONDecoder().decode( - MavenDependencyTreeFixture.self, - from: Data(contentsOf: url) - ) + try MavenDependencyTreeFixture.load() } } @@ -505,11 +544,27 @@ private struct MavenPlatformContractFixture: Decodable { let storageIdentityCases: [StorageIdentityCase] } -private struct MavenDependencyTreeFixture: Decodable { +/// The shared `maven.dependencies` compatibility fixture. +struct MavenDependencyTreeFixture: Decodable { let version: Int let modulePath: String - let output: String + let treeFile: String let expected: RustCoreBridge.MavenDependenciesPayload + + static func load() throws -> MavenDependencyTreeFixture { + let repositoryRoot = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + let url = repositoryRoot.appendingPathComponent( + "shared/fixtures/maven/dependency-tree-v2.json" + ) + return try JSONDecoder().decode( + MavenDependencyTreeFixture.self, + from: Data(contentsOf: url) + ) + } } private struct MavenTestFileStorage: FileStorage { diff --git a/rust/lithe-core/src/project/maven.rs b/rust/lithe-core/src/project/maven.rs index dccc63fa3..f9442e7e1 100644 --- a/rust/lithe-core/src/project/maven.rs +++ b/rust/lithe-core/src/project/maven.rs @@ -1,9 +1,9 @@ //! Maven reactor inspection, profile discovery, and source diagnostics. +use super::maven_dependency_tree; use super::maven_test_reports::{self, MavenTestReportsRequest}; use crate::protocol::{CoreError, ErrorCode}; use crate::protocol::{ - MavenDependenciesResponse, MavenDependencyResolutionResponse, MavenDependencyResponse, MavenDiagnosticResponse, MavenDiagnosticsResponse, MavenLaunchExecutableResponse, MavenLaunchPlanResponse, MavenModuleResponse, MavenProfileResponse, MavenScanResponse, MavenSourceRootKind, MavenSourceRootResponse, MavenTestFailureResponse, @@ -48,11 +48,6 @@ pub struct MavenTestResultsRequest { } const MAVEN_CONTEXT_VERSION: u32 = 1; -const MAVEN_DEPENDENCY_PLUGIN_GOAL: &str = - "org.apache.maven.plugins:maven-dependency-plugin:3.8.1:tree"; -const MAX_MAVEN_DEPENDENCY_OUTPUT_CHARACTERS: usize = 500_000; -const MAX_MAVEN_DEPENDENCY_NODES: usize = 10_000; -const MAX_MAVEN_DEPENDENCY_DEPTH: usize = 64; const MAX_MAVEN_TEST_OUTPUT_CHARACTERS: usize = 500_000; const MAX_MAVEN_TEST_FAILURES: usize = 10_000; const MAX_MAVEN_TEST_SOURCE_SEARCH_DIRECTORIES: usize = 10_000; @@ -99,14 +94,9 @@ pub struct MavenDependencyPlanRequest { pub context: MavenLaunchContextRequest, #[serde(default)] pub module: Option, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -/// Captured Maven dependency-plugin output associated with one reactor module. -pub struct MavenDependenciesRequest { - pub module_path: String, - pub output: String, + /// Absolute platform-owned file the plugin writes the tree to. The same + /// path is later passed to `maven.dependencies`. + pub output_file: String, } #[derive(Debug, Clone)] @@ -155,22 +145,33 @@ pub fn launch_plan(request: MavenLaunchPlanRequest) -> Result Result { + let output_file = maven_dependency_tree::validated_output_file(&request.output_file)?; + let selects_root = request + .module + .as_deref() + .map(|module| normalized_project_path(module, "Maven module")) + .transpose()? + .is_none_or(|module| module == "."); + let mut arguments = Vec::new(); + if selects_root { + arguments.push("-N".to_string()); + } + arguments.extend(maven_dependency_tree::dependency_tree_arguments( + &output_file, + )); launch_plan_with_arguments( request.root, request.context, request.module, - vec![ - MAVEN_DEPENDENCY_PLUGIN_GOAL.to_string(), - "-Dverbose=true".to_string(), - "-DoutputType=text".to_string(), - "-Dstyle.color=never".to_string(), - "-Duser.language=en".to_string(), - "-Duser.country=US".to_string(), - ], + arguments, false, ) } @@ -611,7 +612,7 @@ fn normalized_tool_window_arguments(values: Vec) -> Result, Ok(arguments) } -fn normalized_project_path(value: &str, label: &str) -> Result { +pub(super) fn normalized_project_path(value: &str, label: &str) -> Result { let trimmed = value.trim(); if trimmed == "." { return Ok(".".to_string()); @@ -1585,247 +1586,6 @@ fn workspace_relative_path(root: &Path, path: &Path) -> Option { Some(relative.to_string_lossy().replace('\\', "/")) } -#[derive(Clone)] -struct ParsedDependency { - depth: usize, - node: MavenDependencyResponse, -} - -/// Parses bounded Maven dependency-plugin text into a deterministic tree. -pub fn dependencies( - request: MavenDependenciesRequest, -) -> Result { - if request - .output - .chars() - .nth(MAX_MAVEN_DEPENDENCY_OUTPUT_CHARACTERS) - .is_some() - { - return Err(CoreError::new( - ErrorCode::ParseFailed, - "Maven dependency output exceeds the supported limit", - ) - .with_details(format!( - "maximumCharacters={MAX_MAVEN_DEPENDENCY_OUTPUT_CHARACTERS}" - ))); - } - let module_path = normalized_project_path(&request.module_path, "Maven module")?; - let ansi = - Regex::new(r"\x1b\[[0-?]*[ -/]*[@-~]").expect("static ANSI escape expression is valid"); - let mut parsed = Vec::new(); - for raw_line in request.output.lines() { - let line = ansi.replace_all(raw_line, ""); - let Some(entry) = parse_dependency_line(&line, &module_path)? else { - continue; - }; - if parsed.len() == MAX_MAVEN_DEPENDENCY_NODES { - return Err(CoreError::new( - ErrorCode::ParseFailed, - "Maven dependency count exceeds the supported limit", - ) - .with_details(format!("maximumNodes={MAX_MAVEN_DEPENDENCY_NODES}"))); - } - parsed.push(entry); - } - - let mut cursor = 0; - let dependencies = if parsed.is_empty() { - Vec::new() - } else { - if parsed[0].depth != 0 { - return Err(invalid_dependency_structure()); - } - let dependencies = build_dependency_level(&parsed, &mut cursor, 0)?; - if cursor != parsed.len() { - return Err(invalid_dependency_structure()); - } - dependencies - }; - Ok(MavenDependenciesResponse { - module_path, - dependencies, - }) -} - -fn parse_dependency_line( - raw_line: &str, - module_path: &str, -) -> Result, CoreError> { - let trimmed = raw_line.trim(); - let line = trimmed - .strip_prefix("[INFO]") - .and_then(|value| value.strip_prefix(' ')) - .unwrap_or(trimmed); - let marker = match (line.find("+- "), line.find("\\- ")) { - (Some(left), Some(right)) => left.min(right), - (Some(index), None) | (None, Some(index)) => index, - (None, None) => return Ok(None), - }; - let prefix = &line[..marker]; - let mut chunks = prefix.as_bytes().chunks_exact(3); - if !chunks.all(|chunk| chunk == b"| " || chunk == b" ") || !chunks.remainder().is_empty() { - return Ok(None); - } - let depth = prefix.len() / 3; - if depth >= MAX_MAVEN_DEPENDENCY_DEPTH { - return Err(CoreError::new( - ErrorCode::ParseFailed, - "Maven dependency tree exceeds the supported depth", - ) - .with_details(format!("maximumDepth={MAX_MAVEN_DEPENDENCY_DEPTH}"))); - } - - let value = line[(marker + 3)..].trim(); - // Verbose dependency-plugin output wraps omitted nodes as - // `(coordinate - annotation)`, while resolved nodes use - // `coordinate (annotation)`. Normalize both forms before splitting the - // Maven coordinate so duplicate/conflict markers remain observable. - let (coordinate, annotation) = if let Some(inner) = value - .strip_prefix('(') - .and_then(|value| value.strip_suffix(')')) - { - inner - .split_once(" - ") - .map(|(coordinate, annotation)| (coordinate.trim(), Some(annotation.trim()))) - .unwrap_or((inner.trim(), None)) - } else { - value - .split_once(" (") - .map(|(coordinate, annotation)| (coordinate, Some(annotation.trim_end_matches(')')))) - .unwrap_or((value, None)) - }; - let parts = coordinate.split(':').collect::>(); - let (group_id, artifact_id, artifact_type, classifier, version, scope) = match parts.as_slice() - { - [group_id, artifact_id, artifact_type, version, scope] => ( - *group_id, - *artifact_id, - *artifact_type, - None, - *version, - *scope, - ), - [group_id, artifact_id, artifact_type, classifier, version, scope] => ( - *group_id, - *artifact_id, - *artifact_type, - Some(*classifier), - *version, - *scope, - ), - _ => return Ok(None), - }; - if [group_id, artifact_id, artifact_type, version, scope] - .iter() - .any(|value| value.trim().is_empty()) - || classifier.is_some_and(|value| value.trim().is_empty()) - { - return Ok(None); - } - - let (resolution, selected_version) = dependency_resolution(annotation); - Ok(Some(ParsedDependency { - depth, - node: MavenDependencyResponse { - module_path: module_path.to_string(), - group_id: group_id.to_string(), - artifact_id: artifact_id.to_string(), - version: version.to_string(), - r#type: artifact_type.to_string(), - classifier: classifier.map(str::to_string), - scope: scope.to_string(), - resolution, - selected_version, - children: Vec::new(), - }, - })) -} - -fn dependency_resolution( - annotation: Option<&str>, -) -> (MavenDependencyResolutionResponse, Option) { - let Some(annotation) = annotation else { - return (MavenDependencyResolutionResponse::Resolved, None); - }; - let normalized = annotation.to_ascii_lowercase(); - const CONFLICT: &str = "omitted for conflict with "; - if let Some(index) = normalized.find(CONFLICT) { - let selected_version = annotation[(index + CONFLICT.len())..] - .split_whitespace() - .next() - .map(|value| value.trim_end_matches([',', ')']).to_string()) - .filter(|value| !value.is_empty()); - return ( - MavenDependencyResolutionResponse::OmittedConflict, - selected_version, - ); - } - if normalized.contains("omitted for duplicate") { - return (MavenDependencyResolutionResponse::OmittedDuplicate, None); - } - (MavenDependencyResolutionResponse::Resolved, None) -} - -fn build_dependency_level( - parsed: &[ParsedDependency], - cursor: &mut usize, - depth: usize, -) -> Result, CoreError> { - let mut dependencies = Vec::new(); - while *cursor < parsed.len() { - let entry_depth = parsed[*cursor].depth; - if entry_depth < depth { - break; - } - if entry_depth > depth { - return Err(invalid_dependency_structure()); - } - let mut dependency = parsed[*cursor].node.clone(); - *cursor += 1; - if *cursor < parsed.len() { - let next_depth = parsed[*cursor].depth; - if next_depth > depth + 1 { - return Err(invalid_dependency_structure()); - } - if next_depth == depth + 1 { - dependency.children = build_dependency_level(parsed, cursor, depth + 1)?; - } - } - sort_dependencies(&mut dependency.children); - dependencies.push(dependency); - } - sort_dependencies(&mut dependencies); - Ok(dependencies) -} - -fn sort_dependencies(dependencies: &mut [MavenDependencyResponse]) { - dependencies.sort_by(|left, right| { - ( - &left.group_id, - &left.artifact_id, - &left.r#type, - &left.classifier, - &left.version, - &left.scope, - ) - .cmp(&( - &right.group_id, - &right.artifact_id, - &right.r#type, - &right.classifier, - &right.version, - &right.scope, - )) - }); -} - -fn invalid_dependency_structure() -> CoreError { - CoreError::new( - ErrorCode::ParseFailed, - "Maven dependency tree structure is invalid", - ) -} - fn module( root: &Path, base: &Path, diff --git a/rust/lithe-core/src/project/maven_dependency_tree.rs b/rust/lithe-core/src/project/maven_dependency_tree.rs new file mode 100644 index 000000000..f20674971 --- /dev/null +++ b/rust/lithe-core/src/project/maven_dependency_tree.rs @@ -0,0 +1,474 @@ +//! Maven dependency trees read from the file the dependency plugin writes. +//! +//! The tool window asks `maven-dependency-plugin:tree` for its verbose text +//! tree and sends it to a platform-owned file through `-DoutputFile`, so the +//! tree never shares a channel or a size budget with Maven's console log. +//! Resolution semantics stay with Maven: Core only validates the file's +//! structure and turns each node's annotations into explicit fields. +//! +//! The plugin version is pinned, which fixes the serialization format. Every +//! line after the root must therefore be a well-formed node carrying only +//! annotations that version produces; anything else is reported as a format +//! failure instead of being skipped, because skipping would present a partial +//! tree as a successful result. +//! +//! Note: the data-source decision is recorded in .agents/notes/implemented/architecture/2026-09-26-maven-dependency-tree-output-file.md + +use super::maven::normalized_project_path; +use crate::protocol::{ + CoreError, ErrorCode, MavenDependenciesResponse, MavenDependencyResolutionResponse, + MavenDependencyResponse, +}; +use serde::Deserialize; +use std::fs::File; +use std::io::{BufRead, BufReader, ErrorKind, Read}; +use std::path::Path; + +/// Fully qualified goal; pinning the version pins the text format parsed here. +const DEPENDENCY_PLUGIN_GOAL: &str = "org.apache.maven.plugins:maven-dependency-plugin:3.8.1:tree"; +/// Most nodes accepted from one module's tree. +const MAX_NODES: usize = 10_000; +/// Deepest nesting accepted; each level adds one three-character indent. +const MAX_DEPTH: usize = 64; +/// Longest accepted line in bytes. The deepest indent plus a long coordinate +/// and every annotation the plugin writes stays well below this. +const MAX_LINE_BYTES: usize = 4 * 1024; +/// Largest accepted tree file: the root line plus the node limit, each at the +/// line limit. The budget follows from the tree limits, not from log volume. +const MAX_TREE_BYTES: u64 = ((MAX_NODES + 1) * MAX_LINE_BYTES) as u64; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +/// Dependency-tree file written by one `maven.dependencyPlan` invocation. +pub struct MavenDependenciesRequest { + /// Reactor-relative module the plan selected; `.` is the reactor root. + pub module_path: String, + /// Absolute platform-owned path passed to the same plan as `outputFile`. + /// The platform creates a fresh path per invocation and removes it after + /// the result, cancellation, or failure, so a file from an earlier run is + /// never read here. + pub output_file: String, +} + +/// Validates the platform-owned file the plugin will write the tree to. +pub(crate) fn validated_output_file(value: &str) -> Result { + let invalid = || { + CoreError::new( + ErrorCode::InvalidRequest, + "Maven dependency-tree output path is invalid", + ) + }; + let trimmed = value.trim(); + // The path becomes one `-DoutputFile=` argument. Surrounding whitespace or + // control characters would change which file Maven writes. + if trimmed.is_empty() || trimmed != value || value.chars().any(char::is_control) { + return Err(invalid()); + } + if !Path::new(value).is_absolute() { + return Err(invalid()); + } + Ok(value.to_string()) +} + +/// Plugin goal and properties that write one module's verbose tree to a file. +/// +/// Every format-affecting property is explicit so user or project defaults +/// cannot change the parsed representation: text output, standard tree +/// tokens, UTF-8, and overwrite rather than append. +pub(crate) fn dependency_tree_arguments(output_file: &str) -> Vec { + vec![ + DEPENDENCY_PLUGIN_GOAL.to_string(), + "-Dverbose=true".to_string(), + "-DoutputType=text".to_string(), + "-Dtokens=standard".to_string(), + format!("-DoutputFile={output_file}"), + "-DoutputEncoding=UTF-8".to_string(), + "-DappendOutput=false".to_string(), + "-Dstyle.color=never".to_string(), + "-Duser.language=en".to_string(), + "-Duser.country=US".to_string(), + ] +} + +/// Reads and normalizes the tree file written by a finished dependency plan. +pub fn dependencies( + request: MavenDependenciesRequest, +) -> Result { + let module_path = normalized_project_path(&request.module_path, "Maven module")?; + let output_file = validated_output_file(&request.output_file)?; + let parsed = read_tree_file(Path::new(&output_file), &module_path)?; + + let mut cursor = 0; + let dependencies = if parsed.is_empty() { + Vec::new() + } else { + if parsed[0].depth != 0 { + return Err(invalid_structure()); + } + let dependencies = build_level(&parsed, &mut cursor, 0)?; + if cursor != parsed.len() { + return Err(invalid_structure()); + } + dependencies + }; + Ok(MavenDependenciesResponse { + module_path, + dependencies, + }) +} + +struct ParsedDependency { + depth: usize, + node: MavenDependencyResponse, +} + +fn read_tree_file(path: &Path, module_path: &str) -> Result, CoreError> { + let file = File::open(path).map_err(|error| { + if error.kind() == ErrorKind::NotFound { + // A successful exit without the file means the plugin did not + // honor `outputFile`, for example because the project's POM + // configures a different output for the dependency plugin. + CoreError::new( + ErrorCode::ProcessFailed, + "Maven finished without writing the dependency tree", + ) + } else { + CoreError::new( + ErrorCode::ProcessFailed, + "Unable to read the Maven dependency tree", + ) + .with_details(error.kind().to_string()) + } + })?; + let length = file + .metadata() + .map_err(|error| { + CoreError::new( + ErrorCode::ProcessFailed, + "Unable to read the Maven dependency tree", + ) + .with_details(error.kind().to_string()) + })? + .len(); + if length > MAX_TREE_BYTES { + return Err(CoreError::new( + ErrorCode::ParseFailed, + "Maven dependency tree exceeds the supported size", + ) + .with_details(format!("maximumBytes={MAX_TREE_BYTES}"))); + } + + // `take` keeps the bound even if the file grows after the size check. + let mut reader = BufReader::new(file.take(MAX_TREE_BYTES + 1)); + let mut line = Vec::new(); + let mut line_number = 0usize; + let mut saw_root = false; + let mut parsed = Vec::new(); + loop { + line.clear(); + let read = reader.read_until(b'\n', &mut line).map_err(|error| { + CoreError::new( + ErrorCode::ProcessFailed, + "Unable to read the Maven dependency tree", + ) + .with_details(error.kind().to_string()) + })?; + if read == 0 { + break; + } + line_number += 1; + if line.len() > MAX_LINE_BYTES { + return Err(unexpected_format(line_number)); + } + let text = std::str::from_utf8(&line).map_err(|_| unexpected_format(line_number))?; + // The plugin writes with the JVM line separator, so Windows files use CRLF. + let text = text.trim_end_matches(['\n', '\r']); + if text.trim().is_empty() { + continue; + } + if !saw_root { + if !is_root_line(text) { + return Err(unexpected_format(line_number)); + } + saw_root = true; + continue; + } + let entry = parse_line(text, module_path)?.ok_or_else(|| unexpected_format(line_number))?; + if parsed.len() == MAX_NODES { + return Err(CoreError::new( + ErrorCode::ParseFailed, + "Maven dependency count exceeds the supported limit", + ) + .with_details(format!("maximumNodes={MAX_NODES}"))); + } + parsed.push(entry); + } + if !saw_root { + return Err(unexpected_format(0)); + } + Ok(parsed) +} + +/// The first line names the module itself as `group:artifact:packaging:version`. +fn is_root_line(line: &str) -> bool { + let parts = line.split(':').collect::>(); + !line.starts_with(char::is_whitespace) + && !line.starts_with(['+', '\\', '|', '(']) + && parts.len() >= 4 + && parts.iter().all(|part| !part.trim().is_empty()) +} + +fn parse_line(line: &str, module_path: &str) -> Result, CoreError> { + let marker = match (line.find("+- "), line.find("\\- ")) { + (Some(left), Some(right)) => left.min(right), + (Some(index), None) | (None, Some(index)) => index, + (None, None) => return Ok(None), + }; + let prefix = &line[..marker]; + let mut chunks = prefix.as_bytes().chunks_exact(3); + if !chunks.all(|chunk| chunk == b"| " || chunk == b" ") || !chunks.remainder().is_empty() { + return Ok(None); + } + let depth = prefix.len() / 3; + if depth >= MAX_DEPTH { + return Err(CoreError::new( + ErrorCode::ParseFailed, + "Maven dependency tree exceeds the supported depth", + ) + .with_details(format!("maximumDepth={MAX_DEPTH}"))); + } + + let value = &line[(marker + 3)..]; + // Included nodes are written as `coordinate (items)` and omitted nodes as + // `(coordinate - items)`; items are separated by `; ` in both forms. + let (coordinate, items) = if let Some(inner) = value + .strip_prefix('(') + .and_then(|value| value.strip_suffix(')')) + { + match inner.split_once(" - ") { + Some((coordinate, items)) => (coordinate, Some(items)), + None => return Ok(None), + } + } else if let Some((coordinate, items)) = value.split_once(" (") { + match items.strip_suffix(')') { + Some(items) => (coordinate, Some(items)), + None => return Ok(None), + } + } else { + (value, None) + }; + let Some(coordinate) = parse_coordinate(coordinate) else { + return Ok(None); + }; + let Some(annotations) = parse_annotations(items) else { + return Ok(None); + }; + // Only the omitted form may carry the omission item, and it must carry one. + if value.starts_with('(') != annotations.omission.is_some() { + return Ok(None); + } + let (resolution, selected_version) = match annotations.omission { + None => (MavenDependencyResolutionResponse::Resolved, None), + Some(Omission::Duplicate) => (MavenDependencyResolutionResponse::OmittedDuplicate, None), + Some(Omission::Conflict(winner)) => ( + MavenDependencyResolutionResponse::OmittedConflict, + Some(winner), + ), + }; + Ok(Some(ParsedDependency { + depth, + node: MavenDependencyResponse { + module_path: module_path.to_string(), + group_id: coordinate.group_id.to_string(), + artifact_id: coordinate.artifact_id.to_string(), + version: coordinate.version.to_string(), + r#type: coordinate.artifact_type.to_string(), + classifier: coordinate.classifier.map(str::to_string), + scope: coordinate.scope.to_string(), + resolution, + selected_version, + premanaged_version: annotations.premanaged_version, + premanaged_scope: annotations.premanaged_scope, + original_scope: annotations.original_scope, + ignored_scope: annotations.ignored_scope, + children: Vec::new(), + }, + })) +} + +struct Coordinate<'a> { + group_id: &'a str, + artifact_id: &'a str, + artifact_type: &'a str, + classifier: Option<&'a str>, + version: &'a str, + scope: &'a str, +} + +/// Splits `group:artifact:type[:classifier]:version:scope`. +fn parse_coordinate(value: &str) -> Option> { + let parts = value.split(':').collect::>(); + let coordinate = match parts.as_slice() { + [group_id, artifact_id, artifact_type, version, scope] => Coordinate { + group_id, + artifact_id, + artifact_type, + classifier: None, + version, + scope, + }, + [group_id, artifact_id, artifact_type, classifier, version, scope] => Coordinate { + group_id, + artifact_id, + artifact_type, + classifier: Some(classifier), + version, + scope, + }, + _ => return None, + }; + let required = [ + coordinate.group_id, + coordinate.artifact_id, + coordinate.artifact_type, + coordinate.version, + coordinate.scope, + ]; + let valid = required + .iter() + .chain(coordinate.classifier.iter()) + .all(|part| !part.is_empty() && !part.contains(char::is_whitespace)); + valid.then_some(coordinate) +} + +/// Why Maven left an occurrence out of the effective tree. +enum Omission { + /// The same version was already selected elsewhere in the tree. + Duplicate, + /// Another version won mediation; holds the selected version. + Conflict(String), +} + +/// Annotation items the pinned plugin writes after a node's coordinate. +#[derive(Default)] +struct Annotations { + /// `version managed from X`: the version before dependency management. + premanaged_version: Option, + /// `scope managed from X`: the scope before dependency management. + premanaged_scope: Option, + /// `scope updated from X`: the declared scope before mediation widened it. + original_scope: Option, + /// `scope not updated to X`: a wider scope mediation did not apply. + ignored_scope: Option, + omission: Option, +} + +fn parse_annotations(items: Option<&str>) -> Option { + let mut annotations = Annotations::default(); + let Some(items) = items else { + return Some(annotations); + }; + for item in items.split("; ") { + let slot_and_value = [ + ("version managed from ", &mut annotations.premanaged_version), + ("scope managed from ", &mut annotations.premanaged_scope), + ("scope updated from ", &mut annotations.original_scope), + ("scope not updated to ", &mut annotations.ignored_scope), + ] + .into_iter() + .find_map(|(prefix, slot)| item.strip_prefix(prefix).map(|value| (slot, value))); + if let Some((slot, value)) = slot_and_value { + if slot.is_some() || !is_token(value) { + return None; + } + *slot = Some(value.to_string()); + continue; + } + if annotations.omission.is_some() { + return None; + } + if item == "omitted for duplicate" { + annotations.omission = Some(Omission::Duplicate); + } else if let Some(winner) = item.strip_prefix("omitted for conflict with ") { + if !is_token(winner) { + return None; + } + annotations.omission = Some(Omission::Conflict(winner.to_string())); + } else { + return None; + } + } + Some(annotations) +} + +fn is_token(value: &str) -> bool { + !value.is_empty() && !value.contains(char::is_whitespace) +} + +fn build_level( + parsed: &[ParsedDependency], + cursor: &mut usize, + depth: usize, +) -> Result, CoreError> { + let mut dependencies = Vec::new(); + while *cursor < parsed.len() { + let entry_depth = parsed[*cursor].depth; + if entry_depth < depth { + break; + } + if entry_depth > depth { + return Err(invalid_structure()); + } + let mut dependency = parsed[*cursor].node.clone(); + *cursor += 1; + if *cursor < parsed.len() { + let next_depth = parsed[*cursor].depth; + if next_depth > depth + 1 { + return Err(invalid_structure()); + } + if next_depth == depth + 1 { + dependency.children = build_level(parsed, cursor, depth + 1)?; + } + } + sort_dependencies(&mut dependency.children); + dependencies.push(dependency); + } + sort_dependencies(&mut dependencies); + Ok(dependencies) +} + +fn sort_dependencies(dependencies: &mut [MavenDependencyResponse]) { + dependencies.sort_by(|left, right| { + ( + &left.group_id, + &left.artifact_id, + &left.r#type, + &left.classifier, + &left.version, + &left.scope, + ) + .cmp(&( + &right.group_id, + &right.artifact_id, + &right.r#type, + &right.classifier, + &right.version, + &right.scope, + )) + }); +} + +fn invalid_structure() -> CoreError { + CoreError::new( + ErrorCode::ParseFailed, + "Maven dependency tree structure is invalid", + ) +} + +fn unexpected_format(line_number: usize) -> CoreError { + CoreError::new( + ErrorCode::ParseFailed, + "Maven dependency tree is not in the expected text format", + ) + .with_details(format!("line={line_number}")) +} diff --git a/rust/lithe-core/src/project/mod.rs b/rust/lithe-core/src/project/mod.rs index 7cb9dbd77..0fa151a93 100644 --- a/rust/lithe-core/src/project/mod.rs +++ b/rust/lithe-core/src/project/mod.rs @@ -5,6 +5,7 @@ pub(crate) mod files; mod history; mod markdown; mod maven; +mod maven_dependency_tree; mod maven_test_reports; mod search_index; @@ -13,3 +14,4 @@ pub(crate) use files::*; pub(crate) use history::*; pub(crate) use markdown::*; pub(crate) use maven::*; +pub(crate) use maven_dependency_tree::{dependencies, MavenDependenciesRequest}; diff --git a/rust/lithe-core/src/protocol/contracts.rs b/rust/lithe-core/src/protocol/contracts.rs index 3774de460..2b5b429fb 100644 --- a/rust/lithe-core/src/protocol/contracts.rs +++ b/rust/lithe-core/src/protocol/contracts.rs @@ -285,6 +285,14 @@ pub struct MavenDependencyResponse { pub resolution: MavenDependencyResolutionResponse, /// Version Maven selected when this occurrence was omitted for conflict. pub selected_version: Option, + /// Version before dependency management replaced it with `version`. + pub premanaged_version: Option, + /// Scope before dependency management replaced it with `scope`. + pub premanaged_scope: Option, + /// Declared scope before mediation widened it to `scope`. + pub original_scope: Option, + /// Wider scope from another path that mediation did not apply here. + pub ignored_scope: Option, pub children: Vec, } diff --git a/rust/lithe-core/src/tests/languages.rs b/rust/lithe-core/src/tests/languages.rs index 067f47022..b0d6ea533 100644 --- a/rust/lithe-core/src/tests/languages.rs +++ b/rust/lithe-core/src/tests/languages.rs @@ -5,6 +5,7 @@ use crate::project::{ }; use serde_json::Value; use std::fs; +use std::path::{Path, PathBuf}; #[test] fn jdt_workspace_key_matches_the_shared_compatibility_fixture() { @@ -433,22 +434,33 @@ fn maven_launch_plan_matches_the_shared_compatibility_fixture() { fs::remove_dir_all(root).expect("Maven launch-plan fixture should be removable"); } -#[test] -fn maven_dependency_plan_is_fixed_and_module_scoped() { - let root = temporary_root("maven-dependency-plan"); - fs::create_dir_all(root.join("service")).expect("Maven module should be creatable"); - fs::write( - root.join("pom.xml"), - r#"demopomservice"#, - ) - .expect("reactor pom should be writable"); - fs::write( - root.join("service/pom.xml"), - r#"service"#, - ) - .expect("module pom should be writable"); +/// Temporary directory that is removed even when an assertion fails. +struct TreeDirectory(PathBuf); - let response: Value = serde_json::from_str(&execute_json( +impl TreeDirectory { + fn new(label: &str) -> Self { + let path = temporary_root(label); + fs::create_dir_all(&path).expect("temporary directory should be creatable"); + Self(path) + } + + fn write(&self, name: &str, contents: impl AsRef<[u8]>) -> PathBuf { + let path = self.0.join(name); + fs::write(&path, contents).expect("dependency-tree file should be writable"); + path + } +} + +impl Drop for TreeDirectory { + fn drop(&mut self) { + if let Err(error) = fs::remove_dir_all(&self.0) { + eprintln!("Could not remove dependency-tree fixture: {error}"); + } + } +} + +fn maven_dependency_plan(root: &Path, module: Value, output_file: Value) -> Value { + serde_json::from_str(&execute_json( &serde_json::json!({ "id": "maven-dependency-plan", "command": "maven.dependencyPlan", @@ -459,90 +471,291 @@ fn maven_dependency_plan_is_fixed_and_module_scoped() { "reactorPath": ".", "profiles": ["dev"] }, - "module": "service" + "module": module, + "outputFile": output_file } }) .to_string(), )) - .expect("Maven dependency-plan response should be JSON"); - - assert_eq!(response["ok"], true, "{response}"); - assert_eq!( - response["data"]["arguments"], - serde_json::json!([ - "-B", - "-ntp", - "-P", - "dev", - "-pl", - "service", - "org.apache.maven.plugins:maven-dependency-plugin:3.8.1:tree", - "-Dverbose=true", - "-DoutputType=text", - "-Dstyle.color=never", - "-Duser.language=en", - "-Duser.country=US" - ]) - ); - assert!(!response["data"]["arguments"] - .as_array() - .expect("arguments should be an array") - .iter() - .any(|argument| argument == "-am")); - fs::remove_dir_all(root).expect("Maven dependency-plan fixture should be removable"); + .expect("Maven dependency-plan response should be JSON") } -#[test] -fn maven_dependencies_match_the_shared_compatibility_fixture() { - let fixture: Value = serde_json::from_str(include_str!( - "../../../../shared/fixtures/maven/dependency-tree-v1.json" - )) - .expect("Maven dependency-tree fixture should be valid JSON"); - let response: Value = serde_json::from_str(&execute_json( +fn maven_dependencies(module_path: &str, output_file: &Path) -> Value { + serde_json::from_str(&execute_json( &serde_json::json!({ "id": "maven-dependencies", "command": "maven.dependencies", - "payload": { - "modulePath": fixture["modulePath"], - "output": fixture["output"] - } + "payload": {"modulePath": module_path, "outputFile": output_file} }) .to_string(), )) - .expect("Maven dependency response should be JSON"); + .expect("Maven dependency response should be JSON") +} - assert_eq!(response["ok"], true, "{response}"); - assert_eq!(response["data"], fixture["expected"]); +/// One tree line per node, as the pinned plugin writes a flat direct list. +fn flat_tree(nodes: usize) -> String { + let mut tree = String::from("com.example:service:jar:1.0.0\n"); + for index in 0..nodes { + tree.push_str(&format!( + "+- org.example.application.platform.dependencies:library-{index}:jar:1.0.0:compile (version managed from 0.9.0)\n" + )); + } + tree +} + +#[test] +fn maven_dependency_plan_writes_one_module_tree_to_the_platform_file() { + let workspace = TreeDirectory::new("maven-dependency-plan"); + let root = &workspace.0; + fs::create_dir_all(root.join("service")).expect("Maven module should be creatable"); + fs::write( + root.join("pom.xml"), + r#"demopomservice"#, + ) + .expect("reactor pom should be writable"); + fs::write( + root.join("service/pom.xml"), + r#"service"#, + ) + .expect("module pom should be writable"); + let output_file = root.join("scratch/tree.txt"); + let tree_arguments = |output_file: &Path| { + vec![ + Value::from("org.apache.maven.plugins:maven-dependency-plugin:3.8.1:tree"), + Value::from("-Dverbose=true"), + Value::from("-DoutputType=text"), + Value::from("-Dtokens=standard"), + Value::from(format!("-DoutputFile={}", output_file.display())), + Value::from("-DoutputEncoding=UTF-8"), + Value::from("-DappendOutput=false"), + Value::from("-Dstyle.color=never"), + Value::from("-Duser.language=en"), + Value::from("-Duser.country=US"), + ] + }; + + let module = + maven_dependency_plan(root, Value::from("service"), serde_json::json!(output_file)); + assert_eq!(module["ok"], true, "{module}"); + let mut expected = vec![ + Value::from("-B"), + Value::from("-ntp"), + Value::from("-P"), + Value::from("dev"), + Value::from("-pl"), + Value::from("service"), + ]; + expected.extend(tree_arguments(&output_file)); + assert_eq!(module["data"]["arguments"], Value::Array(expected)); + + // Without `-N` every reactor project would overwrite the same file, and + // the root query would return whichever module Maven visited last. + for root_module in [Value::Null, Value::from(".")] { + let reactor = + maven_dependency_plan(root, root_module.clone(), serde_json::json!(output_file)); + assert_eq!(reactor["ok"], true, "{root_module}: {reactor}"); + let mut expected = vec![ + Value::from("-B"), + Value::from("-ntp"), + Value::from("-P"), + Value::from("dev"), + Value::from("-N"), + ]; + expected.extend(tree_arguments(&output_file)); + assert_eq!( + reactor["data"]["arguments"], + Value::Array(expected), + "{root_module}" + ); + } +} + +#[test] +fn maven_dependency_plan_rejects_output_paths_maven_would_misread() { + let workspace = TreeDirectory::new("maven-dependency-plan-output"); + let root = &workspace.0; + fs::write( + root.join("pom.xml"), + r#"demo"#, + ) + .expect("pom should be writable"); + let absolute = root.join("tree.txt").display().to_string(); + for (name, output_file) in [ + ("missing", Value::Null), + ("empty", Value::from("")), + ("relative", Value::from("scratch/tree.txt")), + ("padded", Value::from(format!(" {absolute}"))), + ( + "control", + Value::from(format!("{absolute}\n-Dverbose=false")), + ), + ] { + let response = maven_dependency_plan(root, Value::Null, output_file); + assert_eq!(response["ok"], false, "case {name}: {response}"); + assert_eq!(response["error"]["code"], "invalid_request", "case {name}"); + } } #[test] -fn maven_dependencies_reject_bounded_output_node_and_depth_overflow() { - let oversized_output = "x".repeat(500_001); - let too_many_nodes = (0..10_001) - .map(|index| format!("[INFO] +- example:dependency-{index}:jar:1:compile")) - .collect::>() - .join("\n"); - let too_deep = format!("[INFO] {}\\- example:deep:jar:1:compile", "| ".repeat(64)); - - for (name, output) in [ - ("output", oversized_output), - ("nodes", too_many_nodes), - ("depth", too_deep), +fn maven_dependencies_match_the_shared_compatibility_fixture() { + let fixture: Value = serde_json::from_str(include_str!( + "../../../../shared/fixtures/maven/dependency-tree-v2.json" + )) + .expect("Maven dependency-tree fixture should be valid JSON"); + let directory = TreeDirectory::new("maven-dependencies-fixture"); + let tree = fixture["treeFile"] + .as_str() + .expect("fixture tree should be text"); + let module_path = fixture["modulePath"] + .as_str() + .expect("fixture module should be text"); + + // The plugin writes with the JVM line separator, so Windows produces CRLF. + for (name, contents) in [ + ("lf", tree.to_string()), + ("crlf", tree.replace('\n', "\r\n")), ] { - let response: Value = serde_json::from_str(&execute_json( - &serde_json::json!({ - "id": name, - "command": "maven.dependencies", - "payload": {"modulePath": ".", "output": output} - }) - .to_string(), - )) - .expect("bounded Maven dependency response should be JSON"); + let file = directory.write(&format!("{name}.txt"), contents); + let response = maven_dependencies(module_path, &file); + assert_eq!(response["ok"], true, "case {name}: {response}"); + assert_eq!(response["data"], fixture["expected"], "case {name}"); + } +} + +#[test] +fn maven_dependencies_accept_a_large_tree_that_exceeded_the_console_budget() { + // Issue #890: 6,000 nodes are about 700 KB of text, above the former + // 500,000-character console budget but well inside the node limit. + let directory = TreeDirectory::new("maven-dependencies-large"); + let tree = flat_tree(6_000); + assert!(tree.len() > 500_000); + let response = maven_dependencies(".", &directory.write("tree.txt", tree)); + assert_eq!(response["ok"], true, "{}", response["error"]); + let dependencies = response["data"]["dependencies"] + .as_array() + .expect("dependencies should be an array"); + assert_eq!(dependencies.len(), 6_000); + assert!(dependencies + .iter() + .all(|dependency| dependency["premanagedVersion"] == "0.9.0")); + + let limit = maven_dependencies(".", &directory.write("limit.txt", flat_tree(10_000))); + assert_eq!(limit["ok"], true, "{}", limit["error"]); +} + +#[test] +fn maven_dependencies_reject_trees_beyond_their_bounds() { + let directory = TreeDirectory::new("maven-dependencies-bounds"); + let oversized = directory.0.join("oversized.txt"); + // A sparse file reaches the byte limit without writing tens of megabytes. + fs::File::create(&oversized) + .and_then(|file| file.set_len((10_000 + 1) * 4 * 1024 + 1)) + .expect("oversized tree should be creatable"); + let long_line = format!( + "com.example:service:jar:1.0.0\n+- org.example:{}:jar:1:compile\n", + "a".repeat(4 * 1024) + ); + let too_deep = format!( + "com.example:service:jar:1.0.0\n{}\\- example:deep:jar:1:compile\n", + "| ".repeat(64) + ); + + for (name, file, details) in [ + ("bytes", oversized, "maximumBytes="), + ( + "nodes", + directory.write("nodes.txt", flat_tree(10_001)), + "maximumNodes=10000", + ), + ("line", directory.write("line.txt", long_line), "line=2"), + ( + "depth", + directory.write("depth.txt", too_deep), + "maximumDepth=64", + ), + ] { + let response = maven_dependencies(".", &file); assert_eq!(response["ok"], false, "case {name}: {response}"); assert_eq!(response["error"]["code"], "parse_failed", "case {name}"); + assert!( + response["error"]["details"] + .as_str() + .is_some_and(|value| value.starts_with(details)), + "case {name}: {response}" + ); } } +#[test] +fn maven_dependencies_report_unexpected_file_contents_instead_of_a_partial_tree() { + // Each case would previously have been skipped line by line and shown as + // a shorter tree. A project POM can override the plugin's output type or + // tokens, so the file format is validated rather than assumed. + let directory = TreeDirectory::new("maven-dependencies-format"); + for (name, contents) in [ + ("empty", String::new()), + ( + "dot", + "digraph \"com.example:service:jar:1.0.0\" {\n\t\"com.example:service:jar:1.0.0\" -> \"org.example:lib:jar:1.0:compile\" ;\n }\n".to_string(), + ), + ( + "extended-tokens", + "com.example:service:jar:1.0.0\n\u{251C}\u{2500} org.example:lib:jar:1.0:compile\n".to_string(), + ), + ( + "console-log", + "com.example:service:jar:1.0.0\n+- org.example:lib:jar:1.0:compile\n[INFO] BUILD SUCCESS\n".to_string(), + ), + ( + "unknown-annotation", + "com.example:service:jar:1.0.0\n+- org.example:lib:jar:1.0:compile (version selected from range [1.0,2.0))\n".to_string(), + ), + ( + "omitted-without-reason", + "com.example:service:jar:1.0.0\n+- (org.example:lib:jar:1.0:compile - version managed from 0.9)\n".to_string(), + ), + ( + "included-with-omission", + "com.example:service:jar:1.0.0\n+- org.example:lib:jar:1.0:compile (omitted for duplicate)\n".to_string(), + ), + ( + "invalid-utf8", + "com.example:service:jar:1.0.0\n+- org.example:lib-\u{FFFD}:jar:1.0:compile\n".to_string(), + ), + ] { + let mut bytes = contents.into_bytes(); + if name == "invalid-utf8" { + let replacement = "\u{FFFD}".as_bytes(); + let index = bytes + .windows(replacement.len()) + .position(|window| window == replacement) + .expect("placeholder should be present"); + bytes.splice(index..index + replacement.len(), [0xFF]); + } + let response = maven_dependencies(".", &directory.write(&format!("{name}.txt"), bytes)); + assert_eq!(response["ok"], false, "case {name}: {response}"); + assert_eq!(response["error"]["code"], "parse_failed", "case {name}"); + } + + let root_only = maven_dependencies( + ".", + &directory.write("root-only.txt", "com.example:service:jar:1.0.0\n"), + ); + assert_eq!(root_only["ok"], true, "{root_only}"); + assert_eq!(root_only["data"]["dependencies"], serde_json::json!([])); +} + +#[test] +fn maven_dependencies_report_a_missing_tree_file_as_a_process_failure() { + let directory = TreeDirectory::new("maven-dependencies-missing"); + let response = maven_dependencies(".", &directory.0.join("never-written.txt")); + assert_eq!(response["ok"], false, "{response}"); + assert_eq!(response["error"]["code"], "process_failed"); + + let relative = maven_dependencies(".", Path::new("tree.txt")); + assert_eq!(relative["error"]["code"], "invalid_request", "{relative}"); +} + #[test] fn java_run_markers_match_the_shared_compatibility_fixture() { let fixture: Value = serde_json::from_str(include_str!( diff --git a/scripts/verify-rust-core.sh b/scripts/verify-rust-core.sh index 76408e379..bd4af1fd8 100755 --- a/scripts/verify-rust-core.sh +++ b/scripts/verify-rust-core.sh @@ -76,12 +76,13 @@ cargo build --manifest-path rust/Cargo.toml -p lithe-core python3 scripts/test-git-execution.py --application "$BINARY" node --input-type=module -e 'import { writeTestReportArtifacts } from "./.agents/skills/write-stable-tests/scripts/generate-test-report.mjs"; writeTestReportArtifacts(".artifacts/test-stability/git-execution-integration.json");' -# Exercise the macOS journal through a real non-Git-feature entry point. The -# ordinary Swift unit lane does not link Core, so this integration is explicit. +# Exercise the macOS journal through a real non-Git-feature entry point, and +# the Maven dependency-tree file through the linked bridge. The ordinary Swift +# unit lane does not link Core, so these integrations are explicit. LITHE_RUN_GIT_EXECUTION_INTEGRATION=1 \ ./.agents/skills/write-stable-tests/scripts/test-stability-macos.sh \ --suite-timeout-seconds 900 \ --report .artifacts/test-stability/git-console-bridge.json \ - -- --filter 'MacGitHubGitOperationsTests|GitConsolePresentationBridgeTests|GitConsoleLifecycleBridgeTests' -Xlinker -force_load -Xlinker "$RUST_LIBRARY" + -- --filter 'MacGitHubGitOperationsTests|GitConsolePresentationBridgeTests|GitConsoleLifecycleBridgeTests|MavenDependencyTreeBridgeTests' -Xlinker -force_load -Xlinker "$RUST_LIBRARY" print "Rust Core verification passed: comments, Rust tests, Swift bridge, linked symbols, and Git execution integration" diff --git a/shared/contracts/rust-core-api.md b/shared/contracts/rust-core-api.md index 16bf0c375..1d970fa68 100644 --- a/shared/contracts/rust-core-api.md +++ b/shared/contracts/rust-core-api.md @@ -107,7 +107,7 @@ stable error code and a user-facing message: | `maven.scan` | Parse a Maven project descriptor and recursively return modules/profiles | | `maven.launchPlan` | Produce a deterministic Maven invocation from a versioned project context | | `maven.dependencyPlan` | Produce a bounded dependency-tree invocation for one Maven module | -| `maven.dependencies` | Normalize bounded Maven dependency-plugin output into a deterministic tree | +| `maven.dependencies` | Normalize the bounded dependency-tree file one plan wrote into a deterministic tree | | `maven.diagnostics` | Parse stable Maven compiler diagnostics from build output | | `maven.testResults` | Parse bounded JUnit/Surefire result summaries and failure locations | | `debug.createSession` | Create a transport-neutral DAP session and return its initialize frame | @@ -1506,23 +1506,37 @@ expand or duplicate that file's arguments. Fixtures are in `shared/fixtures/maven/launch-plan-v1.json`. `maven.dependencyPlan` accepts the same workspace `root`, versioned `context`, -and optional reactor-relative `module`. It returns a launch plan for the fixed -`maven-dependency-plugin:3.8.1:tree` goal with verbose text output, disabled -color, and an English locale. Module queries use `-pl ` without `-am`; -the read-only query does not build reactor dependencies. Platform adapters own -the child process, apply a bounded timeout, and keep it independent from an -ordinary Maven build session. - -`maven.dependencies` accepts `{ "modulePath": string, "output": string }` and -returns the normalized module path plus a recursively nested `dependencies` -array. Each node contains `modulePath`, `groupId`, `artifactId`, `version`, -`type`, nullable `classifier`, `scope`, `resolution`, nullable -`selectedVersion`, and `children`. Resolution is `resolved`, -`omittedDuplicate`, or `omittedConflict`. Core removes ANSI control sequences -and unrelated Maven log lines, then sorts every level deterministically. Input -is limited to 500,000 Unicode scalar values, 10,000 dependency nodes, and 64 -levels; malformed or excessive output returns `parse_failed`. The compatibility -fixture is `shared/fixtures/maven/dependency-tree-v1.json`. +optional reactor-relative `module`, and a required absolute `outputFile`. It +returns a launch plan for the fixed `maven-dependency-plugin:3.8.1:tree` goal +that writes the verbose text tree to `outputFile` in UTF-8 with standard tree +tokens, disabled color, and an English locale. Exactly one project runs because +every project in the session would overwrite the same file: module queries use +`-pl ` without `-am`, and reactor-root queries use `-N`. The read-only +query does not build reactor dependencies. Platform adapters own the child +process, apply a bounded timeout, and keep it independent from an ordinary Maven +build session. They also own `outputFile`: each invocation receives a fresh +path in a platform scratch directory, and the platform removes it after the +result, cancellation, timeout, or failure. The process's console output is log +text only and is never parsed as dependency data. + +`maven.dependencies` accepts `{ "modulePath": string, "outputFile": string }` +after the plan's process exits successfully and returns the normalized module +path plus a recursively nested `dependencies` array. Each node contains +`modulePath`, `groupId`, `artifactId`, `version`, `type`, nullable `classifier`, +`scope`, `resolution`, nullable `selectedVersion`, nullable +`premanagedVersion` and `premanagedScope` (values before dependency +management), nullable `originalScope` (declared scope before mediation widened +it), nullable `ignoredScope` (a wider scope mediation did not apply), and +`children`. Resolution is `resolved`, `omittedDuplicate`, or `omittedConflict`; +`selectedVersion` is the winning version of an omitted conflict. Every level is +sorted deterministically. + +The first line of the file must name the module and every later line must be a +node with only the annotations the pinned plugin writes; any other content, +invalid UTF-8, a line over 4 KiB, more than 10,000 nodes, 64 levels, or a file +over the byte limit derived from those bounds returns `parse_failed` instead of +a partial tree. A missing file returns `process_failed`. The compatibility +fixture is `shared/fixtures/maven/dependency-tree-v2.json`. `maven.diagnostics` accepts `{ "root": string, "output": string }` and returns `{ "issues": [] }`. Diagnostic paths may be absolute or workspace-relative; diff --git a/shared/fixtures/maven/dependency-tree-v1.json b/shared/fixtures/maven/dependency-tree-v2.json similarity index 54% rename from shared/fixtures/maven/dependency-tree-v1.json rename to shared/fixtures/maven/dependency-tree-v2.json index 2ff7f486a..de3937b1c 100644 --- a/shared/fixtures/maven/dependency-tree-v1.json +++ b/shared/fixtures/maven/dependency-tree-v2.json @@ -1,7 +1,7 @@ { - "version": 1, + "version": 2, "modulePath": "service", - "output": "\u001b[36m[INFO]\u001b[0m Scanning for projects...\n[INFO] com.example:service:jar:1.0.0\n[INFO] +- org.example:runtime-lib:jar:2.0:runtime\n[INFO] | \\- org.example:transitive-lib:jar:1.5:runtime\n[INFO] +- org.example:provided-lib:jar:3.0:provided\n[INFO] +- org.example:test-lib:jar:tests:4.0:test\n[INFO] \\- org.example:compile-lib:jar:1.0:compile\n[INFO] +- (org.example:duplicate-lib:jar:1.0:compile - version managed from 1.0; omitted for duplicate)\n[INFO] \\- (org.example:conflict-lib:jar:1.0:compile - version managed from 1.0; omitted for conflict with 2.0)\n[INFO] BUILD SUCCESS\n", + "treeFile": "com.example:service:jar:1.0.0\n+- org.example:runtime-lib:jar:2.0:runtime\n| \\- org.example:transitive-lib:jar:1.5:runtime (scope updated from test)\n+- org.example:provided-lib:jar:3.0:provided (scope managed from compile)\n+- org.example:test-lib:jar:tests:4.0:test\n+- org.example:managed-lib:jar:2.1:compile (version managed from 0.9.0; scope not updated to compile)\n\\- org.example:compile-lib:jar:1.0:compile\n +- (org.example:duplicate-lib:jar:1.0:compile - version managed from 1.0; omitted for duplicate)\n \\- (org.example:conflict-lib:jar:1.0:compile - version managed from 1.1; omitted for conflict with 2.0)\n", "expected": { "modulePath": "service", "dependencies": [ @@ -15,6 +15,10 @@ "scope": "compile", "resolution": "resolved", "selectedVersion": null, + "premanagedVersion": null, + "premanagedScope": null, + "originalScope": null, + "ignoredScope": null, "children": [ { "modulePath": "service", @@ -26,6 +30,10 @@ "scope": "compile", "resolution": "omittedConflict", "selectedVersion": "2.0", + "premanagedVersion": "1.1", + "premanagedScope": null, + "originalScope": null, + "ignoredScope": null, "children": [] }, { @@ -38,10 +46,30 @@ "scope": "compile", "resolution": "omittedDuplicate", "selectedVersion": null, + "premanagedVersion": "1.0", + "premanagedScope": null, + "originalScope": null, + "ignoredScope": null, "children": [] } ] }, + { + "modulePath": "service", + "groupId": "org.example", + "artifactId": "managed-lib", + "version": "2.1", + "type": "jar", + "classifier": null, + "scope": "compile", + "resolution": "resolved", + "selectedVersion": null, + "premanagedVersion": "0.9.0", + "premanagedScope": null, + "originalScope": null, + "ignoredScope": "compile", + "children": [] + }, { "modulePath": "service", "groupId": "org.example", @@ -52,6 +80,10 @@ "scope": "provided", "resolution": "resolved", "selectedVersion": null, + "premanagedVersion": null, + "premanagedScope": "compile", + "originalScope": null, + "ignoredScope": null, "children": [] }, { @@ -64,6 +96,10 @@ "scope": "runtime", "resolution": "resolved", "selectedVersion": null, + "premanagedVersion": null, + "premanagedScope": null, + "originalScope": null, + "ignoredScope": null, "children": [ { "modulePath": "service", @@ -75,6 +111,10 @@ "scope": "runtime", "resolution": "resolved", "selectedVersion": null, + "premanagedVersion": null, + "premanagedScope": null, + "originalScope": "test", + "ignoredScope": null, "children": [] } ] @@ -89,6 +129,10 @@ "scope": "test", "resolution": "resolved", "selectedVersion": null, + "premanagedVersion": null, + "premanagedScope": null, + "originalScope": null, + "ignoredScope": null, "children": [] } ] diff --git a/shared/platform-feature-matrix.json b/shared/platform-feature-matrix.json index 08859ea70..30090b51d 100644 --- a/shared/platform-feature-matrix.json +++ b/shared/platform-feature-matrix.json @@ -636,20 +636,25 @@ "macos": { "evidence": [ "macos/Sources/Lithe/Views/Run", - "macos/Sources/Lithe/Services/Java" + "macos/Sources/Lithe/Services/Java", + "macos/Sources/LitheExecutionModule/Services/MavenService.swift", + "macos/Sources/Lithe/Platform/MacOS/Persistence/MacMavenDependencyOutputStore.swift", + "rust/lithe-core/src/project/maven_dependency_tree.rs" ], "implementationStatus": "implemented", "verificationStatus": "pending" }, "windows": { "evidence": [ - "windows/tauri/src/features/maven" + "windows/tauri/src/features/maven", + "windows/tauri/src-tauri/src/maven.rs", + "rust/lithe-core/src/project/maven_dependency_tree.rs" ], "implementationStatus": "implemented", "verificationStatus": "pending" }, "owner": "Java / Maven", - "verification": "切换 Maven profile 并刷新依赖树,确认模块归属、顺序和错误边界。" + "verification": "切换 Maven profile 并刷新依赖树,确认模块归属、顺序、版本冲突/重复/依赖管理标注和错误边界;在依赖节点超过 6,000 个的项目中确认依赖树完整加载。" }, { "id": "java-maven-toolbar-tooltips", diff --git a/windows/tauri/src-tauri/src/main.rs b/windows/tauri/src-tauri/src/main.rs index 73d716999..2cb29a660 100644 --- a/windows/tauri/src-tauri/src/main.rs +++ b/windows/tauri/src-tauri/src/main.rs @@ -90,6 +90,7 @@ fn main() { app.manage(run::RunProcessManager::default()); app.manage(debug::DebugAdapterManager::default()); run::cleanup_legacy_appdata(app.handle()); + maven::clear_dependency_outputs(app.handle()); if let Some(window) = app.get_webview_window("main") { host::apply_window_taskbar_icon(&window); } @@ -179,6 +180,8 @@ fn main() { lsp::lsp_rebuild_java_index, maven::maven_load_configuration, maven::maven_write_configuration, + maven::maven_create_dependency_output, + maven::maven_remove_dependency_output, run::run_list_java_sources, run::run_write_generated, run::run_write_documents, diff --git a/windows/tauri/src-tauri/src/maven.rs b/windows/tauri/src-tauri/src/maven.rs index 6aef0c935..081d032a8 100644 --- a/windows/tauri/src-tauri/src/maven.rs +++ b/windows/tauri/src-tauri/src/maven.rs @@ -2,6 +2,12 @@ //! //! Portable selections stay below the workspace `.lithe` directory. Maven, //! JDK, and settings paths are stored only in the application data directory. +//! +//! This module also owns the scratch files the dependency plugin writes its +//! tree to. Each dependency session gets its own file in the application cache +//! directory; the frontend removes it when the session ends and the directory +//! is cleared at startup, so a crash cannot leave trees behind or feed an old +//! tree to a later session. use crate::run::atomic_write; use serde::{de::DeserializeOwned, Deserialize, Serialize}; @@ -11,6 +17,8 @@ use std::path::{Path, PathBuf}; use tauri::{AppHandle, Manager}; const MAVEN_CONFIGURATION_VERSION: u32 = 1; +/// Cache subdirectory holding one dependency-tree file per active session. +const DEPENDENCY_TREE_DIRECTORY: &str = "maven-dependency-trees"; #[derive(Debug, Clone, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] @@ -83,6 +91,79 @@ pub fn maven_write_configuration( ) } +/// Returns a fresh path for the tree one dependency session writes. +/// +/// A file left by an earlier session with the same ID is removed first, so +/// Core can only read what this session's Maven process wrote. +#[tauri::command] +pub fn maven_create_dependency_output( + app: AppHandle, + session_id: String, +) -> Result { + let path = prepare_dependency_output(&dependency_tree_directory(&app)?, &session_id)?; + Ok(path.to_string_lossy().into_owned()) +} + +/// Removes a dependency session's tree file; a file that was never written is not an error. +#[tauri::command] +pub fn maven_remove_dependency_output(app: AppHandle, session_id: String) -> Result<(), String> { + remove_dependency_output(&dependency_tree_directory(&app)?, &session_id) +} + +/// Deletes trees left by a previous run. The application is single-instance, +/// so no other process can own files in this directory at startup. +pub fn clear_dependency_outputs(app: &AppHandle) { + let Ok(directory) = dependency_tree_directory(app) else { + return; + }; + if let Err(error) = clear_dependency_output_directory(&directory) { + eprintln!("[maven] Could not clear dependency-tree scratch files: {error}"); + } +} + +fn dependency_tree_directory(app: &AppHandle) -> Result { + Ok(app + .path() + .app_cache_dir() + .map_err(|error| error.to_string())? + .join(DEPENDENCY_TREE_DIRECTORY)) +} + +fn dependency_output_path(directory: &Path, session_id: &str) -> Result { + // Session IDs come from the frontend. Restricting them keeps the file name + // inside the scratch directory and valid on Windows, where `:` is reserved. + if session_id.is_empty() + || session_id.len() > 128 + || !session_id.chars().all(|character| { + character.is_ascii_alphanumeric() || matches!(character, '-' | '_' | ':') + }) + { + return Err("The Maven dependency session is invalid.".into()); + } + Ok(directory.join(format!("{}.txt", session_id.replace(':', "_")))) +} + +fn prepare_dependency_output(directory: &Path, session_id: &str) -> Result { + let path = dependency_output_path(directory, session_id)?; + fs::create_dir_all(directory).map_err(|error| error.to_string())?; + remove_dependency_output(directory, session_id)?; + Ok(path) +} + +fn remove_dependency_output(directory: &Path, session_id: &str) -> Result<(), String> { + match fs::remove_file(dependency_output_path(directory, session_id)?) { + Err(error) if error.kind() != std::io::ErrorKind::NotFound => Err(error.to_string()), + _ => Ok(()), + } +} + +fn clear_dependency_output_directory(directory: &Path) -> std::io::Result<()> { + match fs::remove_dir_all(directory) { + Err(error) if error.kind() != std::io::ErrorKind::NotFound => Err(error), + _ => Ok(()), + } +} + fn validate_versions( portable: Option<&MavenPortableConfiguration>, local: Option<&MavenLocalConfiguration>, @@ -182,6 +263,58 @@ mod tests { path } + #[test] + fn dependency_outputs_are_fresh_per_session_and_removed_on_request() { + let cache = temp_directory(); + let directory = cache.join(DEPENDENCY_TREE_DIRECTORY); + let session = "maven-dependency:3f2a9c1e-0000-4000-8000-000000000001"; + + let path = prepare_dependency_output(&directory, session).expect("create output"); + assert_eq!(path.parent(), Some(directory.as_path())); + assert_eq!( + path.file_name().and_then(|name| name.to_str()), + Some("maven-dependency_3f2a9c1e-0000-4000-8000-000000000001.txt") + ); + // A tree left by an earlier session with the same ID is never reused. + fs::write(&path, "stale").expect("write stale tree"); + assert_eq!( + prepare_dependency_output(&directory, session).expect("recreate"), + path + ); + assert!(!path.exists()); + + fs::write(&path, "tree").expect("write tree"); + remove_dependency_output(&directory, session).expect("remove output"); + assert!(!path.exists()); + remove_dependency_output(&directory, session).expect("removing twice is not an error"); + + fs::write(&path, "tree").expect("write tree"); + clear_dependency_output_directory(&directory).expect("clear directory"); + assert!(!directory.exists()); + clear_dependency_output_directory(&directory).expect("clearing twice is not an error"); + fs::remove_dir_all(cache).expect("remove temp directory"); + } + + #[test] + fn dependency_output_sessions_cannot_name_other_paths() { + let directory = PathBuf::from("scratch"); + for session in [ + "", + "..", + "../tree", + "a/b", + "a\\b", + "C:\\tree", + "a b", + &"a".repeat(129), + ] { + assert!( + dependency_output_path(&directory, session).is_err(), + "session {session:?} should be rejected" + ); + } + } + #[test] fn portable_configuration_round_trips_without_local_paths() { let root = temp_directory(); diff --git a/windows/tauri/src/features/maven/api/maven-core-api.ts b/windows/tauri/src/features/maven/api/maven-core-api.ts index cc653915d..4b9480fb1 100644 --- a/windows/tauri/src/features/maven/api/maven-core-api.ts +++ b/windows/tauri/src/features/maven/api/maven-core-api.ts @@ -55,20 +55,27 @@ export function createMavenLaunchPlan( }); } +/** + * Plans a dependency-tree query that writes the tree to `outputFile`, a + * host-owned scratch file from `createMavenDependencyOutput`. + */ export function createMavenDependencyPlan( root: string, context: MavenLaunchContext, - module?: string | null, + module: string | null, + outputFile: string, ) { return mavenCore("maven.dependencyPlan", { root, context, - module: module ?? null, + module, + outputFile, }); } -export function parseMavenDependencies(modulePath: string, output: string) { - return mavenCore("maven.dependencies", { modulePath, output }); +/** Reads the tree a finished dependency plan wrote to `outputFile`. */ +export function readMavenDependencies(modulePath: string, outputFile: string) { + return mavenCore("maven.dependencies", { modulePath, outputFile }); } export async function parseMavenDiagnostics(root: string, output: string) { diff --git a/windows/tauri/src/features/maven/api/maven-host-api.ts b/windows/tauri/src/features/maven/api/maven-host-api.ts index aa2554f92..0404649e1 100644 --- a/windows/tauri/src/features/maven/api/maven-host-api.ts +++ b/windows/tauri/src/features/maven/api/maven-host-api.ts @@ -26,6 +26,20 @@ export function resolveMavenInstallation(root: string, overridePath?: string) { return invoke("maven_resolve_installation", { root, overridePath }); } +/** + * Returns a fresh host-owned file for one dependency session's tree. + * + * The session must call `removeMavenDependencyOutput` once it ends, whether + * the tree was read, cancelled, timed out, or failed. + */ +export function createMavenDependencyOutput(sessionId: string) { + return invoke("maven_create_dependency_output", { sessionId }); +} + +export function removeMavenDependencyOutput(sessionId: string) { + return invoke("maven_remove_dependency_output", { sessionId }); +} + export function writeMavenConfiguration( root: string, reactorPath: string, diff --git a/windows/tauri/src/features/maven/components/maven-pane.tsx b/windows/tauri/src/features/maven/components/maven-pane.tsx index 8d8e0df79..9fe009ef6 100644 --- a/windows/tauri/src/features/maven/components/maven-pane.tsx +++ b/windows/tauri/src/features/maven/components/maven-pane.tsx @@ -60,6 +60,7 @@ import { findMavenModuleJavaPath, findMavenModuleRunConfiguration, } from "../utils/maven-module-operations"; +import { mavenDependencySubtitle } from "../utils/maven-dependency-subtitle"; interface TreeNodeProps { id: string; @@ -434,14 +435,7 @@ export default function MavenPane({ onClose }: MavenPaneProps) { }; const renderDependency = (dependency: MavenDependency, id: string): ReactNode => { - const marker = - dependency.resolution === "omittedConflict" - ? `${t("maven.omittedConflict")}${dependency.selectedVersion ? ` -> ${dependency.selectedVersion}` : ""}` - : dependency.resolution === "omittedDuplicate" - ? t("maven.omittedDuplicate") - : null; - const classifier = dependency.classifier ? `:${dependency.classifier}` : ""; - const subtitle = `${dependency.groupId}:${dependency.version}:${dependency.type}${classifier} [${dependency.scope}]${marker ? ` (${marker})` : ""}`; + const subtitle = mavenDependencySubtitle(dependency, t); const children = dependency.children.length > 0 ? dependency.children.map((child, index) => diff --git a/windows/tauri/src/features/maven/hooks/use-maven-process-events.test.ts b/windows/tauri/src/features/maven/hooks/use-maven-process-events.test.ts index 868406636..cf1ec325f 100644 --- a/windows/tauri/src/features/maven/hooks/use-maven-process-events.test.ts +++ b/windows/tauri/src/features/maven/hooks/use-maven-process-events.test.ts @@ -9,7 +9,6 @@ const windowListen = mock(async (event: string, handler: (event: { payload: unkn }); const globalListen = mock(async () => () => {}); const appendOutput = mock(() => undefined); -const appendDependencyOutput = mock(() => undefined); const finishProcess = mock(() => undefined); const finishDependencyProcess = mock(async () => undefined); const releaseMavenSessionWorkspace = mock(() => undefined); @@ -26,7 +25,6 @@ mock.module("./maven-process-session", () => ({ getState: () => ({ actions: { appendOutput, - appendDependencyOutput, finishProcess, finishDependencyProcess, }, @@ -40,7 +38,6 @@ const { ensureMavenProcessListeners } = await import("./use-maven-process-events describe("maven process event listeners", () => { beforeEach(() => { appendOutput.mockClear(); - appendDependencyOutput.mockClear(); finishProcess.mockClear(); finishDependencyProcess.mockClear(); releaseMavenSessionWorkspace.mockClear(); @@ -79,7 +76,7 @@ describe("maven process event listeners", () => { expect(appendOutput).toHaveBeenCalledWith("maven:task-1", "BUILD SUCCESS\n"); }); - test("routes dependency sessions without mutating build output", () => { + test("finishes dependency sessions without treating their log as data", () => { const outputHandler = eventHandlers.get("run-output"); const exitHandler = eventHandlers.get("run-exit"); expect(outputHandler).toBeDefined(); @@ -92,10 +89,7 @@ describe("maven process event listeners", () => { payload: { sessionId: "maven-dependency:task-1", exitCode: 0 }, }); - expect(appendDependencyOutput).toHaveBeenCalledWith( - "maven-dependency:task-1", - "[INFO] tree\n", - ); + // The tree arrives in the session's output file, so the log reaches no store. expect(appendOutput).not.toHaveBeenCalled(); expect(finishDependencyProcess).toHaveBeenCalledWith("maven-dependency:task-1", 0); }); diff --git a/windows/tauri/src/features/maven/hooks/use-maven-process-events.ts b/windows/tauri/src/features/maven/hooks/use-maven-process-events.ts index 35dc9128e..f194e579b 100644 --- a/windows/tauri/src/features/maven/hooks/use-maven-process-events.ts +++ b/windows/tauri/src/features/maven/hooks/use-maven-process-events.ts @@ -24,11 +24,9 @@ export async function ensureMavenProcessListeners(): Promise { if (!outputUnlisten) { outputUnlisten = await currentWindow.listen("run-output", (event) => { const sessionId = event.payload.sessionId; - if (sessionId.startsWith("maven-dependency:")) { - mavenStoreForSession(sessionId) - .getState() - .actions.appendDependencyOutput(sessionId, event.payload.chunk); - } else if (sessionId.startsWith("maven:")) { + // Dependency sessions write their tree to a file; their console output + // is Maven's log and is not dependency data. + if (sessionId.startsWith("maven:")) { mavenStoreForSession(sessionId) .getState() .actions.appendOutput(sessionId, event.payload.chunk); diff --git a/windows/tauri/src/features/maven/stores/maven.store.test.ts b/windows/tauri/src/features/maven/stores/maven.store.test.ts index 7d5b91667..5c3453722 100644 --- a/windows/tauri/src/features/maven/stores/maven.store.test.ts +++ b/windows/tauri/src/features/maven/stores/maven.store.test.ts @@ -88,10 +88,26 @@ const dependencyTree: MavenDependenciesResponse = { const scanMavenProject = mock(async (_root: string, _paths?: string[]) => project); const createMavenLaunchPlan = mock(async () => launchPlan); -const createMavenDependencyPlan = mock(async () => launchPlan); -const parseMavenDependencies = mock( - async (_modulePath: string, _output: string): Promise => - dependencyTree, +const dependencyOutputFile = (sessionId: string) => + `C:/Users/dev/AppData/Local/lithe/cache/maven-dependency-trees/${sessionId.replace(":", "_")}.txt`; +/** Host scratch-file and Core calls in the order the store makes them. */ +const dependencyFileEvents: string[] = []; +const createMavenDependencyOutput = mock(async (sessionId: string) => { + dependencyFileEvents.push(`create ${sessionId}`); + return dependencyOutputFile(sessionId); +}); +const removeMavenDependencyOutput = mock(async (sessionId: string): Promise => { + dependencyFileEvents.push(`remove ${sessionId}`); +}); +const createMavenDependencyPlan = mock( + async (_root: string, _context: unknown, _module: string | null, _outputFile: string) => + launchPlan, +); +const readMavenDependencies = mock( + async (_modulePath: string, outputFile: string): Promise => { + dependencyFileEvents.push(`read ${outputFile}`); + return dependencyTree; + }, ); const parseMavenDiagnostics = mock( async (_root: string, _output: string): Promise => [], @@ -138,12 +154,14 @@ const resolveJavaTestClass = mock(async (_root: string, _file: string, className const dependencies = { createMavenPomWatchOperations, + createMavenDependencyOutput, createMavenDependencyPlan, createMavenLaunchPlan, loadMavenConfiguration, parseMavenDiagnostics, parseMavenTestResults, - parseMavenDependencies, + readMavenDependencies, + removeMavenDependencyOutput, resolveEffectiveMavenExecutable, resolveMavenLaunch, resolveJavaTestClass, @@ -183,8 +201,10 @@ beforeEach(() => { success: true, failureDetails: [], }); - parseMavenDependencies.mockReset(); - parseMavenDependencies.mockResolvedValue(dependencyTree); + dependencyFileEvents.length = 0; + createMavenDependencyOutput.mockClear(); + removeMavenDependencyOutput.mockClear(); + readMavenDependencies.mockClear(); resolveMavenLaunch.mockClear(); saveWorkspaceBeforeLaunch.mockReset(); saveWorkspaceBeforeLaunch.mockResolvedValue(undefined); @@ -1227,17 +1247,24 @@ describe("Maven dependency state", () => { const sessionId = store.getState().activeDependencySessionId; expect(sessionId).toStartWith("maven-dependency:"); + const outputFile = dependencyOutputFile(sessionId!); expect(createMavenDependencyPlan).toHaveBeenCalledWith( "D:/work", expect.objectContaining({ reactorPath: "reactor" }), "service", + outputFile, ); expect(store.getState().dependencyLoads.service?.status).toBe("loading"); expect(store.getState().output).toBe("existing build output"); - store.getState().actions.appendDependencyOutput(sessionId!, "[INFO] tree\n"); await store.getState().actions.finishDependencyProcess(sessionId!, 0); - expect(parseMavenDependencies).toHaveBeenCalledWith("service", "[INFO] tree\n"); + expect(readMavenDependencies).toHaveBeenCalledWith("service", outputFile); + // The file is removed only after Core has read it. + expect(dependencyFileEvents).toEqual([ + `create ${sessionId}`, + `read ${outputFile}`, + `remove ${sessionId}`, + ]); expect(store.getState().dependencyLoads.service).toEqual({ status: "ready", dependencies: dependencyTree.dependencies, @@ -1261,6 +1288,7 @@ describe("Maven dependency state", () => { await timer.fireNext(); expect(stopMavenProcess).toHaveBeenCalledWith(sessionId); + expect(removeMavenDependencyOutput).toHaveBeenCalledWith(sessionId); expect(store.getState().activeDependencySessionId).toBeNull(); expect(store.getState().dependencyLoads.service?.status).toBe("failed"); expect(store.getState().dependencyLoads.service?.error).toContain("timed out"); @@ -1275,6 +1303,8 @@ describe("Maven dependency state", () => { await store.getState().actions.cancelDependencies("service"); expect(stopMavenProcess).toHaveBeenCalledWith(sessionId); + expect(removeMavenDependencyOutput).toHaveBeenCalledWith(sessionId); + expect(readMavenDependencies).not.toHaveBeenCalled(); expect(store.getState().dependencyLoads.service).toEqual({ status: "cancelled", dependencies: [], @@ -1284,7 +1314,7 @@ describe("Maven dependency state", () => { test("drops a parsed result after Maven configuration invalidates the request", async () => { const pending = deferred(); - parseMavenDependencies.mockImplementationOnce(async () => pending.promise); + readMavenDependencies.mockImplementationOnce(async () => pending.promise); const store = createMavenStore("workspace", dependencies); await store.getState().actions.loadProject("D:/work", ["reactor/pom.xml"]); await store.getState().actions.loadDependencies("service"); @@ -1296,11 +1326,12 @@ describe("Maven dependency state", () => { await finishing; expect(store.getState().dependencyLoads).toEqual({}); + expect(removeMavenDependencyOutput).toHaveBeenCalledWith(sessionId); }); test("cancels a module still being parsed when another dependency request starts", async () => { const pending = deferred(); - parseMavenDependencies.mockImplementationOnce(async () => pending.promise); + readMavenDependencies.mockImplementationOnce(async () => pending.promise); const timer = new ManualTimer(); const store = createMavenStore("workspace", dependencies, { setTimer: timer.set, @@ -1318,8 +1349,84 @@ describe("Maven dependency state", () => { pending.resolve(dependencyTree); await finishing; expect(store.getState().dependencyLoads.service?.status).toBe("cancelled"); + expect(removeMavenDependencyOutput).toHaveBeenCalledWith(sessionId); await store.getState().actions.cancelDependencies("other"); }); + + test("removes the tree file when Maven exits without success", async () => { + const store = createMavenStore("workspace", dependencies); + await store.getState().actions.loadProject("D:/work", ["reactor/pom.xml"]); + await store.getState().actions.loadDependencies("service"); + const sessionId = store.getState().activeDependencySessionId; + + await store.getState().actions.finishDependencyProcess(sessionId!, 1); + + expect(readMavenDependencies).not.toHaveBeenCalled(); + expect(removeMavenDependencyOutput).toHaveBeenCalledWith(sessionId); + expect(store.getState().dependencyLoads.service).toEqual({ + status: "failed", + dependencies: [], + error: "Maven dependency resolution exited with code 1.", + }); + }); + + test("reports a tree Core rejects and still removes its file", async () => { + readMavenDependencies.mockImplementationOnce(async () => { + throw new Error("Maven dependency tree is not in the expected text format"); + }); + const store = createMavenStore("workspace", dependencies); + await store.getState().actions.loadProject("D:/work", ["reactor/pom.xml"]); + await store.getState().actions.loadDependencies("service"); + const sessionId = store.getState().activeDependencySessionId; + + await store.getState().actions.finishDependencyProcess(sessionId!, 0); + + expect(removeMavenDependencyOutput).toHaveBeenCalledWith(sessionId); + expect(store.getState().dependencyLoads.service).toEqual({ + status: "failed", + dependencies: [], + error: "Maven dependency tree is not in the expected text format", + }); + }); + + test("keeps a read tree when its scratch file cannot be removed", async () => { + removeMavenDependencyOutput.mockImplementationOnce(async () => { + throw new Error("The process cannot access the file"); + }); + const store = createMavenStore("workspace", dependencies); + await store.getState().actions.loadProject("D:/work", ["reactor/pom.xml"]); + await store.getState().actions.loadDependencies("service"); + const sessionId = store.getState().activeDependencySessionId; + + await store.getState().actions.finishDependencyProcess(sessionId!, 0); + // Removal is fire-and-forget; let its rejection reach the trace handler. + await Promise.resolve(); + await Promise.resolve(); + + expect(store.getState().dependencyLoads.service?.status).toBe("ready"); + expect(trace).toHaveBeenCalledWith( + "warn", + "maven.dependencies", + "Dependency tree file was not removed", + expect.objectContaining({ sessionId, error: "The process cannot access the file" }), + ); + }); + + test("stops and removes a running session when Maven configuration changes", async () => { + const store = createMavenStore("workspace", dependencies); + await store.getState().actions.loadProject("D:/work", ["reactor/pom.xml"]); + await store.getState().actions.loadDependencies("service"); + const sessionId = store.getState().activeDependencySessionId; + + store.getState().actions.setSelectedProfiles(["dev"]); + await Promise.resolve(); + await Promise.resolve(); + + expect(stopMavenProcess).toHaveBeenCalledWith(sessionId); + expect(removeMavenDependencyOutput).toHaveBeenCalledWith(sessionId); + expect(store.getState().activeDependencySessionId).toBeNull(); + expect(store.getState().dependencyLoads).toEqual({}); + }); }); describe("Maven test outcomes for editor Run markers", () => { diff --git a/windows/tauri/src/features/maven/stores/maven.store.ts b/windows/tauri/src/features/maven/stores/maven.store.ts index 0b3763065..3c0f5c7c1 100644 --- a/windows/tauri/src/features/maven/stores/maven.store.ts +++ b/windows/tauri/src/features/maven/stores/maven.store.ts @@ -6,13 +6,15 @@ import { frontendTrace } from "@/utils/frontend-trace"; import { createMavenDependencyPlan, createMavenLaunchPlan, - parseMavenDependencies, + readMavenDependencies, parseMavenDiagnostics, parseMavenTestResults, scanMavenProject, } from "../api/maven-core-api"; import { + createMavenDependencyOutput, loadMavenConfiguration, + removeMavenDependencyOutput, resolveMavenLaunch, startMavenProcess, stopMavenProcess, @@ -48,7 +50,6 @@ import { } from "../utils/maven-test-selection"; const MAXIMUM_OUTPUT_CHARACTERS = 500_000; -const MAXIMUM_DEPENDENCY_OUTPUT_CHARACTERS = 500_000; const MAVEN_DEPENDENCY_TIMEOUT_MILLISECONDS = 60_000; const MAVEN_TEST_TIMEOUT_MILLISECONDS = 120_000; const MAVEN_TEST_ALLOW_EMPTY_UPSTREAM_MODULES = "-Dsurefire.failIfNoSpecifiedTests=false"; @@ -63,12 +64,14 @@ const mavenProjectLoads = new Map(); export interface MavenStoreDependencies { createMavenPomWatchOperations: typeof createMavenPomWatchOperations; + createMavenDependencyOutput: typeof createMavenDependencyOutput; createMavenDependencyPlan: typeof createMavenDependencyPlan; createMavenLaunchPlan: typeof createMavenLaunchPlan; loadMavenConfiguration: typeof loadMavenConfiguration; parseMavenDiagnostics: typeof parseMavenDiagnostics; - parseMavenDependencies: typeof parseMavenDependencies; + readMavenDependencies: typeof readMavenDependencies; parseMavenTestResults: typeof parseMavenTestResults; + removeMavenDependencyOutput: typeof removeMavenDependencyOutput; resolveEffectiveMavenExecutable: typeof resolveEffectiveMavenExecutable; resolveMavenLaunch: typeof resolveMavenLaunch; resolveJavaTestClass: typeof resolveJavaTestClass; @@ -82,12 +85,14 @@ export interface MavenStoreDependencies { const defaultMavenStoreDependencies: MavenStoreDependencies = { createMavenPomWatchOperations, + createMavenDependencyOutput, createMavenDependencyPlan, createMavenLaunchPlan, loadMavenConfiguration, parseMavenDiagnostics, - parseMavenDependencies, + readMavenDependencies, parseMavenTestResults, + removeMavenDependencyOutput, resolveEffectiveMavenExecutable, resolveMavenLaunch, resolveJavaTestClass, @@ -159,7 +164,6 @@ export interface MavenState { dependencyLoads: Record; activeDependencySessionId: string | null; activeDependencyModulePath: string | null; - dependencyOutput: string; actions: { loadProject: (root: string, visiblePaths?: string[]) => Promise; markPomReloadRequired: (changedPath: string) => void; @@ -197,7 +201,6 @@ export interface MavenState { finishProcess: (sessionId: string, exitCode: number) => void; loadDependencies: (modulePath: string) => Promise; cancelDependencies: (modulePath: string) => Promise; - appendDependencyOutput: (sessionId: string, chunk: string) => void; finishDependencyProcess: (sessionId: string, exitCode: number) => Promise; }; } @@ -407,6 +410,24 @@ export const createMavenStore = ( })); }; + // Maven writes each session's tree to its own host scratch file. Every + // path that ends a session releases it here, after which the session can + // no longer produce a result, so no tree outlives the session that wrote it. + const dependencyOutputFiles = new Map(); + const releaseDependencySession = (sessionId: string) => { + releaseMavenSessionWorkspace(sessionId); + dependencyOutputFiles.delete(sessionId); + void dependencies.removeMavenDependencyOutput(sessionId).catch((error: unknown) => { + // The host clears the scratch directory at startup, so a file the + // dying Maven process still held open is removed on the next launch. + dependencies.trace("warn", "maven.dependencies", "Dependency tree file was not removed", { + workspaceId, + sessionId, + error: error instanceof Error ? error.message : String(error), + }); + }); + }; + const invalidateDependencies = () => { dependencyRevision += 1; clearDependencyTimer(); @@ -415,7 +436,6 @@ export const createMavenStore = ( dependencyLoads: {}, activeDependencySessionId: null, activeDependencyModulePath: null, - dependencyOutput: "", }); if (!sessionId) return; void dependencies @@ -424,7 +444,7 @@ export const createMavenStore = ( // Invalidation owns stale-result rejection even if the native process // has already exited before the stop request reaches it. }) - .finally(() => releaseMavenSessionWorkspace(sessionId)); + .finally(() => releaseDependencySession(sessionId)); }; const failDependencySession = async ( @@ -443,7 +463,6 @@ export const createMavenStore = ( set({ activeDependencySessionId: null, activeDependencyModulePath: null, - dependencyOutput: "", }); setDependencyLoad(modulePath, { status: "failed", dependencies: [], error: message }); try { @@ -452,7 +471,7 @@ export const createMavenStore = ( // The failure state remains actionable when the process exited while // the stop request was in flight. } finally { - releaseMavenSessionWorkspace(sessionId); + releaseDependencySession(sessionId); } }; @@ -585,7 +604,6 @@ export const createMavenStore = ( dependencyLoads: {}, activeDependencySessionId: null, activeDependencyModulePath: null, - dependencyOutput: "", actions: { loadProject: async (root, visiblePaths = []) => { const revision = ++projectLoadRevision; @@ -1198,7 +1216,6 @@ export const createMavenStore = ( set({ activeDependencySessionId: null, activeDependencyModulePath: null, - dependencyOutput: "", }); if (previousModulePath) { setDependencyLoad(previousModulePath, { @@ -1214,7 +1231,7 @@ export const createMavenStore = ( // A superseded request remains cancelled when its native process // completed before the stop reached the host. } finally { - releaseMavenSessionWorkspace(previousSessionId); + releaseDependencySession(previousSessionId); } } } @@ -1229,23 +1246,25 @@ export const createMavenStore = ( set({ activeDependencySessionId: sessionId, activeDependencyModulePath: modulePath, - dependencyOutput: "", }); setDependencyLoad(modulePath, { status: "loading", dependencies: [], error: null }); try { await dependencies.saveWorkspaceBeforeLaunch(workspaceId); + const outputFile = await dependencies.createMavenDependencyOutput(sessionId); + dependencyOutputFiles.set(sessionId, outputFile); const plan = await dependencies.createMavenDependencyPlan( root, context, modulePath === "." ? null : modulePath, + outputFile, ); const resolved = await dependencies.resolveMavenLaunch(root, context, plan); if ( dependencyRevision !== revision || get().activeDependencySessionId !== sessionId ) { - releaseMavenSessionWorkspace(sessionId); + releaseDependencySession(sessionId); return; } dependencyTimer = dependencyScheduler.setTimer( @@ -1270,14 +1289,14 @@ export const createMavenStore = ( ) { clearDependencyTimer(); await dependencies.stopMavenProcess(sessionId).catch(() => undefined); - releaseMavenSessionWorkspace(sessionId); + releaseDependencySession(sessionId); } } catch (error) { if ( dependencyRevision !== revision || get().activeDependencySessionId !== sessionId ) { - releaseMavenSessionWorkspace(sessionId); + releaseDependencySession(sessionId); return; } clearDependencyTimer(); @@ -1288,10 +1307,9 @@ export const createMavenStore = ( set({ activeDependencySessionId: null, activeDependencyModulePath: null, - dependencyOutput: "", }); setDependencyLoad(modulePath, { status: "failed", dependencies: [], error: message }); - releaseMavenSessionWorkspace(sessionId); + releaseDependencySession(sessionId); } }, @@ -1313,7 +1331,6 @@ export const createMavenStore = ( set({ activeDependencySessionId: null, activeDependencyModulePath: null, - dependencyOutput: "", }); setDependencyLoad(modulePath, { status: "cancelled", dependencies: [], error: null }); if (!sessionId) return; @@ -1326,30 +1343,10 @@ export const createMavenStore = ( : "Unable to stop Maven dependency resolution."; setDependencyLoad(modulePath, { status: "failed", dependencies: [], error: message }); } finally { - releaseMavenSessionWorkspace(sessionId); + releaseDependencySession(sessionId); } }, - appendDependencyOutput: (sessionId, chunk) => { - const state = get(); - if ( - state.activeDependencySessionId !== sessionId || - !state.activeDependencyModulePath - ) { - return; - } - const output = (state.dependencyOutput + chunk).replace(/\r/g, ""); - if (output.length > MAXIMUM_DEPENDENCY_OUTPUT_CHARACTERS) { - void failDependencySession( - sessionId, - state.activeDependencyModulePath, - "Maven dependency output exceeded the supported limit.", - ); - return; - } - set({ dependencyOutput: output }); - }, - finishDependencyProcess: async (sessionId, exitCode) => { const state = get(); if (state.activeDependencySessionId !== sessionId || !state.activeDependencyModulePath) { @@ -1357,24 +1354,23 @@ export const createMavenStore = ( } const revision = dependencyRevision; const modulePath = state.activeDependencyModulePath; - const output = state.dependencyOutput; + const outputFile = dependencyOutputFiles.get(sessionId); clearDependencyTimer(); - releaseMavenSessionWorkspace(sessionId); - set({ - activeDependencySessionId: null, - dependencyOutput: "", - }); - if (exitCode !== 0) { - set({ activeDependencyModulePath: null }); - setDependencyLoad(modulePath, { - status: "failed", - dependencies: [], - error: `Maven dependency resolution exited with code ${exitCode}.`, - }); - return; - } + set({ activeDependencySessionId: null }); try { - const result = await dependencies.parseMavenDependencies(modulePath, output); + if (exitCode !== 0 || !outputFile) { + set({ activeDependencyModulePath: null }); + setDependencyLoad(modulePath, { + status: "failed", + dependencies: [], + error: + exitCode !== 0 + ? `Maven dependency resolution exited with code ${exitCode}.` + : "Unable to parse Maven dependencies for this module.", + }); + return; + } + const result = await dependencies.readMavenDependencies(modulePath, outputFile); if ( dependencyRevision !== revision || get().activeDependencyModulePath !== modulePath || @@ -1405,6 +1401,9 @@ export const createMavenStore = ( ? error.message : "Unable to parse Maven dependencies for this module.", }); + } finally { + // Core has finished reading, successfully or not, before the file goes. + releaseDependencySession(sessionId); } }, }, diff --git a/windows/tauri/src/features/maven/types/maven.types.ts b/windows/tauri/src/features/maven/types/maven.types.ts index 342479909..f4f35a2d7 100644 --- a/windows/tauri/src/features/maven/types/maven.types.ts +++ b/windows/tauri/src/features/maven/types/maven.types.ts @@ -76,6 +76,14 @@ export interface MavenDependency { scope: string; resolution: MavenDependencyResolution; selectedVersion?: string | null; + /** Version before dependency management replaced it with `version`. */ + premanagedVersion?: string | null; + /** Scope before dependency management replaced it with `scope`. */ + premanagedScope?: string | null; + /** Declared scope before mediation widened it to `scope`. */ + originalScope?: string | null; + /** Wider scope from another path that mediation did not apply here. */ + ignoredScope?: string | null; children: MavenDependency[]; } diff --git a/windows/tauri/src/features/maven/utils/maven-dependency-subtitle.test.ts b/windows/tauri/src/features/maven/utils/maven-dependency-subtitle.test.ts new file mode 100644 index 000000000..df0f13f8d --- /dev/null +++ b/windows/tauri/src/features/maven/utils/maven-dependency-subtitle.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, test } from "bun:test"; +import { createTranslator } from "@/i18n/locale"; +import type { MavenDependency } from "../types/maven.types"; +import { mavenDependencySubtitle } from "./maven-dependency-subtitle"; + +const dependency: MavenDependency = { + modulePath: "service", + groupId: "org.example", + artifactId: "managed-lib", + version: "2.1", + type: "jar", + classifier: null, + scope: "compile", + resolution: "resolved", + selectedVersion: null, + children: [], +}; + +describe("Maven dependency subtitle", () => { + test("shows a plain coordinate without annotations", () => { + expect(mavenDependencySubtitle(dependency, createTranslator("en-US"))).toBe( + "org.example:2.1:jar [compile]", + ); + }); + + test("keeps every annotation Maven reported, in its own order", () => { + const annotated: MavenDependency = { + ...dependency, + classifier: "tests", + resolution: "omittedConflict", + selectedVersion: "3.0", + premanagedVersion: "0.9.0", + premanagedScope: "test", + originalScope: "runtime", + ignoredScope: "compile", + }; + + expect(mavenDependencySubtitle(annotated, createTranslator("en-US"))).toBe( + "org.example:2.1:jar:tests [compile] (version managed from 0.9.0; scope managed from test; " + + "scope updated from runtime; scope not updated to compile; conflict with -> 3.0)", + ); + expect(mavenDependencySubtitle(annotated, createTranslator("zh-CN"))).toBe( + "org.example:2.1:jar:tests [compile] (依赖管理前版本为 0.9.0; 依赖管理前 scope 为 test; " + + "scope 由 runtime 提升; 未提升为 compile scope; 版本冲突,采用 -> 3.0)", + ); + }); + + test("marks a duplicate that dependency management also changed", () => { + expect( + mavenDependencySubtitle( + { ...dependency, resolution: "omittedDuplicate", premanagedVersion: "1.0" }, + createTranslator("en-US"), + ), + ).toBe("org.example:2.1:jar [compile] (version managed from 1.0; duplicate omitted)"); + }); +}); diff --git a/windows/tauri/src/features/maven/utils/maven-dependency-subtitle.ts b/windows/tauri/src/features/maven/utils/maven-dependency-subtitle.ts new file mode 100644 index 000000000..8b5c1aa0e --- /dev/null +++ b/windows/tauri/src/features/maven/utils/maven-dependency-subtitle.ts @@ -0,0 +1,30 @@ +import type { MavenDependency } from "../types/maven.types"; + +type Translate = (key: string, values?: Record) => string; + +/** + * Describes one dependency row: its coordinate, scope, and Maven's verbose + * annotations in the order `dependency:tree` prints them. + */ +export function mavenDependencySubtitle(dependency: MavenDependency, t: Translate): string { + const resolution = + dependency.resolution === "omittedConflict" + ? `${t("maven.omittedConflict")}${dependency.selectedVersion ? ` -> ${dependency.selectedVersion}` : ""}` + : dependency.resolution === "omittedDuplicate" + ? t("maven.omittedDuplicate") + : null; + const markers = [ + dependency.premanagedVersion + ? t("maven.versionManagedFrom", { value: dependency.premanagedVersion }) + : null, + dependency.premanagedScope + ? t("maven.scopeManagedFrom", { value: dependency.premanagedScope }) + : null, + dependency.originalScope ? t("maven.scopeUpdatedFrom", { value: dependency.originalScope }) : null, + dependency.ignoredScope ? t("maven.scopeNotUpdatedTo", { value: dependency.ignoredScope }) : null, + resolution, + ].filter((marker) => marker !== null); + const classifier = dependency.classifier ? `:${dependency.classifier}` : ""; + const annotations = markers.length > 0 ? ` (${markers.join("; ")})` : ""; + return `${dependency.groupId}:${dependency.version}:${dependency.type}${classifier} [${dependency.scope}]${annotations}`; +} diff --git a/windows/tauri/src/i18n/locale.ts b/windows/tauri/src/i18n/locale.ts index 2281a4fce..e6962dc85 100644 --- a/windows/tauri/src/i18n/locale.ts +++ b/windows/tauri/src/i18n/locale.ts @@ -1622,6 +1622,10 @@ const catalogs = { "maven.noDependencies": "No dependencies", "maven.omittedDuplicate": "duplicate omitted", "maven.omittedConflict": "conflict with", + "maven.versionManagedFrom": "version managed from {value}", + "maven.scopeManagedFrom": "scope managed from {value}", + "maven.scopeUpdatedFrom": "scope updated from {value}", + "maven.scopeNotUpdatedTo": "scope not updated to {value}", "maven.settings": "Maven Settings", "maven.automatic": "Automatic", "maven.localRepository": "Local repository", @@ -6031,6 +6035,10 @@ const catalogs = { "maven.noDependencies": "没有依赖项", "maven.omittedDuplicate": "已省略重复依赖", "maven.omittedConflict": "版本冲突,采用", + "maven.versionManagedFrom": "依赖管理前版本为 {value}", + "maven.scopeManagedFrom": "依赖管理前 scope 为 {value}", + "maven.scopeUpdatedFrom": "scope 由 {value} 提升", + "maven.scopeNotUpdatedTo": "未提升为 {value} scope", "maven.settings": "Maven 设置", "maven.automatic": "自动检测", "maven.localRepository": "本地仓库",