From d32309d34362f33e93524499a459171f28d4a227 Mon Sep 17 00:00:00 2001 From: lick <2188718831@qq.com> Date: Sat, 26 Sep 2026 08:29:56 +0000 Subject: [PATCH 1/3] fix(maven): read dependency trees from the plugin's output file (#890) The Maven tool window parsed the dependency tree out of the whole console output, so the tree shared one 500,000-character budget with Maven's log. Large projects (6,000 nodes is about 700 KB) failed with "Maven dependency output exceeded the supported limit." on both macOS and Windows. The dependency plugin now writes the verbose text tree to a platform-owned scratch file through -DoutputFile, and Core reads that file. Console output is log only. Core - maven.dependencyPlan takes a required absolute outputFile and pins every format-affecting property (text, standard tokens, UTF-8, overwrite). The reactor root runs with -N so only one project writes the file. - maven.dependencies takes { modulePath, outputFile } and validates the file strictly: the root line, then only well-formed nodes with annotations the pinned plugin writes. Unknown content fails with parse_failed instead of producing a partial tree; a missing file is process_failed. - Bounds come from the tree (10,000 nodes, 64 levels, 4 KiB lines, a byte limit derived from those), not from log volume. - Nodes now carry premanagedVersion, premanagedScope, originalScope, and ignoredScope from the verbose annotations that were previously dropped. - The shared fixture moves to dependency-tree-v2.json. Windows - The Tauri host creates and removes one scratch file per dependency session in the app cache directory and clears the directory at startup. - The Maven store no longer buffers dependency output; every path that ends a session (read, cancel, timeout, failure, invalidation, supersession) removes the file, and only after Core has read it. macOS - New MavenDependencyOutputStoring port, implemented by MacMavenDependencyOutputStore with a per-process directory; directories of exited processes are removed so concurrent Lithe builds keep their files. - MavenService follows the same lifecycle as Windows. Both dependency trees show the new management and scope annotations. The decision and rejected alternatives (JDT LS/m2e, JSON output, a Maven Server, raising the limit) are recorded in an Agent Note. Co-Authored-By: Claude Opus 5.5 --- ...09-26-maven-dependency-tree-output-file.md | 150 ++++++ docs/development/platform-parity-matrix.csv | 2 +- docs/development/platform-parity-matrix.md | 2 +- macos/Resources/en.lproj/Localizable.strings | 11 +- .../zh-Hans.lproj/Localizable.strings | 11 +- .../Lithe/Core/Rust/RustCoreBridge.swift | 28 +- .../Core/Rust/RustJavaMavenOperations.swift | 14 +- .../Platform/MacOS/MacServiceContainer.swift | 3 +- .../MacMavenDependencyOutputStore.swift | 96 ++++ .../Run/MavenDependencyLocalization.swift | 28 +- .../Execution/MavenContracts.swift | 42 +- .../Services/MavenService.swift | 81 +-- .../ExecutionModuleTests.swift | 170 ++++++- .../MavenDependencyLocalizationTests.swift | 23 + .../Tests/LitheTests/MavenRuntimeTests.swift | 103 +++- rust/lithe-core/src/project/maven.rs | 290 +---------- .../src/project/maven_dependency_tree.rs | 474 ++++++++++++++++++ rust/lithe-core/src/project/mod.rs | 2 + rust/lithe-core/src/protocol/contracts.rs | 8 + rust/lithe-core/src/tests/languages.rs | 367 +++++++++++--- shared/contracts/rust-core-api.md | 50 +- ...y-tree-v1.json => dependency-tree-v2.json} | 48 +- shared/platform-feature-matrix.json | 11 +- windows/tauri/src-tauri/src/main.rs | 3 + windows/tauri/src-tauri/src/maven.rs | 133 +++++ .../src/features/maven/api/maven-core-api.ts | 15 +- .../src/features/maven/api/maven-host-api.ts | 14 + .../features/maven/components/maven-pane.tsx | 10 +- .../hooks/use-maven-process-events.test.ts | 10 +- .../maven/hooks/use-maven-process-events.ts | 8 +- .../features/maven/stores/maven.store.test.ts | 129 ++++- .../src/features/maven/stores/maven.store.ts | 121 +++-- .../src/features/maven/types/maven.types.ts | 8 + .../utils/maven-dependency-subtitle.test.ts | 56 +++ .../maven/utils/maven-dependency-subtitle.ts | 30 ++ windows/tauri/src/i18n/locale.ts | 8 + 36 files changed, 2024 insertions(+), 535 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-09-26-maven-dependency-tree-output-file.md create mode 100644 macos/Sources/Lithe/Platform/MacOS/Persistence/MacMavenDependencyOutputStore.swift create mode 100644 rust/lithe-core/src/project/maven_dependency_tree.rs rename shared/fixtures/maven/{dependency-tree-v1.json => dependency-tree-v2.json} (54%) create mode 100644 windows/tauri/src/features/maven/utils/maven-dependency-subtitle.test.ts create mode 100644 windows/tauri/src/features/maven/utils/maven-dependency-subtitle.ts 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..4cc1bb5cf --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-26-maven-dependency-tree-output-file.md @@ -0,0 +1,150 @@ +# 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` 通过真实 Core 读取 fixture 文件并验证 PID 目录清理。 + +## 适用范围 + +- `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/MavenRuntimeTests.swift b/macos/Tests/LitheTests/MavenRuntimeTests.swift index 3c7bdb4a5..ab2bd5bb5 100644 --- a/macos/Tests/LitheTests/MavenRuntimeTests.swift +++ b/macos/Tests/LitheTests/MavenRuntimeTests.swift @@ -146,21 +146,114 @@ 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 mavenDependencyTreeFileIsPlannedAndReadThroughCore() throws { + let fixture = try Self.dependencyTreeFixture() + 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") + ) + } + } + + @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 @@ -484,7 +577,7 @@ struct MavenRuntimeTests { .deletingLastPathComponent() .deletingLastPathComponent() let url = repositoryRoot.appendingPathComponent( - "shared/fixtures/maven/dependency-tree-v1.json" + "shared/fixtures/maven/dependency-tree-v2.json" ) return try JSONDecoder().decode( MavenDependencyTreeFixture.self, @@ -508,7 +601,7 @@ private struct MavenPlatformContractFixture: Decodable { private struct MavenDependencyTreeFixture: Decodable { let version: Int let modulePath: String - let output: String + let treeFile: String let expected: RustCoreBridge.MavenDependenciesPayload } 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/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..e1f1b5549 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,8 +1216,7 @@ export const createMavenStore = ( set({ activeDependencySessionId: null, activeDependencyModulePath: null, - dependencyOutput: "", - }); + }); if (previousModulePath) { setDependencyLoad(previousModulePath, { status: "cancelled", @@ -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,8 +1331,7 @@ export const createMavenStore = ( set({ activeDependencySessionId: null, activeDependencyModulePath: null, - dependencyOutput: "", - }); + }); setDependencyLoad(modulePath, { status: "cancelled", dependencies: [], error: null }); if (!sessionId) return; try { @@ -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": "本地仓库", From d246435193568e902c3e8babfa1ea69124ae4cc2 Mon Sep 17 00:00:00 2001 From: lick <2188718831@qq.com> Date: Sat, 26 Sep 2026 08:45:09 +0000 Subject: [PATCH 2/3] style(windows): realign Maven dependency state updates Removing the dependency output buffer left four closing braces of set() calls indented one level too deep in maven.store.ts. Co-Authored-By: Claude Opus 5.5 --- windows/tauri/src/features/maven/stores/maven.store.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/windows/tauri/src/features/maven/stores/maven.store.ts b/windows/tauri/src/features/maven/stores/maven.store.ts index e1f1b5549..3c0f5c7c1 100644 --- a/windows/tauri/src/features/maven/stores/maven.store.ts +++ b/windows/tauri/src/features/maven/stores/maven.store.ts @@ -1216,7 +1216,7 @@ export const createMavenStore = ( set({ activeDependencySessionId: null, activeDependencyModulePath: null, - }); + }); if (previousModulePath) { setDependencyLoad(previousModulePath, { status: "cancelled", @@ -1246,7 +1246,7 @@ export const createMavenStore = ( set({ activeDependencySessionId: sessionId, activeDependencyModulePath: modulePath, - }); + }); setDependencyLoad(modulePath, { status: "loading", dependencies: [], error: null }); try { @@ -1307,7 +1307,7 @@ export const createMavenStore = ( set({ activeDependencySessionId: null, activeDependencyModulePath: null, - }); + }); setDependencyLoad(modulePath, { status: "failed", dependencies: [], error: message }); releaseDependencySession(sessionId); } @@ -1331,7 +1331,7 @@ export const createMavenStore = ( set({ activeDependencySessionId: null, activeDependencyModulePath: null, - }); + }); setDependencyLoad(modulePath, { status: "cancelled", dependencies: [], error: null }); if (!sessionId) return; try { From 6cbbc8206db86adca8d40ac0fda2d538c0545c32 Mon Sep 17 00:00:00 2001 From: lick <2188718831@qq.com> Date: Sat, 26 Sep 2026 09:14:39 +0000 Subject: [PATCH 3/3] test(macos): run the Maven tree bridge test only where Core is linked The ordinary Swift unit lane does not link Rust Core, so the end-to-end dependency-tree test failed there with "Rust Core is unavailable". Move it into MavenDependencyTreeBridgeTests, gate it on RustCoreBridge.isAvailable as the other bridge tests do, and add the suite to the Core-linked lane in verify-rust-core.sh so CI still runs it. Co-Authored-By: Claude Opus 5.5 --- ...09-26-maven-dependency-tree-output-file.md | 4 +- .../MavenDependencyTreeBridgeTests.swift | 55 ++++++++++++++ .../Tests/LitheTests/MavenRuntimeTests.swift | 74 +++++-------------- scripts/verify-rust-core.sh | 7 +- 4 files changed, 80 insertions(+), 60 deletions(-) create mode 100644 macos/Tests/LitheTests/MavenDependencyTreeBridgeTests.swift 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 index 4cc1bb5cf..faa2f618a 100644 --- 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 @@ -136,7 +136,9 @@ m2e 内嵌的 Maven,而不是用户配置的 Maven 或 mvnd,违反“依赖 `bun test --preload ./src/test-utils/vite-assets.ts src/features/maven`,覆盖 每条结束路径都删除临时文件、读完才删除、删除失败只记日志。 - macOS:`./scripts/test-macos.sh`,其中 `ExecutionModuleTests` 覆盖服务生命周期, - `MavenRuntimeTests` 通过真实 Core 读取 fixture 文件并验证 PID 目录清理。 + `MavenRuntimeTests` 验证 fixture 解码与 PID 目录清理。普通 Swift 单测不链接 + Core,`MavenDependencyTreeBridgeTests` 在 `./scripts/verify-rust-core.sh` 的 + Core 链接测试中通过真实 Core 规划并读取树文件。 ## 适用范围 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 ab2bd5bb5..c2c1d6cde 100644 --- a/macos/Tests/LitheTests/MavenRuntimeTests.swift +++ b/macos/Tests/LitheTests/MavenRuntimeTests.swift @@ -169,49 +169,6 @@ struct MavenRuntimeTests { #expect(testDependency.scope == "test") } - @Test - func mavenDependencyTreeFileIsPlannedAndReadThroughCore() throws { - let fixture = try Self.dependencyTreeFixture() - 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") - ) - } - } - @Test func dependencyOutputStoreKeepsFilesPerProcessAndRemovesAbandonedOnes() throws { let root = FileManager.default.temporaryDirectory @@ -571,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-v2.json" - ) - return try JSONDecoder().decode( - MavenDependencyTreeFixture.self, - from: Data(contentsOf: url) - ) + try MavenDependencyTreeFixture.load() } } @@ -598,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 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/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"